use crate::git::load_stored_credential; use reqwest::blocking::{Client, Response}; use reqwest::header::{ACCEPT, USER_AGENT}; use serde::{Deserialize, Serialize}; use std::time::Duration; const PAGE_SIZE: usize = 100; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct IntegrationRepository { pub id: String, pub name: String, pub full_name: String, pub description: String, pub clone_url: String, pub ssh_url: String, pub web_url: String, pub updated_at: String, pub private: bool, } #[derive(Debug, Deserialize)] struct GitLabProject { id: u64, name: String, path_with_namespace: String, #[serde(default)] description: Option, http_url_to_repo: String, #[serde(default)] ssh_url_to_repo: String, #[serde(default)] web_url: String, #[serde(default)] last_activity_at: String, #[serde(default)] visibility: String, } #[derive(Debug, Deserialize)] struct GitHubRepository { id: u64, name: String, full_name: String, #[serde(default)] description: Option, clone_url: String, #[serde(default)] ssh_url: String, #[serde(default)] html_url: String, #[serde(default)] updated_at: String, #[serde(default)] private: bool, } #[derive(Debug, Deserialize)] struct GiteaRepository { id: u64, name: String, full_name: String, #[serde(default)] description: String, clone_url: String, #[serde(default)] ssh_url: String, #[serde(default)] html_url: String, #[serde(default)] updated_at: String, #[serde(default)] private: bool, } #[derive(Debug, Deserialize)] struct AzureRepositoryList { #[serde(default)] value: Vec, } #[derive(Debug, Deserialize)] struct AzureRepository { id: String, name: String, project: AzureProject, #[serde(default, rename = "remoteUrl")] remote_url: String, #[serde(default, rename = "sshUrl")] ssh_url: String, #[serde(default, rename = "webUrl")] web_url: String, } #[derive(Debug, Deserialize)] struct AzureProject { name: String, } fn integration_key(provider: &str, account_id: Option<&str>) -> Result { if provider == "azure-devops" { if let Some(account_id) = account_id.filter(|value| !value.is_empty()) { if account_id.len() > 80 || !account_id.chars().all(|character| { character.is_ascii_alphanumeric() || matches!(character, '-' | '_') }) { return Err("Invalid integration account identifier.".to_string()); } if account_id != "default" { return Ok(format!("integration:{provider}:{account_id}")); } } } Ok(format!("integration:{provider}")) } fn client() -> Result { Client::builder() .timeout(Duration::from_secs(25)) .build() .map_err(|err| format!("Could not initialize the integration client: {err}")) } fn normalized_base_url(base_url: &str) -> Result { let base_url = base_url.trim().trim_end_matches('/'); if !(base_url.starts_with("https://") || base_url.starts_with("http://")) { return Err("The integration URL must start with http:// or https://.".to_string()); } Ok(base_url.to_string()) } fn github_api_base_url(base_url: &str) -> Result { match normalized_base_url(base_url)?.to_ascii_lowercase().as_str() { "https://github.com" | "https://www.github.com" => Ok("https://api.github.com".to_string()), "https://api.github.com" => Ok("https://api.github.com".to_string()), _ => Err("The GitHub integration URL must be https://github.com.".to_string()), } } fn response_error(response: Response, provider: &str) -> String { let status = response.status(); let detail = response.text().ok().and_then(|body| { serde_json::from_str::(&body) .ok() .and_then(|value| { value .get("message") .or_else(|| value.get("error")) .and_then(|value| value.as_str()) .map(str::to_string) }) }); match detail { Some(detail) if !detail.trim().is_empty() => { format!("{provider} returned {status}: {detail}") } _ => format!("{provider} returned {status}."), } } fn gitlab_repositories( client: &Client, base_url: &str, token: &str, ) -> Result, String> { let mut repositories = Vec::new(); let mut page = 1usize; loop { let response = client .get(format!("{base_url}/api/v4/projects")) .header(USER_AGENT, "Gitty") .header(ACCEPT, "application/json") .header("PRIVATE-TOKEN", token) .query(&[ ("membership", "true"), ("simple", "true"), ("order_by", "last_activity_at"), ("sort", "desc"), ("per_page", &PAGE_SIZE.to_string()), ("page", &page.to_string()), ]) .send() .map_err(|err| format!("Could not reach GitLab: {err}"))?; if !response.status().is_success() { return Err(response_error(response, "GitLab")); } let next_page = response .headers() .get("x-next-page") .and_then(|value| value.to_str().ok()) .unwrap_or("") .to_string(); let projects = response .json::>() .map_err(|err| format!("GitLab returned an unreadable repository list: {err}"))?; repositories.extend(projects.into_iter().map(|project| IntegrationRepository { id: project.id.to_string(), name: project.name, full_name: project.path_with_namespace, description: project.description.unwrap_or_default(), clone_url: project.http_url_to_repo, ssh_url: project.ssh_url_to_repo, web_url: project.web_url, updated_at: project.last_activity_at, private: project.visibility == "private", })); if next_page.is_empty() { break; } page = next_page.parse().unwrap_or(page + 1); } Ok(repositories) } fn github_repositories( client: &Client, base_url: &str, token: &str, ) -> Result, String> { let api_base_url = github_api_base_url(base_url)?; let mut repositories = Vec::new(); let mut page = 1usize; loop { let response = client .get(format!("{api_base_url}/user/repos")) .header(USER_AGENT, "Gitty") .header(ACCEPT, "application/vnd.github+json") .header("Authorization", format!("Bearer {token}")) .header("X-GitHub-Api-Version", "2026-03-10") .query(&[ ("per_page", PAGE_SIZE.to_string()), ("page", page.to_string()), ("sort", "updated".to_string()), ("direction", "desc".to_string()), ]) .send() .map_err(|err| format!("Could not reach GitHub: {err}"))?; if !response.status().is_success() { return Err(response_error(response, "GitHub")); } let page_repositories = response .json::>() .map_err(|err| format!("GitHub returned an unreadable repository list: {err}"))?; let count = page_repositories.len(); repositories.extend(page_repositories.into_iter().map(|repository| { IntegrationRepository { id: repository.id.to_string(), name: repository.name, full_name: repository.full_name, description: repository.description.unwrap_or_default(), clone_url: repository.clone_url, ssh_url: repository.ssh_url, web_url: repository.html_url, updated_at: repository.updated_at, private: repository.private, } })); if count < PAGE_SIZE { break; } page += 1; } Ok(repositories) } fn gitea_repositories( client: &Client, base_url: &str, token: &str, ) -> Result, String> { let mut repositories = Vec::new(); let mut page = 1usize; loop { let response = client .get(format!("{base_url}/api/v1/user/repos")) .header(USER_AGENT, "Gitty") .header(ACCEPT, "application/json") .header("Authorization", format!("token {token}")) .query(&[ ("limit", PAGE_SIZE.to_string()), ("page", page.to_string()), ("sort", "updated".to_string()), ]) .send() .map_err(|err| format!("Could not reach Gitea: {err}"))?; if !response.status().is_success() { return Err(response_error(response, "Gitea")); } let page_repositories = response .json::>() .map_err(|err| format!("Gitea returned an unreadable repository list: {err}"))?; let count = page_repositories.len(); repositories.extend(page_repositories.into_iter().map(|repository| { IntegrationRepository { id: repository.id.to_string(), name: repository.name, full_name: repository.full_name, description: repository.description, clone_url: repository.clone_url, ssh_url: repository.ssh_url, web_url: repository.html_url, updated_at: repository.updated_at, private: repository.private, } })); if count < PAGE_SIZE { break; } page += 1; } Ok(repositories) } fn azure_repositories( client: &Client, base_url: &str, username: &str, token: &str, ) -> Result, String> { let response = client .get(format!("{base_url}/_apis/git/repositories")) .header(USER_AGENT, "Gitty") .header(ACCEPT, "application/json") .basic_auth(username, Some(token)) .query(&[("api-version", "7.1")]) .send() .map_err(|err| format!("Could not reach Azure DevOps: {err}"))?; if !response.status().is_success() { return Err(response_error(response, "Azure DevOps")); } let repositories = response .json::() .map_err(|err| format!("Azure DevOps returned an unreadable repository list: {err}"))?; Ok(repositories .value .into_iter() .map(|repository| IntegrationRepository { id: repository.id, full_name: format!("{}/{}", repository.project.name, repository.name), name: repository.name, description: String::new(), clone_url: repository.remote_url, ssh_url: repository.ssh_url, web_url: repository.web_url, updated_at: String::new(), private: true, }) .collect()) } #[tauri::command] pub async fn list_integration_repositories( provider: String, base_url: String, account_id: Option, ) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { let credential_key = integration_key(&provider, account_id.as_deref())?; let credential = load_stored_credential(&credential_key)? .ok_or_else(|| "No token is stored for this integration.".to_string())?; let base_url = normalized_base_url(&base_url)?; let client = client()?; match provider.as_str() { "github" => github_repositories(&client, &base_url, &credential.password), "gitlab" | "gitlab-self-hosted" => { gitlab_repositories(&client, &base_url, &credential.password) } "azure-devops" => azure_repositories( &client, &base_url, if credential.username.trim().is_empty() { "gitty" } else { &credential.username }, &credential.password, ), "gitea" => gitea_repositories(&client, &base_url, &credential.password), _ => Err("Unsupported integration provider.".to_string()), } }) .await .map_err(|err| format!("Could not load integration repositories: {err}"))? } #[cfg(test)] mod tests { use super::*; #[test] fn base_urls_are_normalized_and_validated() { assert_eq!( normalized_base_url(" https://gitlab.example.com/ ").unwrap(), "https://gitlab.example.com" ); assert!(normalized_base_url("gitlab.example.com").is_err()); assert_eq!( github_api_base_url("https://github.com/").unwrap(), "https://api.github.com" ); assert!(github_api_base_url("https://github.example.com").is_err()); } #[test] fn integration_credential_keys_match_the_frontend() { assert_eq!( integration_key("github", None).unwrap(), "integration:github" ); assert_eq!(integration_key("gitea", None).unwrap(), "integration:gitea"); assert_eq!( integration_key("azure-devops", Some("org-123")).unwrap(), "integration:azure-devops:org-123" ); assert_eq!( integration_key("azure-devops", Some("default")).unwrap(), "integration:azure-devops" ); assert!(integration_key("azure-devops", Some("../invalid")).is_err()); } #[test] fn provider_repository_payloads_deserialize() { let github: Vec = serde_json::from_str( r#"[{"id":6,"name":"desktop","full_name":"team/desktop","description":null,"clone_url":"https://github.com/team/desktop.git","ssh_url":"git@github.com:team/desktop.git","html_url":"https://github.com/team/desktop","updated_at":"2026-08-29T09:00:00Z","private":true}]"#, ) .unwrap(); assert_eq!(github[0].full_name, "team/desktop"); assert!(github[0].description.is_none()); let gitlab: Vec = serde_json::from_str( r#"[{"id":7,"name":"app","path_with_namespace":"team/app","description":"Demo","http_url_to_repo":"https://gitlab.test/team/app.git","ssh_url_to_repo":"git@gitlab.test:team/app.git","web_url":"https://gitlab.test/team/app","last_activity_at":"2026-08-29T10:00:00Z","visibility":"private"}]"#, ) .unwrap(); assert_eq!(gitlab[0].path_with_namespace, "team/app"); assert_eq!(gitlab[0].visibility, "private"); let gitea: Vec = serde_json::from_str( r#"[{"id":8,"name":"api","full_name":"team/api","description":"","clone_url":"https://gitea.test/team/api.git","ssh_url":"git@gitea.test:team/api.git","html_url":"https://gitea.test/team/api","updated_at":"2026-08-29T11:00:00Z","private":false}]"#, ) .unwrap(); assert_eq!(gitea[0].full_name, "team/api"); assert!(!gitea[0].private); let azure: AzureRepositoryList = serde_json::from_str( r#"{"value":[{"id":"repo-id","name":"web","project":{"name":"Platform"},"remoteUrl":"https://dev.azure.com/org/Platform/_git/web","sshUrl":"git@ssh.dev.azure.com:v3/org/Platform/web","webUrl":"https://dev.azure.com/org/Platform/_git/web"}]}"#, ) .unwrap(); assert_eq!(azure.value[0].project.name, "Platform"); assert_eq!( azure.value[0].remote_url, "https://dev.azure.com/org/Platform/_git/web" ); } }