From a55a3ca300da3e7924cbdd66aadbe2e74797c091 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Sun, 6 Sep 2026 22:53:44 +0200 Subject: [PATCH 1/2] feat(integrations): add review fetching and Linux keyring support Add a cross-provider Review Center and improve credential handling. Backend integrations fetch and normalize PRs from GitHub, GitLab, Gitea, and Azure DevOps with improved timeouts and parsing. Credentials now use a global lock and support secret-tool on Linux to avoid races. - Normalize review data across providers (GitHub/GitLab/Gitea/Azure) - Serialize credential access with OnceLock and use secret-tool on Linux - Add frontend ReviewCenter component and related UI updates --- src-tauri/Cargo.toml | 2 +- src-tauri/src/git.rs | 152 ++- src-tauri/src/integrations.rs | 935 +++++++++++++++++- src-tauri/src/main.rs | 10 +- src/App.svelte | 56 +- src/lib/RepoTabs.svelte | 11 +- src/lib/components/RepositoryDashboard.svelte | 2 +- src/lib/components/ReviewCenter.svelte | 454 +++++++++ src/lib/git.ts | 22 + src/lib/telemetry.ts | 4 + src/lib/types.ts | 33 + 11 files changed, 1666 insertions(+), 15 deletions(-) create mode 100644 src/lib/components/ReviewCenter.svelte 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..cce3116 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 = @@ -468,6 +470,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 +584,8 @@ // ── Lifecycle ────────────────────────────────────────────────────────────── + resumeFrontend(); + onMount(() => { themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)"); themeMediaQuery.addEventListener("change", handleSystemThemeChange); @@ -610,7 +615,6 @@ }); onDestroy(() => { - handleAppShutdown(); themeMediaQuery?.removeEventListener("change", handleSystemThemeChange); window.removeEventListener("beforeunload", handleAppShutdown); window.removeEventListener("pagehide", handleAppShutdown); @@ -1249,12 +1253,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 +2587,13 @@ void backgroundRepoStatusTick(false); } + function openReviewCenter() { + if (isBusy) return; + closeRepoTabContextMenu(); + 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 +3666,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 +3970,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 +5178,7 @@ {isBusy} language={appLanguage} onOpenManagement={openRepoManagement} + onOpenReviewCenter={openReviewCenter} isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)} onSelect={selectRepoTab} onClose={closeRepoTab} @@ -5307,6 +5348,13 @@ onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository} onRemoveRecent={removeRepoFromRecent} /> + {:else if activeView === "review-center"} + { appSettingsOpen = true; }} + /> {:else}
- import { Folder, GitBranch, Plus, X } from "@lucide/svelte"; + import { Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte"; interface RepositoryTabItem { path: string; @@ -7,11 +7,12 @@ branch: string | null; } - export let activeView: "management" | "repository" = "management"; + export let activeView: "management" | "review-center" | "repository" = "management"; export let repoTabs: RepositoryTabItem[] = []; export let isBusy: boolean = false; export let language: "en" | "de" = "en"; export let onOpenManagement: () => void = () => {}; + export let onOpenReviewCenter: () => void = () => {}; export let isActive: (path: string) => boolean = () => false; export let onSelect: (path: string) => void | Promise = () => {}; export let onClose: (path: string, event: MouseEvent) => void | Promise = () => {}; @@ -33,6 +34,12 @@
+ {#each repoTabs as repo (repo.path)}
.repo-dashboard{display:flex;flex:1;min-height:0;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}button,input{font:inherit}button{color:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.5} .dashboard-header{display:flex;min-height:58px;align-items:center;justify-content:space-between;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-header h1{margin:0;font-size:18px;line-height:1.2;font-weight:700}.header-actions,.workspace-tools{display:flex;align-items:center;gap:8px}.header-actions button,.workspace-tools button{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}button.primary{border-color:var(--color-primary);background:var(--color-primary);color:#fff} - .dashboard-toolbar{display:flex;min-height:48px;align-items:center;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)} + .dashboard-toolbar{display:flex;min-height:48px;align-items:center;gap:8px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)} .dashboard-summary{padding:8px 20px;color:var(--color-ink-muted)}.dashboard-summary span{padding:0 6px;color:var(--color-ink-faint)}.dashboard-content{flex:1;min-height:0;overflow:auto;padding:0 20px 24px}.dashboard-section+.dashboard-section{margin-top:12px}.section-header{min-height:31px}.section-toggle{display:flex;width:100%;min-height:31px;align-items:center;justify-content:flex-start;gap:7px;padding:0;border:0;background:transparent;color:var(--color-ink);text-align:left}.section-toggle:hover:not(:disabled){border:0;background:transparent;color:var(--color-ink)}.section-toggle :global(svg){flex:0 0 auto;color:var(--color-ink-muted)}.section-toggle strong{font-size:13px;line-height:1.2;font-weight:750}.section-toggle span{color:var(--color-ink-faint);font-weight:400}.section-toggle i{height:1px;flex:1;background:var(--color-border-subtle)}.category-empty{margin:0;padding:9px 12px;border:1px solid var(--color-border-subtle);color:var(--color-ink-faint);background:var(--color-surface)} .repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 70px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-path{width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed,.behind{color:#eeb94e}.clean{color:#68c878}.ahead{color:var(--color-accent)}.muted{color:var(--color-ink-muted)} .card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)} diff --git a/src/lib/components/ReviewCenter.svelte b/src/lib/components/ReviewCenter.svelte new file mode 100644 index 0000000..2927815 --- /dev/null +++ b/src/lib/components/ReviewCenter.svelte @@ -0,0 +1,454 @@ + + +
+
+

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; From c6553218a28e882d74ac47566b182112a0bc8ed7 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 7 Sep 2026 16:19:26 +0200 Subject: [PATCH 2/2] feat(dashboard): show PR badges and deep-link to Review Center Add pull request badges to the repository dashboard and enable deep- linking to the Review Center with a repository and source pre-filter. Badges are populated by resolving remotes, matching them to configured integration sources, and querying providers for open PRs while handling loading, error, and unavailable states. The dashboard badge opens the Review Center pre-filled and the app stores initial query/source values to support the deep-link. - Resolve and normalize remotes to match integration sources reliably. - Query provider APIs using stored credentials with sensible timeouts. - Expose a badge action that navigates to the Review Center pre-filtered. --- src/App.svelte | 12 +- src/lib/components/RepositoryDashboard.svelte | 166 +++++++++++++++++- src/lib/components/ReviewCenter.svelte | 7 +- 3 files changed, 178 insertions(+), 7 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index cce3116..f0fca95 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -315,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[] = []; @@ -2587,9 +2589,11 @@ void backgroundRepoStatusTick(false); } - function openReviewCenter() { + 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 }); } @@ -5178,7 +5182,7 @@ {isBusy} language={appLanguage} onOpenManagement={openRepoManagement} - onOpenReviewCenter={openReviewCenter} + onOpenReviewCenter={() => openReviewCenter()} isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)} onSelect={selectRepoTab} onClose={closeRepoTab} @@ -5343,15 +5347,19 @@ {#if activeView === "management"} openReviewCenter(repository, sourceId)} /> {:else if activeView === "review-center"} { appSettingsOpen = true; }} /> diff --git a/src/lib/components/RepositoryDashboard.svelte b/src/lib/components/RepositoryDashboard.svelte index e1ac24e..9095a73 100644 --- a/src/lib/components/RepositoryDashboard.svelte +++ b/src/lib/components/RepositoryDashboard.svelte @@ -1,5 +1,8 @@