mod merge; pub use merge::get_integration_review_merge_options; use merge::merge_payload; mod issue_creation; pub use issue_creation::*; mod issue_actions; pub use issue_actions::*; mod issue_comments; pub use issue_comments::*; mod boards; pub use boards::*; mod issues; pub use issues::*; use crate::git::load_stored_credential; use reqwest::blocking::{Client, Response}; use reqwest::header::{ACCEPT, USER_AGENT}; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use std::time::Duration; mod creation; pub use creation::{create_integration_review_request, list_integration_repository_branches}; const PAGE_SIZE: usize = 100; const REVIEW_REQUEST_TIMEOUT: Duration = Duration::from_secs(35); const AZURE_PROJECT_WORKERS: usize = 8; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[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, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct IntegrationReviewRequest { pub id: String, pub number: u64, pub provider: String, pub repository_id: String, pub repository_name: String, pub title: String, pub description: String, pub author: String, pub state: String, pub source_branch: String, pub target_branch: String, pub web_url: String, pub created_at: String, pub updated_at: String, #[serde(default)] pub collaborators: Vec, #[serde(default)] pub additions: Option, #[serde(default)] pub deletions: Option, #[serde(default)] pub changed_files: Option, #[serde(default)] pub merge_status: String, #[serde(default)] pub comments: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct IntegrationReviewComment { pub id: String, pub author: String, pub body: String, pub created_at: String, } #[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() .connect_timeout(Duration::from_secs(7)) .timeout(Duration::from_secs(15)) .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()) } fn value_string(value: &serde_json::Value, path: &[&str]) -> String { path.iter() .try_fold(value, |current, key| current.get(*key)) .and_then(serde_json::Value::as_str) .unwrap_or_default() .to_string() } fn value_u64(value: &serde_json::Value, key: &str) -> u64 { value .get(key) .and_then(serde_json::Value::as_u64) .unwrap_or_default() } fn github_repository_name(repository_url: &str) -> String { let parts: Vec<_> = repository_url.trim_end_matches('/').split('/').collect(); if parts.len() < 2 { return String::new(); } format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1]) } fn parse_github_review(value: &serde_json::Value) -> Option { let pull = value.get("pull_request")?; let number = value_u64(value, "number"); let repository_url = value_string(value, &["repository_url"]); let state = if pull.get("merged_at").is_some_and(|item| !item.is_null()) { "merged" } else if value_string(value, &["state"]) == "open" { if value.get("draft").and_then(serde_json::Value::as_bool) == Some(true) { "draft" } else { "open" } } else { "closed" }; Some(IntegrationReviewRequest { id: format!("github:{repository_url}:{number}"), number, provider: "github".to_string(), repository_id: repository_url.clone(), repository_name: github_repository_name(&repository_url), title: value_string(value, &["title"]), description: value_string(value, &["body"]), author: value_string(value, &["user", "login"]), state: state.to_string(), source_branch: String::new(), target_branch: String::new(), web_url: value_string(value, &["html_url"]), created_at: value_string(value, &["created_at"]), updated_at: value_string(value, &["updated_at"]), collaborators: Vec::new(), additions: None, deletions: None, changed_files: None, merge_status: String::new(), comments: Vec::new(), }) } fn parse_gitlab_review(value: &serde_json::Value, provider: &str) -> IntegrationReviewRequest { let number = value_u64(value, "iid"); let project_id = value.get("project_id").map_or_else(String::new, |id| { id.as_u64().map_or_else( || id.as_str().unwrap_or_default().to_string(), |id| id.to_string(), ) }); let reference = value_string(value, &["references", "full"]); let repository_name = reference .rsplit_once('!') .map_or(reference.clone(), |(name, _)| name.to_string()); let raw_state = value_string(value, &["state"]); let draft = value.get("draft").and_then(serde_json::Value::as_bool) == Some(true) || value .get("work_in_progress") .and_then(serde_json::Value::as_bool) == Some(true); let state = match raw_state.as_str() { "opened" if draft => "draft", "opened" => "open", "merged" => "merged", _ => "closed", }; IntegrationReviewRequest { id: format!("{provider}:{project_id}:{number}"), number, provider: provider.to_string(), repository_id: project_id, repository_name, title: value_string(value, &["title"]), description: value_string(value, &["description"]), author: value_string(value, &["author", "name"]), state: state.to_string(), source_branch: value_string(value, &["source_branch"]), target_branch: value_string(value, &["target_branch"]), web_url: value_string(value, &["web_url"]), created_at: value_string(value, &["created_at"]), updated_at: value_string(value, &["updated_at"]), collaborators: value.get("reviewers").and_then(serde_json::Value::as_array).into_iter().flatten().map(|item| value_string(item, &["name"])).filter(|name| !name.is_empty()).collect(), additions: None, deletions: None, changed_files: value_string(value, &["changes_count"]).parse().ok(), merge_status: match value_string(value, &["detailed_merge_status"]).as_str() { "mergeable" | "can_be_merged" => "mergeable", "conflict" | "conflicts" => "conflicts", "checking" | "unchecked" | "preparing" => "checking", "" => "", _ => "blocked" }.to_string(), comments: Vec::new(), } } fn parse_gitea_review(value: &serde_json::Value) -> Option { value.get("pull_request")?; let number = value_u64(value, "number"); let repository_id = value .get("repository") .and_then(|repository| repository.get("id")) .and_then(serde_json::Value::as_u64) .map_or_else(String::new, |id| id.to_string()); let is_open = value_string(value, &["state"]) == "open"; let merged = value .get("pull_request") .and_then(|pull| pull.get("merged_at")) .is_some_and(|item| !item.is_null()); Some(IntegrationReviewRequest { id: format!("gitea:{repository_id}:{number}"), number, provider: "gitea".to_string(), repository_id, repository_name: value_string(value, &["repository", "full_name"]), title: value_string(value, &["title"]), description: value_string(value, &["body"]), author: value_string(value, &["user", "login"]), state: if merged { "merged" } else if is_open { "open" } else { "closed" } .to_string(), source_branch: { let branch = value_string(value, &["pull_request", "head", "ref"]); if branch.is_empty() { value_string(value, &["pull_request", "head", "label"]) } else { branch } }, target_branch: { let branch = value_string(value, &["pull_request", "base", "ref"]); if branch.is_empty() { value_string(value, &["pull_request", "base", "label"]) } else { branch } }, web_url: value_string(value, &["html_url"]), created_at: value_string(value, &["created_at"]), updated_at: value_string(value, &["updated_at"]), collaborators: Vec::new(), additions: None, deletions: None, changed_files: None, merge_status: value.get("pull_request").and_then(|pull| pull.get("mergeable")).and_then(serde_json::Value::as_bool).map(|mergeable| if mergeable { "mergeable" } else { "conflicts" }).unwrap_or_default().to_string(), comments: Vec::new(), }) } fn parse_azure_review( value: &serde_json::Value, repository: &IntegrationRepository, ) -> IntegrationReviewRequest { let number = value_u64(value, "pullRequestId"); let raw_state = value_string(value, &["status"]); let draft = value.get("isDraft").and_then(serde_json::Value::as_bool) == Some(true); let state = match raw_state.as_str() { "active" if draft => "draft", "active" => "open", "completed" => "merged", _ => "closed", }; let trim_branch = |branch: String| { branch .strip_prefix("refs/heads/") .unwrap_or(&branch) .to_string() }; IntegrationReviewRequest { id: format!("azure-devops:{}:{number}", repository.id), number, provider: "azure-devops".to_string(), repository_id: repository.id.clone(), repository_name: repository.full_name.clone(), title: value_string(value, &["title"]), description: value_string(value, &["description"]), author: value_string(value, &["createdBy", "displayName"]), state: state.to_string(), source_branch: trim_branch(value_string(value, &["sourceRefName"])), target_branch: trim_branch(value_string(value, &["targetRefName"])), web_url: format!( "{}/pullrequest/{number}", repository.web_url.trim_end_matches('/') ), created_at: value_string(value, &["creationDate"]), updated_at: value_string(value, &["closedDate"]), collaborators: value.get("reviewers").and_then(serde_json::Value::as_array).into_iter().flatten().map(|item| value_string(item, &["displayName"])).filter(|name| !name.is_empty()).collect(), additions: None, deletions: None, changed_files: None, merge_status: match value_string(value, &["mergeStatus"]).as_str() { "succeeded" => "mergeable", "conflicts" => "conflicts", "queued" | "notSet" => "checking", "" => "", _ => "blocked" }.to_string(), comments: Vec::new(), } } fn github_reviews( client: &Client, base_url: &str, token: &str, state: &str, ) -> Result, String> { let query = match state { "open" => "is:pr is:open", "merged" => "is:pr is:merged", "closed" => "is:pr is:closed is:unmerged", _ => return Err("Unsupported review state.".to_string()), }; let response = client .get(format!("{}/search/issues", github_api_base_url(base_url)?)) .header(USER_AGENT, "Gitty") .header(ACCEPT, "application/vnd.github+json") .header("Authorization", format!("Bearer {token}")) .header("X-GitHub-Api-Version", "2026-03-10") .query(&[ ("q", query), ("sort", "updated"), ("order", "desc"), ("per_page", "100"), ]) .send() .map_err(|err| format!("Could not reach GitHub: {err}"))?; if !response.status().is_success() { return Err(response_error(response, "GitHub")); } let payload = response .json::() .map_err(|err| format!("GitHub returned an unreadable pull request list: {err}"))?; Ok(payload .get("items") .and_then(serde_json::Value::as_array) .into_iter() .flatten() .filter_map(parse_github_review) .map(|mut review| { if state != "open" { review.state = state.to_string(); } review }) .collect()) } fn gitlab_reviews( client: &Client, base_url: &str, token: &str, provider: &str, state: &str, ) -> Result, String> { let api_state = match state { "open" => "opened", "merged" => "merged", "closed" => "closed", _ => return Err("Unsupported review state.".to_string()), }; let response = client .get(format!("{base_url}/api/v4/merge_requests")) .header(USER_AGENT, "Gitty") .header(ACCEPT, "application/json") .header("PRIVATE-TOKEN", token) .query(&[ ("scope", "all"), ("state", api_state), ("order_by", "updated_at"), ("sort", "desc"), ("per_page", "100"), ]) .send() .map_err(|err| format!("Could not reach GitLab: {err}"))?; if !response.status().is_success() { return Err(response_error(response, "GitLab")); } Ok(response .json::>() .map_err(|err| format!("GitLab returned an unreadable merge request list: {err}"))? .iter() .map(|value| parse_gitlab_review(value, provider)) .collect()) } fn gitea_reviews( client: &Client, base_url: &str, token: &str, state: &str, ) -> Result, String> { let api_state = if state == "open" { "open" } else { "closed" }; let response = client .get(format!("{base_url}/api/v1/repos/issues/search")) .header(USER_AGENT, "Gitty") .header(ACCEPT, "application/json") .header("Authorization", format!("token {token}")) .query(&[("type", "pulls"), ("state", api_state), ("limit", "100")]) .send() .map_err(|err| format!("Could not reach Gitea: {err}"))?; if !response.status().is_success() { return Err(response_error(response, "Gitea")); } Ok(response .json::>() .map_err(|err| format!("Gitea returned an unreadable pull request list: {err}"))? .iter() .filter_map(parse_gitea_review) .filter(|review| state == "open" || review.state == state) .collect()) } fn azure_project_reviews( client: &Client, base_url: &str, username: &str, token: &str, api_state: &str, project: &str, repositories: &[IntegrationRepository], ) -> Result, String> { let mut endpoint = reqwest::Url::parse(base_url).map_err(|err| format!("Invalid Azure DevOps URL: {err}"))?; endpoint .path_segments_mut() .map_err(|_| "Invalid Azure DevOps URL.".to_string())? .push(project) .push("_apis") .push("git") .push("pullrequests"); let response = client .get(endpoint) .timeout(Duration::from_secs(15)) .header(USER_AGENT, "Gitty") .header(ACCEPT, "application/json") .basic_auth(username, Some(token)) .query(&[ ("searchCriteria.status", api_state), ("$top", "100"), ("api-version", "7.1"), ]) .send() .map_err(|err| format!("Could not reach Azure DevOps project {project}: {err}"))?; if !response.status().is_success() { return Err(response_error(response, "Azure DevOps")); } let payload = response .json::() .map_err(|err| format!("Azure DevOps returned an unreadable pull request list: {err}"))?; Ok(payload .get("value") .and_then(serde_json::Value::as_array) .into_iter() .flatten() .filter_map(|value| { let repository_id = value_string(value, &["repository", "id"]); repositories .iter() .find(|repository| repository.id == repository_id) .map(|repository| parse_azure_review(value, repository)) }) .collect()) } fn azure_reviews( client: &Client, base_url: &str, username: &str, token: &str, state: &str, ) -> Result, String> { let api_state = match state { "open" => "active", "merged" => "completed", "closed" => "abandoned", _ => return Err("Unsupported review state.".to_string()), }; let repositories = azure_repositories(client, base_url, username, token)?; let projects: Vec<_> = repositories .iter() .filter_map(|repository| { repository .full_name .split_once('/') .map(|(project, _)| project) }) .collect::>() .into_iter() .collect(); if projects.is_empty() { return Ok(Vec::new()); } let chunk_size = projects.len().div_ceil(AZURE_PROJECT_WORKERS); let results = std::thread::scope(|scope| { let handles = projects .chunks(chunk_size) .map(|project_chunk| { let repositories = &repositories; scope.spawn(move || { project_chunk .iter() .map(|project| { azure_project_reviews( client, base_url, username, token, api_state, project, repositories, ) }) .collect::>() }) }) .collect::>(); let mut results = Vec::with_capacity(projects.len()); for handle in handles { match handle.join() { Ok(worker_results) => results.extend(worker_results), Err(_) => results.push(Err("Azure DevOps project worker failed.".to_string())), } } results }); let mut reviews = Vec::new(); let mut errors = Vec::new(); let mut successful_projects = 0usize; for result in results { match result { Ok(project_reviews) => { successful_projects += 1; reviews.extend(project_reviews); } Err(error) => errors.push(error), } } if successful_projects == 0 && !errors.is_empty() { let omitted = errors.len().saturating_sub(3); let mut detail = errors.into_iter().take(3).collect::>().join("; "); if omitted > 0 { detail.push_str(&format!("; and {omitted} more project request(s) failed.")); } return Err(detail); } reviews.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); Ok(reviews) } fn ensure_action_response(response: Response, provider: &str, action: &str) -> Result<(), String> { if response.status().is_success() { Ok(()) } else { Err(format!("Could not {action} review request: {}", response_error(response, provider))) } } fn github_review_action( client: &Client, base_url: &str, token: &str, repository_name: &str, number: u64, action: &str, merge_method: Option<&str>, ) -> Result<(), String> { if repository_name.split('/').count() != 2 { return Err("GitHub returned an invalid repository name.".to_string()); } let endpoint = format!("{}/repos/{repository_name}/pulls/{number}", github_api_base_url(base_url)?); let request = match action { "merge" => client.put(format!("{endpoint}/merge")).json(&merge_payload("github", merge_method)?), "approve" => client.post(format!("{endpoint}/reviews")).json(&serde_json::json!({ "event": "APPROVE" })), "close" => client.patch(&endpoint).json(&serde_json::json!({ "state": "closed" })), "reopen" => client.patch(&endpoint).json(&serde_json::json!({ "state": "open" })), _ => return Err("Unsupported review action.".to_string()), }; let response = request .header(USER_AGENT, "Gitty") .header(ACCEPT, "application/vnd.github+json") .header("Authorization", format!("Bearer {token}")) .header("X-GitHub-Api-Version", "2026-03-10") .send() .map_err(|err| format!("Could not reach GitHub: {err}"))?; ensure_action_response(response, "GitHub", action) } fn gitlab_review_action( client: &Client, base_url: &str, token: &str, repository_id: &str, number: u64, action: &str, merge_method: Option<&str>, ) -> Result<(), String> { if repository_id.trim().is_empty() { return Err("GitLab returned an invalid project identifier.".to_string()); } let endpoint = format!("{base_url}/api/v4/projects/{repository_id}/merge_requests/{number}"); let request = match action { "merge" => client.put(format!("{endpoint}/merge")).json(&merge_payload("gitlab", merge_method)?), "approve" => client.post(format!("{endpoint}/approve")), "close" => client.put(&endpoint).query(&[("state_event", "close")]), "reopen" => client.put(&endpoint).query(&[("state_event", "reopen")]), _ => return Err("Unsupported review action.".to_string()), }; let response = request .header(USER_AGENT, "Gitty") .header(ACCEPT, "application/json") .header("PRIVATE-TOKEN", token) .send() .map_err(|err| format!("Could not reach GitLab: {err}"))?; ensure_action_response(response, "GitLab", action) } fn gitea_review_action( client: &Client, base_url: &str, token: &str, repository_name: &str, number: u64, action: &str, merge_method: Option<&str>, ) -> Result<(), String> { if repository_name.split('/').count() != 2 { return Err("Gitea returned an invalid repository name.".to_string()); } let endpoint = format!("{base_url}/api/v1/repos/{repository_name}/pulls/{number}"); let request = match action { "merge" => client.post(format!("{endpoint}/merge")).json(&merge_payload("gitea", merge_method)?), "approve" => client.post(format!("{endpoint}/reviews")).json(&serde_json::json!({ "event": "APPROVED", "body": "" })), "close" => client.patch(&endpoint).json(&serde_json::json!({ "state": "closed" })), "reopen" => client.patch(&endpoint).json(&serde_json::json!({ "state": "open" })), _ => return Err("Unsupported review action.".to_string()), }; let response = request .header(USER_AGENT, "Gitty") .header(ACCEPT, "application/json") .header("Authorization", format!("token {token}")) .send() .map_err(|err| format!("Could not reach Gitea: {err}"))?; ensure_action_response(response, "Gitea", action) } fn azure_review_endpoint( base_url: &str, repository_name: &str, repository_id: &str, number: u64, ) -> Result { let project = repository_name .split_once('/') .map(|(project, _)| project) .filter(|project| !project.is_empty()) .ok_or_else(|| "Azure DevOps returned an invalid repository name.".to_string())?; let mut endpoint = reqwest::Url::parse(base_url) .map_err(|err| format!("Invalid Azure DevOps URL: {err}"))?; endpoint .path_segments_mut() .map_err(|_| "Invalid Azure DevOps URL.".to_string())? .push(project) .push("_apis") .push("git") .push("repositories") .push(repository_id) .push("pullrequests") .push(&number.to_string()); endpoint.query_pairs_mut().append_pair("api-version", "7.1"); Ok(endpoint) } fn azure_review_action( client: &Client, base_url: &str, username: &str, token: &str, repository_name: &str, repository_id: &str, number: u64, action: &str, merge_method: Option<&str>, ) -> Result<(), String> { let endpoint = azure_review_endpoint(base_url, repository_name, repository_id, number)?; let auth_user = if username.trim().is_empty() { "gitty" } else { username }; if action == "approve" { let mut identity_endpoint = reqwest::Url::parse(base_url) .map_err(|err| format!("Invalid Azure DevOps URL: {err}"))?; identity_endpoint.path_segments_mut().map_err(|_| "Invalid Azure DevOps URL.".to_string())?.push("_apis").push("connectionData"); identity_endpoint.query_pairs_mut().append_pair("connectOptions", "1").append_pair("lastChangeId", "-1").append_pair("lastChangeId64", "-1"); let identity = client.get(identity_endpoint).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send() .map_err(|err| format!("Could not reach Azure DevOps: {err}"))?; if !identity.status().is_success() { return Err(response_error(identity, "Azure DevOps")); } let payload = identity.json::().map_err(|err| format!("Azure DevOps returned an unreadable identity: {err}"))?; let reviewer_id = value_string(&payload, &["authenticatedUser", "id"]); if reviewer_id.is_empty() { return Err("Azure DevOps did not return the authenticated user.".to_string()); } let reviewer_endpoint = format!("{}/reviewers/{reviewer_id}?api-version=7.1", endpoint.as_str().split('?').next().unwrap_or_default()); let response = client.put(reviewer_endpoint).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).json(&serde_json::json!({ "vote": 10 })).send() .map_err(|err| format!("Could not reach Azure DevOps: {err}"))?; return ensure_action_response(response, "Azure DevOps", action); } let body = match action { "close" => serde_json::json!({ "status": "abandoned" }), "reopen" => serde_json::json!({ "status": "active" }), "merge" => { let current = client.get(endpoint.clone()).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send() .map_err(|err| format!("Could not reach Azure DevOps: {err}"))?; if !current.status().is_success() { return Err(response_error(current, "Azure DevOps")); } let payload = current.json::().map_err(|err| format!("Azure DevOps returned an unreadable pull request: {err}"))?; let commit_id = value_string(&payload, &["lastMergeSourceCommit", "commitId"]); if commit_id.is_empty() { return Err("Azure DevOps did not return the current source commit.".to_string()); } { let mut body = merge_payload("azure-devops", merge_method)?; body["status"] = serde_json::json!("completed"); body["lastMergeSourceCommit"] = serde_json::json!({ "commitId": commit_id }); body } } _ => return Err("Unsupported review action.".to_string()), }; let response = client.patch(endpoint).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).json(&body).send() .map_err(|err| format!("Could not reach Azure DevOps: {err}"))?; ensure_action_response(response, "Azure DevOps", action) } fn review_names(value: &serde_json::Value, key: &str, name_key: &str) -> Vec { value.get(key).and_then(serde_json::Value::as_array).into_iter().flatten() .map(|item| value_string(item, &[name_key])) .filter(|name| !name.is_empty()).collect() } fn json_id(value: &serde_json::Value) -> String { value.get("id").map_or_else(String::new, |id| id.as_str().map(str::to_string).or_else(|| id.as_u64().map(|id| id.to_string())).unwrap_or_default()) } fn review_comments(client: &Client, base_url: &str, username: &str, token: &str, review: &IntegrationReviewRequest) -> Result, String> { let (payload, provider) = match review.provider.as_str() { "github" => { let url = format!("{}/repos/{}/issues/{}/comments", github_api_base_url(base_url)?, review.repository_name, review.number); let response = client.get(url).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","100")]).send().map_err(|err|format!("Could not load GitHub comments: {err}"))?; if !response.status().is_success(){return Err(response_error(response,"GitHub"));} (response.json::().map_err(|err|format!("GitHub returned unreadable comments: {err}"))?,"github") } "gitlab" | "gitlab-self-hosted" => { let url=format!("{base_url}/api/v4/projects/{}/merge_requests/{}/notes",review.repository_id,review.number); let response=client.get(url).header(USER_AGENT,"Gitty").header("PRIVATE-TOKEN",token).query(&[("per_page","100"),("sort","asc")]).send().map_err(|err|format!("Could not load GitLab comments: {err}"))?; if !response.status().is_success(){return Err(response_error(response,"GitLab"));} (response.json::().map_err(|err|format!("GitLab returned unreadable comments: {err}"))?,"gitlab") } "gitea" => { let url=format!("{base_url}/api/v1/repos/{}/issues/{}/comments",review.repository_name,review.number); let response=client.get(url).header(USER_AGENT,"Gitty").header("Authorization",format!("token {token}")).query(&[("limit","100")]).send().map_err(|err|format!("Could not load Gitea comments: {err}"))?; if !response.status().is_success(){return Err(response_error(response,"Gitea"));} (response.json::().map_err(|err|format!("Gitea returned unreadable comments: {err}"))?,"gitea") } "azure-devops" => { let mut url=azure_review_endpoint(base_url,&review.repository_name,&review.repository_id,review.number)?; url.set_query(None); url.path_segments_mut().map_err(|_|"Invalid Azure DevOps URL.".to_string())?.push("threads"); url.query_pairs_mut().append_pair("api-version","7.1"); let auth=if username.trim().is_empty(){"gitty"}else{username}; let response=client.get(url).header(USER_AGENT,"Gitty").basic_auth(auth,Some(token)).send().map_err(|err|format!("Could not load Azure DevOps comments: {err}"))?; if !response.status().is_success(){return Err(response_error(response,"Azure DevOps"));} (response.json::().map_err(|err|format!("Azure DevOps returned unreadable comments: {err}"))?,"azure-devops") } _=>return Err("Unsupported integration provider.".to_string()), }; let mut comments=Vec::new(); if provider=="azure-devops" { for thread in payload.get("value").and_then(serde_json::Value::as_array).into_iter().flatten() { for item in thread.get("comments").and_then(serde_json::Value::as_array).into_iter().flatten() { if item.get("isDeleted").and_then(serde_json::Value::as_bool)==Some(true){continue;} comments.push(IntegrationReviewComment{id:format!("{}:{}",json_id(thread),json_id(item)),author:value_string(item,&["author","displayName"]),body:value_string(item,&["content"]),created_at:value_string(item,&["publishedDate"])}); } } } else if let Some(items)=payload.as_array() { for item in items { if provider=="gitlab" && item.get("system").and_then(serde_json::Value::as_bool)==Some(true){continue;} comments.push(IntegrationReviewComment{id:json_id(item),author:if provider=="gitlab"{value_string(item,&["author","name"])}else{value_string(item,&["user","login"])},body:value_string(item,&["body"]),created_at:value_string(item,&["created_at"])}); } } Ok(comments) } fn post_review_comment(client:&Client,base_url:&str,username:&str,token:&str,review:&IntegrationReviewRequest,body:&str)->Result<(),String>{ let response=match review.provider.as_str(){ "github"=>client.post(format!("{}/repos/{}/issues/{}/comments",github_api_base_url(base_url)?,review.repository_name,review.number)).header(USER_AGENT,"Gitty").header(ACCEPT,"application/vnd.github+json").header("Authorization",format!("Bearer {token}")).header("X-GitHub-Api-Version","2026-03-10").json(&serde_json::json!({"body":body})).send().map_err(|err|format!("Could not reach GitHub: {err}"))?, "gitlab"|"gitlab-self-hosted"=>client.post(format!("{base_url}/api/v4/projects/{}/merge_requests/{}/notes",review.repository_id,review.number)).header(USER_AGENT,"Gitty").header("PRIVATE-TOKEN",token).json(&serde_json::json!({"body":body})).send().map_err(|err|format!("Could not reach GitLab: {err}"))?, "gitea"=>client.post(format!("{base_url}/api/v1/repos/{}/issues/{}/comments",review.repository_name,review.number)).header(USER_AGENT,"Gitty").header("Authorization",format!("token {token}")).json(&serde_json::json!({"body":body})).send().map_err(|err|format!("Could not reach Gitea: {err}"))?, "azure-devops"=>{let mut url=azure_review_endpoint(base_url,&review.repository_name,&review.repository_id,review.number)?;url.set_query(None);url.path_segments_mut().map_err(|_|"Invalid Azure DevOps URL.".to_string())?.push("threads");url.query_pairs_mut().append_pair("api-version","7.1");let auth=if username.trim().is_empty(){"gitty"}else{username};client.post(url).header(USER_AGENT,"Gitty").basic_auth(auth,Some(token)).json(&serde_json::json!({"comments":[{"parentCommentId":0,"content":body,"commentType":1}],"status":1})).send().map_err(|err|format!("Could not reach Azure DevOps: {err}"))?}, _=>return Err("Unsupported integration provider.".to_string())}; ensure_action_response(response, provider_label_for_error(&review.provider), "add comment") } fn provider_label_for_error(provider:&str)->&str{match provider{"github"=>"GitHub","gitlab"|"gitlab-self-hosted"=>"GitLab","gitea"=>"Gitea","azure-devops"=>"Azure DevOps",_=>"Integration"}} fn load_review_details( client: &Client, base_url: &str, username: &str, token: &str, mut review: IntegrationReviewRequest, ) -> Result { let payload = match review.provider.as_str() { "github" => { let endpoint = format!("{}/repos/{}/pulls/{}", github_api_base_url(base_url)?, review.repository_name, review.number); let response = client.get(endpoint).header(USER_AGENT, "Gitty").header(ACCEPT, "application/vnd.github+json").header("Authorization", format!("Bearer {token}")).header("X-GitHub-Api-Version", "2026-03-10").send().map_err(|err| format!("Could not reach GitHub: {err}"))?; if !response.status().is_success() { return Err(response_error(response, "GitHub")); } response.json::().map_err(|err| format!("GitHub returned unreadable pull request details: {err}"))? } "gitlab" | "gitlab-self-hosted" => { let endpoint = format!("{base_url}/api/v4/projects/{}/merge_requests/{}", review.repository_id, review.number); let response = client.get(&endpoint).header(USER_AGENT, "Gitty").header("PRIVATE-TOKEN", token).send().map_err(|err| format!("Could not reach GitLab: {err}"))?; if !response.status().is_success() { return Err(response_error(response, "GitLab")); } let mut payload = response.json::().map_err(|err| format!("GitLab returned unreadable merge request details: {err}"))?; let diffs = client.get(format!("{endpoint}/diffs")).header(USER_AGENT, "Gitty").header("PRIVATE-TOKEN", token).query(&[("per_page", "100")]).send().map_err(|err| format!("Could not load GitLab diffs: {err}"))?; if diffs.status().is_success() { if let Ok(items) = diffs.json::>() { payload["gitty_diffs"] = serde_json::Value::Array(items); } } payload } "gitea" => { let endpoint = format!("{base_url}/api/v1/repos/{}/pulls/{}", review.repository_name, review.number); let response = client.get(endpoint).header(USER_AGENT, "Gitty").header("Authorization", format!("token {token}")).send().map_err(|err| format!("Could not reach Gitea: {err}"))?; if !response.status().is_success() { return Err(response_error(response, "Gitea")); } response.json::().map_err(|err| format!("Gitea returned unreadable pull request details: {err}"))? } "azure-devops" => { let endpoint = azure_review_endpoint(base_url, &review.repository_name, &review.repository_id, review.number)?; let auth_user = if username.trim().is_empty() { "gitty" } else { username }; let response = client.get(endpoint.clone()).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send().map_err(|err| format!("Could not reach Azure DevOps: {err}"))?; if !response.status().is_success() { return Err(response_error(response, "Azure DevOps")); } let mut payload = response.json::().map_err(|err| format!("Azure DevOps returned unreadable pull request details: {err}"))?; let mut iterations_url = endpoint; iterations_url.set_query(None); iterations_url.path_segments_mut().map_err(|_| "Invalid Azure DevOps URL.".to_string())?.push("iterations"); iterations_url.query_pairs_mut().append_pair("api-version", "7.1"); if let Ok(iterations) = client.get(iterations_url.clone()).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send() { if iterations.status().is_success() { if let Ok(value) = iterations.json::() { if let Some(id) = value.get("value").and_then(serde_json::Value::as_array).and_then(|items| items.last()).and_then(|item| item.get("id")).and_then(serde_json::Value::as_u64) { iterations_url.set_query(None); iterations_url.path_segments_mut().map_err(|_| "Invalid Azure DevOps URL.".to_string())?.push(&id.to_string()).push("changes"); iterations_url.query_pairs_mut().append_pair("$top", "2000").append_pair("api-version", "7.1"); if let Ok(changes) = client.get(iterations_url).header(USER_AGENT, "Gitty").basic_auth(auth_user, Some(token)).send() { if changes.status().is_success() { if let Ok(value) = changes.json::() { payload["gitty_changed_files"] = serde_json::json!(value.get("changeEntries").and_then(serde_json::Value::as_array).map_or(0, Vec::len)); } } } } } } } payload } _ => return Err("Unsupported integration provider.".to_string()), }; match review.provider.as_str() { "github" => { review.additions = payload.get("additions").and_then(serde_json::Value::as_u64); review.deletions = payload.get("deletions").and_then(serde_json::Value::as_u64); review.changed_files = payload.get("changed_files").and_then(serde_json::Value::as_u64); review.collaborators = review_names(&payload, "requested_reviewers", "login"); review.source_branch = value_string(&payload, &["head", "ref"]); review.target_branch = value_string(&payload, &["base", "ref"]); review.merge_status = match payload.get("mergeable").and_then(serde_json::Value::as_bool) { Some(true) => "mergeable", Some(false) => "conflicts", None => "checking" }.to_string(); } "gitlab" | "gitlab-self-hosted" => { review.collaborators = review_names(&payload, "reviewers", "name"); review.changed_files = value_string(&payload, &["changes_count"]).parse().ok(); review.merge_status = match value_string(&payload, &["detailed_merge_status"]).as_str() { "mergeable" | "can_be_merged" => "mergeable", "conflict" | "conflicts" => "conflicts", "checking" | "unchecked" | "preparing" => "checking", _ => "blocked" }.to_string(); if let Some(diffs) = payload.get("gitty_diffs").and_then(serde_json::Value::as_array) { let mut plus=0; let mut minus=0; for line in diffs.iter().filter_map(|item| item.get("diff").and_then(serde_json::Value::as_str)).flat_map(str::lines) { if line.starts_with('+') && !line.starts_with("+++") { plus+=1; } else if line.starts_with('-') && !line.starts_with("---") { minus+=1; } } review.additions=Some(plus); review.deletions=Some(minus); review.changed_files=Some(diffs.len() as u64); } } "gitea" => { review.additions=payload.get("additions").and_then(serde_json::Value::as_u64); review.deletions=payload.get("deletions").and_then(serde_json::Value::as_u64); review.changed_files=payload.get("changed_files").and_then(serde_json::Value::as_u64); review.collaborators=review_names(&payload,"requested_reviewers","login"); let source=value_string(&payload,&["head","ref"]); if !source.is_empty(){review.source_branch=source;} let target=value_string(&payload,&["base","ref"]); if !target.is_empty(){review.target_branch=target;} review.merge_status=payload.get("mergeable").and_then(serde_json::Value::as_bool).map(|ok|if ok{"mergeable"}else{"conflicts"}).unwrap_or("checking").to_string(); } "azure-devops" => { review.collaborators=review_names(&payload,"reviewers","displayName"); review.changed_files=payload.get("gitty_changed_files").and_then(serde_json::Value::as_u64); review.merge_status=match value_string(&payload,&["mergeStatus"]).as_str(){"succeeded"=>"mergeable","conflicts"=>"conflicts","queued"|"notSet"=>"checking",_=>"blocked"}.to_string(); } _ => {} } review.comments = review_comments(client, base_url, username, token, &review).unwrap_or_default(); Ok(review) } #[tauri::command] pub async fn list_integration_review_requests( provider: String, base_url: String, username: String, token: String, state: String, ) -> Result, String> { tokio::time::timeout( REVIEW_REQUEST_TIMEOUT, tauri::async_runtime::spawn_blocking(move || { if token.trim().is_empty() { return Err("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_reviews(&client, &base_url, &token, &state), "gitlab" | "gitlab-self-hosted" => { gitlab_reviews(&client, &base_url, &token, &provider, &state) } "gitea" => gitea_reviews(&client, &base_url, &token, &state), "azure-devops" => azure_reviews( &client, &base_url, if username.trim().is_empty() { "gitty" } else { &username }, &token, &state, ), _ => Err("Unsupported integration provider.".to_string()), } }), ) .await .map_err(|_| "The integration API did not respond within 35 seconds.".to_string())? .map_err(|err| format!("Could not load review requests: {err}"))? } #[tauri::command] pub async fn run_integration_review_action( provider: String, base_url: String, username: String, token: String, repository_id: String, repository_name: String, number: u64, action: String, merge_method: Option, ) -> Result<(), String> { tokio::time::timeout( REVIEW_REQUEST_TIMEOUT, tauri::async_runtime::spawn_blocking(move || { if token.trim().is_empty() { return Err("No token is stored for this integration.".to_string()); } if !matches!(action.as_str(), "merge" | "approve" | "close" | "reopen") { return Err("Unsupported review action.".to_string()); } if action == "merge" { merge_payload(&provider, merge_method.as_deref())?; } let base_url = normalized_base_url(&base_url)?; let client = client()?; match provider.as_str() { "github" => github_review_action(&client, &base_url, &token, &repository_name, number, &action, merge_method.as_deref()), "gitlab" | "gitlab-self-hosted" => gitlab_review_action(&client, &base_url, &token, &repository_id, number, &action, merge_method.as_deref()), "gitea" => gitea_review_action(&client, &base_url, &token, &repository_name, number, &action, merge_method.as_deref()), "azure-devops" => azure_review_action(&client, &base_url, &username, &token, &repository_name, &repository_id, number, &action, merge_method.as_deref()), _ => Err("Unsupported integration provider.".to_string()), } }), ) .await .map_err(|_| "The integration API did not respond within 35 seconds.".to_string())? .map_err(|err| format!("Could not update review request: {err}"))? } #[tauri::command] pub async fn get_integration_review_details(provider: String, base_url: String, username: String, token: String, request: IntegrationReviewRequest) -> Result { tokio::time::timeout(REVIEW_REQUEST_TIMEOUT, tauri::async_runtime::spawn_blocking(move || { if token.trim().is_empty() { return Err("No token is stored for this integration.".to_string()); } let base_url = normalized_base_url(&base_url)?; let client = client()?; if provider != request.provider { return Err("Review provider does not match the selected integration.".to_string()); } load_review_details(&client, &base_url, &username, &token, request) })).await.map_err(|_| "The integration API did not respond within 35 seconds.".to_string())? .map_err(|err| format!("Could not load review details: {err}"))? } #[tauri::command] pub async fn add_integration_review_comment(provider:String,base_url:String,username:String,token:String,request:IntegrationReviewRequest,body:String)->Result<(),String>{ tokio::time::timeout(REVIEW_REQUEST_TIMEOUT,tauri::async_runtime::spawn_blocking(move||{ let body=body.trim(); if body.is_empty(){return Err("Comment cannot be empty.".to_string());} if body.chars().count()>100_000{return Err("Comment is too long.".to_string());} if provider!=request.provider{return Err("Review provider does not match the selected integration.".to_string());} let base_url=normalized_base_url(&base_url)?; let client=client()?; post_review_comment(&client,&base_url,&username,&token,&request,body) })).await.map_err(|_|"The integration API did not respond within 35 seconds.".to_string())?.map_err(|err|format!("Could not add comment: {err}"))? } #[tauri::command] pub fn open_in_browser(url: String) -> Result<(), String> { let normalized = normalized_base_url(&url)?; #[cfg(target_os = "windows")] let mut command = { let mut command = std::process::Command::new("rundll32"); command.args(["url.dll,FileProtocolHandler", &normalized]); command }; #[cfg(target_os = "macos")] let mut command = { let mut command = std::process::Command::new("open"); command.arg(&normalized); command }; #[cfg(not(any(target_os = "windows", target_os = "macos")))] let mut command = { let mut command = std::process::Command::new("xdg-open"); command.arg(&normalized); command }; command .spawn() .map(|_| ()) .map_err(|err| format!("Could not open the browser: {err}")) } #[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" ); } #[test] fn provider_review_payloads_map_to_common_shape() { let github: serde_json::Value = serde_json::from_str(r#"{ "number":42,"title":"Improve dashboard","body":"Details","state":"open", "repository_url":"https://api.github.com/repos/team/app","html_url":"https://github.com/team/app/pull/42", "user":{"login":"alex"},"pull_request":{"merged_at":null},"created_at":"2026-09-01","updated_at":"2026-09-02" }"#).unwrap(); let review = parse_github_review(&github).unwrap(); assert_eq!(review.repository_name, "team/app"); assert_eq!(review.state, "open"); let gitlab: serde_json::Value = serde_json::from_str(r#"{ "iid":7,"project_id":9,"title":"Add bisect","state":"opened","draft":true, "references":{"full":"team/app!7"},"author":{"name":"Sam"}, "source_branch":"feature","target_branch":"main","web_url":"https://gitlab.test/team/app/-/merge_requests/7" }"#).unwrap(); let review = parse_gitlab_review(&gitlab, "gitlab-self-hosted"); assert_eq!(review.repository_name, "team/app"); assert_eq!(review.state, "draft"); let gitea: serde_json::Value = serde_json::from_str(r#"{ "number":3,"title":"Fix menu","state":"closed","repository":{"id":2,"full_name":"team/ui"}, "user":{"login":"dev"},"pull_request":{"merged_at":"2026-09-03"},"html_url":"https://gitea.test/team/ui/pulls/3" }"#).unwrap(); let review = parse_gitea_review(&gitea).unwrap(); assert_eq!(review.state, "merged"); } #[test] fn azure_review_action_url_keeps_organization_and_encodes_project() { let endpoint = azure_review_endpoint( "https://dev.azure.com/example-org", "Machine Kits/api", "repo-id", 42, ) .unwrap(); assert_eq!( endpoint.as_str(), "https://dev.azure.com/example-org/Machine%20Kits/_apis/git/repositories/repo-id/pullrequests/42?api-version=7.1" ); } }