diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2de9793..d5f864f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,7 +20,7 @@ tauri-plugin-dialog = "=2.7.0" tauri-plugin-aptabase = "1.0" keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] } commit_ai = { path = "crates/commit_ai" } -tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "time"] } log = "0.4" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 3ac8c24..fe3ee72 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -8,7 +8,7 @@ use std::{ path::{Path, PathBuf}, process::{Command, Output, Stdio}, sync::{ - Arc, Mutex, + Arc, Mutex, OnceLock, atomic::{AtomicU64, Ordering}, }, thread, @@ -2861,6 +2861,14 @@ fn push_args_with_http_1_1(push_args: Vec) -> Vec { // ── Credential storage (OS keychain) ──────────────────────────────────────── const CRED_SERVICE: &str = "tauri_git_lite"; +static CREDENTIAL_STORE_LOCK: OnceLock> = OnceLock::new(); + +fn credential_store_lock() -> std::sync::MutexGuard<'static, ()> { + CREDENTIAL_STORE_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredCredential { @@ -2878,17 +2886,141 @@ fn cred_entry(key: &str) -> Result { keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}")) } -pub(crate) fn load_stored_credential(key: &str) -> Result, String> { +#[cfg(target_os = "linux")] +fn secret_tool_load(key: &str) -> Result, String> { + let output = match Command::new("secret-tool") + .args(["lookup", "service", CRED_SERVICE, "username", key]) + .stdin(Stdio::null()) + .output() + { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return keyring_load(key), + Err(error) => { + return Err(format!( + "Could not start the system keychain helper: {error}" + )); + } + }; + if output.status.success() { + return String::from_utf8(output.stdout) + .map(|value| Some(value.trim_end_matches(['\r', '\n']).to_string())) + .map_err(|error| format!("System keychain returned unreadable data: {error}")); + } + if output.status.code() == Some(1) && output.stderr.is_empty() { + return Ok(None); + } + let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if detail.is_empty() { + "System keychain lookup failed.".to_string() + } else { + format!("System keychain lookup failed: {detail}") + }) +} + +fn keyring_load(key: &str) -> Result, String> { let entry = cred_entry(key)?; match entry.get_password() { - Ok(json) => serde_json::from_str::(&json) - .map(Some) - .map_err(|err| format!("Stored credentials unreadable: {err}")), + Ok(json) => Ok(Some(json)), Err(keyring::Error::NoEntry) => Ok(None), Err(err) => Err(format!("Keychain access failed: {err}")), } } +#[cfg(target_os = "linux")] +fn secret_tool_save(key: &str, value: &str) -> Result<(), String> { + let mut child = match Command::new("secret-tool") + .args([ + "store", + "--label=Gitty credentials", + "service", + CRED_SERVICE, + "username", + key, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return cred_entry(key)? + .set_password(value) + .map_err(|err| format!("Saving to keychain failed: {err}")); + } + Err(error) => { + return Err(format!( + "Could not start the system keychain helper: {error}" + )); + } + }; + child + .stdin + .take() + .ok_or_else(|| "Could not open the system keychain helper input.".to_string())? + .write_all(value.as_bytes()) + .map_err(|error| format!("Could not pass credentials to the system keychain: {error}"))?; + let output = child + .wait_with_output() + .map_err(|error| format!("System keychain helper failed: {error}"))?; + if output.status.success() { + Ok(()) + } else { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if detail.is_empty() { + "Saving to the system keychain failed.".to_string() + } else { + format!("Saving to the system keychain failed: {detail}") + }) + } +} + +#[cfg(target_os = "linux")] +fn secret_tool_delete(key: &str) -> Result<(), String> { + let output = match Command::new("secret-tool") + .args(["clear", "service", CRED_SERVICE, "username", key]) + .stdin(Stdio::null()) + .output() + { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return match cred_entry(key)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(err) => Err(format!("Deleting from keychain failed: {err}")), + }; + } + Err(error) => { + return Err(format!( + "Could not start the system keychain helper: {error}" + )); + } + }; + if output.status.success() || output.status.code() == Some(1) { + Ok(()) + } else { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if detail.is_empty() { + "Deleting from the system keychain failed.".to_string() + } else { + format!("Deleting from the system keychain failed: {detail}") + }) + } +} + +pub(crate) fn load_stored_credential(key: &str) -> Result, String> { + let _guard = credential_store_lock(); + #[cfg(target_os = "linux")] + let stored = secret_tool_load(key)?; + #[cfg(not(target_os = "linux"))] + let stored = keyring_load(key)?; + stored + .map(|json| { + serde_json::from_str::(&json) + .map_err(|err| format!("Stored credentials unreadable: {err}")) + }) + .transpose() +} + /// Returns the remote URL used for auth key derivation (upstream remote of the /// current branch, falling back to `origin`, then the first configured remote). #[tauri::command(async)] @@ -3084,6 +3216,8 @@ pub fn cred_save( password: String, mode: Option, ) -> Result<(), String> { + let _guard = credential_store_lock(); + #[cfg(not(target_os = "linux"))] let entry = cred_entry(&key)?; let mode = match mode.as_deref() { Some("token") => Some("token".to_string()), @@ -3096,6 +3230,9 @@ pub fn cred_save( }; let json = serde_json::to_string(&cred) .map_err(|err| format!("Could not serialize credentials: {err}"))?; + #[cfg(target_os = "linux")] + return secret_tool_save(&key, &json); + #[cfg(not(target_os = "linux"))] entry .set_password(&json) .map_err(|err| format!("Saving to keychain failed: {err}")) @@ -3103,7 +3240,12 @@ pub fn cred_save( #[tauri::command(async)] pub fn cred_delete(key: String) -> Result<(), String> { + let _guard = credential_store_lock(); + #[cfg(target_os = "linux")] + return secret_tool_delete(&key); + #[cfg(not(target_os = "linux"))] let entry = cred_entry(&key)?; + #[cfg(not(target_os = "linux"))] match entry.delete_credential() { Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Err(err) => Err(format!("Deleting from keychain failed: {err}")), diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs index 9de5486..d189e77 100644 --- a/src-tauri/src/integrations.rs +++ b/src-tauri/src/integrations.rs @@ -2,9 +2,12 @@ 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; 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)] #[serde(rename_all = "camelCase")] @@ -20,6 +23,46 @@ pub struct IntegrationRepository { 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, @@ -118,7 +161,8 @@ fn integration_key(provider: &str, account_id: Option<&str>) -> Result Result { Client::builder() - .timeout(Duration::from_secs(25)) + .connect_timeout(Duration::from_secs(7)) + .timeout(Duration::from_secs(15)) .build() .map_err(|err| format!("Could not initialize the integration client: {err}")) } @@ -350,6 +394,852 @@ fn azure_repositories( .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, +) -> 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(&serde_json::json!({})), + "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, +) -> 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")), + "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, +) -> 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(&serde_json::json!({ "Do": "merge" })), + "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, +) -> 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()); } + serde_json::json!({ "status": "completed", "lastMergeSourceCommit": { "commitId": commit_id } }) + } + _ => 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, +) -> 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()); } + 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), + "gitlab" | "gitlab-self-hosted" => gitlab_review_action(&client, &base_url, &token, &repository_id, number, &action), + "gitea" => gitea_review_action(&client, &base_url, &token, &repository_name, number, &action), + "azure-devops" => azure_review_action(&client, &base_url, &username, &token, &repository_name, &repository_id, number, &action), + _ => 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, @@ -454,4 +1344,47 @@ mod tests { "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" + ); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ca96627..40b4e87 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -33,7 +33,10 @@ use git::{ stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files, untrack_paths, update_remote, }; -use integrations::list_integration_repositories; +use integrations::{ + add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser, + run_integration_review_action, +}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use tauri::{Emitter, Manager}; @@ -435,6 +438,11 @@ async fn main() { cred_save, cred_delete, list_integration_repositories, + list_integration_review_requests, + get_integration_review_details, + add_integration_review_comment, + run_integration_review_action, + open_in_browser, set_sync_badge, close_splashscreen, set_telemetry_enabled, diff --git a/src/App.svelte b/src/App.svelte index 77815a5..f0fca95 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -6,11 +6,12 @@ import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte"; - import { beginFrontendShutdown } from "./lib/telemetry"; + import { beginFrontendShutdown, resumeFrontend } from "./lib/telemetry"; import TitleBar from "./lib/TitleBar.svelte"; import RepoToolbar from "./lib/RepoToolbar.svelte"; import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte"; + import ReviewCenter from "./lib/components/ReviewCenter.svelte"; import RepoTabs from "./lib/RepoTabs.svelte"; import AiReviewDialog from "./lib/components/AiReviewDialog.svelte"; import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte"; @@ -200,6 +201,7 @@ resolveDetectedExternalToolPrograms, } from "./lib/externalTools"; import { + configuredIntegrationSources, defaultGitIntegrationSettings, integrationCredentialKey, normaliseGitIntegrationSettings, @@ -215,7 +217,7 @@ import { setTelemetryEnabled, tracedInvoke } from "./lib/telemetry"; type UpdateToastState = "available" | "downloading" | "installed" | "error"; - type AppView = "management" | "repository"; + type AppView = "management" | "review-center" | "repository"; type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete"; type CredentialMode = "credentials" | "token"; type PendingDiscard = @@ -313,6 +315,8 @@ let unlistenStartupRepository: (() => void) | undefined; let activeRepoPath = ""; let activeView: AppView = "management"; + let reviewCenterInitialQuery = ""; + let reviewCenterInitialSourceId = ""; let repoTabs: RepoTab[] = []; let repoTabContextMenu: RepoTabContextMenu | null = null; let recentRepoPaths: string[] = []; @@ -468,6 +472,7 @@ let credDialogUsername = ""; let credDialogMode: CredentialMode = "credentials"; const rejectedCredentialKeys = new Set(); + const credentialCache = new Map(); let lastStatusFingerprint = ""; const AUTO_REFRESH_INTERVAL = 4000; let autoRefreshTimer: ReturnType | undefined; @@ -581,6 +586,8 @@ // ── Lifecycle ────────────────────────────────────────────────────────────── + resumeFrontend(); + onMount(() => { themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)"); themeMediaQuery.addEventListener("change", handleSystemThemeChange); @@ -610,7 +617,6 @@ }); onDestroy(() => { - handleAppShutdown(); themeMediaQuery?.removeEventListener("change", handleSystemThemeChange); window.removeEventListener("beforeunload", handleAppShutdown); window.removeEventListener("pagehide", handleAppShutdown); @@ -1249,12 +1255,14 @@ const providerConfig = integrationsToSave.providers[update.provider]; if (update.removeToken) { await credDelete(key); + credentialCache.set(key, null); if (azureOrganization) azureOrganization.tokenStored = false; else providerConfig.tokenStored = false; } else if (update.token) { const fallbackUsername = update.provider === "github" ? "x-access-token" : "oauth2"; const username = (azureOrganization?.username ?? providerConfig.username).trim() || fallbackUsername; await credSave(key, username, update.token, "token"); + credentialCache.set(key, { username, password: update.token, mode: "token" }); if (azureOrganization) azureOrganization.tokenStored = true; else providerConfig.tokenStored = true; } @@ -2581,6 +2589,15 @@ void backgroundRepoStatusTick(false); } + function openReviewCenter(repository = "", sourceId = "") { + if (isBusy) return; + closeRepoTabContextMenu(); + reviewCenterInitialQuery = repository; + reviewCenterInitialSourceId = sourceId; + activeView = "review-center"; + trackEvent("review_center_opened", { integrations: Object.values(gitIntegrationSettings.providers).filter((provider) => provider.enabled && provider.tokenStored).length }); + } + async function selectRepoTab(path: string) { if (isBusy) return; if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return; @@ -3653,8 +3670,34 @@ async function loadStoredCredential(key: string | null): Promise { if (!key) return null; + if (credentialCache.has(key)) return credentialCache.get(key) ?? null; + const repoHost = key.startsWith("integration:") ? "" : key.split("/", 1)[0].toLowerCase(); + const integrationAliases = repoHost + ? configuredIntegrationSources(gitIntegrationSettings) + .filter((source) => { + try { return new URL(source.baseUrl).host.toLowerCase() === repoHost; } + catch { return false; } + }) + .map((source) => integrationCredentialKey(source.provider, source.accountId)) + : []; + const candidates = [...new Set([...integrationAliases, key])]; try { - return await credLoad(key); + for (const candidate of candidates) { + const cached = credentialCache.get(candidate); + if (cached) { + credentialCache.set(key, cached); + return cached; + } + if (credentialCache.has(candidate)) continue; + const credential = await credLoad(candidate); + credentialCache.set(candidate, credential); + if (credential) { + credentialCache.set(key, credential); + return credential; + } + } + credentialCache.set(key, null); + return null; } catch { return null; } @@ -3931,6 +3974,7 @@ if (save && key) { try { await credSave(key, username, password, mode); + credentialCache.set(key, { username, password, mode }); } catch (error) { credDialogError = errorToMessage(error); return; @@ -5138,6 +5182,7 @@ {isBusy} language={appLanguage} onOpenManagement={openRepoManagement} + onOpenReviewCenter={() => openReviewCenter()} isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)} onSelect={selectRepoTab} onClose={closeRepoTab} @@ -5302,10 +5347,21 @@ {#if activeView === "management"} openReviewCenter(repository, sourceId)} + /> + {:else if activeView === "review-center"} + { appSettingsOpen = true; }} /> {:else} diff --git a/src/lib/RepoTabs.svelte b/src/lib/RepoTabs.svelte index c7041f6..a070b65 100644 --- a/src/lib/RepoTabs.svelte +++ b/src/lib/RepoTabs.svelte @@ -1,5 +1,5 @@ + +
+
+

Review Center

{activeSource?.label ?? ""}
+
+ + {#if sources.length === 0} +

{de ? "Keine Integration eingerichtet" : "No integration configured"}

{de ? "Verbinde GitHub, GitLab, Gitea oder Azure DevOps, um Pull- und Merge-Requests hier zu sehen." : "Connect GitHub, GitLab, Gitea, or Azure DevOps to see pull and merge requests here."}

+ {:else} + + +
+
+ + + +
+ + +
+ + {#if actionNotice}
{actionNotice}
{/if} + + {#if errors.length > 0} +
+ {backendRestartRequired ? (de ? "Gitty muss neu gestartet werden, damit das Review-Backend geladen wird." : "Restart Gitty to load the Review Center backend.") : (de ? "Die ausgewählte Integration konnte nicht geladen werden." : "The selected integration could not be loaded.")} +
    {#each errors as error}
  • {error.source}: {error.message}
  • {/each}
+
+ {/if} + +
+
+
+ Status{de ? "Request" : "Request"}{de ? "Autor" : "Author"}CollaboratorsRepo/Branch{de ? "Aktion" : "Action"} +
+ {#if loading && requests.length === 0} +
{de ? "Requests werden geladen …" : "Loading requests …"}
+ {:else if groupedRequests.length === 0} +
{de ? "Keine passenden Requests" : "No matching requests"}{de ? "Passe Status oder Suche an." : "Adjust the status or search."}
+ {:else} +
+ {#each groupedRequests as group (group.name)} +
+ + {#if !collapsedRepositories.has(group.name)} + {#each group.items as request (request.id)} +
selectRequest(request)} onkeydown={(event) => { if (event.key === "Enter") selectRequest(request); }}> + + {#if request.state === "merged"}{:else if request.state === "closed"}{:else if request.state === "draft"}{:else}{/if} + {stateLabel(request.state)}{formatRelativeDate(request.updatedAt || request.createdAt)} + + #{request.number}{request.title}+{request.additions ?? "–"}/−{request.deletions ?? "–"}{#if request.changedFiles !== null}{request.changedFiles} {de ? "Dateien" : "files"}{:else if detailLoadingId === request.id}{de ? "Lädt …" : "Loading …"}{/if} + {initials(request.author)}{request.author || (de ? "Unbekannt" : "Unknown")} + {#if request.collaborators.length}{#each request.collaborators.slice(0, 3) as collaborator}{initials(collaborator)}{/each}{:else}{/if} + {request.repositoryName}{#if request.sourceBranch && request.targetBranch}{request.sourceBranch}{request.targetBranch}{:else}{de ? "Branches werden geladen …" : "Loading branches …"}{/if} + + + + + {#if actionMenuId === request.id} + + + {#if request.state === "open" || request.state === "draft"} + + + + {:else if request.state === "closed"} + + {/if} + + {/if} + + + +
+ {/each} + {/if} +
+ {/each} +
+ {/if} +
+ + {#if detailOpen && selected} + + {/if} +
+ {/if} +
+ + diff --git a/src/lib/git.ts b/src/lib/git.ts index 39d70aa..572a327 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -17,6 +17,8 @@ import type { GitIgnoreKind, GitIntegrationProvider, GitIntegrationRepository, + IntegrationReviewRequest, + IntegrationReviewAction, GitLfsStatus, GitRepositoryFile, GitRemote, @@ -59,6 +61,26 @@ export function listIntegrationRepositories(provider: GitIntegrationProvider, ba return invoke("list_integration_repositories", { provider, baseUrl, accountId: accountId ?? null }); } +export function listIntegrationReviewRequests(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, state: "open" | "merged" | "closed"): Promise { + return invoke("list_integration_review_requests", { provider, baseUrl, username, token, state }); +} + +export function runIntegrationReviewAction(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, action: IntegrationReviewAction): Promise { + return invoke("run_integration_review_action", { provider, baseUrl, username, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName, number: request.number, action }); +} + +export function getIntegrationReviewDetails(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest): Promise { + return invoke("get_integration_review_details", { provider, baseUrl, username, token, request }); +} + +export function addIntegrationReviewComment(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, body: string): Promise { + return invoke("add_integration_review_comment", { provider, baseUrl, username, token, request, body }); +} + +export function openInBrowser(url: string): Promise { + return invoke("open_in_browser", { url }); +} + export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise { return invoke("launch_external_tool", { path, file: file ?? null, command }); } diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 08222c5..6ad9e35 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -11,6 +11,10 @@ export function beginFrontendShutdown() { frontendShuttingDown = true; } +export function resumeFrontend() { + frontendShuttingDown = false; +} + // Telemetry must never contain repository contents or identifying local data. function sanitize(message: string): string { return message diff --git a/src/lib/types.ts b/src/lib/types.ts index 5e178c7..a3200df 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -65,6 +65,39 @@ export interface GitIntegrationRepository { private: boolean; } +export type IntegrationReviewState = "open" | "draft" | "merged" | "closed"; +export type IntegrationReviewAction = "merge" | "approve" | "close" | "reopen"; + +export interface IntegrationReviewComment { + id: string; + author: string; + body: string; + createdAt: string; +} + +export interface IntegrationReviewRequest { + id: string; + number: number; + provider: GitIntegrationProvider; + repositoryId: string; + repositoryName: string; + title: string; + description: string; + author: string; + state: IntegrationReviewState; + sourceBranch: string; + targetBranch: string; + webUrl: string; + createdAt: string; + updatedAt: string; + collaborators: string[]; + additions: number | null; + deletions: number | null; + changedFiles: number | null; + mergeStatus: "" | "mergeable" | "conflicts" | "checking" | "blocked"; + comments: IntegrationReviewComment[]; +} + export interface CustomThemeColors { background: string; surface: string;