diff --git a/.gitignore b/.gitignore index 44a4ff1e..a5226469 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,5 @@ research/ .release-notes-v1.5.0.md website/.vitepress/.temp .claude/worktrees/ + +.gitwandrc diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea8c93d..7b4bc9da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Cross-fork pull requests** — when the repo's `origin` is a fork, the PR create view shows a target-repository selector (upstream parent vs your fork), defaulting to upstream. Works on both the REST (token) path — head is qualified as `fork-owner:branch` — and the `gh` path (`--repo`). New `gh_fork_info` command detects the relationship. +- **Fork PRs in the list** — on the REST (token) path, the PR list for a fork now also includes the PRs you opened on the upstream repo (head repo == your fork), merged and sorted with origin's own PRs. PR detail/diff/checks/merge transparently resolve to the upstream repo for those entries. +- **Sign in with GitHub (no `gh` CLI required)** — Settings → Accounts now offers a "Sign in with GitHub" button using the OAuth device flow. The resulting token is stored in the OS keychain; once present, the GitHub PR workflow (list, count, detail, diff, checks, files, create, merge, checkout, mark-ready) routes through the GitHub REST API instead of shelling out to `gh`. The `gh` CLI still works as before when no Settings token is configured — the ambient `GH_TOKEN`/`GITHUB_TOKEN` env path is unchanged. +- **Azure DevOps support (new forge)** — Settings → Accounts now offers "Sign in with Azure" using the Entra ID OAuth device flow (same UX as GitHub), with automatic access-token refresh via the stored refresh token. The tokens are stored in the OS keychain (`gitwand:azure/oauth` + `oauth-refresh`). Azure DevOps remotes (`dev.azure.com`, `*.visualstudio.com`) are auto-detected and routed to a new `AzureProvider` backed by the Azure DevOps REST API (api-version 7.1): PR list/count/detail/diff/files/create/merge/checkout, draft→ready, comments (threads), CI checks (branch-policy evaluations) and reviewer-vote reviews — so the merge-readiness chip reflects real build + approval state. Diff, file lists and change stats are produced from local git (Azure has no unified-patch endpoint). Comment edit/delete, line-anchored comment creation, reviewer pickers and submitting reviews are not wired yet and degrade gracefully. + +### Notes + +- Ships with GitWand's registered GitHub OAuth App `client_id` (`Ov23licwiCpPiRPRodWN`, public — device flow enabled) baked into `github_api.rs`. Override at runtime or build time via `GITWAND_GH_CLIENT_ID` if needed. +- Azure DevOps sign-in ships with a **temporary** default Entra ID client id — the well-known Azure CLI public client (`04b07795-8ddb-461a-bbee-02f9e1bf7b46`, device flow enabled) — so it works without registering an app. Stop-gap only: the consent screen reads "Microsoft Azure CLI" and Microsoft may restrict reuse. Override with a dedicated GitWand Entra app via `GITWAND_AZURE_CLIENT_ID` (runtime or build time) before shipping. + ## [2.17.0] - 2026-06-04 v2.17 rounds out the agent-CLI lineup with **opencode** as a first-class AI provider, and gives every CLI agent its own model picker — a second select under the provider dropdown, scoped per provider so switching back restores the previous choice. diff --git a/apps/desktop/dev-server.mjs b/apps/desktop/dev-server.mjs index 913efd36..9ecfb881 100644 --- a/apps/desktop/dev-server.mjs +++ b/apps/desktop/dev-server.mjs @@ -152,6 +152,12 @@ function gitSpawn(args, cwd) { }); } +// ── Mock state: GitHub OAuth device flow (dev:web only) ────────────────────── +// The real flow lives in Rust (`github_api.rs`). In the browser mock we fake it +// so the Settings > Accounts UI can be exercised without Tauri. It does NOT log +// you in — it just returns a static code then "succeeds" after a couple polls. +let _mockGithubPolls = 0; + /** * Get a GitHub OAuth token — tries in order: * 1. GH_TOKEN / GITHUB_TOKEN env vars @@ -2179,6 +2185,39 @@ async function handleRequest(req, res) { // ─── GitHub REST API endpoints (no gh binary needed) ────── + // POST /api/github-device-start — MOCK device flow (dev:web only). + // Returns a static user code + verification URL. The frontend opens the URL + // and starts polling; see /api/github-device-poll below. + if (url.pathname === "/api/github-device-start" && req.method === "POST") { + _mockGithubPolls = 0; + // NOTE: verification_uri intentionally does NOT point at the real + // github.com/login/device — this is a fake flow and a real code is never + // issued. Sending the user to GitHub with a bogus code only confuses. + return jsonResponse(req, res, { + device_code: "mock-device-code", + user_code: "DEV-MOCK", + verification_uri: "about:blank#gitwand-dev-mock", + verification_uri_complete: "", + expires_in: 900, + interval: 1, + }); + } + + // POST /api/github-device-poll — MOCK: "pending" twice, then "success". + // No real token is stored; this only unblocks UI iteration in dev:web. + if (url.pathname === "/api/github-device-poll" && req.method === "POST") { + _mockGithubPolls += 1; + if (_mockGithubPolls < 3) { + return jsonResponse(req, res, { status: "pending", login: "", error: "" }); + } + return jsonResponse(req, res, { status: "success", login: "dev-user", error: "" }); + } + + // POST /api/github-token-present — MOCK: never "logged in" in dev:web. + if (url.pathname === "/api/github-token-present" && req.method === "POST") { + return jsonResponse(req, res, false); + } + // GET /api/gh-current-user — returns the authenticated GitHub login if (url.pathname === "/api/gh-current-user" && req.method === "GET") { try { @@ -2572,6 +2611,41 @@ async function handleRequest(req, res) { } } + // GET /api/gh-pr-issue-comments?cwd=&number= + // Issue-level (conversation) comments — not anchored to a diff line. + if (url.pathname === "/api/gh-pr-issue-comments" && req.method === "GET") { + const cwd = url.searchParams.get("cwd"); + const number = url.searchParams.get("number"); + if (!cwd || !number) return jsonResponse(req, res, { error: "Missing cwd or number" }, 400); + try { + const token = getGithubToken(); + if (!token) return jsonResponse(req, res, { error: "No GitHub token" }, 401); + const nwo = getRepoNwo(resolve(cwd)); + if (!nwo) return jsonResponse(req, res, { error: "Could not determine GitHub repo" }, 400); + const resp = await githubFetch(`/repos/${nwo}/issues/${number}/comments?per_page=100`, token); + if (!resp.ok) return jsonResponse(req, res, { error: `GitHub API ${resp.status}` }, 500); + const raw = await resp.json(); + return jsonResponse(req, res, raw.map((c) => ({ + id: c.id, + body: c.body, + author: c.user?.login ?? "", + created_at: c.created_at, + updated_at: c.updated_at, + path: "", + line: null, + original_line: null, + side: "RIGHT", + start_line: null, + start_side: null, + in_reply_to_id: null, + diff_hunk: "", + url: c.html_url ?? "", + }))); + } catch (err) { + return jsonResponse(req, res, { error: err.stderr?.toString() || err.message }, 500); + } + } + // POST /api/gh-pr-comment — create or reply // Body: { cwd, number, body, path, line, side, start_line?, start_side?, in_reply_to_id? } if (url.pathname === "/api/gh-pr-comment" && req.method === "POST") { diff --git a/apps/desktop/src-tauri/src/commands/ai.rs b/apps/desktop/src-tauri/src/commands/ai.rs index 7e215f46..6495f48c 100644 --- a/apps/desktop/src-tauri/src/commands/ai.rs +++ b/apps/desktop/src-tauri/src/commands/ai.rs @@ -124,7 +124,7 @@ fn resolve_codex_binary() -> Option { /// stderr surfaces a clear "please log in" message that the calling /// command (`claude_cli_prompt`) propagates as an error. #[tauri::command] -pub(crate) fn detect_claude_cli() -> Result { +pub(crate) async fn detect_claude_cli() -> Result { let binary = match resolve_claude_binary() { Some(b) => b, None => { @@ -165,7 +165,7 @@ pub(crate) fn detect_claude_cli() -> Result { /// The CLI already handles auth via the user's subscription — we just pipe /// text in and get text back. #[tauri::command] -pub(crate) fn claude_cli_prompt( +pub(crate) async fn claude_cli_prompt( prompt: String, system_prompt: Option, cwd: Option, @@ -244,7 +244,7 @@ pub(crate) fn claude_cli_prompt( /// for a prompt they never asked for. Auth verifies implicitly on the /// first real prompt via `codex_cli_prompt`. #[tauri::command] -pub(crate) fn detect_codex_cli() -> Result { +pub(crate) async fn detect_codex_cli() -> Result { let binary = match resolve_codex_binary() { Some(b) => b, None => { @@ -277,7 +277,7 @@ pub(crate) fn detect_codex_cli() -> Result { } #[tauri::command] -pub(crate) fn codex_cli_prompt( +pub(crate) async fn codex_cli_prompt( prompt: String, system_prompt: Option, cwd: Option, @@ -381,7 +381,7 @@ fn resolve_opencode_binary() -> Option { /// Claude / Codex detectors: no prompt is sent to verify auth — that is /// confirmed implicitly on the first real `opencode_cli_prompt`. #[tauri::command] -pub(crate) fn detect_opencode_cli() -> Result { +pub(crate) async fn detect_opencode_cli() -> Result { let binary = match resolve_opencode_binary() { Some(b) => b, None => { @@ -414,7 +414,7 @@ pub(crate) fn detect_opencode_cli() -> Result { } #[tauri::command] -pub(crate) fn opencode_cli_prompt( +pub(crate) async fn opencode_cli_prompt( prompt: String, system_prompt: Option, cwd: Option, @@ -471,7 +471,7 @@ pub(crate) fn opencode_cli_prompt( /// when the binary is missing or the command fails, so the UI can fall back /// to free-text entry gracefully. #[tauri::command] -pub(crate) fn opencode_list_models() -> Result, String> { +pub(crate) async fn opencode_list_models() -> Result, String> { let binary = match resolve_opencode_binary() { Some(b) => b, None => return Ok(Vec::new()), @@ -501,7 +501,7 @@ pub(crate) fn opencode_list_models() -> Result, String> { /// embed a PTY because this is a one-shot setup flow: the user validates /// in their browser and comes back to GitWand. #[tauri::command] -pub(crate) fn claude_cli_login() -> Result<(), String> { +pub(crate) async fn claude_cli_login() -> Result<(), String> { let binary = resolve_claude_binary() .ok_or_else(|| "Binaire `claude` introuvable. Installez-le d'abord.".to_string())?; diff --git a/apps/desktop/src-tauri/src/commands/azure.rs b/apps/desktop/src-tauri/src/commands/azure.rs new file mode 100644 index 00000000..0ba9ce25 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/azure.rs @@ -0,0 +1,1516 @@ +//! Azure DevOps REST / Entra ID OAuth Tauri commands. +//! +//! ## Why this exists +//! +//! Mirrors `github_api.rs` for Azure DevOps Services (dev.azure.com / +//! *.visualstudio.com). GitWand had no Azure forge before; this module adds a +//! self-contained auth + PR workflow path so users can sign in and drive PRs +//! without any CLI. +//! +//! 1. **Entra ID device flow** (`azure_device_start` / `azure_device_poll`): +//! "Sign in with Azure" from Settings → Accounts. Microsoft's identity +//! platform exposes the OAuth 2.0 device authorization grant, which maps +//! 1-for-1 onto the existing GitHub device-flow UI. The resulting access +//! token is stored in the OS keychain under `service = "gitwand:azure"`, +//! `account = "oauth"`. +//! 2. **REST API calls** via `curl` (Bearer token) against the Azure DevOps +//! `_apis/git` endpoints (api-version 7.1). +//! +//! ## Auth resource / scope +//! +//! Azure DevOps is a first-party Entra resource (app id +//! `499b84ac-1321-427f-aa17-267ca6975798`). We request `.default` on it plus +//! `offline_access` so a refresh token is available for follow-up work. +//! +//! ## Security note +//! +//! The token is injected via the `Authorization` header in `curl` process +//! arguments — same exposure profile as `github_api.rs` / `bitbucket.rs`. The +//! token is never logged or returned to the frontend. + +use crate::git::{git_cmd, hidden_cmd}; +use crate::types::*; +use rayon::prelude::*; +use std::collections::HashMap; + +// ─── Constants ────────────────────────────────────────────────────────────── + +/// Keychain service for the Settings-managed Azure token. +pub(crate) const AZ_SERVICE: &str = "gitwand:azure"; +/// Keychain account key — fixed, resolved without knowing the login up front. +pub(crate) const AZ_ACCOUNT: &str = "oauth"; +/// Keychain account key for the Entra refresh token (used to renew the access +/// token, which expires ~1h after sign-in). +const AZ_ACCOUNT_REFRESH: &str = "oauth-refresh"; + +/// Azure DevOps first-party resource app id — the audience our access token +/// targets. Stable, public, documented by Microsoft. +const AZURE_DEVOPS_RESOURCE: &str = "499b84ac-1321-427f-aa17-267ca6975798"; + +/// Azure DevOps REST api-version pinned across this module. +const API_VERSION: &str = "7.1"; + +/// Entra ID OAuth endpoints. `organizations` admits any work/school tenant +/// (the common case for Azure DevOps) while excluding personal MSAs, which +/// cannot hold Azure DevOps identities anyway. +const DEVICECODE_URL: &str = + "https://login.microsoftonline.com/organizations/oauth2/v2.0/devicecode"; +const TOKEN_URL: &str = "https://login.microsoftonline.com/organizations/oauth2/v2.0/token"; + +/// Entra ID public-client application id (device-flow enabled). +/// +/// Resolution order mirrors `github_api.rs::client_id`: +/// 1. `GITWAND_AZURE_CLIENT_ID` at runtime (env of the running app). +/// 2. `GITWAND_AZURE_CLIENT_ID` baked in at build time. +/// 3. Fallback default (see below). +/// +/// **TEMPORARY default** — the well-known Azure CLI public client id +/// (`04b07795-8ddb-461a-bbee-02f9e1bf7b46`). It is a Microsoft first-party +/// public client with device flow enabled, so it works out of the box without +/// registering our own app. This is a stop-gap: it is *not* GitWand's app, the +/// consent screen reads "Microsoft Azure CLI", and Microsoft may restrict its +/// reuse at any time. Replace with a dedicated GitWand Entra app registration +/// (Azure Portal → App registrations → "Allow public client flows" = Yes) and +/// supply its id via `GITWAND_AZURE_CLIENT_ID` before shipping. +fn client_id() -> String { + if let Ok(v) = std::env::var("GITWAND_AZURE_CLIENT_ID") { + let v = v.trim().to_string(); + if !v.is_empty() { + return v; + } + } + option_env!("GITWAND_AZURE_CLIENT_ID") + .unwrap_or("04b07795-8ddb-461a-bbee-02f9e1bf7b46") + .to_string() +} + +// ─── Token resolution ─────────────────────────────────────────────────────── + +/// Read a value from the keychain under `AZ_SERVICE` / `account`. +fn read_secret(account: &str) -> Option { + let entry = keyring::Entry::new(AZ_SERVICE, account).ok()?; + let v = entry.get_password().ok()?; + let v = v.trim().to_string(); + if v.is_empty() { None } else { Some(v) } +} + +/// Store a value in the keychain under `AZ_SERVICE` / `account`. +fn write_secret(account: &str, value: &str) -> Result<(), String> { + let entry = keyring::Entry::new(AZ_SERVICE, account) + .map_err(|e| format!("keyring init failed: {}", e))?; + entry + .set_password(value) + .map_err(|e| format!("Failed to store Azure token: {}", e)) +} + +/// Read the Settings-managed Azure access token from the OS keychain. +pub(crate) fn settings_azure_token() -> Option { + read_secret(AZ_ACCOUNT) +} + +/// Read the stored Entra refresh token. +fn settings_azure_refresh() -> Option { + read_secret(AZ_ACCOUNT_REFRESH) +} + +/// Persist the access token (and refresh token, when the grant returns one). +fn store_tokens(access: &str, refresh: &str) -> Result<(), String> { + write_secret(AZ_ACCOUNT, access)?; + if !refresh.is_empty() { + write_secret(AZ_ACCOUNT_REFRESH, refresh)?; + } + Ok(()) +} + +/// Exchange the stored refresh token for a fresh access token (Entra rotates the +/// refresh token, so persist the new one too). Returns the new access token. +/// +/// Entra access tokens live ~1h; without this, the PR workflow breaks an hour +/// after sign-in and Azure DevOps starts returning an HTML sign-in page. +fn refresh_access_token() -> Result { + let refresh = settings_azure_refresh().ok_or_else(|| { + "Azure session expired. Open Settings → Accounts and sign in with Azure again." + .to_string() + })?; + let cid = client_id(); + let scope = format!("{}/.default offline_access", AZURE_DEVOPS_RESOURCE); + let (_status, text) = curl_form( + TOKEN_URL, + &[ + ("grant_type", "refresh_token"), + ("client_id", &cid), + ("refresh_token", &refresh), + ("scope", &scope), + ], + )?; + let v: serde_json::Value = serde_json::from_str(text.trim()) + .map_err(|e| format!("Failed to parse refresh response: {}", e))?; + let access = js(&v, "access_token"); + if access.is_empty() { + return Err( + "Azure session expired. Open Settings → Accounts and sign in with Azure again." + .to_string(), + ); + } + // Entra refresh-token rotation: store whatever new refresh token it returns. + let _ = store_tokens(&access, &js(&v, "refresh_token")); + Ok(access) +} + +// ─── org/project/repo resolution ──────────────────────────────────────────── + +/// Identifies an Azure DevOps repository: organization, project, repo. +struct AzureRepo { + org: String, + project: String, + repo: String, +} + +impl AzureRepo { + /// Base URL for the git REST surface of this repo. + fn api_base(&self) -> String { + format!( + "https://dev.azure.com/{}/{}/_apis/git/repositories/{}", + urlenc(&self.org), + urlenc(&self.project), + urlenc(&self.repo), + ) + } +} + +/// Minimal percent-encoding for path segments (space + a few reserved chars). +/// Azure org/project names allow spaces; the rest of the chars we care about +/// are alphanumerics, `-`, `_`, `.`. +fn urlenc(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +/// Parse `(org, project, repo)` from `git remote get-url origin`. +/// +/// Handles the common Azure DevOps remote URL shapes: +/// - `https://dev.azure.com/{org}/{project}/_git/{repo}` +/// - `https://{org}@dev.azure.com/{org}/{project}/_git/{repo}` +/// - `https://{org}.visualstudio.com/{project}/_git/{repo}` +/// - `https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}` +/// - `git@ssh.dev.azure.com:v3/{org}/{project}/{repo}` +fn azure_repo(cwd: &str) -> Result { + let output = hidden_cmd("git") + .args(["remote", "get-url", "origin"]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git remote get-url: {}", e))?; + if !output.status.success() { + return Err("No 'origin' remote found in this repo.".to_string()); + } + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + parse_azure_remote(&url) + .ok_or_else(|| format!("Could not parse an Azure DevOps repo from remote URL: {}", url)) +} + +fn parse_azure_remote(url: &str) -> Option { + // SSH: git@ssh.dev.azure.com:v3/{org}/{project}/{repo} + if let Some(rest) = url.split_once("ssh.dev.azure.com:").map(|(_, r)| r) { + let rest = rest.trim_start_matches("v3/").trim_end_matches(".git"); + let parts: Vec<&str> = rest.split('/').filter(|p| !p.is_empty()).collect(); + if parts.len() >= 3 { + return Some(AzureRepo { + org: parts[0].to_string(), + project: parts[1].to_string(), + repo: parts[2..].join("/"), + }); + } + return None; + } + + // HTTPS — strip scheme + any `user@` userinfo. + let after_scheme = url.splitn(2, "://").nth(1).unwrap_or(url); + let after_userinfo = after_scheme.splitn(2, '@').last().unwrap_or(after_scheme); + let (host, path) = after_userinfo.split_once('/')?; + let path = path.trim_end_matches('/').trim_end_matches(".git"); + + // dev.azure.com/{org}/{project}/_git/{repo} + if host.eq_ignore_ascii_case("dev.azure.com") { + let (left, repo) = path.split_once("/_git/")?; + let segs: Vec<&str> = left.split('/').filter(|s| !s.is_empty()).collect(); + if segs.len() >= 2 { + return Some(AzureRepo { + org: segs[0].to_string(), + project: segs[1..].join("/"), + repo: repo.to_string(), + }); + } + return None; + } + + // {org}.visualstudio.com[/DefaultCollection]/{project}/_git/{repo} + if let Some(org) = host.strip_suffix(".visualstudio.com") { + let (left, repo) = path.split_once("/_git/")?; + let segs: Vec<&str> = left + .split('/') + .filter(|s| !s.is_empty() && !s.eq_ignore_ascii_case("DefaultCollection")) + .collect(); + if !segs.is_empty() { + return Some(AzureRepo { + org: org.to_string(), + project: segs.join("/"), + repo: repo.to_string(), + }); + } + } + None +} + +/// Current branch name (`git rev-parse --abbrev-ref HEAD`). +fn current_branch(cwd: &str) -> Result { + let output = hidden_cmd("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git rev-parse: {}", e))?; + if !output.status.success() { + return Err("Could not determine current branch.".to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +// ─── HTTP transport (curl) ────────────────────────────────────────────────── + +/// Run a `curl` request and return `(http_status, body_text)`. +fn curl_raw( + method: &str, + url: &str, + token: Option<&str>, + body_json: Option<&str>, +) -> Result<(i32, String), String> { + const MARKER: &str = "\n__GW_HTTP_STATUS__"; + let mut args: Vec = vec![ + "-s".to_string(), + "-X".to_string(), method.to_string(), + "-H".to_string(), "Accept: application/json".to_string(), + "-H".to_string(), "User-Agent: GitWand".to_string(), + ]; + if let Some(tok) = token { + args.push("-H".to_string()); + args.push(format!("Authorization: Bearer {}", tok)); + } + if let Some(b) = body_json { + args.push("-H".to_string()); + args.push("Content-Type: application/json".to_string()); + args.push("-d".to_string()); + args.push(b.to_string()); + } + args.push("-w".to_string()); + args.push(format!("{}%{{http_code}}", MARKER)); + args.push(url.to_string()); + + let output = hidden_cmd("curl") + .args(&args) + .output() + .map_err(|e| format!("curl not found or failed to spawn: {}", e))?; + + let combined = String::from_utf8_lossy(&output.stdout).to_string(); + let (body, status) = match combined.rsplit_once(MARKER) { + Some((b, s)) => (b.to_string(), s.trim().parse::().unwrap_or(0)), + None => (combined, 0), + }; + Ok((status, body)) +} + +/// Form-encoded POST (Entra token/devicecode endpoints expect this), returns +/// `(status, body)`. +fn curl_form(url: &str, fields: &[(&str, &str)]) -> Result<(i32, String), String> { + const MARKER: &str = "\n__GW_HTTP_STATUS__"; + let mut args: Vec = vec![ + "-s".to_string(), + "-X".to_string(), "POST".to_string(), + "-H".to_string(), "Accept: application/json".to_string(), + "-H".to_string(), "User-Agent: GitWand".to_string(), + ]; + for (k, v) in fields { + args.push("--data-urlencode".to_string()); + args.push(format!("{}={}", k, v)); + } + args.push("-w".to_string()); + args.push(format!("{}%{{http_code}}", MARKER)); + args.push(url.to_string()); + + let output = hidden_cmd("curl") + .args(&args) + .output() + .map_err(|e| format!("curl not found or failed to spawn: {}", e))?; + let combined = String::from_utf8_lossy(&output.stdout).to_string(); + let (body, status) = match combined.rsplit_once(MARKER) { + Some((b, s)) => (b.to_string(), s.trim().parse::().unwrap_or(0)), + None => (combined, 0), + }; + Ok((status, body)) +} + +/// Whether a response means "the access token is no longer accepted". +/// +/// Azure DevOps does not always answer 401: when an OAuth token is expired or +/// rejected it frequently replies **HTTP 200 with an HTML sign-in page**. So we +/// also treat a non-JSON HTML body as an auth failure. +fn auth_failed(status: i32, body: &str) -> bool { + if status == 401 || status == 203 { + return true; + } + let t = body.trim_start(); + t.starts_with(" Result { + if status >= 400 { + let msg = serde_json::from_str::(body.trim()) + .ok() + .and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from)) + .unwrap_or_else(|| format!("HTTP {}", status)); + return Err(format!("Azure DevOps API error ({}): {}", status, msg)); + } + if body.trim().is_empty() { + return Ok(serde_json::Value::Null); + } + serde_json::from_str(body.trim()) + .map_err(|e| format!("Failed to parse Azure DevOps response: {}", e)) +} + +/// Perform an authenticated Azure DevOps JSON API call. +/// +/// Resolves the access token from the keychain; on an auth failure it refreshes +/// the token once (via the stored refresh token) and retries. A persistent +/// failure surfaces a clear "sign in again" error rather than a JSON parse error. +fn az_json( + method: &str, + url: &str, + body_json: Option<&str>, +) -> Result { + let token = settings_azure_token().ok_or_else(|| { + "Not signed in to Azure DevOps. Open Settings → Accounts and sign in with Azure." + .to_string() + })?; + let (status, body) = curl_raw(method, url, Some(&token), body_json)?; + if !auth_failed(status, &body) { + return finalize_json(status, &body); + } + // Token rejected/expired → refresh once and retry. + let fresh = refresh_access_token()?; + let (status2, body2) = curl_raw(method, url, Some(&fresh), body_json)?; + if auth_failed(status2, &body2) { + return Err( + "Azure session expired. Open Settings → Accounts and sign in with Azure again." + .to_string(), + ); + } + finalize_json(status2, &body2) +} + +/// Append the pinned `api-version` query parameter to a URL. +fn with_api_version(url: &str) -> String { + let sep = if url.contains('?') { '&' } else { '?' }; + format!("{}{}api-version={}", url, sep, API_VERSION) +} + +// ─── JSON field helpers ───────────────────────────────────────────────────── + +fn js(v: &serde_json::Value, key: &str) -> String { + v.get(key).and_then(|x| x.as_str()).unwrap_or("").to_string() +} + +fn ji(v: &serde_json::Value, key: &str) -> i64 { + v.get(key).and_then(|x| x.as_i64()).unwrap_or(0) +} + +fn jb(v: &serde_json::Value, key: &str) -> bool { + v.get(key).and_then(|x| x.as_bool()).unwrap_or(false) +} + +fn jnested(v: &serde_json::Value, outer: &str, inner: &str) -> String { + v.get(outer).and_then(|o| o.get(inner)).and_then(|s| s.as_str()).unwrap_or("").to_string() +} + +/// Strip the `refs/heads/` prefix from an Azure ref name. +fn short_ref(full: &str) -> String { + full.strip_prefix("refs/heads/").unwrap_or(full).to_string() +} + +/// Map Azure PR `status` to GitWand's vocabulary. +fn map_status(status: &str) -> String { + match status { + "completed" => "merged".to_string(), + "abandoned" => "closed".to_string(), + _ => "open".to_string(), // "active" + } +} + +/// Web URL for a PR (Azure REST omits a ready-made one in list responses). +fn pr_web_url(r: &AzureRepo, id: i64) -> String { + format!( + "https://dev.azure.com/{}/{}/_git/{}/pullrequest/{}", + urlenc(&r.org), urlenc(&r.project), urlenc(&r.repo), id + ) +} + +// ─── Mapping ──────────────────────────────────────────────────────────────── + +/// Derive a GitHub-style review decision from the reviewer votes carried on the +/// PR list/detail object, so the sidebar can flag PRs that are still waiting. +fn review_decision(pr: &serde_json::Value) -> &'static str { + match pr.get("reviewers").and_then(|a| a.as_array()) { + Some(rv) if !rv.is_empty() => { + if rv.iter().any(|r| ji(r, "vote") <= -5) { + "CHANGES_REQUESTED" + } else if rv.iter().any(|r| ji(r, "vote") >= 5) { + "APPROVED" + } else { + "REVIEW_REQUIRED" + } + } + _ => "", + } +} + +fn json_to_pr(r: &AzureRepo, pr: &serde_json::Value) -> PullRequest { + let id = ji(pr, "pullRequestId"); + PullRequest { + number: id, + title: js(pr, "title"), + state: map_status(&js(pr, "status")), + author: jnested(pr, "createdBy", "displayName"), + branch: short_ref(&js(pr, "sourceRefName")), + base: short_ref(&js(pr, "targetRefName")), + draft: jb(pr, "isDraft"), + created_at: js(pr, "creationDate"), + updated_at: js(pr, "creationDate"), + url: pr_web_url(r, id), + additions: 0, + deletions: 0, + labels: Vec::new(), + assignees: Vec::new(), + review_requested: Vec::new(), + review_decision: review_decision(pr).to_string(), + merge_state_status: js(pr, "mergeStatus").to_uppercase(), + checks_rollup: String::new(), + comment_count: 0, + } +} + +fn json_to_detail(r: &AzureRepo, pr: &serde_json::Value) -> PullRequestDetail { + let id = ji(pr, "pullRequestId"); + let status = js(pr, "status"); + let merged_at = if status == "completed" { js(pr, "closedDate") } else { String::new() }; + // Azure `mergeStatus`: succeeded / conflicts / queued / … + let mergeable = match js(pr, "mergeStatus").as_str() { + "succeeded" => "MERGEABLE".to_string(), + "conflicts" => "CONFLICTING".to_string(), + _ => "UNKNOWN".to_string(), + }; + PullRequestDetail { + number: id, + title: js(pr, "title"), + body: js(pr, "description"), + state: map_status(&status), + author: jnested(pr, "createdBy", "displayName"), + branch: short_ref(&js(pr, "sourceRefName")), + base: short_ref(&js(pr, "targetRefName")), + draft: jb(pr, "isDraft"), + created_at: js(pr, "creationDate"), + updated_at: js(pr, "creationDate"), + merged_at, + url: pr_web_url(r, id), + additions: 0, + deletions: 0, + changed_files: 0, + comments: 0, + review_comments: 0, + labels: Vec::new(), + reviewers: pr + .get("reviewers") + .and_then(|a| a.as_array()) + .map(|arr| arr.iter().map(|u| js(u, "displayName")).filter(|s| !s.is_empty()).collect()) + .unwrap_or_default(), + mergeable, + checks_status: String::new(), + // Azure merge permission needs the Security/Permissions API — not + // cheaply available here. Unknown ⇒ UI gates on errors only. + can_merge: None, + } +} + +// ─── REST PR workflow ─────────────────────────────────────────────────────── + +fn rest_current_user() -> Result { + let url = with_api_version("https://app.vssps.visualstudio.com/_apis/profile/profiles/me"); + let v = az_json("GET", &url, None)?; + let name = js(&v, "displayName"); + let name = if name.is_empty() { js(&v, "emailAddress") } else { name }; + if name.is_empty() { + return Err("Azure DevOps returned an empty profile for this token.".to_string()); + } + Ok(name) +} + +/// Resolve the display name for an explicit token (used right after sign-in, +/// before the token is fully wired into the keychain-backed `az_json` path). +fn rest_current_user_with(token: &str) -> Result { + let url = with_api_version("https://app.vssps.visualstudio.com/_apis/profile/profiles/me"); + let (status, body) = curl_raw("GET", &url, Some(token), None)?; + let v = finalize_json(status, &body)?; + let name = js(&v, "displayName"); + let name = if name.is_empty() { js(&v, "emailAddress") } else { name }; + Ok(name) +} + +fn search_status(state: &str) -> &'static str { + match state { + "closed" => "abandoned", + "merged" => "completed", + "all" => "all", + _ => "active", + } +} + +fn rest_list_prs(cwd: &str, state: &str, limit: i64, offset: i64) -> Result, String> { + let r = azure_repo(cwd)?; + let top = (limit + offset).clamp(1, 100); + let url = with_api_version(&format!( + "{}/pullrequests?searchCriteria.status={}&$top={}", + r.api_base(), search_status(state), top + )); + let v = az_json("GET", &url, None)?; + let mut prs: Vec = v + .get("value") + .and_then(|a| a.as_array()) + .map(|arr| arr.iter().map(|pr| json_to_pr(&r, pr)).collect()) + .unwrap_or_default(); + if offset > 0 { + let skip = (offset as usize).min(prs.len()); + prs.drain(..skip); + } + prs.truncate(limit.max(1) as usize); + + // Azure PR objects carry no line stats — compute +/- locally per PR. The + // remote-tracking branches are refreshed once on the first page only (a + // single incremental network call); paginated loads reuse those refs to + // avoid a `git fetch` on every scroll. Branches that no longer exist + // (merged/closed) leave the stats at 0. + if !prs.is_empty() { + if offset == 0 { + let _ = git_cmd().args(["fetch", "origin"]).current_dir(cwd).output(); + } + prs.par_iter_mut().for_each(|pr| { + let (_, adds, dels) = diff_numstat(cwd, &pr.branch, &pr.base); + pr.additions = adds; + pr.deletions = dels; + }); + // Aggregate each PR's branch-policy evaluations into a rollup so the + // sidebar dot can colour (red = a build/policy failed, yellow = pending, + // green = all pass). Parallel — one policy round-trip per PR. + let rollups: HashMap = prs + .par_iter() + .filter_map(|pr| { + let rollup = rollup_from_checks(&rest_pr_checks(cwd, pr.number).ok()?); + if rollup.is_empty() { None } else { Some((pr.number, rollup)) } + }) + .collect(); + for pr in &mut prs { + if let Some(state) = rollups.get(&pr.number) { + pr.checks_rollup = state.clone(); + } + } + } + Ok(prs) +} + +fn rest_pr_count(cwd: &str, state: &str) -> Result { + let r = azure_repo(cwd)?; + let url = with_api_version(&format!( + "{}/pullrequests?searchCriteria.status={}&$top=1000", + r.api_base(), search_status(state) + )); + let v = az_json("GET", &url, None)?; + Ok(v.get("count").and_then(|c| c.as_i64()).unwrap_or_else(|| { + v.get("value").and_then(|a| a.as_array()).map(|a| a.len() as i64).unwrap_or(0) + })) +} + +/// Fetch a single PR object together with its repo descriptor. +fn get_pr_json(cwd: &str, number: i64) -> Result<(AzureRepo, serde_json::Value), String> { + let r = azure_repo(cwd)?; + let url = with_api_version(&format!("{}/pullrequests/{}", r.api_base(), number)); + let v = az_json("GET", &url, None)?; + Ok((r, v)) +} + +fn rest_pr_detail(cwd: &str, number: i64) -> Result { + let (r, v) = get_pr_json(cwd, number)?; + let mut detail = json_to_detail(&r, &v); + // Azure's PR object has no file/line stats — compute them from local git so + // the Files tab badge and the changed-files/additions/deletions stats are + // populated (best-effort; leaves zeros if the branches can't be fetched). + let source = short_ref(&js(&v, "sourceRefName")); + let target = short_ref(&js(&v, "targetRefName")); + if fetch_pr_branches(cwd, &source, &target).is_ok() { + let (files, adds, dels) = diff_numstat(cwd, &source, &target); + detail.changed_files = files; + detail.additions = adds; + detail.deletions = dels; + } + // Aggregate branch-policy evaluations into a rollup so the CI tab can colour + // itself red (a build/policy failed) / yellow (pending) / green (all pass). + detail.checks_status = rollup_from_checks(&rest_pr_checks(cwd, number).unwrap_or_default()); + Ok(detail) +} + +/// `git diff --numstat target...source` → (changed_files, additions, deletions). +fn diff_numstat(cwd: &str, source: &str, target: &str) -> (i64, i64, i64) { + let range = format!("origin/{}...origin/{}", target, source); + let out = match git_cmd() + .args(["diff", "--numstat", &range]) + .current_dir(cwd) + .output() + { + Ok(o) if o.status.success() => o, + _ => return (0, 0, 0), + }; + let text = String::from_utf8_lossy(&out.stdout); + let (mut files, mut adds, mut dels) = (0i64, 0i64, 0i64); + for line in text.lines() { + let mut cols = line.split('\t'); + let a = cols.next().unwrap_or(""); + let d = cols.next().unwrap_or(""); + files += 1; + // Binary files show "-" for counts. + adds += a.parse::().unwrap_or(0); + dels += d.parse::().unwrap_or(0); + } + (files, adds, dels) +} + +/// Diff is produced locally: fetch both PR branches from origin and diff the +/// merge base. Azure DevOps has no single unified-patch endpoint. +fn fetch_pr_branches(cwd: &str, source: &str, target: &str) -> Result<(), String> { + let out = git_cmd() + .args(["fetch", "origin", source, target]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git fetch failed: {}", e))?; + if !out.status.success() { + return Err(format!( + "git fetch {}/{} failed: {}", + source, target, String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(()) +} + +fn rest_pr_diff(cwd: &str, number: i64) -> Result { + let (_r, pr) = get_pr_json(cwd, number)?; + let source = short_ref(&js(&pr, "sourceRefName")); + let target = short_ref(&js(&pr, "targetRefName")); + fetch_pr_branches(cwd, &source, &target)?; + let range = format!("origin/{}...origin/{}", target, source); + let out = git_cmd() + .args(["diff", &range]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git diff failed: {}", e))?; + if !out.status.success() { + return Err(format!("git diff failed: {}", String::from_utf8_lossy(&out.stderr).trim())); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + +fn rest_pr_files(cwd: &str, number: i64) -> Result, String> { + let (_r, pr) = get_pr_json(cwd, number)?; + let source = short_ref(&js(&pr, "sourceRefName")); + let target = short_ref(&js(&pr, "targetRefName")); + fetch_pr_branches(cwd, &source, &target)?; + let range = format!("origin/{}...origin/{}", target, source); + let out = git_cmd() + .args(["diff", "--name-only", &range]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git diff --name-only failed: {}", e))?; + if !out.status.success() { + return Ok(Vec::new()); + } + Ok(String::from_utf8_lossy(&out.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect()) +} + +fn rest_create_pr( + cwd: &str, + title: String, + body: String, + base: String, + draft: bool, +) -> Result { + let r = azure_repo(cwd)?; + let head_branch = current_branch(cwd)?; + let base = if base.is_empty() { "main".to_string() } else { base }; + let payload = serde_json::json!({ + "sourceRefName": format!("refs/heads/{}", head_branch), + "targetRefName": format!("refs/heads/{}", base), + "title": title, + "description": body, + "isDraft": draft, + }); + let url = with_api_version(&format!("{}/pullrequests", r.api_base())); + let created = az_json("POST", &url, Some(&payload.to_string()))?; + Ok(json_to_pr(&r, &created)) +} + +fn rest_merge_pr(cwd: &str, number: i64, method: &str) -> Result<(), String> { + let (r, pr) = get_pr_json(cwd, number)?; + let merge_strategy = match method { + "squash" => "squash", + "rebase" => "rebase", + _ => "noFastForward", + }; + let commit_id = pr + .get("lastMergeSourceCommit") + .and_then(|c| c.get("commitId")) + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + let payload = serde_json::json!({ + "status": "completed", + "lastMergeSourceCommit": { "commitId": commit_id }, + "completionOptions": { + "mergeStrategy": merge_strategy, + "deleteSourceBranch": true, + }, + }); + let url = with_api_version(&format!("{}/pullrequests/{}", r.api_base(), number)); + az_json("PATCH", &url, Some(&payload.to_string()))?; + Ok(()) +} + +fn rest_pr_ready(cwd: &str, number: i64) -> Result<(), String> { + let r = azure_repo(cwd)?; + let payload = serde_json::json!({ "isDraft": false }); + let url = with_api_version(&format!("{}/pullrequests/{}", r.api_base(), number)); + az_json("PATCH", &url, Some(&payload.to_string()))?; + Ok(()) +} + +fn rest_checkout_pr(cwd: &str, number: i64) -> Result<(), String> { + let (_r, pr) = get_pr_json(cwd, number)?; + let source = short_ref(&js(&pr, "sourceRefName")); + let fetch = git_cmd() + .args(["fetch", "origin", &source]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git fetch failed: {}", e))?; + if !fetch.status.success() { + return Err(format!( + "git fetch {} failed: {}", + source, String::from_utf8_lossy(&fetch.stderr).trim() + )); + } + let checkout = git_cmd() + .args(["checkout", &source]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git checkout failed: {}", e))?; + if !checkout.status.success() { + return Err(format!( + "git checkout {} failed: {}", + source, String::from_utf8_lossy(&checkout.stderr).trim() + )); + } + Ok(()) +} + +// ─── Comments (Azure DevOps threads) ──────────────────────────────────────── +// +// Azure groups PR comments into *threads*. A thread may be anchored to a file +// (`threadContext.filePath` + line range) or be a general PR comment. Comment +// ids restart at 1 within each thread, so we synthesize a globally-unique id +// (`thread_id * COMMENT_ID_STRIDE + comment_id`) to keep Vue keys and reply +// links unambiguous. The same transform is applied to `parentCommentId`. + +const COMMENT_ID_STRIDE: i64 = 1_000_000; + +/// Map Azure threads into the snake_case shape the frontend `PrReviewComment` +/// type expects (consumed directly by `azPrComments`). +fn threads_to_comments(threads: &serde_json::Value) -> Vec { + let mut out = Vec::new(); + let empty: Vec = vec![]; + let threads_arr = threads.get("value").and_then(|a| a.as_array()).unwrap_or(&empty); + for thread in threads_arr { + let thread_id = ji(thread, "id"); + let ctx = thread.get("threadContext"); + let path = ctx + .and_then(|c| c.get("filePath")) + .and_then(|s| s.as_str()) + .unwrap_or("") + .trim_start_matches('/') + .to_string(); + let line = ctx + .and_then(|c| c.get("rightFileStart").or_else(|| c.get("rightFileEnd"))) + .and_then(|p| p.get("line")) + .and_then(|l| l.as_i64()); + + let comments = thread.get("comments").and_then(|a| a.as_array()).cloned().unwrap_or_default(); + for c in &comments { + // Skip system-generated entries (status changes, votes, etc.). + if js(c, "commentType") == "system" { + continue; + } + let content = js(c, "content"); + if content.trim().is_empty() { + continue; + } + let cid = ji(c, "id"); + let parent = ji(c, "parentCommentId"); + let in_reply_to = if parent > 0 { + serde_json::json!(thread_id * COMMENT_ID_STRIDE + parent) + } else { + serde_json::Value::Null + }; + out.push(serde_json::json!({ + "id": thread_id * COMMENT_ID_STRIDE + cid, + "body": content, + "author": jnested(c, "author", "displayName"), + "created_at": js(c, "publishedDate"), + "updated_at": js(c, "lastUpdatedDate"), + "path": path, + "line": line, + "original_line": serde_json::Value::Null, + "side": "RIGHT", + "start_line": serde_json::Value::Null, + "start_side": serde_json::Value::Null, + "in_reply_to_id": in_reply_to, + "diff_hunk": "", + "url": "", + })); + } + } + out +} + +fn rest_pr_comments(cwd: &str, number: i64) -> Result, String> { + let r = azure_repo(cwd)?; + let url = with_api_version(&format!("{}/pullrequests/{}/threads", r.api_base(), number)); + let threads = az_json("GET", &url, None)?; + Ok(threads_to_comments(&threads)) +} + +/// Create a general PR comment (a new thread). File-anchored creation is not +/// wired yet — `path`/`line` are accepted but ignored, producing a general +/// comment so nothing is silently dropped. +fn rest_pr_create_comment(cwd: &str, number: i64, body: &str) -> Result { + let r = azure_repo(cwd)?; + let payload = serde_json::json!({ + "comments": [{ "parentCommentId": 0, "content": body, "commentType": "text" }], + "status": "active", + }); + let url = with_api_version(&format!("{}/pullrequests/{}/threads", r.api_base(), number)); + let thread = az_json("POST", &url, Some(&payload.to_string()))?; + // Return the created comment in PrReviewComment shape (first non-system one). + threads_to_comments(&serde_json::json!({ "value": [thread] })) + .into_iter() + .next() + .ok_or_else(|| "Azure DevOps returned no comment after create.".to_string()) +} + +// ─── CI checks (Azure branch-policy evaluations) ──────────────────────────── +// +// Azure surfaces "merge state" through branch *policy evaluations* (build +// validation, required/minimum reviewers, comment resolution, external status). +// This is a richer signal than raw commit statuses and matches what the PR page +// shows under "Checks", so we map each evaluation to a `CICheck`. + +/// Map an Azure policy-evaluation `status` to (state, conclusion) in the +/// vocabulary the frontend merge-readiness logic understands. +fn map_policy_status(status: &str) -> (&'static str, &'static str) { + match status { + "approved" => ("completed", "SUCCESS"), + "rejected" => ("completed", "FAILURE"), + // "broken" = the policy evaluation itself errored (e.g. a build that + // failed to run) — surface it as a red failure, not a yellow pending. + "broken" => ("completed", "FAILURE"), + "notApplicable" => ("completed", "SKIPPED"), + "running" => ("in_progress", ""), + // "queued" | "notSubmitted" | anything else → still pending. + _ => ("queued", ""), + } +} + +/// Resolve a build's real outcome to `(state, conclusion)`. Build-validation +/// policy evaluations can report `queued` while the build itself has already +/// completed — possibly failed — so the build result is the source of truth. +/// Best-effort: returns `None` on any error so the caller keeps the policy +/// status. A still-running build maps to pending (yellow). +fn azure_build_outcome(org: &str, project: &str, build_id: i64) -> Option<(&'static str, &'static str)> { + let url = format!( + "https://dev.azure.com/{}/{}/_apis/build/builds/{}?api-version=7.1", + urlenc(org), + urlenc(project), + build_id, + ); + let v = az_json("GET", &url, None).ok()?; + // `status`: notStarted / inProgress / completed / cancelling / postponed. + if js(&v, "status") != "completed" { + return Some(("in_progress", "")); + } + // `result`: succeeded / partiallySucceeded / failed / canceled. + Some(match js(&v, "result").as_str() { + "succeeded" => ("completed", "SUCCESS"), + "failed" | "partiallySucceeded" | "canceled" => ("completed", "FAILURE"), + _ => ("completed", ""), + }) +} + +/// Rank a check's `conclusion` by how "resolved & positive" it is, so duplicate +/// evaluations of the same policy can be collapsed to their best outcome. +/// Higher wins: a passed copy beats a still-queued one. +fn conclusion_rank(conclusion: &str) -> u8 { + match conclusion.to_uppercase().as_str() { + "SUCCESS" => 3, + "SKIPPED" | "NEUTRAL" => 2, + "FAILURE" | "ERROR" => 1, + _ => 0, // "" / queued / running / expired + } +} + +/// Collapse duplicate checks by `key`. Azure surfaces the same branch policy +/// twice (inherited + branch-level); when one copy is approved and the other is +/// still queued, the requirement is already met, so the whole group is success. +/// Distinct build definitions get distinct keys, so they are NOT merged. +/// First-seen order is preserved. +fn dedupe_checks(checks: Vec<(String, CICheck)>) -> Vec { + let mut order: Vec = Vec::new(); + let mut best: HashMap = HashMap::new(); + for (key, c) in checks { + match best.get(&key) { + Some(existing) if conclusion_rank(&existing.conclusion) >= conclusion_rank(&c.conclusion) => {} + _ => { + if !best.contains_key(&key) { + order.push(key.clone()); + } + best.insert(key, c); + } + } + } + order.into_iter().filter_map(|k| best.remove(&k)).collect() +} + +/// Aggregate a PR's policy/check evaluations into one rollup state: +/// `FAILURE` (red) / `PENDING` (yellow) / `SUCCESS` (green), or `""` when there +/// are no checks. Precedence: any failure ⇒ red, else any unfinished ⇒ yellow. +fn rollup_from_checks(checks: &[CICheck]) -> String { + if checks.is_empty() { + return String::new(); + } + let mut pending = false; + for c in checks { + match c.conclusion.to_uppercase().as_str() { + "FAILURE" | "ERROR" => return "FAILURE".to_string(), + "SUCCESS" | "SKIPPED" | "NEUTRAL" => {} + // EXPIRED (stale build, needs re-queue) → pending/warning, not red. + _ => pending = true, // "" / EXPIRED → still running / queued + } + if c.state.to_lowercase() != "completed" { + pending = true; + } + } + if pending { "PENDING".to_string() } else { "SUCCESS".to_string() } +} + +fn rest_pr_checks(cwd: &str, number: i64) -> Result, String> { + let (r, pr) = get_pr_json(cwd, number)?; + let project_id = pr + .get("repository") + .and_then(|repo| repo.get("project")) + .and_then(|p| p.get("id")) + .and_then(|s| s.as_str()) + .unwrap_or(""); + if project_id.is_empty() { + return Ok(Vec::new()); + } + // artifactId identifies the PR for the policy engine. The evaluations + // endpoint is project-scoped (by GUID) and is still a *preview* API even on + // 7.1 — a plain `api-version=7.1` returns 400, so we pin the preview tag. + let artifact = format!("vstfs:///CodeReview/CodeReviewId/{}/{}", project_id, number); + let url = format!( + "https://dev.azure.com/{}/{}/_apis/policy/evaluations?artifactId={}&api-version=7.1-preview.1", + urlenc(&r.org), + urlenc(project_id), + urlenc(&artifact), + ); + // Policies may be disabled on the repo — treat any failure as "no checks". + let v = match az_json("GET", &url, None) { + Ok(v) => v, + Err(_) => return Ok(Vec::new()), + }; + let evals = v.get("value").and_then(|a| a.as_array()).cloned().unwrap_or_default(); + let checks: Vec<(String, CICheck)> = evals + .iter() + .filter_map(|e| { + let cfg = e.get("configuration"); + let display = cfg + .and_then(|c| c.get("type")) + .and_then(|t| t.get("displayName")) + .and_then(|s| s.as_str()) + .unwrap_or("Policy") + .to_string(); + // Skip non-blocking housekeeping policies that always read as + // "pending" and add noise to the readiness banner (e.g. the merge + // strategy, which the user picks at merge time). + let dl = display.to_lowercase(); + if dl.contains("merge strategy") || dl.contains("stratégie de fusion") { + return None; + } + let settings = cfg.and_then(|c| c.get("settings")); + // Spell out the reviewer requirement ("At least N reviewer(s) must + // approve") so the merge-readiness banner is self-explanatory. + let is_reviewer = display.to_lowercase().contains("reviewer"); + let name = if is_reviewer { + let n = settings + .and_then(|s| s.get("minimumApproverCount")) + .and_then(|c| c.as_i64()) + .unwrap_or(0); + if n > 0 { + format!("At least {} reviewer{} must approve", n, if n > 1 { "s" } else { "" }) + } else { + display + } + } else { + // Build / Status policies all share the generic type displayName + // ("Build"); their real name lives in `settings.displayName`. Use + // it so distinct builds don't all read as "Build". + settings + .and_then(|s| s.get("displayName")) + .and_then(|x| x.as_str()) + .filter(|x| !x.is_empty()) + .map(String::from) + .unwrap_or(display) + }; + // Dedupe key: reviewer policies legitimately appear twice (inherited + + // branch-level) and must collapse, but distinct build definitions can + // share a name and must NOT — so fold the build definition id into the + // key when present. + let build_def_id = settings + .and_then(|s| s.get("buildDefinitionId")) + .and_then(|b| b.as_i64()); + let dedupe_key = match build_def_id { + Some(id) => format!("build:{}", id), + None => name.clone(), + }; + let (mut state, mut conclusion) = map_policy_status(&js(e, "status")); + let is_expired = e + .get("context") + .and_then(|c| c.get("isExpired")) + .and_then(|b| b.as_bool()) + .unwrap_or(false); + if is_expired { + // The build ran against a stale commit — Azure flags it + // "Expired (Please Re-queue)" and it blocks the merge. Surface it + // as a labelled failure so the user knows to re-run it. + state = "completed"; + conclusion = "EXPIRED"; + } else if let Some(bid) = e + .get("context") + .and_then(|c| c.get("buildId")) + .and_then(|b| b.as_i64()) + { + // Build-validation policies: the policy `status` can sit at + // "queued" even after the build has finished and *failed* (Azure + // shows "1 required check failed" while the policy re-evaluation + // is still queued). The build result is authoritative, so resolve + // it when a `buildId` is present and override the policy status. + if let Some((s, c)) = azure_build_outcome(&r.org, project_id, bid) { + state = s; + conclusion = c; + } + } + Some(( + dedupe_key, + CICheck { + name, + state: state.to_string(), + conclusion: conclusion.to_string(), + details_url: String::new(), + }, + )) + }) + .collect(); + + // Azure reports the same policy twice (inherited + branch-level) — collapse + // duplicates so an approved copy isn't shown alongside a queued one. Keyed by + // build definition where applicable so distinct builds are kept separate. + let checks = dedupe_checks(checks); + + // Fallback: the preview policy API can be unavailable (permissions, tag). + // Still convey "waiting for reviewer" from the PR's own reviewer list — if + // reviewers are assigned and none has approved (vote >= 5), surface it. + if checks.is_empty() { + if let Some(c) = pending_reviewer_check(&pr) { + return Ok(vec![c]); + } + } + Ok(checks) +} + +/// Build a "waiting for reviewer approval" pending check from the PR's reviewer +/// votes, used when branch-policy evaluations aren't available. +fn pending_reviewer_check(pr: &serde_json::Value) -> Option { + let reviewers = pr.get("reviewers").and_then(|a| a.as_array())?; + if reviewers.is_empty() { + return None; + } + let any_approved = reviewers.iter().any(|r| ji(r, "vote") >= 5); + let any_rejected = reviewers.iter().any(|r| ji(r, "vote") <= -5); + if any_approved && !any_rejected { + return None; // requirement already satisfied + } + Some(CICheck { + name: if any_rejected { + "Changes requested by a reviewer".to_string() + } else { + "Waiting for reviewer approval".to_string() + }, + state: "queued".to_string(), + conclusion: if any_rejected { "FAILURE" } else { "" }.to_string(), + details_url: String::new(), + }) +} + +// ─── Reviews (reviewer votes) ─────────────────────────────────────────────── +// +// Azure has no GitHub-style review submissions; approval is a per-reviewer +// *vote*: 10 approved, 5 approved-with-suggestions, 0 none, -5 waiting for +// author, -10 rejected. We map non-zero votes onto the review vocabulary so the +// merge-readiness chip can reason about approvals / requested changes. + +fn map_vote(vote: i64) -> Option<&'static str> { + match vote { + v if v >= 5 => Some("APPROVED"), + -5 => Some("CHANGES_REQUESTED"), // waiting for author + -10 => Some("CHANGES_REQUESTED"), // rejected + _ => None, // 0 — no vote yet + } +} + +/// Resolve the signed-in user's Azure DevOps identity id (a GUID) for the org. +/// This is the `reviewerId` the vote endpoint expects. +fn current_user_id(org: &str) -> Result { + let url = format!("https://dev.azure.com/{}/_apis/connectionData", urlenc(org)); + let v = az_json("GET", &url, None)?; + let id = v + .get("authenticatedUser") + .and_then(|u| u.get("id")) + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + if id.is_empty() { + return Err("Could not resolve your Azure DevOps identity.".to_string()); + } + Ok(id) +} + +/// Cast / update the signed-in user's vote on a PR (Azure's review model). +/// +/// `event` maps onto a vote: APPROVE → 10, REQUEST_CHANGES → -10. COMMENT casts +/// no vote. Any `body` is posted as a general comment first, so a "Comment" +/// review still leaves a visible note. +fn rest_submit_review(cwd: &str, number: i64, event: &str, body: &str) -> Result { + let r = azure_repo(cwd)?; + if !body.trim().is_empty() { + // Best-effort — never fail the vote because the note failed to post. + let _ = rest_pr_create_comment(cwd, number, body); + } + let vote: i64 = match event { + "APPROVE" => 10, + "REQUEST_CHANGES" => -10, + _ => 0, + }; + let state = if vote > 0 { + "APPROVED" + } else if vote < 0 { + "CHANGES_REQUESTED" + } else { + "COMMENTED" + }; + if vote != 0 { + let uid = current_user_id(&r.org)?; + let payload = serde_json::json!({ "vote": vote }); + let url = with_api_version(&format!( + "{}/pullrequests/{}/reviewers/{}", + r.api_base(), number, urlenc(&uid) + )); + az_json("PUT", &url, Some(&payload.to_string()))?; + } + Ok(serde_json::json!({ + "id": 0, + "state": state, + "body": body, + "user": { "login": "", "avatar_url": "" }, + "submitted_at": "", + "html_url": "", + })) +} + +fn rest_pr_reviews(cwd: &str, number: i64) -> Result, String> { + let (_r, pr) = get_pr_json(cwd, number)?; + let reviewers = pr.get("reviewers").and_then(|a| a.as_array()).cloned().unwrap_or_default(); + let mut out = Vec::new(); + for (i, rev) in reviewers.iter().enumerate() { + let Some(state) = map_vote(ji(rev, "vote")) else { continue }; + out.push(serde_json::json!({ + "id": i as i64 + 1, + "state": state, + "body": "", + "user": { + "login": js(rev, "displayName"), + "avatar_url": js(rev, "imageUrl"), + }, + "submitted_at": "", + "html_url": "", + })); + } + Ok(out) +} + +// ─── Tauri commands — PR workflow ─────────────────────────────────────────── + +// These forge commands do blocking network I/O (Azure DevOps REST, ~1–2s each). +// Tauri runs *synchronous* commands on the webview main thread, so a bare `fn` +// here freezes the whole UI for the call's duration. Declaring them `async` and +// running the blocking body via `spawn_blocking` keeps the work off the main +// thread — the UI stays interactive while the request is in flight. +#[tauri::command] +pub(crate) async fn az_current_user() -> Result { + tauri::async_runtime::spawn_blocking(rest_current_user) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_list_prs(cwd: String, state: String, limit: i64, offset: i64) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || rest_list_prs(&cwd, &state, limit, offset)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_pr_count(cwd: String, state: String) -> Result { + tauri::async_runtime::spawn_blocking(move || rest_pr_count(&cwd, &state)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_pr_detail(cwd: String, number: i64) -> Result { + tauri::async_runtime::spawn_blocking(move || rest_pr_detail(&cwd, number)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_pr_diff(cwd: String, number: i64) -> Result { + tauri::async_runtime::spawn_blocking(move || rest_pr_diff(&cwd, number)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_pr_files(cwd: String, number: i64) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || rest_pr_files(&cwd, number)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_create_pr( + cwd: String, + title: String, + body: String, + base: Option, + draft: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + rest_create_pr(&cwd, title, body, base.unwrap_or_default(), draft.unwrap_or(false)) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_merge_pr(cwd: String, number: i64, method: Option) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + rest_merge_pr(&cwd, number, &method.unwrap_or_else(|| "merge".to_string())) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_pr_ready(cwd: String, number: i64) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || rest_pr_ready(&cwd, number)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_checkout_pr(cwd: String, number: i64) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || rest_checkout_pr(&cwd, number)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_pr_comments(cwd: String, number: i64) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || rest_pr_comments(&cwd, number)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_pr_create_comment(cwd: String, number: i64, body: String) -> Result { + tauri::async_runtime::spawn_blocking(move || rest_pr_create_comment(&cwd, number, &body)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_pr_checks(cwd: String, number: i64) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || rest_pr_checks(&cwd, number)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_pr_reviews(cwd: String, number: i64) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || rest_pr_reviews(&cwd, number)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn az_submit_review( + cwd: String, + number: i64, + event: String, + body: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + rest_submit_review(&cwd, number, &event, &body.unwrap_or_default()) + }) + .await + .map_err(|e| e.to_string())? +} + +// ─── Entra ID device flow ─────────────────────────────────────────────────── + +/// Begin the Entra ID device authorization grant. +#[tauri::command] +pub(crate) async fn azure_device_start() -> Result { + let cid = client_id(); + if cid.starts_with("REPLACE_WITH") { + return Err( + "Azure login is not configured: missing Entra ID client_id. \ + Set GITWAND_AZURE_CLIENT_ID at build time or update azure.rs." + .to_string(), + ); + } + let scope = format!("{}/.default offline_access", AZURE_DEVOPS_RESOURCE); + let (status, text) = curl_form(DEVICECODE_URL, &[("client_id", &cid), ("scope", &scope)])?; + if status >= 400 { + let msg = serde_json::from_str::(text.trim()) + .ok() + .and_then(|v| v.get("error_description").and_then(|m| m.as_str()).map(String::from)) + .unwrap_or_else(|| format!("HTTP {}", status)); + return Err(format!("Azure device-code request failed: {}", msg)); + } + let v: serde_json::Value = serde_json::from_str(text.trim()) + .map_err(|e| format!("Failed to parse device-code response: {}", e))?; + Ok(GithubDeviceCode { + device_code: js(&v, "device_code"), + user_code: js(&v, "user_code"), + verification_uri: js(&v, "verification_uri"), + // Entra returns `verification_uri_complete` only with some configs. + verification_uri_complete: js(&v, "verification_uri_complete"), + expires_in: ji(&v, "expires_in"), + interval: ji(&v, "interval").max(5), + }) +} + +/// Poll once for the Entra access token. On success the token is stored in the +/// OS keychain; the secret never reaches the frontend. +#[tauri::command] +pub(crate) async fn azure_device_poll(device_code: String) -> Result { + let cid = client_id(); + let (status, text) = curl_form( + TOKEN_URL, + &[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("client_id", &cid), + ("device_code", &device_code), + ], + )?; + let v: serde_json::Value = serde_json::from_str(text.trim()) + .map_err(|e| format!("Failed to parse token response: {}", e))?; + + if let Some(err) = v.get("error").and_then(|e| e.as_str()) { + let kind = match err { + "authorization_pending" => "pending", + "slow_down" => "slow_down", + _ => "error", + }; + let detail = js(&v, "error_description"); + return Ok(GithubDevicePoll { + status: kind.to_string(), + login: String::new(), + error: if kind == "error" { + if detail.is_empty() { err.to_string() } else { detail } + } else { + String::new() + }, + }); + } + if status >= 400 { + return Err(format!("Azure token poll failed (HTTP {})", status)); + } + + let token = js(&v, "access_token"); + if token.is_empty() { + return Ok(GithubDevicePoll { + status: "pending".to_string(), + login: String::new(), + error: String::new(), + }); + } + + // Persist access + refresh tokens so the PR workflow survives the ~1h + // access-token lifetime (refreshed transparently by `az_json`). + store_tokens(&token, &js(&v, "refresh_token"))?; + + let login = rest_current_user_with(&token).unwrap_or_default(); + Ok(GithubDevicePoll { + status: "success".to_string(), + login, + error: String::new(), + }) +} + +/// Whether a Settings-managed Azure token is currently stored. +#[tauri::command] +pub(crate) async fn azure_token_present() -> Result { + Ok(settings_azure_token().is_some()) +} diff --git a/apps/desktop/src-tauri/src/commands/bitbucket.rs b/apps/desktop/src-tauri/src/commands/bitbucket.rs index 37414beb..0b9fb2e7 100644 --- a/apps/desktop/src-tauri/src/commands/bitbucket.rs +++ b/apps/desktop/src-tauri/src/commands/bitbucket.rs @@ -24,6 +24,8 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; use crate::git::hidden_cmd; use crate::types::*; +use rayon::prelude::*; +use std::collections::HashMap; // ─── Credential helpers ──────────────────────────────────────────────────────── @@ -386,6 +388,9 @@ fn bb_pr_to_detail(pr: &serde_json::Value) -> PullRequestDetail { reviewers, mergeable: String::new(), // Not directly available in v2.10 checks_status: String::new(), // Bitbucket Pipelines needs a separate call + // Bitbucket merge permission needs a separate privileges call — + // unknown ⇒ UI gates on errors only. + can_merge: None, } } @@ -405,7 +410,7 @@ impl OrElseEmpty for String { /// /// `state` accepts "OPEN" (default), "MERGED", "DECLINED", "ALL". #[tauri::command] -pub(crate) fn bb_list_prs( +pub(crate) async fn bb_list_prs( cwd: String, state: String, limit: Option, @@ -457,12 +462,36 @@ pub(crate) fn bb_list_prs( prs.drain(..skip); } + // Colour the sidebar dot from each PR's head-commit build statuses. The list + // payload carries `source.commit.hash`, so resolve it per PR (no extra PR + // fetch) and aggregate statuses in parallel (red = failed, yellow = running). + let sha_by_num: HashMap = values + .iter() + .filter_map(|pr| { + let hash = jdeep(pr, "source", "commit", "hash"); + if hash.is_empty() { None } else { Some((ji(pr, "id"), hash)) } + }) + .collect(); + let rollups: HashMap = prs + .par_iter() + .filter_map(|pr| { + let sha = sha_by_num.get(&pr.number)?; + let rollup = bb_rollup_for_sha(&workspace, &slug, sha, &auth); + if rollup.is_empty() { None } else { Some((pr.number, rollup)) } + }) + .collect(); + for pr in &mut prs { + if let Some(state) = rollups.get(&pr.number) { + pr.checks_rollup = state.clone(); + } + } + Ok(prs) } /// Count PRs for a given state. #[tauri::command] -pub(crate) fn bb_pr_count(cwd: String, state: String) -> Result { +pub(crate) async fn bb_pr_count(cwd: String, state: String) -> Result { let (workspace, slug) = parse_workspace_slug(&cwd)?; let (username, app_password) = get_bb_creds(&cwd)?; let auth = basic_auth_header(&username, &app_password); @@ -485,19 +514,23 @@ pub(crate) fn bb_pr_count(cwd: String, state: String) -> Result { /// Get detailed PR info. #[tauri::command] -pub(crate) fn bb_get_pr(cwd: String, pr_id: i64) -> Result { +pub(crate) async fn bb_get_pr(cwd: String, pr_id: i64) -> Result { let (workspace, slug) = parse_workspace_slug(&cwd)?; let (username, app_password) = get_bb_creds(&cwd)?; let auth = basic_auth_header(&username, &app_password); let url = format!("{}/pullrequests/{}", repo_api(&workspace, &slug), pr_id); let resp = bb_curl("GET", &url, None, &auth)?; - Ok(bb_pr_to_detail(&resp)) + let mut detail = bb_pr_to_detail(&resp); + // Aggregate the head commit's build statuses so the CI tab can colour itself. + let sha = jdeep(&resp, "source", "commit", "hash"); + detail.checks_status = bb_rollup_for_sha(&workspace, &slug, &sha, &auth); + Ok(detail) } /// Get the diff of a PR as a unified diff string. #[tauri::command] -pub(crate) fn bb_pr_diff(cwd: String, pr_id: i64) -> Result { +pub(crate) async fn bb_pr_diff(cwd: String, pr_id: i64) -> Result { let (workspace, slug) = parse_workspace_slug(&cwd)?; let (username, app_password) = get_bb_creds(&cwd)?; let auth = basic_auth_header(&username, &app_password); @@ -527,7 +560,7 @@ pub(crate) fn bb_pr_diff(cwd: String, pr_id: i64) -> Result { /// If `source_branch` is empty, the current HEAD branch is resolved via /// `git rev-parse --abbrev-ref HEAD` (mirrors the `glab mr create` convention). #[tauri::command] -pub(crate) fn bb_create_pr( +pub(crate) async fn bb_create_pr( cwd: String, title: String, body: String, @@ -575,7 +608,7 @@ pub(crate) fn bb_create_pr( /// /// `method` accepts "merge" (default), "squash", "fast_forward". #[tauri::command] -pub(crate) fn bb_merge_pr(cwd: String, pr_id: i64, method: String) -> Result<(), String> { +pub(crate) async fn bb_merge_pr(cwd: String, pr_id: i64, method: String) -> Result<(), String> { let (workspace, slug) = parse_workspace_slug(&cwd)?; let (username, app_password) = get_bb_creds(&cwd)?; let auth = basic_auth_header(&username, &app_password); @@ -602,7 +635,7 @@ pub(crate) fn bb_merge_pr(cwd: String, pr_id: i64, method: String) -> Result<(), /// 1. Fetch the source branch from origin /// 2. Switch to it (create tracking branch if needed) #[tauri::command] -pub(crate) fn bb_checkout_pr(cwd: String, pr_id: i64) -> Result<(), String> { +pub(crate) async fn bb_checkout_pr(cwd: String, pr_id: i64) -> Result<(), String> { let (workspace, slug) = parse_workspace_slug(&cwd)?; let (username, app_password) = get_bb_creds(&cwd)?; let auth = basic_auth_header(&username, &app_password); @@ -655,7 +688,7 @@ pub(crate) fn bb_checkout_pr(cwd: String, pr_id: i64) -> Result<(), String> { /// List comments on a PR. Returns raw JSON array for TypeScript parsing. #[tauri::command] -pub(crate) fn bb_pr_comments(cwd: String, pr_id: i64) -> Result { +pub(crate) async fn bb_pr_comments(cwd: String, pr_id: i64) -> Result { let (workspace, slug) = parse_workspace_slug(&cwd)?; let (username, app_password) = get_bb_creds(&cwd)?; let auth = basic_auth_header(&username, &app_password); @@ -671,7 +704,7 @@ pub(crate) fn bb_pr_comments(cwd: String, pr_id: i64) -> Result Result<(), String> { +pub(crate) async fn bb_approve_pr(cwd: String, pr_id: i64) -> Result<(), String> { let (workspace, slug) = parse_workspace_slug(&cwd)?; let (username, app_password) = get_bb_creds(&cwd)?; let auth = basic_auth_header(&username, &app_password); @@ -751,7 +784,7 @@ pub(crate) fn bb_approve_pr(cwd: String, pr_id: i64) -> Result<(), String> { /// List files changed in a PR. #[tauri::command] -pub(crate) fn bb_pr_files(cwd: String, pr_id: i64) -> Result, String> { +pub(crate) async fn bb_pr_files(cwd: String, pr_id: i64) -> Result, String> { let (workspace, slug) = parse_workspace_slug(&cwd)?; let (username, app_password) = get_bb_creds(&cwd)?; let auth = basic_auth_header(&username, &app_password); @@ -786,7 +819,7 @@ pub(crate) fn bb_pr_files(cwd: String, pr_id: i64) -> Result, String /// Get the current Bitbucket user (the one whose credentials are stored). #[tauri::command] -pub(crate) fn bb_current_user(cwd: String) -> Result { +pub(crate) async fn bb_current_user(cwd: String) -> Result { let (username, app_password) = get_bb_creds(&cwd)?; let auth = basic_auth_header(&username, &app_password); @@ -801,7 +834,7 @@ pub(crate) fn bb_current_user(cwd: String) -> Result { /// List reviewer candidates (repo members with write access). #[tauri::command] -pub(crate) fn bb_reviewer_candidates(cwd: String) -> Result, String> { +pub(crate) async fn bb_reviewer_candidates(cwd: String) -> Result, String> { let (workspace, slug) = parse_workspace_slug(&cwd)?; let (username, app_password) = get_bb_creds(&cwd)?; let auth = basic_auth_header(&username, &app_password); @@ -847,12 +880,46 @@ pub(crate) fn bb_reviewer_candidates(cwd: String) -> Result String { + if values.is_empty() { + return String::new(); + } + let mut pending = false; + for s in values { + // Bitbucket status states: SUCCESSFUL / FAILED / INPROGRESS / STOPPED. + match js(s, "state").as_str() { + "FAILED" | "STOPPED" => return "FAILURE".to_string(), + "SUCCESSFUL" => {} + _ => pending = true, // INPROGRESS / anything unknown → still running + } + } + if pending { "PENDING".to_string() } else { "SUCCESS".to_string() } +} + +/// Fetch + aggregate the build statuses of commit `sha`. Sync, best-effort +/// (empty on any error) so it's safe to fan out under rayon. +fn bb_rollup_for_sha(workspace: &str, slug: &str, sha: &str, auth: &str) -> String { + if sha.is_empty() { + return String::new(); + } + let url = format!( + "https://api.bitbucket.org/2.0/repositories/{}/{}/commit/{}/statuses?pagelen=100", + workspace, slug, sha + ); + let resp = bb_curl("GET", &url, None, auth).unwrap_or(serde_json::Value::Null); + let values = resp.get("values").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + bb_rollup_from_statuses(&values) +} + /// Get CI status checks for a PR via Bitbucket Pipelines commit statuses endpoint. /// /// Endpoint: GET /2.0/repositories/{ws}/{slug}/commit/{sha}/statuses /// The head commit SHA is read from the PR's `source.commit.hash` field. #[tauri::command] -pub(crate) fn bb_pr_ci_checks(cwd: String, pr_id: i64) -> Result, String> { +pub(crate) async fn bb_pr_ci_checks(cwd: String, pr_id: i64) -> Result, String> { let (workspace, slug) = parse_workspace_slug(&cwd)?; let (username, app_password) = get_bb_creds(&cwd)?; let auth = basic_auth_header(&username, &app_password); @@ -913,7 +980,7 @@ pub(crate) fn bb_pr_ci_checks(cwd: String, pr_id: i64) -> Result, S /// Bitbucket has no native draft concept — the convention is a "Draft: " title /// prefix. This command strips it via a PUT update on the PR. #[tauri::command] -pub(crate) fn bb_convert_draft_to_ready(cwd: String, pr_id: i64) -> Result<(), String> { +pub(crate) async fn bb_convert_draft_to_ready(cwd: String, pr_id: i64) -> Result<(), String> { let (workspace, slug) = parse_workspace_slug(&cwd)?; let (username, app_password) = get_bb_creds(&cwd)?; let auth = basic_auth_header(&username, &app_password); diff --git a/apps/desktop/src-tauri/src/commands/credentials.rs b/apps/desktop/src-tauri/src/commands/credentials.rs index 5593de59..c4db8f6e 100644 --- a/apps/desktop/src-tauri/src/commands/credentials.rs +++ b/apps/desktop/src-tauri/src/commands/credentials.rs @@ -30,7 +30,7 @@ /// `account` — sub-key within the service, e.g. `"workspace:username"`. /// `value` — the secret (PAT, app-password, etc.). #[tauri::command] -pub(crate) fn set_credential(service: String, account: String, value: String) -> Result<(), String> { +pub(crate) async fn set_credential(service: String, account: String, value: String) -> Result<(), String> { let entry = keyring::Entry::new(&service, &account) .map_err(|e| format!("keyring init failed for {}/{}: {}", service, account, e))?; entry @@ -44,7 +44,7 @@ pub(crate) fn set_credential(service: String, account: String, value: String) -> /// the keychain is locked. The caller should surface this to the user as /// "Please configure your credentials in Settings > Accounts." #[tauri::command] -pub(crate) fn get_credential(service: String, account: String) -> Result { +pub(crate) async fn get_credential(service: String, account: String) -> Result { let entry = keyring::Entry::new(&service, &account) .map_err(|e| format!("keyring init failed for {}/{}: {}", service, account, e))?; entry @@ -59,7 +59,7 @@ pub(crate) fn get_credential(service: String, account: String) -> Result Result<(), String> { +pub(crate) async fn delete_credential(service: String, account: String) -> Result<(), String> { let entry = match keyring::Entry::new(&service, &account) { Ok(e) => e, Err(_) => return Ok(()), // Entry cannot exist if we can't init diff --git a/apps/desktop/src-tauri/src/commands/files.rs b/apps/desktop/src-tauri/src/commands/files.rs index 679bb326..8ca1f5b6 100644 --- a/apps/desktop/src-tauri/src/commands/files.rs +++ b/apps/desktop/src-tauri/src/commands/files.rs @@ -18,13 +18,13 @@ use crate::types::*; use std::path::PathBuf; #[tauri::command] -pub(crate) fn read_file(cwd: String, path: String) -> Result { +pub(crate) async fn read_file(cwd: String, path: String) -> Result { let full = safe_repo_path(&cwd, &path)?; std::fs::read_to_string(&full).map_err(|e| format!("Failed to read {}: {}", path, e)) } #[tauri::command] -pub(crate) fn write_file(cwd: String, path: String, content: String) -> Result<(), String> { +pub(crate) async fn write_file(cwd: String, path: String, content: String) -> Result<(), String> { let full = safe_repo_path(&cwd, &path)?; std::fs::write(&full, &content).map_err(|e| format!("Failed to write {}: {}", path, e)) } @@ -44,7 +44,7 @@ pub(crate) fn write_file(cwd: String, path: String, content: String) -> Result<( /// here (`.gitwandrc` / `.gitwandrc.json`), so no user-supplied path /// component reaches the filesystem. #[tauri::command] -pub(crate) fn write_gitwandrc(cwd: String, content: String) -> Result<(), String> { +pub(crate) async fn write_gitwandrc(cwd: String, content: String) -> Result<(), String> { if cwd.trim().is_empty() { return Err("cwd must not be empty".to_string()); } @@ -68,7 +68,7 @@ pub(crate) fn write_gitwandrc(cwd: String, content: String) -> Result<(), String } #[tauri::command] -pub(crate) fn read_file_at_revision( +pub(crate) async fn read_file_at_revision( cwd: String, rev: String, path: String, @@ -136,7 +136,7 @@ pub(crate) fn read_file_at_revision( } #[tauri::command] -pub(crate) fn folder_diff(cwd: String, ref_a: String, ref_b: String) -> Result { +pub(crate) async fn folder_diff(cwd: String, ref_a: String, ref_b: String) -> Result { if cwd.trim().is_empty() { return Err("cwd must not be empty".to_string()); } @@ -223,7 +223,7 @@ pub(crate) fn folder_diff(cwd: String, ref_a: String, ref_b: String) -> Result) -> Result { +pub(crate) async fn list_dir(path: Option) -> Result { let home_path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); let home = home_path.to_string_lossy().to_string(); diff --git a/apps/desktop/src-tauri/src/commands/gh.rs b/apps/desktop/src-tauri/src/commands/gh.rs index 3c350c78..14223b66 100644 --- a/apps/desktop/src-tauri/src/commands/gh.rs +++ b/apps/desktop/src-tauri/src/commands/gh.rs @@ -15,6 +15,8 @@ use crate::git::*; use crate::types::*; +use crate::commands::github_api; +use serde_json; /// List pull requests using `gh` CLI. /// @@ -39,12 +41,17 @@ use crate::types::*; /// CI/merge state pieces — keeps the list query light while still letting /// the sidebar render CI/merge dots without a click. #[tauri::command] -pub(crate) fn gh_list_prs( +pub(crate) async fn gh_list_prs( cwd: String, state: String, limit: Option, offset: Option, ) -> Result, String> { + // Settings-managed token present → tokenless REST path (no `gh` needed). + if let Some(tok) = github_api::settings_github_token() { + let st = if state.is_empty() { "open" } else { &state }; + return github_api::rest_list_prs(&cwd, st, limit.unwrap_or(10), offset.unwrap_or(0), &tok); + } let st = if state.is_empty() { "open" } else { &state }; // Naïve pagination: `gh pr list` has no native --offset, so we ask for // `offset + limit` and slice. Works correctly for the small windows the @@ -100,7 +107,10 @@ pub(crate) fn gh_list_prs( /// Returns 0 on any non-fatal failure (no remote, no token, etc.) so the /// dashboard can still render — the caller decides whether to surface. #[tauri::command] -pub(crate) fn gh_pr_count(cwd: String, state: String) -> Result { +pub(crate) async fn gh_pr_count(cwd: String, state: String) -> Result { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_pr_count(&cwd, &state, &tok); + } let st = state.to_lowercase(); let states_expr = match st.as_str() { "closed" => "[CLOSED]", @@ -111,8 +121,10 @@ pub(crate) fn gh_pr_count(cwd: String, state: String) -> Result { // Resolve owner/name once via `gh repo view`. `nameWithOwner` is the // canonical "org/repo" slug — splittable by '/' without ambiguity. + // v2.17: include isFork and parent to correctly count PRs in the base repo + // (the target repo that `gh pr list` would use by default). let view = hidden_cmd("gh") - .args(["repo", "view", "--json", "nameWithOwner"]) + .args(["repo", "view", "--json", "nameWithOwner,isFork,parent"]) .current_dir(&cwd) .output() .map_err(|e| format!("gh repo view (for pr count): {}", e))?; @@ -122,19 +134,22 @@ pub(crate) fn gh_pr_count(cwd: String, state: String) -> Result { String::from_utf8_lossy(&view.stderr) )); } - let nwo = { - let stdout = String::from_utf8_lossy(&view.stdout); - // Tiny, dependency-free extraction — avoids dragging in serde_json - // just for one string field. Mirrors extract_json_string in parse.rs. - let key = "\"nameWithOwner\""; - let start = stdout.find(key) - .ok_or_else(|| "nameWithOwner missing from gh repo view output".to_string())?; - let after = &stdout[start + key.len()..]; - let q1 = after.find('"').ok_or_else(|| "malformed nameWithOwner".to_string())?; - let rest = &after[q1 + 1..]; - let q2 = rest.find('"').ok_or_else(|| "unterminated nameWithOwner".to_string())?; - rest[..q2].to_string() + + let v: serde_json::Value = serde_json::from_slice(&view.stdout).map_err(|e| e.to_string())?; + let is_fork = v.get("isFork").and_then(|b| b.as_bool()).unwrap_or(false); + let nwo = if is_fork { + v.get("parent") + .and_then(|p| { + let owner = p.get("owner").and_then(|o| o.get("login")).and_then(|s| s.as_str())?; + let name = p.get("name").and_then(|s| s.as_str())?; + Some(format!("{}/{}", owner, name)) + }) + .or_else(|| v.get("nameWithOwner").and_then(|s| s.as_str()).map(|s| s.to_string())) + .unwrap_or_default() + } else { + v.get("nameWithOwner").and_then(|s| s.as_str()).unwrap_or("").to_string() }; + let (owner, name) = match nwo.split_once('/') { Some((o, n)) if !o.is_empty() && !n.is_empty() => (o.to_string(), n.to_string()), _ => return Err(format!("Unexpected nameWithOwner format: {}", nwo)), @@ -183,14 +198,18 @@ pub(crate) fn gh_pr_count(cwd: String, state: String) -> Result { /// Create a pull request using `gh` CLI. #[tauri::command] -pub(crate) fn gh_create_pr( +pub(crate) async fn gh_create_pr( cwd: String, title: String, body: String, base: String, + base_repo: Option, draft: bool, reviewers: Option>, ) -> Result { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_create_pr(&cwd, title, body, base, base_repo, draft, reviewers, &tok); + } let mut args = vec![ "pr".to_string(), "create".to_string(), @@ -200,6 +219,15 @@ pub(crate) fn gh_create_pr( body, ]; + // Cross-fork target: `gh pr create --repo owner/repo` opens the PR against + // that repo, using the current fork branch as head automatically. + if let Some(repo) = base_repo { + if !repo.trim().is_empty() { + args.push("--repo".to_string()); + args.push(repo.trim().to_string()); + } + } + if !base.is_empty() { args.push("--base".to_string()); args.push(base); @@ -294,7 +322,7 @@ pub(crate) fn gh_create_pr( /// Calls `gh api /repos/:owner/:repo/assignees` (paginated) which returns /// users with push access — exactly the set GitHub allows as reviewers. #[tauri::command] -pub(crate) fn gh_list_reviewer_candidates(cwd: String) -> Result, String> { +pub(crate) async fn gh_list_reviewer_candidates(cwd: String) -> Result, String> { // Discover owner/repo from the current repo. let view = hidden_cmd("gh") .args(["repo", "view", "--json", "owner,name"]) @@ -367,7 +395,10 @@ pub(crate) fn gh_list_reviewer_candidates(cwd: String) -> Result Result<(), String> { +pub(crate) async fn gh_checkout_pr(cwd: String, number: i64) -> Result<(), String> { + if github_api::settings_github_token().is_some() { + return github_api::rest_checkout_pr(&cwd, number); + } let output = hidden_cmd("gh") .args(["pr", "checkout", &number.to_string()]) .current_dir(&cwd) @@ -384,7 +415,10 @@ pub(crate) fn gh_checkout_pr(cwd: String, number: i64) -> Result<(), String> { /// Merge a PR using `gh` CLI. #[tauri::command] -pub(crate) fn gh_merge_pr(cwd: String, number: i64, method: String) -> Result<(), String> { +pub(crate) async fn gh_merge_pr(cwd: String, number: i64, method: String) -> Result<(), String> { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_merge_pr(&cwd, number, &method, &tok); + } let merge_flag = match method.as_str() { "squash" => "--squash", "rebase" => "--rebase", @@ -410,7 +444,10 @@ pub(crate) fn gh_merge_pr(cwd: String, number: i64, method: String) -> Result<() /// Idempotent — `gh pr ready` on a non-draft PR exits 0 with a message like /// "Pull request #N is already marked as ready for review." #[tauri::command] -pub(crate) fn gh_pr_ready(cwd: String, number: i64) -> Result<(), String> { +pub(crate) async fn gh_pr_ready(cwd: String, number: i64) -> Result<(), String> { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_pr_ready(&cwd, number, &tok); + } let output = hidden_cmd("gh") .args(["pr", "ready", &number.to_string()]) .current_dir(&cwd) @@ -427,7 +464,10 @@ pub(crate) fn gh_pr_ready(cwd: String, number: i64) -> Result<(), String> { /// Get detailed PR information using `gh` CLI. #[tauri::command] -pub(crate) fn gh_pr_detail(cwd: String, number: i64) -> Result { +pub(crate) async fn gh_pr_detail(cwd: String, number: i64) -> Result { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_pr_detail(&cwd, number, &tok); + } let output = hidden_cmd("gh") .args([ "pr", "view", &number.to_string(), @@ -445,12 +485,52 @@ pub(crate) fn gh_pr_detail(cwd: String, number: i64) -> Result Option { + let nwo = github_nwo_from_pr_url(pr_url)?; + let output = hidden_cmd("gh") + .args(["api", &format!("repos/{}", nwo), "--jq", ".permissions.push"]) + .current_dir(cwd) + .output() + .ok()?; + if !output.status.success() { + return None; + } + match String::from_utf8_lossy(&output.stdout).trim() { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +/// Extract `owner/repo` from a GitHub PR url such as +/// `https://github.com/owner/repo/pull/123`. +fn github_nwo_from_pr_url(url: &str) -> Option { + let rest = url.split("github.com/").nth(1)?; + let mut parts = rest.split('/'); + let owner = parts.next().filter(|s| !s.is_empty())?; + let repo = parts.next().filter(|s| !s.is_empty())?; + Some(format!("{}/{}", owner, repo)) } /// Get the diff of a PR using `gh` CLI. #[tauri::command] -pub(crate) fn gh_pr_diff(cwd: String, number: i64) -> Result { +pub(crate) async fn gh_pr_diff(cwd: String, number: i64) -> Result { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_pr_diff(&cwd, number, &tok); + } let output = hidden_cmd("gh") .args(["pr", "diff", &number.to_string()]) .current_dir(&cwd) @@ -466,7 +546,10 @@ pub(crate) fn gh_pr_diff(cwd: String, number: i64) -> Result { /// Get CI checks for a PR using `gh` CLI. #[tauri::command] -pub(crate) fn gh_pr_checks(cwd: String, number: i64) -> Result, String> { +pub(crate) async fn gh_pr_checks(cwd: String, number: i64) -> Result, String> { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_pr_checks(&cwd, number, &tok); + } let output = hidden_cmd("gh") .args([ "pr", "checks", &number.to_string(), @@ -519,3 +602,83 @@ pub(crate) fn gh_pr_checks(cwd: String, number: i64) -> Result, Str Ok(checks) } + +/// List inline review comments (anchored to diff lines) for a PR. +/// Token present → REST; otherwise `gh api`. +#[tauri::command] +pub(crate) async fn gh_pr_comments(cwd: String, number: i64) -> Result, String> { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_pr_comments(&cwd, number, &tok); + } + let json = gh_api_json( + &cwd, + &format!("repos/{{owner}}/{{repo}}/pulls/{}/comments?per_page=100", number), + )?; + Ok(github_api::map_comments(&json, false)) +} + +/// List issue-level (conversation) comments for a PR. +/// Token present → REST; otherwise `gh api`. +#[tauri::command] +pub(crate) async fn gh_pr_issue_comments(cwd: String, number: i64) -> Result, String> { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_pr_issue_comments(&cwd, number, &tok); + } + let json = gh_api_json( + &cwd, + &format!("repos/{{owner}}/{{repo}}/issues/{}/comments?per_page=100", number), + )?; + Ok(github_api::map_comments(&json, true)) +} + +/// Run `gh api ` in `cwd` and parse stdout as JSON. Shared by the +/// comment-list fallbacks when no token is configured. +fn gh_api_json(cwd: &str, path: &str) -> Result { + let output = hidden_cmd("gh") + .args(["api", path]) + .current_dir(cwd) + .output() + .map_err(|e| format!("gh api: {}", e))?; + if !output.status.success() { + return Err(format!("gh api failed: {}", String::from_utf8_lossy(&output.stderr))); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let trimmed = stdout.trim(); + if trimmed.is_empty() { + return Ok(serde_json::Value::Array(Vec::new())); + } + serde_json::from_str(trimmed).map_err(|e| format!("parse gh api response: {}", e)) +} + +/// Report the current repo's fork relationship so the PR create view can offer +/// "open against upstream". Token present → REST; otherwise `gh repo view`. +#[tauri::command] +pub(crate) async fn gh_fork_info(cwd: String) -> Result { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_fork_info(&cwd, &tok); + } + let output = hidden_cmd("gh") + .args(["repo", "view", "--json", "isFork,parent,nameWithOwner"]) + .current_dir(&cwd) + .output() + .map_err(|e| format!("gh repo view (fork info): {}", e))?; + if !output.status.success() { + return Err(format!( + "gh repo view failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let v: serde_json::Value = + serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())?; + let origin = v.get("nameWithOwner").and_then(|s| s.as_str()).unwrap_or("").to_string(); + let is_fork = v.get("isFork").and_then(|b| b.as_bool()).unwrap_or(false); + let parent = v + .get("parent") + .and_then(|p| { + let owner = p.get("owner").and_then(|o| o.get("login")).and_then(|s| s.as_str())?; + let name = p.get("name").and_then(|s| s.as_str())?; + Some(format!("{}/{}", owner, name)) + }) + .unwrap_or_default(); + Ok(ForkInfo { is_fork, origin, parent }) +} diff --git a/apps/desktop/src-tauri/src/commands/github_api.rs b/apps/desktop/src-tauri/src/commands/github_api.rs new file mode 100644 index 00000000..7d5352b1 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/github_api.rs @@ -0,0 +1,979 @@ +//! GitHub REST/OAuth Tauri commands — tokenless PR workflow. +//! +//! ## Why this exists +//! +//! The historical GitHub integration (`gh.rs`, `ops.rs`) shells out to the +//! `gh` CLI. That requires the user to install and `gh auth login` from a +//! terminal — a friction point for non-CLI users. This module adds a second, +//! self-contained auth path: +//! +//! 1. **OAuth device flow** (`github_device_start` / `github_device_poll`): +//! "Sign in with GitHub" from Settings → Accounts. The resulting token is +//! stored in the OS keychain under `service = "gitwand:github"`, +//! `account = "oauth"`. +//! 2. **REST API calls** via `curl` (mirrors `bitbucket.rs`): when a keychain +//! token is present, the `gh_*` commands route here instead of spawning +//! `gh`, so the `gh` binary is no longer required. +//! +//! ## Routing rule (see `settings_github_token`) +//! +//! REST is used **only** when an explicit Settings token exists in the keychain. +//! The ambient `GH_TOKEN` / `GITHUB_TOKEN` env vars (auto-populated from +//! `gh auth token` on macOS, see `shell_env.rs`) keep driving the `gh` CLI path +//! exactly as before — we never silently change behaviour for existing `gh` +//! users. Token in keychain ⇒ REST; otherwise ⇒ `gh`. +//! +//! ## Scope +//! +//! Core PR workflow only: current-user, list, count, detail, diff, checks, +//! files, create, merge, checkout, ready. Comments / reviews / reviewer +//! candidates / intelligence keep the `gh` path (or degrade gracefully). +//! +//! ## Security note +//! +//! The token is injected via the `Authorization` header in `curl` process +//! arguments — same exposure profile as `bitbucket.rs` (acceptable for now; +//! harden via `curl --config -` stdin piping in a follow-up). The token is +//! never logged or included in error messages. + +use crate::git::{git_cmd, hidden_cmd}; +use crate::types::*; +use rayon::prelude::*; + +// ─── Constants ────────────────────────────────────────────────────────────── + +/// Keychain service for the Settings-managed GitHub token. +pub(crate) const GH_SERVICE: &str = "gitwand:github"; +/// Keychain account key — fixed, since the REST helpers resolve the token +/// without knowing the login up front. +pub(crate) const GH_ACCOUNT: &str = "oauth"; + +/// GitHub OAuth App client_id (public, not a secret — safe to ship). +/// +/// **Must be replaced** with a real client_id from a registered GitHub OAuth +/// App that has *device flow* enabled +/// (github.com/settings/developers → New OAuth App → "Enable Device Flow"). +/// +/// Resolution order: +/// 1. `GITWAND_GH_CLIENT_ID` at **runtime** (env var of the running app) — +/// lets `GITWAND_GH_CLIENT_ID=… pnpm tauri dev` work without fighting +/// cargo's build cache. +/// 2. `GITWAND_GH_CLIENT_ID` baked in at **build time** (release builds). +/// 3. Placeholder — surfaces a clear "not configured" error. +fn client_id() -> String { + if let Ok(v) = std::env::var("GITWAND_GH_CLIENT_ID") { + let v = v.trim().to_string(); + if !v.is_empty() { + return v; + } + } + option_env!("GITWAND_GH_CLIENT_ID") + // GitWand's registered GitHub OAuth App (device flow enabled). Public + // client_id — not a secret, safe to ship. + .unwrap_or("Ov23licwiCpPiRPRodWN") + .to_string() +} + +const API_BASE: &str = "https://api.github.com"; + +// ─── Token resolution ─────────────────────────────────────────────────────── + +/// Read the Settings-managed GitHub token from the OS keychain. +/// +/// Returns `Some(token)` only when the user has explicitly signed in via +/// Settings > Accounts. Ambient env tokens are intentionally ignored here so +/// existing `gh` users are unaffected (see module docs). +pub(crate) fn settings_github_token() -> Option { + let entry = keyring::Entry::new(GH_SERVICE, GH_ACCOUNT).ok()?; + let tok = entry.get_password().ok()?; + let tok = tok.trim().to_string(); + if tok.is_empty() { None } else { Some(tok) } +} + +// ─── Owner/repo resolution ────────────────────────────────────────────────── + +/// Parse `(owner, repo)` from `git remote get-url origin` in `cwd`. +/// +/// Handles HTTPS and SSH GitHub remote URL formats. +fn owner_repo(cwd: &str) -> Result<(String, String), String> { + let output = hidden_cmd("git") + .args(["remote", "get-url", "origin"]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git remote get-url: {}", e))?; + if !output.status.success() { + return Err("No 'origin' remote found in this repo.".to_string()); + } + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + let path = if url.starts_with("git@") || url.contains("@github.com:") { + // git@github.com:owner/repo.git → owner/repo + url.splitn(2, ':') + .nth(1) + .unwrap_or("") + .trim_end_matches(".git") + .to_string() + } else { + // https://github.com/owner/repo.git → owner/repo + url.trim_end_matches('/') + .trim_end_matches(".git") + .splitn(2, "github.com/") + .nth(1) + .unwrap_or("") + .to_string() + }; + + let mut parts = path.splitn(2, '/'); + let owner = parts.next().unwrap_or("").to_string(); + let repo = parts.next().unwrap_or("").to_string(); + if owner.is_empty() || repo.is_empty() { + return Err(format!("Could not parse GitHub owner/repo from remote URL: {}", url)); + } + Ok((owner, repo)) +} + +/// Current branch name (`git rev-parse --abbrev-ref HEAD`). +fn current_branch(cwd: &str) -> Result { + let output = hidden_cmd("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git rev-parse: {}", e))?; + if !output.status.success() { + return Err("Could not determine current branch.".to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +// ─── HTTP transport (curl) ────────────────────────────────────────────────── + +/// Run a `curl` request and return `(http_status, body_text)`. +/// +/// `accept` selects the representation (`application/vnd.github+json` for JSON, +/// `application/vnd.github.v3.diff` for raw diffs). The token, when present, +/// is sent as a Bearer credential. +fn curl_raw( + method: &str, + url: &str, + token: Option<&str>, + body_json: Option<&str>, + accept: &str, +) -> Result<(i32, String), String> { + // `-w` appends the HTTP status on its own marker line so we can split it + // off the body without a second request. + const MARKER: &str = "\n__GW_HTTP_STATUS__"; + let mut args: Vec = vec![ + "-s".to_string(), + "-X".to_string(), method.to_string(), + "-H".to_string(), format!("Accept: {}", accept), + "-H".to_string(), "User-Agent: GitWand".to_string(), + "-H".to_string(), "X-GitHub-Api-Version: 2022-11-28".to_string(), + ]; + if let Some(tok) = token { + args.push("-H".to_string()); + args.push(format!("Authorization: Bearer {}", tok)); + } + if let Some(b) = body_json { + args.push("-H".to_string()); + args.push("Content-Type: application/json".to_string()); + args.push("-d".to_string()); + args.push(b.to_string()); + } + args.push("-w".to_string()); + args.push(format!("{}%{{http_code}}", MARKER)); + args.push(url.to_string()); + + let output = hidden_cmd("curl") + .args(&args) + .output() + .map_err(|e| format!("curl not found or failed to spawn: {}", e))?; + + let combined = String::from_utf8_lossy(&output.stdout).to_string(); + let (body, status) = match combined.rsplit_once(MARKER) { + Some((b, s)) => (b.to_string(), s.trim().parse::().unwrap_or(0)), + None => (combined, 0), + }; + Ok((status, body)) +} + +/// Perform a GitHub JSON API call. Maps HTTP ≥ 400 to a user-facing error, +/// preferring GitHub's `message` field. +fn api_json( + method: &str, + url: &str, + token: &str, + body_json: Option<&str>, +) -> Result { + let (status, body) = curl_raw(method, url, Some(token), body_json, "application/vnd.github+json")?; + if status >= 400 { + let msg = serde_json::from_str::(body.trim()) + .ok() + .and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from)) + .unwrap_or_else(|| format!("HTTP {}", status)); + return Err(format!("GitHub API error ({}): {}", status, msg)); + } + if body.trim().is_empty() { + return Ok(serde_json::Value::Null); + } + serde_json::from_str(body.trim()) + .map_err(|e| format!("Failed to parse GitHub response: {}", e)) +} + +// ─── JSON field helpers ───────────────────────────────────────────────────── + +fn js(v: &serde_json::Value, key: &str) -> String { + v.get(key).and_then(|x| x.as_str()).unwrap_or("").to_string() +} + +fn ji(v: &serde_json::Value, key: &str) -> i64 { + v.get(key).and_then(|x| x.as_i64()).unwrap_or(0) +} + +fn jb(v: &serde_json::Value, key: &str) -> bool { + v.get(key).and_then(|x| x.as_bool()).unwrap_or(false) +} + +/// `obj[outer][inner]` as a string. +fn jnested(v: &serde_json::Value, outer: &str, inner: &str) -> String { + v.get(outer).and_then(|o| o.get(inner)).and_then(|s| s.as_str()).unwrap_or("").to_string() +} + +/// Collect `[{ key: "..." }]` string fields into a Vec. +fn jlogins(v: &serde_json::Value, arr_key: &str, field: &str) -> Vec { + v.get(arr_key) + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|u| u.get(field).and_then(|s| s.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +// ─── Mapping ──────────────────────────────────────────────────────────────── + +/// Map a GitHub REST pull-request object to `PullRequest`. +fn json_to_pr(pr: &serde_json::Value) -> PullRequest { + let merged = pr.get("merged_at").map(|m| !m.is_null()).unwrap_or(false); + let state = if merged { "merged".to_string() } else { js(pr, "state") }; + let review_requested = jlogins(pr, "requested_reviewers", "login"); + // REST has no review-decision field (that's GraphQL). Use the requested + // reviewers as a cheap proxy: GitHub removes a reviewer from this list once + // they approve, so a non-empty list ⇒ still waiting on review. Lets the PR + // sidebar flag the PR (yellow dot) without a per-PR roundtrip. + let review_decision = if !review_requested.is_empty() { + "REVIEW_REQUIRED".to_string() + } else { + String::new() + }; + PullRequest { + number: ji(pr, "number"), + title: js(pr, "title"), + state, + author: jnested(pr, "user", "login"), + branch: jnested(pr, "head", "ref"), + base: jnested(pr, "base", "ref"), + draft: jb(pr, "draft"), + created_at: js(pr, "created_at"), + updated_at: js(pr, "updated_at"), + url: js(pr, "html_url"), + additions: ji(pr, "additions"), + deletions: ji(pr, "deletions"), + labels: jlogins(pr, "labels", "name"), + assignees: jlogins(pr, "assignees", "login"), + review_requested, + review_decision, + merge_state_status: js(pr, "mergeable_state").to_uppercase(), + checks_rollup: String::new(), + comment_count: ji(pr, "comments"), + } +} + +/// Map a GitHub REST pull-request object to `PullRequestDetail`. +fn json_to_detail(pr: &serde_json::Value) -> PullRequestDetail { + let merged_at = pr.get("merged_at").and_then(|m| m.as_str()).unwrap_or("").to_string(); + let merged = !merged_at.is_empty(); + let state = if merged { "merged".to_string() } else { js(pr, "state") }; + // GitHub `mergeable`: true / false / null (still computing). + let mergeable = match pr.get("mergeable").and_then(|m| m.as_bool()) { + Some(true) => "MERGEABLE".to_string(), + Some(false) => "CONFLICTING".to_string(), + None => "UNKNOWN".to_string(), + }; + PullRequestDetail { + number: ji(pr, "number"), + title: js(pr, "title"), + body: js(pr, "body"), + state, + author: jnested(pr, "user", "login"), + branch: jnested(pr, "head", "ref"), + base: jnested(pr, "base", "ref"), + draft: jb(pr, "draft"), + created_at: js(pr, "created_at"), + updated_at: js(pr, "updated_at"), + merged_at, + url: js(pr, "html_url"), + additions: ji(pr, "additions"), + deletions: ji(pr, "deletions"), + changed_files: ji(pr, "changed_files"), + comments: ji(pr, "comments"), + review_comments: ji(pr, "review_comments"), + labels: jlogins(pr, "labels", "name"), + reviewers: jlogins(pr, "requested_reviewers", "login"), + mergeable, + checks_status: String::new(), + // Filled in by rest_pr_detail via an explicit repo lookup — the pulls + // response does not embed `permissions` on the nested base repo. + can_merge: None, + } +} + +// ─── REST PR workflow ─────────────────────────────────────────────────────── + +pub(crate) fn rest_current_user(token: &str) -> Result { + let v = api_json("GET", &format!("{}/user", API_BASE), token, None)?; + let login = v.get("login").and_then(|l| l.as_str()).unwrap_or("").to_string(); + if login.is_empty() { + return Err("GitHub returned an empty login for this token.".to_string()); + } + Ok(login) +} + +pub(crate) fn rest_list_prs( + cwd: &str, + state: &str, + limit: i64, + offset: i64, + token: &str, +) -> Result, String> { + let (owner, repo) = owner_repo(cwd)?; + let origin = format!("{}/{}", owner, repo); + let base = base_owner_repo(cwd, token).unwrap_or_else(|_| origin.clone()); + + let page = limit.max(1); + let off = offset.max(0); + // Naïve pagination identical to gh_list_prs: fetch offset+limit, then slice. + let per_page = (page + off).clamp(1, 100); + // "merged" is not a native GitHub pulls state — fetch closed and filter. + let api_state = match state { + "closed" => "closed", + "merged" => "closed", + "all" => "all", + _ => "open", + }; + + // Fetch PRs from the base repository (matches `gh pr list` behavior). + let raw: Vec = api_json( + "GET", + &format!( + "{}/repos/{}/pulls?state={}&per_page={}&page=1&sort=updated&direction=desc", + API_BASE, base, api_state, per_page + ), + token, + None, + )? + .as_array() + .cloned() + .unwrap_or_default(); + + let mut prs: Vec = raw + .iter() + .filter(|pr| { + if state == "merged" { + pr.get("merged_at").map(|m| !m.is_null()).unwrap_or(false) + } else { + true + } + }) + .map(json_to_pr) + .collect(); + if off > 0 { + let skip = (off as usize).min(prs.len()); + prs.drain(..skip); + } + prs.truncate(page as usize); + + // The list endpoint omits additions/deletions (only the per-PR detail has + // them). Fill +/- locally: refresh remote-tracking branches once on the + // first page (a single incremental fetch), then numstat per PR. Fork/deleted + // head branches not present in origin simply leave the stats at 0. + if !prs.is_empty() { + if off == 0 { + let _ = git_cmd().args(["fetch", "origin"]).current_dir(cwd).output(); + } + prs.par_iter_mut().for_each(|pr| { + let (adds, dels) = diff_numstat(cwd, &pr.branch, &pr.base); + pr.additions = adds; + pr.deletions = dels; + }); + // The REST list carries no CI status. Resolve each PR's head SHA from + // the raw list payload, then aggregate its check-runs so the sidebar can + // colour the dot (red = failing, yellow = pending, green = passing). + // We use the REST check-runs endpoint — not GraphQL `statusCheckRollup` + // — because fine-grained / GitHub-App tokens can read check-runs but + // often can't read the GraphQL rollup field, which would silently leave + // every dot green. + let sha_by_num: std::collections::HashMap = raw + .iter() + .filter_map(|pr| { + let n = pr.get("number").and_then(|x| x.as_i64())?; + Some((n, jnested(pr, "head", "sha"))) + }) + .collect(); + let rollups: std::collections::HashMap = prs + .par_iter() + .filter_map(|pr| { + let sha = sha_by_num.get(&pr.number)?; + let rollup = rest_rollup_for_sha(&base, sha, token); + if rollup.is_empty() { None } else { Some((pr.number, rollup)) } + }) + .collect(); + for pr in &mut prs { + if let Some(state) = rollups.get(&pr.number) { + pr.checks_rollup = state.clone(); + } + } + } + Ok(prs) +} + +/// Aggregate the check-runs of commit `sha` in `repo` ("owner/name") into a +/// single rollup state: `FAILURE` / `PENDING` / `SUCCESS`, or `""` when the +/// commit has no checks configured. Best effort — any HTTP error yields `""`. +/// +/// Uses the REST check-runs endpoint (the same one that powers the CI tab's +/// per-check list) rather than GraphQL `statusCheckRollup`, so it keeps working +/// with fine-grained / GitHub-App tokens that can't read the GraphQL field. +fn rest_rollup_for_sha(repo: &str, sha: &str, token: &str) -> String { + if sha.is_empty() { + return String::new(); + } + let url = format!("{}/repos/{}/commits/{}/check-runs", API_BASE, repo, sha); + let v = match api_json("GET", &url, token, None) { + Ok(v) => v, + Err(_) => return String::new(), + }; + let runs = v.get("check_runs").and_then(|r| r.as_array()).cloned().unwrap_or_default(); + rollup_from_check_runs(&runs) +} + +/// Reduce a set of check-run objects to one rollup state. +/// Precedence: any failure ⇒ `FAILURE`, else any still-running ⇒ `PENDING`, +/// else `SUCCESS`. Empty input ⇒ `""`. +fn rollup_from_check_runs(runs: &[serde_json::Value]) -> String { + if runs.is_empty() { + return String::new(); + } + let mut pending = false; + for run in runs { + // `status`: QUEUED / IN_PROGRESS / COMPLETED. + // `conclusion`: only meaningful once COMPLETED. + if js(run, "status").to_uppercase() != "COMPLETED" { + pending = true; + continue; + } + match js(run, "conclusion").to_uppercase().as_str() { + "FAILURE" | "TIMED_OUT" | "CANCELLED" | "ACTION_REQUIRED" | "STARTUP_FAILURE" + | "STALE" => return "FAILURE".to_string(), + "SUCCESS" | "NEUTRAL" | "SKIPPED" => {} + _ => pending = true, + } + } + if pending { "PENDING".to_string() } else { "SUCCESS".to_string() } +} + +/// `git diff --numstat origin/base...origin/head` → (additions, deletions). +/// Returns zeros when the refs aren't available locally. +fn diff_numstat(cwd: &str, head: &str, base: &str) -> (i64, i64) { + if head.is_empty() || base.is_empty() { + return (0, 0); + } + let range = format!("origin/{}...origin/{}", base, head); + let out = match hidden_cmd("git") + .args(["diff", "--numstat", &range]) + .current_dir(cwd) + .output() + { + Ok(o) if o.status.success() => o, + _ => return (0, 0), + }; + let text = String::from_utf8_lossy(&out.stdout); + let (mut adds, mut dels) = (0i64, 0i64); + for line in text.lines() { + let mut cols = line.split('\t'); + adds += cols.next().unwrap_or("").parse::().unwrap_or(0); + dels += cols.next().unwrap_or("").parse::().unwrap_or(0); + } + (adds, dels) +} + +/// Fetch a PR object, trying `origin` first and falling back to the upstream +/// parent (for fork → upstream PRs that don't live in origin). Returns the +/// `owner/repo` the PR actually lives in alongside its JSON, so callers issue +/// follow-up requests against the right repo. +fn get_pr_json(cwd: &str, number: i64, token: &str) -> Result<(String, serde_json::Value), String> { + let (owner, repo) = owner_repo(cwd)?; + let origin = format!("{}/{}", owner, repo); + let (st, body) = curl_raw( + "GET", + &format!("{}/repos/{}/pulls/{}", API_BASE, origin, number), + Some(token), + None, + "application/vnd.github+json", + )?; + if st < 400 { + let v = serde_json::from_str(body.trim()).map_err(|e| format!("parse PR: {}", e))?; + return Ok((origin, v)); + } + if st == 404 { + if let Some(parent) = upstream_parent(cwd, token) { + let (st2, body2) = curl_raw( + "GET", + &format!("{}/repos/{}/pulls/{}", API_BASE, parent, number), + Some(token), + None, + "application/vnd.github+json", + )?; + if st2 < 400 { + let v = serde_json::from_str(body2.trim()).map_err(|e| format!("parse PR: {}", e))?; + return Ok((parent, v)); + } + } + } + Err(format!("GitHub API error ({}) fetching PR #{}", st, number)) +} + +pub(crate) fn rest_pr_count(cwd: &str, state: &str, token: &str) -> Result { + let base = base_owner_repo(cwd, token).unwrap_or_else(|_| { + let (o, r) = owner_repo(cwd).unwrap_or_default(); + format!("{}/{}", o, r) + }); + let qualifier = match state.to_lowercase().as_str() { + "closed" => "+state:closed", + "merged" => "+is:merged", + "all" => "", + _ => "+state:open", + }; + // /search/issues returns total_count without expanding every item. + let url = format!( + "{}/search/issues?q=repo:{}+type:pr{}&per_page=1", + API_BASE, base, qualifier + ); + let v = api_json("GET", &url, token, None)?; + Ok(v.get("total_count").and_then(|c| c.as_i64()).unwrap_or(0)) +} + +pub(crate) fn rest_pr_detail(cwd: &str, number: i64, token: &str) -> Result { + let (repo, v) = get_pr_json(cwd, number, token)?; + let mut detail = json_to_detail(&v); + // The REST PR object carries no CI status; aggregate the head commit's + // check-runs so the CI tab can colour itself (red / yellow / green). + let sha = jnested(&v, "head", "sha"); + detail.checks_status = rest_rollup_for_sha(&repo, &sha, token); + // The nested `base.repo` in a pulls response omits the `permissions` block — + // only the top-level repo endpoint returns it. `repo` is the *base* repo + // (upstream for a fork), so this checks merge rights on the right side. + if let Some(cm) = rest_repo_can_push(&repo, token) { + detail.can_merge = Some(cm); + } + Ok(detail) +} + +/// Whether the authenticated user has push (= merge) access to `repo` +/// (`owner/name`), via `GET /repos/{repo}`'s `permissions.push`. Returns `None` +/// on any failure so the UI falls back to error-only gating. +fn rest_repo_can_push(repo: &str, token: &str) -> Option { + let url = format!("{}/repos/{}", API_BASE, repo); + let v = api_json("GET", &url, token, None).ok()?; + v.get("permissions") + .and_then(|p| p.get("push")) + .and_then(|b| b.as_bool()) +} + +pub(crate) fn rest_pr_diff(cwd: &str, number: i64, token: &str) -> Result { + // Resolve which repo the PR lives in (origin or upstream parent), then fetch + // its diff from there. + let (repo, _pr) = get_pr_json(cwd, number, token)?; + let url = format!("{}/repos/{}/pulls/{}", API_BASE, repo, number); + let (status, body) = curl_raw("GET", &url, Some(token), None, "application/vnd.github.v3.diff")?; + if status >= 400 { + return Err(format!("GitHub diff failed (HTTP {})", status)); + } + Ok(body) +} + +pub(crate) fn rest_pr_checks(cwd: &str, number: i64, token: &str) -> Result, String> { + // Resolve repo + head SHA, then list check-runs for that commit. + let (repo, pr) = get_pr_json(cwd, number, token)?; + let sha = jnested(&pr, "head", "sha"); + if sha.is_empty() { + return Ok(Vec::new()); + } + let url = format!("{}/repos/{}/commits/{}/check-runs", API_BASE, repo, sha); + let v = match api_json("GET", &url, token, None) { + Ok(v) => v, + Err(_) => return Ok(Vec::new()), // no checks configured — not fatal + }; + let runs = v.get("check_runs").and_then(|r| r.as_array()).cloned().unwrap_or_default(); + let checks = runs + .iter() + .map(|run| CICheck { + name: js(run, "name"), + state: js(run, "status"), + conclusion: js(run, "conclusion"), + details_url: js(run, "html_url"), + }) + .collect(); + Ok(checks) +} + +pub(crate) fn rest_pr_files(cwd: &str, number: i64, token: &str) -> Result, String> { + let (repo, _pr) = get_pr_json(cwd, number, token)?; + let url = format!("{}/repos/{}/pulls/{}/files?per_page=100", API_BASE, repo, number); + let v = api_json("GET", &url, token, None)?; + let files = v + .as_array() + .map(|arr| arr.iter().map(|f| js(f, "filename")).filter(|s| !s.is_empty()).collect()) + .unwrap_or_default(); + Ok(files) +} + +/// Map a GitHub comment object to the snake_case shape the frontend +/// `PrReviewComment` interface expects. `issue_level` flags conversation +/// comments (not anchored to a diff line) so their path/line fields are nulled. +pub(crate) fn map_comment(c: &serde_json::Value, issue_level: bool) -> serde_json::Value { + use serde_json::Value; + let side = { + let s = js(c, "side"); + if s.is_empty() { "RIGHT".to_string() } else { s } + }; + serde_json::json!({ + "id": ji(c, "id"), + "body": js(c, "body"), + "author": jnested(c, "user", "login"), + "created_at": js(c, "created_at"), + "updated_at": js(c, "updated_at"), + "path": if issue_level { String::new() } else { js(c, "path") }, + "line": if issue_level { Value::Null } else { c.get("line").cloned().unwrap_or(Value::Null) }, + "original_line": if issue_level { Value::Null } else { c.get("original_line").cloned().unwrap_or(Value::Null) }, + "side": if issue_level { "RIGHT".to_string() } else { side }, + "start_line": if issue_level { Value::Null } else { c.get("start_line").cloned().unwrap_or(Value::Null) }, + "start_side": if issue_level { Value::Null } else { c.get("start_side").cloned().unwrap_or(Value::Null) }, + "in_reply_to_id": if issue_level { Value::Null } else { c.get("in_reply_to_id").cloned().unwrap_or(Value::Null) }, + "diff_hunk": if issue_level { String::new() } else { js(c, "diff_hunk") }, + "url": js(c, "html_url"), + }) +} + +/// Map a JSON array of GitHub comments into frontend `PrReviewComment` shape. +pub(crate) fn map_comments(v: &serde_json::Value, issue_level: bool) -> Vec { + v.as_array() + .map(|arr| arr.iter().map(|c| map_comment(c, issue_level)).collect()) + .unwrap_or_default() +} + +/// List inline review comments (anchored to diff lines) for a PR (REST). +pub(crate) fn rest_pr_comments(cwd: &str, number: i64, token: &str) -> Result, String> { + let (repo, _pr) = get_pr_json(cwd, number, token)?; + let url = format!("{}/repos/{}/pulls/{}/comments?per_page=100", API_BASE, repo, number); + let v = api_json("GET", &url, token, None)?; + Ok(map_comments(&v, false)) +} + +/// List issue-level (conversation) comments for a PR (REST). +pub(crate) fn rest_pr_issue_comments(cwd: &str, number: i64, token: &str) -> Result, String> { + let (repo, _pr) = get_pr_json(cwd, number, token)?; + let url = format!("{}/repos/{}/issues/{}/comments?per_page=100", API_BASE, repo, number); + let v = api_json("GET", &url, token, None)?; + Ok(map_comments(&v, true)) +} + +/// Resolve the current repo's fork relationship (REST). +pub(crate) fn rest_fork_info(cwd: &str, token: &str) -> Result { + let (owner, repo) = owner_repo(cwd)?; + let origin = format!("{}/{}", owner, repo); + let info = api_json("GET", &format!("{}/repos/{}/{}", API_BASE, owner, repo), token, None)?; + let is_fork = info.get("fork").and_then(|f| f.as_bool()).unwrap_or(false); + let parent = info + .get("parent") + .and_then(|p| p.get("full_name")) + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + Ok(ForkInfo { is_fork, origin, parent }) +} + +/// The upstream parent `owner/repo` when the current repo is a fork, else None. +/// Centralizes the `is_fork && !parent.is_empty()` guard shared by the PR list +/// and the origin→upstream PR fallback. +fn upstream_parent(cwd: &str, token: &str) -> Option { + let fi = rest_fork_info(cwd, token).ok()?; + (fi.is_fork && !fi.parent.is_empty()).then_some(fi.parent) +} + +/// The canonical base `owner/repo` for PRs. For a fork, this is the upstream +/// parent. For a regular repo, it's the repo itself. +fn base_owner_repo(cwd: &str, token: &str) -> Result { + let fi = rest_fork_info(cwd, token)?; + if fi.is_fork && !fi.parent.is_empty() { + Ok(fi.parent) + } else { + Ok(fi.origin) + } +} + +pub(crate) fn rest_create_pr( + cwd: &str, + title: String, + body: String, + base: String, + base_repo: Option, + draft: bool, + reviewers: Option>, + token: &str, +) -> Result { + let (owner, repo) = owner_repo(cwd)?; + let head_branch = current_branch(cwd)?; + let origin = format!("{}/{}", owner, repo); + + // Target repo: caller-supplied (cross-fork) or origin. A cross-fork PR must + // qualify the head ref as "fork-owner:branch". + let target = match base_repo { + Some(r) if !r.trim().is_empty() => r.trim().to_string(), + _ => origin.clone(), + }; + let is_cross = target != origin; + let head = if is_cross { + format!("{}:{}", owner, head_branch) + } else { + head_branch + }; + + // Resolve base from the *target* repo's default branch when left empty. + let base = if base.is_empty() { + let repo_info = api_json("GET", &format!("{}/repos/{}", API_BASE, target), token, None)?; + let default = js(&repo_info, "default_branch"); + if default.is_empty() { "main".to_string() } else { default } + } else { + base + }; + + let payload = serde_json::json!({ + "title": title, + "head": head, + "base": base, + "body": body, + "draft": draft, + }); + let url = format!("{}/repos/{}/pulls", API_BASE, target); + let created = api_json("POST", &url, token, Some(&payload.to_string()))?; + let number = ji(&created, "number"); + + // Best-effort reviewer request — never fail PR creation if this errors. + if let Some(revs) = reviewers { + let cleaned: Vec = revs + .into_iter() + .map(|r| r.trim().trim_start_matches('@').to_string()) + .filter(|r| !r.is_empty()) + .collect(); + if !cleaned.is_empty() && number > 0 { + let rev_payload = serde_json::json!({ "reviewers": cleaned }); + let rev_url = format!( + "{}/repos/{}/pulls/{}/requested_reviewers", + API_BASE, target, number + ); + let _ = api_json("POST", &rev_url, token, Some(&rev_payload.to_string())); + } + } + + Ok(json_to_pr(&created)) +} + +pub(crate) fn rest_merge_pr(cwd: &str, number: i64, method: &str, token: &str) -> Result<(), String> { + let merge_method = match method { + "squash" => "squash", + "rebase" => "rebase", + _ => "merge", + }; + // Resolve the repo (origin or upstream) + head branch for cleanup. + let (repo, pr) = get_pr_json(cwd, number, token)?; + let branch = jnested(&pr, "head", "ref"); + + let payload = serde_json::json!({ "merge_method": merge_method }); + let url = format!("{}/repos/{}/pulls/{}/merge", API_BASE, repo, number); + api_json("PUT", &url, token, Some(&payload.to_string()))?; + + // Best-effort branch deletion (mirrors `gh pr merge --delete-branch`). + if !branch.is_empty() { + let ref_url = format!("{}/repos/{}/git/refs/heads/{}", API_BASE, repo, branch); + let _ = api_json("DELETE", &ref_url, token, None); + } + Ok(()) +} + +pub(crate) fn rest_pr_ready(cwd: &str, number: i64, token: &str) -> Result<(), String> { + // Draft→ready is GraphQL-only; resolve the PR node_id first (origin or upstream). + let (_repo, pr) = get_pr_json(cwd, number, token)?; + let node_id = js(&pr, "node_id"); + if node_id.is_empty() { + return Err("Could not resolve PR node_id for ready conversion.".to_string()); + } + let query = format!( + "mutation {{ markPullRequestReadyForReview(input: {{ pullRequestId: \"{}\" }}) {{ pullRequest {{ isDraft }} }} }}", + node_id + ); + let payload = serde_json::json!({ "query": query }); + let v = api_json("POST", &format!("{}/graphql", API_BASE), token, Some(&payload.to_string()))?; + if let Some(errors) = v.get("errors").and_then(|e| e.as_array()) { + if !errors.is_empty() { + let msg = errors[0].get("message").and_then(|m| m.as_str()).unwrap_or("GraphQL error"); + return Err(format!("Mark-ready failed: {}", msg)); + } + } + Ok(()) +} + +pub(crate) fn rest_checkout_pr(cwd: &str, number: i64) -> Result<(), String> { + // Local git fetch of the PR head ref — token not needed (git uses its own + // credential helper). Works for same-repo and fork PRs alike. + let local = format!("pr-{}", number); + let refspec = format!("pull/{}/head:{}", number, local); + let fetch = git_cmd() + .args(["fetch", "origin", &refspec]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git fetch failed: {}", e))?; + if !fetch.status.success() { + return Err(format!( + "git fetch pull/{}/head failed: {}", + number, + String::from_utf8_lossy(&fetch.stderr).trim() + )); + } + let checkout = git_cmd() + .args(["checkout", &local]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git checkout failed: {}", e))?; + if !checkout.status.success() { + return Err(format!( + "git checkout {} failed: {}", + local, + String::from_utf8_lossy(&checkout.stderr).trim() + )); + } + Ok(()) +} + +// ─── OAuth device flow ────────────────────────────────────────────────────── + +/// Begin the OAuth device flow. Returns the user code + verification URL the +/// frontend shows, plus the `device_code` used for polling. +#[tauri::command] +pub(crate) async fn github_device_start() -> Result { + let cid = client_id(); + if cid.starts_with("REPLACE_WITH") { + return Err( + "GitHub login is not configured: missing OAuth App client_id. \ + Set GITWAND_GH_CLIENT_ID at build time or update github_api.rs." + .to_string(), + ); + } + // GitHub's OAuth endpoints accept a JSON body when Content-Type is JSON + // (which `curl_raw` sets whenever a body is present). + let body = serde_json::json!({ "client_id": cid, "scope": "repo" }); + let (status, text) = curl_raw( + "POST", + "https://github.com/login/device/code", + None, + Some(&body.to_string()), + "application/json", + )?; + if status >= 400 { + return Err(format!("GitHub device-code request failed (HTTP {})", status)); + } + let v: serde_json::Value = serde_json::from_str(text.trim()) + .map_err(|e| format!("Failed to parse device-code response: {}", e))?; + if let Some(err) = v.get("error").and_then(|e| e.as_str()) { + return Err(format!("GitHub device-code error: {}", err)); + } + Ok(GithubDeviceCode { + device_code: js(&v, "device_code"), + user_code: js(&v, "user_code"), + verification_uri: js(&v, "verification_uri"), + verification_uri_complete: js(&v, "verification_uri_complete"), + expires_in: ji(&v, "expires_in"), + interval: ji(&v, "interval").max(5), + }) +} + +/// Poll once for the OAuth access token. +/// +/// `status` is one of: `"pending"` (keep polling), `"slow_down"` (back off), +/// `"success"` (token stored, `login` populated), or `"error"`. +/// On success the token is persisted to the OS keychain so the REST path can +/// pick it up; the secret is never returned to the frontend. +#[tauri::command] +pub(crate) async fn github_device_poll(device_code: String) -> Result { + let cid = client_id(); + let body = serde_json::json!({ + "client_id": cid, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }); + let (status, text) = curl_raw( + "POST", + "https://github.com/login/oauth/access_token", + None, + Some(&body.to_string()), + "application/json", + )?; + if status >= 400 { + return Err(format!("GitHub token poll failed (HTTP {})", status)); + } + let v: serde_json::Value = serde_json::from_str(text.trim()) + .map_err(|e| format!("Failed to parse token response: {}", e))?; + + if let Some(err) = v.get("error").and_then(|e| e.as_str()) { + let kind = match err { + "authorization_pending" => "pending", + "slow_down" => "slow_down", + _ => "error", + }; + return Ok(GithubDevicePoll { + status: kind.to_string(), + login: String::new(), + error: if kind == "error" { err.to_string() } else { String::new() }, + }); + } + + let token = js(&v, "access_token"); + if token.is_empty() { + return Ok(GithubDevicePoll { + status: "pending".to_string(), + login: String::new(), + error: String::new(), + }); + } + + // Persist to keychain so the REST path activates. + let entry = keyring::Entry::new(GH_SERVICE, GH_ACCOUNT) + .map_err(|e| format!("keyring init failed: {}", e))?; + entry + .set_password(&token) + .map_err(|e| format!("Failed to store GitHub token: {}", e))?; + + // Resolve the login for display (non-fatal if it fails). + let login = rest_current_user(&token).unwrap_or_default(); + Ok(GithubDevicePoll { + status: "success".to_string(), + login, + error: String::new(), + }) +} + +/// Whether a Settings-managed GitHub token is currently stored. +#[tauri::command] +pub(crate) async fn github_token_present() -> Result { + Ok(settings_github_token().is_some()) +} diff --git a/apps/desktop/src-tauri/src/commands/gitlab.rs b/apps/desktop/src-tauri/src/commands/gitlab.rs index 1413b5f3..963b7c00 100644 --- a/apps/desktop/src-tauri/src/commands/gitlab.rs +++ b/apps/desktop/src-tauri/src/commands/gitlab.rs @@ -17,6 +17,8 @@ use crate::git::hidden_cmd; use crate::types::*; +use rayon::prelude::*; +use std::collections::HashMap; // ─── JSON field helpers ──────────────────────────────────────────────────────── @@ -181,6 +183,11 @@ fn gl_mr_to_detail(mr: &serde_json::Value) -> PullRequestDetail { reviewers: jusernames(mr, "reviewers"), mergeable, checks_status: String::new(), + // GitLab's single-MR endpoint carries `user.can_merge` for the caller. + can_merge: mr + .get("user") + .and_then(|u| u.get("can_merge")) + .and_then(|b| b.as_bool()), } } @@ -188,7 +195,7 @@ fn gl_mr_to_detail(mr: &serde_json::Value) -> PullRequestDetail { /// Detect if `glab` CLI is installed and accessible. #[tauri::command] -pub(crate) fn detect_glab(cwd: String) -> bool { +pub(crate) async fn detect_glab(cwd: String) -> bool { hidden_cmd("glab") .arg("--version") .current_dir(&cwd) @@ -202,7 +209,7 @@ pub(crate) fn detect_glab(cwd: String) -> bool { /// `state` accepts "opened" (default), "closed", "merged", "all". /// Pagination: naïve slice — glab doesn't support cursor pagination via CLI. #[tauri::command] -pub(crate) fn gl_list_mrs( +pub(crate) async fn gl_list_mrs( cwd: String, state: String, limit: Option, @@ -250,6 +257,33 @@ pub(crate) fn gl_list_mrs( mrs.drain(..skip); } + // Colour the sidebar dot from each MR's pipeline. The list payload rarely + // embeds `head_pipeline`, so use it when present (free) and otherwise fetch + // the pipeline per MR in parallel (red = failed, yellow = pending). + let embedded: HashMap = arr + .iter() + .filter_map(|mr| { + let iid = ji(mr, "iid"); + let status = mr.get("head_pipeline").or_else(|| mr.get("pipeline")).map(|p| js(p, "status"))?; + Some((iid, status)) + }) + .collect(); + let rollups: HashMap = mrs + .par_iter() + .filter_map(|mr| { + let rollup = match embedded.get(&mr.number) { + Some(s) => gl_status_to_rollup(s), + None => gl_pipeline_rollup(&cwd, mr.number), + }; + if rollup.is_empty() { None } else { Some((mr.number, rollup)) } + }) + .collect(); + for mr in &mut mrs { + if let Some(state) = rollups.get(&mr.number) { + mr.checks_rollup = state.clone(); + } + } + Ok(mrs) } @@ -257,7 +291,7 @@ pub(crate) fn gl_list_mrs( /// /// Returns 0 on non-fatal errors so the Launchpad badge can still render. #[tauri::command] -pub(crate) fn gl_mr_count(cwd: String, state: String) -> Result { +pub(crate) async fn gl_mr_count(cwd: String, state: String) -> Result { let st = match state.as_str() { "closed" => "closed", "merged" => "merged", @@ -282,7 +316,7 @@ pub(crate) fn gl_mr_count(cwd: String, state: String) -> Result { /// Get detailed MR info using `glab mr view`. #[tauri::command] -pub(crate) fn gl_get_mr(cwd: String, iid: i64) -> Result { +pub(crate) async fn gl_get_mr(cwd: String, iid: i64) -> Result { let output = hidden_cmd("glab") .args(["mr", "view", &iid.to_string(), "--output", "json"]) .current_dir(&cwd) @@ -300,12 +334,25 @@ pub(crate) fn gl_get_mr(cwd: String, iid: i64) -> Result Result { +pub(crate) async fn gl_mr_diff(cwd: String, iid: i64) -> Result { let output = hidden_cmd("glab") .args(["mr", "diff", &iid.to_string()]) .current_dir(&cwd) @@ -327,7 +374,7 @@ pub(crate) fn gl_mr_diff(cwd: String, iid: i64) -> Result { /// Returns the most-recent pipeline as a single-entry list (GitLab only has /// one "active" pipeline per MR at a time). Each job maps to a CICheck entry. #[tauri::command] -pub(crate) fn gl_mr_pipelines(cwd: String, iid: i64) -> Result, String> { +pub(crate) async fn gl_mr_pipelines(cwd: String, iid: i64) -> Result, String> { let endpoint = format!( "projects/:fullpath/merge_requests/{}/pipelines", iid @@ -376,9 +423,44 @@ pub(crate) fn gl_mr_pipelines(cwd: String, iid: i64) -> Result, Str .collect()) } +/// Reduce a GitLab pipeline `status` to a rollup state the frontend colours: +/// `FAILURE` (red) / `PENDING` (yellow) / `SUCCESS` (green), or `""` (no CI). +fn gl_status_to_rollup(status: &str) -> String { + match status { + "success" => "SUCCESS", + "failed" | "canceled" => "FAILURE", + // No pipeline / skipped → no dot. + "" | "skipped" => "", + // created / waiting_for_resource / preparing / pending / running / + // manual / scheduled → still in flight. + _ => "PENDING", + } + .to_string() +} + +/// Fetch a MR's most-recent pipeline and reduce it to a rollup state. Sync and +/// best-effort (empty on any error) so it's safe to fan out under rayon. +fn gl_pipeline_rollup(cwd: &str, iid: i64) -> String { + let endpoint = format!("projects/:fullpath/merge_requests/{}/pipelines", iid); + let out = match hidden_cmd("glab").args(["api", &endpoint]).current_dir(cwd).output() { + Ok(o) if o.status.success() => o, + _ => return String::new(), + }; + let stdout = String::from_utf8_lossy(&out.stdout); + let v: serde_json::Value = + serde_json::from_str(stdout.trim()).unwrap_or(serde_json::Value::Array(vec![])); + // The API returns pipelines newest-first; the first entry is the active one. + let status = v + .as_array() + .and_then(|a| a.first()) + .map(|p| js(p, "status")) + .unwrap_or_default(); + gl_status_to_rollup(&status) +} + /// Create a MR using `glab mr create`. #[tauri::command] -pub(crate) fn gl_create_mr( +pub(crate) async fn gl_create_mr( cwd: String, title: String, body: String, @@ -441,7 +523,7 @@ pub(crate) fn gl_create_mr( /// /// `method` accepts "merge" (default), "squash", "rebase". #[tauri::command] -pub(crate) fn gl_merge_mr(cwd: String, iid: i64, method: String) -> Result<(), String> { +pub(crate) async fn gl_merge_mr(cwd: String, iid: i64, method: String) -> Result<(), String> { let mut args: Vec = vec!["mr".to_string(), "merge".to_string(), iid.to_string()]; match method.as_str() { @@ -470,7 +552,7 @@ pub(crate) fn gl_merge_mr(cwd: String, iid: i64, method: String) -> Result<(), S /// Checkout a MR branch locally using `glab mr checkout`. #[tauri::command] -pub(crate) fn gl_checkout_mr(cwd: String, iid: i64) -> Result<(), String> { +pub(crate) async fn gl_checkout_mr(cwd: String, iid: i64) -> Result<(), String> { let output = hidden_cmd("glab") .args(["mr", "checkout", &iid.to_string()]) .current_dir(&cwd) @@ -488,7 +570,7 @@ pub(crate) fn gl_checkout_mr(cwd: String, iid: i64) -> Result<(), String> { /// Convert a draft MR to ready-for-review using `glab mr update --draft=false`. #[tauri::command] -pub(crate) fn gl_convert_draft_to_ready(cwd: String, iid: i64) -> Result<(), String> { +pub(crate) async fn gl_convert_draft_to_ready(cwd: String, iid: i64) -> Result<(), String> { let output = hidden_cmd("glab") .args(["mr", "update", &iid.to_string(), "--draft=false"]) .current_dir(&cwd) @@ -510,7 +592,7 @@ pub(crate) fn gl_convert_draft_to_ready(cwd: String, iid: i64) -> Result<(), Str /// GitLab notes are simpler than GitHub review comments: no diff-line /// anchoring in v2.10 (that requires the Discussions API). #[tauri::command] -pub(crate) fn gl_mr_notes(cwd: String, iid: i64) -> Result { +pub(crate) async fn gl_mr_notes(cwd: String, iid: i64) -> Result { let endpoint = format!( "projects/:fullpath/merge_requests/{}/notes?sort=asc&per_page=100", iid @@ -536,7 +618,7 @@ pub(crate) fn gl_mr_notes(cwd: String, iid: i64) -> Result Result<(), String> { +pub(crate) async fn gl_approve_mr(cwd: String, iid: i64) -> Result<(), String> { let output = hidden_cmd("glab") .args(["mr", "approve", &iid.to_string()]) .current_dir(&cwd) @@ -634,7 +716,7 @@ pub(crate) fn gl_approve_mr(cwd: String, iid: i64) -> Result<(), String> { /// /// Returns raw JSON — parsed TypeScript-side into PrReview[]. #[tauri::command] -pub(crate) fn gl_list_reviews(cwd: String, iid: i64) -> Result { +pub(crate) async fn gl_list_reviews(cwd: String, iid: i64) -> Result { let endpoint = format!( "projects/:fullpath/merge_requests/{}/approvals", iid @@ -656,7 +738,7 @@ pub(crate) fn gl_list_reviews(cwd: String, iid: i64) -> Result Result { +pub(crate) async fn gl_current_user(cwd: String) -> Result { let output = hidden_cmd("glab") .args(["api", "/user"]) .current_dir(&cwd) @@ -683,7 +765,7 @@ pub(crate) fn gl_current_user(cwd: String) -> Result { /// List reviewer candidates (project members with push access) via `glab api`. #[tauri::command] -pub(crate) fn gl_reviewer_candidates(cwd: String) -> Result, String> { +pub(crate) async fn gl_reviewer_candidates(cwd: String) -> Result, String> { let output = hidden_cmd("glab") .args(["api", "projects/:fullpath/members/all?per_page=100"]) .current_dir(&cwd) @@ -730,7 +812,7 @@ pub(crate) fn gl_reviewer_candidates(cwd: String) -> Result Result, String> { +pub(crate) async fn gl_mr_files(cwd: String, iid: i64) -> Result, String> { let endpoint = format!( "projects/:fullpath/merge_requests/{}/diffs?per_page=100", iid @@ -772,7 +854,7 @@ pub(crate) fn gl_mr_files(cwd: String, iid: i64) -> Result, String> /// Body: { body, position: { base_sha, start_sha, head_sha, position_type, /// new_path, new_line, old_path, old_line } } #[tauri::command] -pub(crate) fn gl_mr_create_discussion( +pub(crate) async fn gl_mr_create_discussion( cwd: String, iid: i64, body: String, diff --git a/apps/desktop/src-tauri/src/commands/mcp_catalog.rs b/apps/desktop/src-tauri/src/commands/mcp_catalog.rs index 4d4e91b9..6bd68bbd 100644 --- a/apps/desktop/src-tauri/src/commands/mcp_catalog.rs +++ b/apps/desktop/src-tauri/src/commands/mcp_catalog.rs @@ -133,7 +133,7 @@ fn known_configs(home: &PathBuf) -> Vec<(String, PathBuf)> { /// Return the list of known MCP config file locations with their current state, /// including the full list of already-configured server keys per file. #[tauri::command] -pub(crate) fn mcp_detect_configs() -> Result, String> { +pub(crate) async fn mcp_detect_configs() -> Result, String> { let home = home_dir().ok_or("Cannot determine home directory")?; let configs = known_configs(&home); let mut result = Vec::new(); @@ -172,7 +172,7 @@ pub(crate) fn mcp_detect_configs() -> Result, String> { /// Read the `mcpServers` map from a config file as a JSON string. /// Returns `"{}"` when the file doesn't exist or has no mcpServers key. #[tauri::command] -pub(crate) fn mcp_read_config(path: String) -> Result { +pub(crate) async fn mcp_read_config(path: String) -> Result { let p = PathBuf::from(&path); let value = read_json(&p)?; let servers = value @@ -193,7 +193,7 @@ pub(crate) fn mcp_read_config(path: String) -> Result { /// Each file is created (with parent dirs) if it does not yet exist. /// Existing `mcpServers` entries for other servers are left untouched. #[tauri::command] -pub(crate) fn mcp_install_server( +pub(crate) async fn mcp_install_server( server_key: String, server_json: String, config_paths: Vec, @@ -234,7 +234,7 @@ pub(crate) fn mcp_install_server( /// Remove a server key from the `mcpServers` map in each specified config file. /// No-ops gracefully when the file or key doesn't exist. #[tauri::command] -pub(crate) fn mcp_uninstall_server( +pub(crate) async fn mcp_uninstall_server( server_key: String, config_paths: Vec, ) -> Result<(), String> { diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 29abc78b..f326147e 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -1,8 +1,10 @@ pub(crate) mod ai; +pub(crate) mod azure; pub(crate) mod bitbucket; pub(crate) mod credentials; pub(crate) mod files; pub(crate) mod gh; +pub(crate) mod github_api; pub(crate) mod gitlab; pub(crate) mod mcp_catalog; pub(crate) mod network; diff --git a/apps/desktop/src-tauri/src/commands/network.rs b/apps/desktop/src-tauri/src/commands/network.rs index 5a1cbdf9..6ae10fda 100644 --- a/apps/desktop/src-tauri/src/commands/network.rs +++ b/apps/desktop/src-tauri/src/commands/network.rs @@ -148,7 +148,7 @@ fn tcp_probe(host: &str, port: u16, timeout: Duration) -> bool { /// `timeout_ms = 0` is normalised to a small floor (250 ms) so the caller /// cannot accidentally turn the probe into a zero-wait fast-fail. #[tauri::command] -pub(crate) fn check_remote_reachable(url: String, timeout_ms: u64) -> Result { +pub(crate) async fn check_remote_reachable(url: String, timeout_ms: u64) -> Result { let parsed = match parse_remote_host_port(&url) { Some(p) => p, None => return Ok(false), diff --git a/apps/desktop/src-tauri/src/commands/ops.rs b/apps/desktop/src-tauri/src/commands/ops.rs index ca726bb6..711be07f 100644 --- a/apps/desktop/src-tauri/src/commands/ops.rs +++ b/apps/desktop/src-tauri/src/commands/ops.rs @@ -9,7 +9,7 @@ use rayon::prelude::*; // ─── Git stage / unstage ───────────────────────────────────── #[tauri::command] -pub(crate) fn git_stage(cwd: String, paths: Vec) -> Result<(), String> { +pub(crate) async fn git_stage(cwd: String, paths: Vec) -> Result<(), String> { let mut cmd = git_cmd(); cmd.arg("add").arg("--").current_dir(&cwd); for p in &paths { @@ -24,7 +24,7 @@ pub(crate) fn git_stage(cwd: String, paths: Vec) -> Result<(), String> { } #[tauri::command] -pub(crate) fn git_unstage(cwd: String, paths: Vec) -> Result<(), String> { +pub(crate) async fn git_unstage(cwd: String, paths: Vec) -> Result<(), String> { let mut cmd = git_cmd(); cmd.arg("reset").arg("HEAD").arg("--").current_dir(&cwd); for p in &paths { @@ -39,7 +39,7 @@ pub(crate) fn git_unstage(cwd: String, paths: Vec) -> Result<(), String> } #[tauri::command] -pub(crate) fn git_stage_patch(cwd: String, patch: String) -> Result<(), String> { +pub(crate) async fn git_stage_patch(cwd: String, patch: String) -> Result<(), String> { let mut cmd = git_cmd(); cmd.args(["apply", "--cached", "--unidiff-zero", "-"]) .current_dir(&cwd) @@ -58,7 +58,7 @@ pub(crate) fn git_stage_patch(cwd: String, patch: String) -> Result<(), String> } #[tauri::command] -pub(crate) fn git_unstage_patch(cwd: String, patch: String) -> Result<(), String> { +pub(crate) async fn git_unstage_patch(cwd: String, patch: String) -> Result<(), String> { let mut cmd = git_cmd(); cmd.args(["apply", "--cached", "--reverse", "--unidiff-zero", "-"]) .current_dir(&cwd) @@ -79,7 +79,7 @@ pub(crate) fn git_unstage_patch(cwd: String, patch: String) -> Result<(), String // ─── Git commit ────────────────────────────────────────────── #[tauri::command] -pub(crate) fn git_commit( +pub(crate) async fn git_commit( cwd: String, message: String, identity_name: Option, @@ -118,7 +118,7 @@ pub(crate) fn git_commit( } #[tauri::command] -pub(crate) fn git_amend_commit(cwd: String, message: String) -> Result { +pub(crate) async fn git_amend_commit(cwd: String, message: String) -> Result { let _t0 = Instant::now(); let output = git_cmd() .args(["commit", "--amend", "-m", &message]) @@ -143,7 +143,7 @@ pub(crate) fn git_amend_commit(cwd: String, message: String) -> Result, force: Option) -> Result { +pub(crate) async fn git_push(cwd: String, set_upstream: Option, force: Option) -> Result { let mut args: Vec<&str> = vec!["push"]; if set_upstream.unwrap_or(false) { args.extend(["--set-upstream", "origin", "HEAD"]); @@ -382,7 +382,7 @@ pub(crate) fn git_push(cwd: String, set_upstream: Option, force: Option Result { +pub(crate) async fn git_fetch(cwd: String) -> Result { let _t0 = Instant::now(); let output = git_cmd() .args(["fetch", "--prune"]) @@ -406,7 +406,7 @@ pub(crate) fn git_fetch(cwd: String) -> Result { } #[tauri::command] -pub(crate) fn git_merge(cwd: String, branch: String) -> Result { +pub(crate) async fn git_merge(cwd: String, branch: String) -> Result { let _t0 = Instant::now(); let output = git_cmd() .args(["merge", &branch]) @@ -434,7 +434,7 @@ pub(crate) fn git_merge(cwd: String, branch: String) -> Result Result { +pub(crate) async fn git_merge_abort(cwd: String) -> Result { let _t0 = Instant::now(); let output = git_cmd() .args(["merge", "--abort"]) @@ -457,7 +457,7 @@ pub(crate) fn git_merge_abort(cwd: String) -> Result } #[tauri::command] -pub(crate) fn git_merge_continue(cwd: String) -> Result { +pub(crate) async fn git_merge_continue(cwd: String) -> Result { let _t0 = Instant::now(); let output = git_cmd() .args(["-c", "core.editor=true", "merge", "--continue"]) @@ -483,7 +483,7 @@ pub(crate) fn git_merge_continue(cwd: String) -> Result Result { +pub(crate) async fn git_pull(cwd: String, rebase: bool) -> Result { let _t0 = Instant::now(); // Pass the strategy flag EXPLICITLY so the user's pull-mode choice is // authoritative. A bare `git pull` defers to the ambient `pull.rebase` @@ -514,7 +514,7 @@ pub(crate) fn git_pull(cwd: String, rebase: bool) -> Result Result<(), String> { +pub(crate) async fn git_rebase_action(cwd: String, action: String) -> Result<(), String> { let arg = match action.as_str() { "continue" | "abort" | "skip" => action.as_str(), _ => return Err(format!("Unknown rebase action '{}'", action)), @@ -540,7 +540,7 @@ pub(crate) fn git_rebase_action(cwd: String, action: String) -> Result<(), Strin // ─── Git discard ─────────────────────────────────────────────── #[tauri::command] -pub(crate) fn git_discard(cwd: String, paths: Vec, untracked: bool) -> Result<(), String> { +pub(crate) async fn git_discard(cwd: String, paths: Vec, untracked: bool) -> Result<(), String> { if untracked { let mut cmd = git_cmd(); cmd.arg("clean").arg("-f").arg("--").current_dir(&cwd); @@ -570,7 +570,7 @@ pub(crate) fn git_discard(cwd: String, paths: Vec, untracked: bool) -> R // ─── Git branches ────────────────────────────────────────────── #[tauri::command] -pub(crate) fn git_branches(cwd: String) -> Result, String> { +pub(crate) async fn git_branches(cwd: String) -> Result, String> { let main_name = get_main_branch_name(&cwd); let output = git_cmd() .args([ @@ -683,7 +683,7 @@ fn get_main_branch_name(cwd: &str) -> String { } #[tauri::command] -pub(crate) fn git_create_branch(cwd: String, name: String, checkout: bool, start_point: Option) -> Result<(), String> { +pub(crate) async fn git_create_branch(cwd: String, name: String, checkout: bool, start_point: Option) -> Result<(), String> { if checkout { let mut args = vec!["checkout", "-b", &name]; if let Some(ref sp) = start_point { args.push(sp); } @@ -717,7 +717,7 @@ pub(crate) fn git_create_branch(cwd: String, name: String, checkout: bool, start } #[tauri::command] -pub(crate) fn git_switch_branch(cwd: String, name: String) -> Result<(), String> { +pub(crate) async fn git_switch_branch(cwd: String, name: String) -> Result<(), String> { let _t0 = Instant::now(); let output = git_cmd() .args(["checkout", &name]) @@ -733,7 +733,7 @@ pub(crate) fn git_switch_branch(cwd: String, name: String) -> Result<(), String> } #[tauri::command] -pub(crate) fn git_delete_branch(cwd: String, name: String, force: bool) -> Result<(), String> { +pub(crate) async fn git_delete_branch(cwd: String, name: String, force: bool) -> Result<(), String> { let flag = if force { "-D" } else { "-d" }; let _t0 = Instant::now(); let output = git_cmd() @@ -750,7 +750,7 @@ pub(crate) fn git_delete_branch(cwd: String, name: String, force: bool) -> Resul } #[tauri::command] -pub(crate) fn git_delete_remote_branch(cwd: String, remote: String, name: String) -> Result<(), String> { +pub(crate) async fn git_delete_remote_branch(cwd: String, remote: String, name: String) -> Result<(), String> { let _t0 = Instant::now(); let output = git_cmd() .args(["push", &remote, "--delete", &name]) @@ -766,7 +766,7 @@ pub(crate) fn git_delete_remote_branch(cwd: String, remote: String, name: String } #[tauri::command] -pub(crate) fn git_rename_branch(cwd: String, old_name: String, new_name: String) -> Result<(), String> { +pub(crate) async fn git_rename_branch(cwd: String, old_name: String, new_name: String) -> Result<(), String> { let output = git_cmd() .args(["branch", "-m", &old_name, &new_name]) .current_dir(&cwd) @@ -782,7 +782,7 @@ pub(crate) fn git_rename_branch(cwd: String, old_name: String, new_name: String) // ─── Git stash ──────────────────────────────────────────── #[tauri::command] -pub(crate) fn git_stash(cwd: String, message: Option) -> Result<(), String> { +pub(crate) async fn git_stash(cwd: String, message: Option) -> Result<(), String> { let mut args: Vec<&str> = vec!["stash", "push", "--include-untracked"]; let trimmed = message.as_deref().map(str::trim).filter(|s| !s.is_empty()); if let Some(m) = trimmed { @@ -804,7 +804,7 @@ pub(crate) fn git_stash(cwd: String, message: Option) -> Result<(), Stri } #[tauri::command] -pub(crate) fn git_stash_pop(cwd: String) -> Result<(), String> { +pub(crate) async fn git_stash_pop(cwd: String) -> Result<(), String> { let _t0 = Instant::now(); let output = git_cmd() .args(["stash", "pop"]) @@ -820,7 +820,7 @@ pub(crate) fn git_stash_pop(cwd: String) -> Result<(), String> { } #[tauri::command] -pub(crate) fn git_stash_list(cwd: String) -> Result, String> { +pub(crate) async fn git_stash_list(cwd: String) -> Result, String> { let output = git_cmd() .args(["stash", "list", "--format=%H%x00%gd%x00%gs%x00%ai"]) .current_dir(&cwd) @@ -874,7 +874,7 @@ pub(crate) fn git_stash_list(cwd: String) -> Result, String> { } #[tauri::command] -pub(crate) fn git_stash_apply(cwd: String, index: usize) -> Result<(), String> { +pub(crate) async fn git_stash_apply(cwd: String, index: usize) -> Result<(), String> { let stash_ref = format!("stash@{{{}}}", index); let _t0 = Instant::now(); let output = git_cmd() @@ -893,7 +893,7 @@ pub(crate) fn git_stash_apply(cwd: String, index: usize) -> Result<(), String> { } #[tauri::command] -pub(crate) fn git_stash_drop(cwd: String, index: usize) -> Result<(), String> { +pub(crate) async fn git_stash_drop(cwd: String, index: usize) -> Result<(), String> { let stash_ref = format!("stash@{{{}}}", index); let output = git_cmd() .args(["stash", "drop", &stash_ref]) @@ -910,7 +910,7 @@ pub(crate) fn git_stash_drop(cwd: String, index: usize) -> Result<(), String> { } #[tauri::command] -pub(crate) fn git_stash_clear(cwd: String) -> Result<(), String> { +pub(crate) async fn git_stash_clear(cwd: String) -> Result<(), String> { let output = git_cmd() .args(["stash", "clear"]) .current_dir(&cwd) @@ -926,7 +926,7 @@ pub(crate) fn git_stash_clear(cwd: String) -> Result<(), String> { } #[tauri::command] -pub(crate) fn git_stash_show(cwd: String, index: usize) -> Result { +pub(crate) async fn git_stash_show(cwd: String, index: usize) -> Result { let stash_ref = format!("stash@{{{}}}", index); let output = git_cmd() .args(["stash", "show", "-p", &stash_ref]) @@ -945,7 +945,7 @@ pub(crate) fn git_stash_show(cwd: String, index: usize) -> Result) -> Result { +pub(crate) async fn git_cherry_pick(cwd: String, hashes: Vec) -> Result { let git = git_binary(); let mut args = vec!["cherry-pick".to_string()]; args.extend(hashes); @@ -970,7 +970,7 @@ pub(crate) fn git_cherry_pick(cwd: String, hashes: Vec) -> Result Result<(), String> { +pub(crate) async fn git_cherry_pick_abort(cwd: String) -> Result<(), String> { let _t0 = Instant::now(); let output = git_cmd() .args(["cherry-pick", "--abort"]) @@ -988,7 +988,7 @@ pub(crate) fn git_cherry_pick_abort(cwd: String) -> Result<(), String> { } #[tauri::command] -pub(crate) fn git_cherry_pick_continue(cwd: String) -> Result { +pub(crate) async fn git_cherry_pick_continue(cwd: String) -> Result { let _t0 = Instant::now(); let output = git_cmd() .args(["cherry-pick", "--continue"]) @@ -1011,7 +1011,7 @@ pub(crate) fn git_cherry_pick_continue(cwd: String) -> Result Result<(), String> { +pub(crate) async fn git_checkout_commit(cwd: String, sha: String) -> Result<(), String> { let _t0 = Instant::now(); let output = git_cmd() .args(["checkout", &sha]) @@ -1029,7 +1029,7 @@ pub(crate) fn git_checkout_commit(cwd: String, sha: String) -> Result<(), String } #[tauri::command] -pub(crate) fn git_reset_to_commit(cwd: String, sha: String, mode: String) -> Result<(), String> { +pub(crate) async fn git_reset_to_commit(cwd: String, sha: String, mode: String) -> Result<(), String> { let flag = match mode.as_str() { "soft" => "--soft", "hard" => "--hard", @@ -1053,7 +1053,7 @@ pub(crate) fn git_reset_to_commit(cwd: String, sha: String, mode: String) -> Res } #[tauri::command] -pub(crate) fn git_revert_commit(cwd: String, sha: String, mainline: Option) -> Result { +pub(crate) async fn git_revert_commit(cwd: String, sha: String, mainline: Option) -> Result { let mut args = vec!["revert".to_string(), "--no-edit".to_string()]; if let Some(m) = mainline { args.push("-m".to_string()); @@ -1080,7 +1080,7 @@ pub(crate) fn git_revert_commit(cwd: String, sha: String, mainline: Option) // ─── Git tags ────────────────────────────────────────────────── #[tauri::command] -pub(crate) fn git_create_tag(cwd: String, name: String, sha: String, message: Option) -> Result<(), String> { +pub(crate) async fn git_create_tag(cwd: String, name: String, sha: String, message: Option) -> Result<(), String> { let tag_name = name.clone(); let trimmed = message.as_deref().map(str::trim).filter(|s| !s.is_empty()); let args: Vec = if let Some(m) = trimmed { @@ -1127,7 +1127,7 @@ pub(crate) fn git_create_tag(cwd: String, name: String, sha: String, message: Op } #[tauri::command] -pub(crate) fn git_list_tags(cwd: String) -> Result, String> { +pub(crate) async fn git_list_tags(cwd: String) -> Result, String> { let sep = "\x1f"; let fmt = format!( "%(refname:short){s}%(objecttype){s}%(objectname:short){s}%(*objectname:short){s}%(taggerdate:iso){s}%(creatordate:iso){s}%(contents:subject)", @@ -1167,7 +1167,7 @@ pub(crate) fn git_list_tags(cwd: String) -> Result, String> { } #[tauri::command] -pub(crate) fn git_delete_tag(cwd: String, name: String) -> Result<(), String> { +pub(crate) async fn git_delete_tag(cwd: String, name: String) -> Result<(), String> { let output = git_cmd() .args(["tag", "-d", &name]) .current_dir(&cwd) @@ -1180,7 +1180,7 @@ pub(crate) fn git_delete_tag(cwd: String, name: String) -> Result<(), String> { } #[tauri::command] -pub(crate) fn git_push_tags(cwd: String, remote: String, mode: String, tag_name: Option) -> Result<(), String> { +pub(crate) async fn git_push_tags(cwd: String, remote: String, mode: String, tag_name: Option) -> Result<(), String> { let mut args = vec!["push".to_string(), remote.clone()]; match mode.as_str() { "single" => { @@ -1205,7 +1205,7 @@ pub(crate) fn git_push_tags(cwd: String, remote: String, mode: String, tag_name: } #[tauri::command] -pub(crate) fn git_unpushed_tags(cwd: String, remote: String) -> Result, String> { +pub(crate) async fn git_unpushed_tags(cwd: String, remote: String) -> Result, String> { // Local tags let local_out = git_cmd() .args(["tag", "-l"]) @@ -1243,7 +1243,7 @@ pub(crate) fn git_unpushed_tags(cwd: String, remote: String) -> Result Result<(), String> { +pub(crate) async fn git_delete_remote_tag(cwd: String, remote: String, name: String) -> Result<(), String> { let output = git_cmd() .args(["push", &remote, "--delete", &format!("refs/tags/{}", name)]) .current_dir(&cwd) @@ -1258,7 +1258,7 @@ pub(crate) fn git_delete_remote_tag(cwd: String, remote: String, name: String) - // ─── Git conflict check ───────────────────────────────────── #[tauri::command] -pub(crate) fn git_conflict_check(cwd: String, target_branch: String) -> Result { +pub(crate) async fn git_conflict_check(cwd: String, target_branch: String) -> Result { let git = git_binary(); let base_out = hidden_cmd(&git) @@ -1296,7 +1296,7 @@ pub(crate) fn git_conflict_check(cwd: String, target_branch: String) -> Result Result, String> { +pub(crate) async fn git_submodule_list(cwd: String) -> Result, String> { let gitmodules = std::path::Path::new(&cwd).join(".gitmodules"); if !gitmodules.exists() { return Ok(Vec::new()); @@ -1391,7 +1391,7 @@ pub(crate) fn git_submodule_list(cwd: String) -> Result, Str } #[tauri::command] -pub(crate) fn git_submodule_init(cwd: String) -> Result<(), String> { +pub(crate) async fn git_submodule_init(cwd: String) -> Result<(), String> { let output = git_cmd() .args(["submodule", "init"]) .current_dir(&cwd) @@ -1407,7 +1407,7 @@ pub(crate) fn git_submodule_init(cwd: String) -> Result<(), String> { } #[tauri::command] -pub(crate) fn git_submodule_update(cwd: String, init: bool, recursive: bool) -> Result<(), String> { +pub(crate) async fn git_submodule_update(cwd: String, init: bool, recursive: bool) -> Result<(), String> { let mut cmd = git_cmd(); cmd.arg("submodule").arg("update"); if init { @@ -1431,7 +1431,7 @@ pub(crate) fn git_submodule_update(cwd: String, init: bool, recursive: bool) -> } #[tauri::command] -pub(crate) fn git_submodule_add(cwd: String, url: String, path: String) -> Result<(), String> { +pub(crate) async fn git_submodule_add(cwd: String, url: String, path: String) -> Result<(), String> { let output = git_cmd() .args(["submodule", "add", &url, &path]) .current_dir(&cwd) @@ -1449,7 +1449,7 @@ pub(crate) fn git_submodule_add(cwd: String, url: String, path: String) -> Resul /// List the local branches of a submodule. `submodule_path` is relative to `cwd`. /// Used by the branch picker's "Submodules" section (v2.15.1). #[tauri::command] -pub(crate) fn git_submodule_branches( +pub(crate) async fn git_submodule_branches( cwd: String, submodule_path: String, ) -> Result, String> { @@ -1494,7 +1494,7 @@ pub(crate) fn git_submodule_branches( /// scan stays cheap even on large histories (v2.15.1). Used to badge commits /// in the Git Tree with the submodule SHA they point to. #[tauri::command] -pub(crate) fn git_commit_submodule_changes( +pub(crate) async fn git_commit_submodule_changes( cwd: String, ) -> Result>, String> { let gitmodules = std::path::Path::new(&cwd).join(".gitmodules"); @@ -1598,7 +1598,7 @@ pub(crate) fn git_commit_submodule_changes( // ─── Worktrees ──────────────────────────────────────────────── #[tauri::command] -pub(crate) fn git_worktree_list(cwd: String) -> Result, String> { +pub(crate) async fn git_worktree_list(cwd: String) -> Result, String> { let output = git_cmd() .args(["worktree", "list", "--porcelain"]) .current_dir(&cwd) @@ -1676,7 +1676,7 @@ pub(crate) fn git_worktree_list(cwd: String) -> Result, Strin } #[tauri::command] -pub(crate) fn git_worktree_add( +pub(crate) async fn git_worktree_add( cwd: String, path: String, branch: String, @@ -1739,7 +1739,7 @@ pub(crate) fn git_worktree_add( } #[tauri::command] -pub(crate) fn git_worktree_remove(cwd: String, path: String, force: Option) -> Result<(), String> { +pub(crate) async fn git_worktree_remove(cwd: String, path: String, force: Option) -> Result<(), String> { let mut cmd = git_cmd(); cmd.arg("worktree").arg("remove"); if force.unwrap_or(false) { @@ -1762,7 +1762,7 @@ pub(crate) fn git_worktree_remove(cwd: String, path: String, force: Option } #[tauri::command] -pub(crate) fn git_worktree_prune(cwd: String) -> Result<(), String> { +pub(crate) async fn git_worktree_prune(cwd: String) -> Result<(), String> { let output = git_cmd() .args(["worktree", "prune"]) .current_dir(&cwd) @@ -1779,8 +1779,8 @@ pub(crate) fn git_worktree_prune(cwd: String) -> Result<(), String> { } #[tauri::command] -pub(crate) fn git_worktree_status_all(cwd: String) -> Result, String> { - let worktrees = git_worktree_list(cwd)?; +pub(crate) async fn git_worktree_status_all(cwd: String) -> Result, String> { + let worktrees = git_worktree_list(cwd).await?; let statuses = worktrees.into_par_iter().map(|wt| { let path = wt.path.clone(); @@ -1842,7 +1842,7 @@ pub(crate) fn git_worktree_status_all(cwd: String) -> Result) -> Result<(), String> { +pub(crate) async fn git_worktree_repair(cwd: String, paths: Vec) -> Result<(), String> { let mut cmd = git_cmd(); cmd.args(["worktree", "repair"]); for p in &paths { @@ -1919,7 +1919,7 @@ fn parse_clone_progress(line: &str) -> Option { } #[tauri::command] -pub(crate) fn git_clone(url: String, dest: String, app_handle: tauri::AppHandle) -> Result { +pub(crate) async fn git_clone(url: String, dest: String, app_handle: tauri::AppHandle) -> Result { use std::io::Read; use tauri::Emitter; @@ -1985,7 +1985,7 @@ pub(crate) fn git_clone(url: String, dest: String, app_handle: tauri::AppHandle) } #[tauri::command] -pub(crate) fn gh_fork(url: String, parent_dir: String) -> Result { +pub(crate) async fn gh_fork(url: String, parent_dir: String) -> Result { let url_trim = url.trim(); let parent_trim = parent_dir.trim(); if url_trim.is_empty() { @@ -2029,7 +2029,7 @@ pub(crate) fn gh_fork(url: String, parent_dir: String) -> Result // ─── Git hooks ───────────────────────────────────────────── #[tauri::command] -pub(crate) fn git_hook_list(cwd: String) -> Result, String> { +pub(crate) async fn git_hook_list(cwd: String) -> Result, String> { if cwd.trim().is_empty() { return Err("cwd must not be empty".to_string()); } let repo = PathBuf::from(&cwd); let hooks_dir = repo.join(".git").join("hooks"); @@ -2101,7 +2101,7 @@ pub(crate) fn git_hook_list(cwd: String) -> Result, String> { } #[tauri::command] -pub(crate) fn git_hook_toggle(cwd: String, name: String, enabled: bool) -> Result<(), String> { +pub(crate) async fn git_hook_toggle(cwd: String, name: String, enabled: bool) -> Result<(), String> { if name.contains('/') || name.contains('\\') || name.contains('.') { return Err(format!("Invalid hook name: {}", name)); } @@ -2127,7 +2127,7 @@ pub(crate) fn git_hook_toggle(cwd: String, name: String, enabled: bool) -> Resul } #[tauri::command] -pub(crate) fn git_hook_create(cwd: String, name: String, content: String) -> Result<(), String> { +pub(crate) async fn git_hook_create(cwd: String, name: String, content: String) -> Result<(), String> { if name.contains('/') || name.contains('\\') || name.contains('.') { return Err(format!("Invalid hook name: {}", name)); } @@ -2164,7 +2164,7 @@ pub(crate) fn git_hook_create(cwd: String, name: String, content: String) -> Res } #[tauri::command] -pub(crate) fn git_hook_delete(cwd: String, name: String) -> Result<(), String> { +pub(crate) async fn git_hook_delete(cwd: String, name: String) -> Result<(), String> { if name.contains('/') || name.contains('\\') || name.contains('.') { return Err(format!("Invalid hook name: {}", name)); } @@ -2255,10 +2255,10 @@ fn detect_agent_tool(worktree_path: &str) -> Option { } #[tauri::command] -pub(crate) fn agent_session_list(cwd: String) -> Result, String> { +pub(crate) async fn agent_session_list(cwd: String) -> Result, String> { if cwd.trim().is_empty() { return Err("cwd must not be empty".to_string()); } let path = PathBuf::from(&cwd); - let worktrees = git_worktree_list(path.to_string_lossy().to_string())?; + let worktrees = git_worktree_list(path.to_string_lossy().to_string()).await?; let mut cwds_cache: HashMap> = HashMap::new(); @@ -2317,7 +2317,7 @@ pub(crate) fn agent_session_list(cwd: String) -> Result, Strin } #[tauri::command] -pub(crate) fn agent_session_launch(cwd: String, tool: String) -> Result<(), String> { +pub(crate) async fn agent_session_launch(cwd: String, tool: String) -> Result<(), String> { if cwd.trim().is_empty() { return Err("cwd must not be empty".to_string()); } let path = PathBuf::from(&cwd); let binary = match tool.as_str() { @@ -2340,7 +2340,7 @@ pub(crate) fn agent_session_launch(cwd: String, tool: String) -> Result<(), Stri // ─── Shortlog ──────────────────────────────────────────────── #[tauri::command] -pub(crate) fn git_shortlog(cwd: String) -> Result, String> { +pub(crate) async fn git_shortlog(cwd: String) -> Result, String> { let output = git_cmd() .args(["shortlog", "-sne", "HEAD"]) .current_dir(&cwd) @@ -2363,27 +2363,34 @@ pub(crate) fn git_shortlog(cwd: String) -> Result, String> { // ─── Git exec / autocomplete ───────────────────────────────── +// Async + spawn_blocking: a synchronous Tauri command runs on the webview main +// thread, so a slow `git` invocation (e.g. `status` on a large repo, ~1.3s) +// freezes the UI. Offloading the blocking process spawn keeps the UI responsive. #[tauri::command] -pub(crate) fn git_exec(cwd: String, args: Vec) -> Result { - if args.is_empty() { - return Err("No arguments provided".to_string()); - } +pub(crate) async fn git_exec(cwd: String, args: Vec) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if args.is_empty() { + return Err("No arguments provided".to_string()); + } - let output = git_cmd() - .args(&args) - .current_dir(&cwd) - .output() - .map_err(|e| format!("Failed to execute git command: {}", e))?; + let output = git_cmd() + .args(&args) + .current_dir(&cwd) + .output() + .map_err(|e| format!("Failed to execute git command: {}", e))?; - Ok(TerminalResult { - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - exit_code: output.status.code().unwrap_or(-1), + Ok(TerminalResult { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code: output.status.code().unwrap_or(-1), + }) }) + .await + .map_err(|e| e.to_string())? } #[tauri::command] -pub(crate) fn git_autocomplete(cwd: String, partial: String) -> Result, String> { +pub(crate) async fn git_autocomplete(cwd: String, partial: String) -> Result, String> { let mut suggestions = Vec::new(); if !partial.contains(' ') { @@ -2428,7 +2435,7 @@ pub(crate) fn git_autocomplete(cwd: String, partial: String) -> Result Result<(), String> { +pub(crate) async fn set_git_config(git_path: String) -> Result<(), String> { let mut binary = GIT_BINARY .get_or_init(|| Mutex::new("git".to_string())) .lock() @@ -2444,7 +2451,7 @@ pub(crate) fn set_git_config(git_path: String) -> Result<(), String> { // ─── Conflicted files ────────────────────────────────────── #[tauri::command] -pub(crate) fn get_conflicted_files(cwd: String) -> Result, String> { +pub(crate) async fn get_conflicted_files(cwd: String) -> Result, String> { let output = git_cmd() .args(["diff", "--name-only", "--diff-filter=U"]) .current_dir(&cwd) @@ -2469,53 +2476,59 @@ pub(crate) fn get_conflicted_files(cwd: String) -> Result, String> { // ─── Git remote info ───────────────────────────────────────── #[tauri::command] -pub(crate) fn git_remote_info(cwd: String) -> Result { - let output = git_cmd() - .args(["remote", "-v"]) - .current_dir(&cwd) - .output() - .map_err(|e| format!("Failed to get remote info: {}", e))?; +pub(crate) async fn git_remote_info(cwd: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let output = git_cmd() + .args(["remote", "-v"]) + .current_dir(&cwd) + .output() + .map_err(|e| format!("Failed to get remote info: {}", e))?; - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if !line.contains("(fetch)") { - continue; - } - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 2 { - continue; - } - let name = parts[0].to_string(); - let url = parts[1].to_string(); - - let provider = if url.contains("github.com") { - "github" - } else if url.contains("gitlab.com") || url.contains("gitlab") { - "gitlab" - } else if url.contains("bitbucket.org") || url.contains("bitbucket") { - "bitbucket" - } else { - "unknown" - }; + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if !line.contains("(fetch)") { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; + } + let name = parts[0].to_string(); + let url = parts[1].to_string(); + + let provider = if url.contains("github.com") { + "github" + } else if url.contains("gitlab.com") || url.contains("gitlab") { + "gitlab" + } else if url.contains("bitbucket.org") || url.contains("bitbucket") { + "bitbucket" + } else if url.contains("dev.azure.com") || url.contains("visualstudio.com") { + "azure" + } else { + "unknown" + }; - let (owner, repo) = parse_remote_owner_repo(&url); + let (owner, repo) = parse_remote_owner_repo(&url); - return Ok(RemoteInfo { - name, - url, - provider: provider.to_string(), - owner, - repo, - }); - } + return Ok(RemoteInfo { + name, + url, + provider: provider.to_string(), + owner, + repo, + }); + } - Err("No remote found".to_string()) + Err("No remote found".to_string()) + }) + .await + .map_err(|e| e.to_string())? } // ─── Git user ──────────────────────────────────────────────── #[tauri::command] -pub(crate) fn git_get_user(cwd: String) -> Result { +pub(crate) async fn git_get_user(cwd: String) -> Result { let name_out = git_cmd() .args(["config", "user.name"]) .current_dir(&cwd) @@ -2535,7 +2548,7 @@ pub(crate) fn git_get_user(cwd: String) -> Result { /// Detect monorepo workspaces (pnpm, npm, yarn). #[tauri::command] -pub(crate) fn detect_monorepo(cwd: String) -> Result { +pub(crate) async fn detect_monorepo(cwd: String) -> Result { let cwd_path = std::path::Path::new(&cwd); // Check pnpm-workspace.yaml @@ -2582,7 +2595,7 @@ pub(crate) fn detect_monorepo(cwd: String) -> Result { // ─── Read .gitwandrc ───────────────────────────────────────── #[tauri::command] -pub(crate) fn read_gitwandrc(cwd: String) -> String { +pub(crate) async fn read_gitwandrc(cwd: String) -> String { let cwd_path = std::path::Path::new(&cwd); // 1. .gitwandrc @@ -2625,7 +2638,7 @@ pub(crate) fn read_gitwandrc(cwd: String) -> String { /// localStorage. It runs through `/bin/sh -c`, so the user has full shell /// access, which is intentional (same model as git hooks). #[tauri::command] -pub(crate) fn shell_exec(cwd: String, command: String) -> Result { +pub(crate) async fn shell_exec(cwd: String, command: String) -> Result { if cwd.trim().is_empty() { return Err("cwd must not be empty".to_string()); } @@ -2662,7 +2675,11 @@ pub(crate) fn shell_exec(cwd: String, command: String) -> Result /// Returns the GitHub login of the currently authenticated `gh` user. /// Calls `gh api user --jq .login` — fast, no repo context needed. #[tauri::command] -pub(crate) fn gh_current_user() -> Result { +pub(crate) async fn gh_current_user() -> Result { + // Settings-managed token present → resolve via REST (no `gh` needed). + if let Some(tok) = crate::commands::github_api::settings_github_token() { + return crate::commands::github_api::rest_current_user(&tok); + } // GH_TOKEN propagation: centralized in `hidden_cmd` (cf. git/cmd.rs). let output = hidden_cmd("gh") .args(["api", "user", "--jq", ".login"]) @@ -2689,7 +2706,10 @@ pub(crate) fn gh_current_user() -> Result { // caller (e.g. parity probes); not worth it. Documented here so future // readers don't think it's missing the prefix by accident. #[tauri::command] -pub(crate) fn pr_files(cwd: String, number: i64) -> Result, String> { +pub(crate) async fn pr_files(cwd: String, number: i64) -> Result, String> { + if let Some(tok) = crate::commands::github_api::settings_github_token() { + return crate::commands::github_api::rest_pr_files(&cwd, number, &tok); + } let output = hidden_cmd("gh") .args([ "pr", "view", &number.to_string(), @@ -2718,7 +2738,7 @@ pub(crate) fn pr_files(cwd: String, number: i64) -> Result, String> // common ancestor (unrelated histories, empty repo). #[tauri::command] -pub(crate) fn git_merge_base(cwd: String, ref1: String, ref2: String) -> Result { +pub(crate) async fn git_merge_base(cwd: String, ref1: String, ref2: String) -> Result { let output = git_cmd() .args(["merge-base", &ref1, &ref2]) .current_dir(&cwd) @@ -2731,10 +2751,37 @@ pub(crate) fn git_merge_base(cwd: String, ref1: String, ref2: String) -> Result< Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +// ─── Open a URL in the system browser ──────────────────────── +// +// The webview's `window.open` is a no-op in Tauri, so external links must be +// handed to the OS default handler. Restricted to http(s) to avoid opening +// arbitrary schemes (file://, etc.). + +#[tauri::command] +pub(crate) async fn open_url(url: String) -> Result<(), String> { + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err("Refusing to open non-http(s) URL".to_string()); + } + #[cfg(target_os = "macos")] + let mut cmd = hidden_cmd("open"); + #[cfg(target_os = "linux")] + let mut cmd = hidden_cmd("xdg-open"); + #[cfg(target_os = "windows")] + let mut cmd = { + let mut c = hidden_cmd("cmd"); + c.args(["/C", "start", ""]); + c + }; + cmd.arg(&url) + .spawn() + .map_err(|e| format!("Failed to open URL: {}", e))?; + Ok(()) +} + // ─── Open in external editor ───────────────────────────────── #[tauri::command] -pub(crate) fn open_in_editor(cwd: String, path: String, editor: String) -> Result<(), String> { +pub(crate) async fn open_in_editor(cwd: String, path: String, editor: String) -> Result<(), String> { let editor_cmd = if editor.trim().is_empty() { "code".to_string() } else { @@ -2751,6 +2798,6 @@ pub(crate) fn open_in_editor(cwd: String, path: String, editor: String) -> Resul // ─── Transparent command log ────────────────────────────────── #[tauri::command] -pub(crate) fn get_command_log() -> Vec { +pub(crate) async fn get_command_log() -> Vec { crate::git::cmd::cmd_log_snapshot() } diff --git a/apps/desktop/src-tauri/src/commands/read.rs b/apps/desktop/src-tauri/src/commands/read.rs index 62560260..5af5df4a 100644 --- a/apps/desktop/src-tauri/src/commands/read.rs +++ b/apps/desktop/src-tauri/src/commands/read.rs @@ -39,7 +39,7 @@ use crate::types::*; // fallback ensures we never regress vs. the v2.8 baseline. #[tauri::command] -pub(crate) fn git_status(cwd: String) -> Result { +pub(crate) async fn git_status(cwd: String) -> Result { match git_status_libgit2(&cwd) { Ok(s) => Ok(s), Err(e) => { @@ -537,7 +537,7 @@ fn compute_main_commit_count(cwd: &str, branch: &str) -> i32 { // ─── Git diff ──────────────────────────────────────────────── #[tauri::command] -pub(crate) fn git_diff(cwd: String, path: String, staged: bool) -> Result { +pub(crate) async fn git_diff(cwd: String, path: String, staged: bool) -> Result { let mut cmd = git_cmd(); if staged { cmd.arg("diff").arg("--cached"); @@ -603,7 +603,7 @@ pub(crate) fn git_diff(cwd: String, path: String, staged: bool) -> Result, all: Option, @@ -712,7 +712,7 @@ pub(crate) fn git_log( /// .git directory directly — more reliable than parsing locale-dependent /// `git status` messages. #[tauri::command] -pub(crate) fn git_repo_state(cwd: String) -> Result { +pub(crate) async fn git_repo_state(cwd: String) -> Result { let git_dir = resolve_git_dir(&cwd)?; // ── Plain or interactive rebase (git >= 2.26: rebase-merge dir) ─────── @@ -805,7 +805,7 @@ pub(crate) fn git_repo_state(cwd: String) -> Result // ─── Git show (commit diff) ────────────────────────────────── #[tauri::command] -pub(crate) fn git_show(cwd: String, hash: String) -> Result, String> { +pub(crate) async fn git_show(cwd: String, hash: String) -> Result, String> { let output = git_cmd() .args(["show", "-m", "--first-parent", "--format=", &hash]) .current_dir(&cwd) @@ -961,7 +961,7 @@ pub(crate) fn git_show(cwd: String, hash: String) -> Result, String // ─── File log (v1.9) — pickaxe + line-range ────────────── #[tauri::command] -pub(crate) fn git_file_log(cwd: String, path: String, count: Option) -> Result, String> { +pub(crate) async fn git_file_log(cwd: String, path: String, count: Option) -> Result, String> { let n = count.unwrap_or(50).to_string(); let fmt = "%H\n%h\n%an\n%aI\n%s\n%b\n---END---"; let output = git_cmd() @@ -978,7 +978,7 @@ pub(crate) fn git_file_log(cwd: String, path: String, count: Option) -> Res /// Pickaxe: find commits that added or removed `search` string. /// mode: "S" (literal string) | "G" (regex) #[tauri::command] -pub(crate) fn git_file_log_pickaxe(cwd: String, path: String, search: String, mode: String) -> Result, String> { +pub(crate) async fn git_file_log_pickaxe(cwd: String, path: String, search: String, mode: String) -> Result, String> { let flag = if mode == "G" { "-G" } else { "-S" }; let fmt = "%H\n%h\n%an\n%aI\n%s\n%b\n---END---"; let output = git_cmd() @@ -995,7 +995,7 @@ pub(crate) fn git_file_log_pickaxe(cwd: String, path: String, search: String, mo /// Line-range history: commits that touched lines [start..end] in path. /// Uses git log -L ,: (no --follow; incompatible with -L). #[tauri::command] -pub(crate) fn git_file_log_range(cwd: String, path: String, start_line: u32, end_line: u32) -> Result, String> { +pub(crate) async fn git_file_log_range(cwd: String, path: String, start_line: u32, end_line: u32) -> Result, String> { let range = format!("{},{}:{}", start_line, end_line, path); let fmt = "%H\n%h\n%an\n%aI\n%s\n%b\n---END---"; let output = git_cmd() @@ -1012,7 +1012,7 @@ pub(crate) fn git_file_log_range(cwd: String, path: String, start_line: u32, end /// Run `git blame --porcelain` on a file. /// algorithm: "histogram" | "patience" | "minimal" | "myers" (default "histogram"). #[tauri::command] -pub(crate) fn git_blame(cwd: String, path: String, algorithm: Option) -> Result, String> { +pub(crate) async fn git_blame(cwd: String, path: String, algorithm: Option) -> Result, String> { let algo = algorithm.as_deref().unwrap_or("histogram"); let diff_algo_flag = format!("--diff-algorithm={}", algo); let output = git_cmd() @@ -1079,7 +1079,7 @@ pub(crate) fn git_blame(cwd: String, path: String, algorithm: Option) -> // 7. Retourner la liste brute — le résolveur tourne côté frontend (TypeScript) #[tauri::command] -pub(crate) fn preview_merge(cwd: String, source_branch: String) -> Result, String> { +pub(crate) async fn preview_merge(cwd: String, source_branch: String) -> Result, String> { let git = git_binary(); // 1. Merge-base @@ -1254,7 +1254,7 @@ fn merge_file_preview( /// Excludes the current branch and the default branch itself. /// Equivalent to `git branch --merged --format="%(refname:short)"`. #[tauri::command] -pub(crate) fn git_branch_merged(cwd: String) -> Result, String> { +pub(crate) async fn git_branch_merged(cwd: String) -> Result, String> { use crate::git::cmd::git_cmd; // Resolve default branch (main / master / trunk / …) @@ -1305,7 +1305,7 @@ pub(crate) fn git_branch_merged(cwd: String) -> Result, String> { /// Return the effective git user.name and user.email for the given repo. #[tauri::command] -pub(crate) fn git_config_identity(cwd: String) -> Result<(String, String), String> { +pub(crate) async fn git_config_identity(cwd: String) -> Result<(String, String), String> { use crate::git::cmd::git_cmd; let name_out = git_cmd() @@ -1331,7 +1331,7 @@ pub(crate) fn git_config_identity(cwd: String) -> Result<(String, String), Strin /// Return the absolute path of the commit.template configured for the repo, /// with ~ expanded to the home directory. Returns null (None) if not set. #[tauri::command] -pub(crate) fn git_commit_template_path(cwd: String) -> Result, String> { +pub(crate) async fn git_commit_template_path(cwd: String) -> Result, String> { use crate::git::cmd::git_cmd; let output = git_cmd() diff --git a/apps/desktop/src-tauri/src/commands/workspace.rs b/apps/desktop/src-tauri/src/commands/workspace.rs index 6c5a81e6..114a591a 100644 --- a/apps/desktop/src-tauri/src/commands/workspace.rs +++ b/apps/desktop/src-tauri/src/commands/workspace.rs @@ -17,7 +17,7 @@ use rayon::prelude::*; /// Read a `.gitwand-workspace.json` from the given directory. #[tauri::command] -pub(crate) fn workspace_read(path: String) -> Result { +pub(crate) async fn workspace_read(path: String) -> Result { let dir = std::path::Path::new(&path); let file = dir.join(".gitwand-workspace.json"); if !file.exists() { @@ -31,7 +31,7 @@ pub(crate) fn workspace_read(path: String) -> Result { /// Write a `.gitwand-workspace.json` to the given directory. #[tauri::command] -pub(crate) fn workspace_write(path: String, workspace: WorkspaceConfig) -> Result<(), String> { +pub(crate) async fn workspace_write(path: String, workspace: WorkspaceConfig) -> Result<(), String> { let dir = std::path::Path::new(&path); let file = dir.join(".gitwand-workspace.json"); std::fs::create_dir_all(dir) @@ -48,7 +48,7 @@ pub(crate) fn workspace_write(path: String, workspace: WorkspaceConfig) -> Resul /// Order is preserved by `into_par_iter().map(...).collect::>()` /// because `Vec::into_par_iter()` is an `IndexedParallelIterator`. #[tauri::command] -pub(crate) fn workspace_status_all(repos: Vec) -> Vec { +pub(crate) async fn workspace_status_all(repos: Vec) -> Vec { repos.into_par_iter().map(|repo| { let path = repo.path.clone(); let name = repo.name.clone(); @@ -75,14 +75,14 @@ pub(crate) fn workspace_status_all(repos: Vec) -> Vec) -> Vec { +pub(crate) async fn workspace_fetch_all(repos: Vec) -> Vec { repos.par_iter().for_each(|repo| { let _ = git_cmd() .args(["fetch", "--all", "--prune"]) .current_dir(&repo.path) .output(); }); - workspace_status_all(repos) + workspace_status_all(repos).await } /// Run `git pull --ff-only` on all repos (best-effort). @@ -90,14 +90,14 @@ pub(crate) fn workspace_fetch_all(repos: Vec) -> Vec) -> Vec { +pub(crate) async fn workspace_pull_all(repos: Vec) -> Vec { repos.par_iter().for_each(|repo| { let _ = git_cmd() .args(["pull", "--ff-only"]) .current_dir(&repo.path) .output(); }); - workspace_status_all(repos) + workspace_status_all(repos).await } /// Get detailed WIP status for all repos in a workspace. @@ -107,7 +107,7 @@ pub(crate) fn workspace_pull_all(repos: Vec) -> Vec) -> Vec { +pub(crate) async fn workspace_wip_all(repos: Vec) -> Vec { repos.into_par_iter().map(|repo| { let path = repo.path.clone(); let name = repo.name.clone(); @@ -142,7 +142,7 @@ pub(crate) fn workspace_wip_all(repos: Vec) -> Vec) -> Vec { +pub(crate) async fn workspace_prs_all(repos: Vec) -> Vec { repos.into_par_iter().map(|repo| { let repo_path = repo.path.clone(); let repo_name = repo.name.clone(); @@ -210,7 +210,7 @@ pub(crate) fn workspace_prs_all(repos: Vec) -> Vec, filter: String) -> Vec { +pub(crate) async fn workspace_issues_all(repos: Vec, filter: String) -> Vec { repos.into_par_iter().map(|repo| { let repo_path = repo.path.clone(); let repo_name = repo.name.clone(); diff --git a/apps/desktop/src-tauri/src/git/parse.rs b/apps/desktop/src-tauri/src/git/parse.rs index dc70beef..1b3e7af0 100644 --- a/apps/desktop/src-tauri/src/git/parse.rs +++ b/apps/desktop/src-tauri/src/git/parse.rs @@ -2,10 +2,54 @@ use std::collections::HashMap; use std::path::Path; use crate::git::cmd::git_cmd; use crate::types::{ - DiffHunk, DiffLine, FileLogEntry, FolderDiffNode, GhIssueRaw, GhPrDetailRaw, GhPrRaw, Issue, - MonorepoPackage, PullRequest, PullRequestDetail, RawFileChange, ShortlogEntry, + DiffHunk, DiffLine, FileLogEntry, FolderDiffNode, GhIssueRaw, GhPrDetailRaw, GhPrRaw, + GhPrStatusCheck, Issue, MonorepoPackage, PullRequest, PullRequestDetail, RawFileChange, + ShortlogEntry, }; +/// Aggregate a PR's individual status checks into a single rollup state. +/// +/// Mirrors GitHub's own `statusCheckRollup.state`: a single failing check turns +/// the whole rollup red, regardless of position. Previously only the first +/// check's `conclusion` was read, so a green first check masked later failures +/// (and still-running checks with a null conclusion were dropped entirely). +/// +/// Precedence: any failure ⇒ `FAILURE`, else any pending/running ⇒ `PENDING`, +/// else `SUCCESS`. Empty input ⇒ `""` (no checks configured → no dot). +pub(crate) fn rollup_status_checks(checks: &[GhPrStatusCheck]) -> String { + if checks.is_empty() { + return String::new(); + } + let mut pending = false; + for c in checks { + // CheckRun: lifecycle in `status`, outcome in `conclusion` once COMPLETED. + // StatusContext: outcome directly in `state`. + let outcome = c + .conclusion + .as_deref() + .or(c.state.as_deref()) + .unwrap_or("") + .to_uppercase(); + match outcome.as_str() { + "FAILURE" | "ERROR" | "CANCELLED" | "TIMED_OUT" | "ACTION_REQUIRED" + | "STARTUP_FAILURE" | "STALE" => return "FAILURE".to_string(), + "SUCCESS" | "NEUTRAL" | "SKIPPED" => {} + // PENDING / EXPECTED / QUEUED / "" (running CheckRun with no + // conclusion yet) → not green, not red. + _ => pending = true, + } + // A CheckRun that hasn't completed is still pending even if some stale + // conclusion is absent. + if let Some(st) = c.status.as_deref() { + let st = st.to_uppercase(); + if st != "COMPLETED" && c.conclusion.is_none() { + pending = true; + } + } + } + if pending { "PENDING".to_string() } else { "SUCCESS".to_string() } +} + pub(crate) fn parse_diff_hunks(stdout: &str) -> (Vec, Option) { let mut hunks: Vec = Vec::new(); let mut current_hunk: Option = None; @@ -301,20 +345,15 @@ pub(crate) fn gh_pr_detail_raw_to_detail(r: GhPrDetailRaw) -> PullRequestDetail .into_iter() .filter_map(|rr| rr.requested_reviewer?.login) .collect(); - let checks_status = { - let mut has_failure = false; - let mut has_pending = false; - for c in &r.status_check_rollup { - match c.conclusion.as_deref() { - Some("FAILURE" | "ERROR") => has_failure = true, - Some("PENDING" | "QUEUED" | "IN_PROGRESS") => has_pending = true, - _ => {} - } - } - if has_failure { "failure".to_string() } - else if has_pending { "pending".to_string() } - else if !r.status_check_rollup.is_empty() { "success".to_string() } - else { "unknown".to_string() } + // Same aggregation as the list dot (any failure ⇒ red, else pending ⇒ + // yellow, else green). The old inline logic only looked at `conclusion`, + // so a still-running check (its run state lives in `status`) was missed and + // a single later failure could be masked. + let checks_status = match rollup_status_checks(&r.status_check_rollup).as_str() { + "FAILURE" => "failure".to_string(), + "PENDING" => "pending".to_string(), + "SUCCESS" => "success".to_string(), + _ => "unknown".to_string(), }; PullRequestDetail { number: r.number, @@ -338,6 +377,9 @@ pub(crate) fn gh_pr_detail_raw_to_detail(r: GhPrDetailRaw) -> PullRequestDetail reviewers, mergeable: r.mergeable.unwrap_or_default(), checks_status, + // Filled in by the caller (gh_pr_detail) via a `gh repo view` + // viewerPermission lookup — `gh pr view` doesn't carry it. + can_merge: None, } } @@ -372,11 +414,7 @@ pub(crate) fn gh_pr_raw_to_pr(r: GhPrRaw) -> PullRequest { .into_iter() .filter_map(|rr| rr.requested_reviewer?.login) .collect(); - let checks_rollup = r.status_check_rollup - .into_iter() - .filter_map(|c| c.conclusion) - .next() - .unwrap_or_default(); + let checks_rollup = rollup_status_checks(&r.status_check_rollup); let comment_count = r.comments.len() as i64; let author = r .author diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ae9af778..138119e1 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -202,28 +202,31 @@ pub fn git_log_parity( all: Option, author: Option, ) -> Result, String> { - commands::read::git_log(cwd, count, all, author, None, None) + // Command fns are now `async` (run off the UI thread). These `*_parity` + // wrappers are sync entry points for the parity-probe example, so block on + // the future here to keep their signatures unchanged. + tauri::async_runtime::block_on(commands::read::git_log(cwd, count, all, author, None, None)) } pub fn git_branches_parity(cwd: String) -> Result, String> { - commands::ops::git_branches(cwd) + tauri::async_runtime::block_on(commands::ops::git_branches(cwd)) } pub fn git_stash_list_parity(cwd: String) -> Result, String> { - commands::ops::git_stash_list(cwd) + tauri::async_runtime::block_on(commands::ops::git_stash_list(cwd)) } pub fn git_submodule_branches_parity( cwd: String, submodule_path: String, ) -> Result, String> { - commands::ops::git_submodule_branches(cwd, submodule_path) + tauri::async_runtime::block_on(commands::ops::git_submodule_branches(cwd, submodule_path)) } pub fn git_commit_submodule_changes_parity( cwd: String, ) -> Result>, String> { - commands::ops::git_commit_submodule_changes(cwd) + tauri::async_runtime::block_on(commands::ops::git_commit_submodule_changes(cwd)) } // ─── Tauri entry point ───────────────────────────────────── @@ -303,6 +306,7 @@ pub fn run() { commands::ops::git_stash, commands::ops::git_stash_pop, commands::ops::open_in_editor, + commands::ops::open_url, commands::ops::set_git_config, commands::ops::read_gitwandrc, commands::read::preview_merge, @@ -326,7 +330,31 @@ pub fn run() { commands::gh::gh_pr_detail, commands::gh::gh_pr_diff, commands::gh::gh_pr_checks, + commands::gh::gh_pr_comments, + commands::gh::gh_pr_issue_comments, commands::gh::gh_pr_ready, + commands::gh::gh_fork_info, + commands::github_api::github_device_start, + commands::github_api::github_device_poll, + commands::github_api::github_token_present, + commands::azure::azure_device_start, + commands::azure::azure_device_poll, + commands::azure::azure_token_present, + commands::azure::az_current_user, + commands::azure::az_list_prs, + commands::azure::az_pr_count, + commands::azure::az_pr_detail, + commands::azure::az_pr_diff, + commands::azure::az_pr_files, + commands::azure::az_create_pr, + commands::azure::az_merge_pr, + commands::azure::az_pr_ready, + commands::azure::az_checkout_pr, + commands::azure::az_pr_comments, + commands::azure::az_pr_create_comment, + commands::azure::az_pr_checks, + commands::azure::az_pr_reviews, + commands::azure::az_submit_review, commands::ops::git_exec, commands::ops::git_autocomplete, commands::ops::git_get_user, @@ -502,6 +530,64 @@ mod tests { assert_eq!(pr.checks_rollup, "SUCCESS"); } + #[test] + fn checks_rollup_is_red_when_any_check_fails() { + // Regression: the old parser read only the *first* check's conclusion, + // so a green first check masked a later failure → green dot on a red PR. + let json = r#"[{ + "number": 7, "title": "x", "state": "OPEN", "author": {"login": "a"}, + "headRefName": "h", "baseRefName": "main", "isDraft": false, + "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + "url": "u", "additions": 0, "deletions": 0, "labels": [], "assignees": [], + "reviewRequests": [], "reviewDecision": null, "mergeStateStatus": null, + "statusCheckRollup": [ + {"conclusion": "SUCCESS"}, + {"conclusion": "FAILURE"}, + {"conclusion": "SUCCESS"} + ] + }]"#; + let prs = parse_gh_pr_json(json).unwrap(); + assert_eq!(prs[0].checks_rollup, "FAILURE"); + } + + #[test] + fn checks_rollup_is_pending_while_a_check_runs() { + // A still-running CheckRun (status IN_PROGRESS, conclusion null) and a + // legacy StatusContext (state PENDING) both yield a yellow rollup. + let json = r#"[{ + "number": 8, "title": "x", "state": "OPEN", "author": {"login": "a"}, + "headRefName": "h", "baseRefName": "main", "isDraft": false, + "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + "url": "u", "additions": 0, "deletions": 0, "labels": [], "assignees": [], + "reviewRequests": [], "reviewDecision": null, "mergeStateStatus": null, + "statusCheckRollup": [ + {"conclusion": "SUCCESS"}, + {"status": "IN_PROGRESS", "conclusion": null}, + {"state": "PENDING"} + ] + }]"#; + let prs = parse_gh_pr_json(json).unwrap(); + assert_eq!(prs[0].checks_rollup, "PENDING"); + } + + #[test] + fn checks_rollup_green_only_when_all_pass() { + let json = r#"[{ + "number": 9, "title": "x", "state": "OPEN", "author": {"login": "a"}, + "headRefName": "h", "baseRefName": "main", "isDraft": false, + "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + "url": "u", "additions": 0, "deletions": 0, "labels": [], "assignees": [], + "reviewRequests": [], "reviewDecision": null, "mergeStateStatus": null, + "statusCheckRollup": [ + {"conclusion": "SUCCESS"}, + {"conclusion": "SKIPPED"}, + {"state": "SUCCESS"} + ] + }]"#; + let prs = parse_gh_pr_json(json).unwrap(); + assert_eq!(prs[0].checks_rollup, "SUCCESS"); + } + #[test] fn parse_pr_with_braces_in_title_does_not_silently_drop() { // Regression test: the old char-scanning parser broke when PR titles diff --git a/apps/desktop/src-tauri/src/types.rs b/apps/desktop/src-tauri/src/types.rs index 10a16377..7e82b20f 100644 --- a/apps/desktop/src-tauri/src/types.rs +++ b/apps/desktop/src-tauri/src/types.rs @@ -363,7 +363,17 @@ pub struct GhPrReviewRequest { #[derive(Deserialize)] pub struct GhPrStatusCheck { + /// CheckRun outcome (SUCCESS / FAILURE / …). `None` while still running. + #[serde(default)] pub conclusion: Option, + /// StatusContext outcome (SUCCESS / PENDING / FAILURE / ERROR). Present on + /// legacy commit-status entries that carry no `conclusion`. + #[serde(default)] + pub state: Option, + /// CheckRun lifecycle (QUEUED / IN_PROGRESS / COMPLETED). Used to detect a + /// check that is still running (no conclusion yet). + #[serde(default)] + pub status: Option, } #[derive(Deserialize)] @@ -474,6 +484,54 @@ pub struct PullRequestDetail { pub reviewers: Vec, pub mergeable: String, pub checks_status: String, + /// Whether the current viewer has permission to merge this PR. + /// `None` when the forge does not cheaply expose it (Azure, Bitbucket) — + /// the UI must treat unknown as "allowed" and gate on errors only, never + /// disable the merge button on an unknown permission. + #[serde(default)] + pub can_merge: Option, +} + +// ─── Fork / PR target info ───────────────────────────────────────── + +/// Describes the current repo's GitHub fork relationship, used by the PR +/// create view to offer "open against upstream" for forks. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ForkInfo { + /// True when `origin` is a fork of another GitHub repo. + pub is_fork: bool, + /// `origin` as `owner/repo` (the head side of a cross-fork PR). + pub origin: String, + /// Parent/upstream as `owner/repo`, or "" when not a fork. + pub parent: String, +} + +// ─── GitHub OAuth device flow ────────────────────────────────────── + +/// Returned by `github_device_start` — the user-facing code + the +/// `device_code` the frontend polls with. +#[derive(Serialize)] +pub struct GithubDeviceCode { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + /// `verification_uri` with the user code pre-filled — opening this skips the + /// manual code-entry step. Empty if GitHub omits it. + pub verification_uri_complete: String, + pub expires_in: i64, + /// Minimum seconds between polls (GitHub-mandated, floored at 5). + pub interval: i64, +} + +/// Returned by `github_device_poll`. `status` ∈ +/// `"pending" | "slow_down" | "success" | "error"`. The token itself is never +/// returned — it is stored directly in the OS keychain on success. +#[derive(Serialize)] +pub struct GithubDevicePoll { + pub status: String, + pub login: String, + pub error: String, } // ─── CI Check ────────────────────────────────────────────────────── diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index eb6508fc..d1c24372 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -88,6 +88,7 @@ import { LOG_FOCUS_SEARCH_KEY, LAUNCHPAD_OPEN_REQUEST_KEY, TOGGLE_GIT_TREE_KEY, + OPEN_SETTINGS_KEY, } from "./composables/branchPickerBridge"; import { gitStash, gitStashPop, gitStashList, openInEditor, setGitConfig, gitDiscard, gitAddToGitignore, gitDeleteBranch, gitDeleteTag, gitDeleteRemoteTag, gitRemoteInfo, gitUnpushedTags, gitPushTags, workspaceRead, gitMergeBase, gitResetToCommit, gitCommitSubmoduleChanges, type CommitSubmoduleChange } from "./utils/backend"; import { useCommitActions } from "./composables/useCommitActions"; @@ -102,7 +103,7 @@ const { settings, refreshSettings } = useSettings(); const { isOffline: navIsOffline } = useNetworkStatus(); const { isOnline: probedOnline, probeConnectivity } = useConnectivity(); const isOffline = computed(() => navIsOffline.value || !probedOnline.value); -import { isTauri, registerBrowserFolderPicker, pickFolder, checkForUpdates, fetchBetaUpdate, installUpdate, gitRepoState } from "./utils/backend"; +import { isTauri, registerBrowserFolderPicker, pickFolder, checkForUpdates, fetchBetaUpdate, installUpdate, gitRepoState, openExternalUrl } from "./utils/backend"; import type { UpdateInfo, RepoOperationState, WorkspaceRepo } from "./utils/backend"; // UpdateModal moved above (lazy-loaded) — type imported as UpdateModalType for the template ref @@ -337,6 +338,7 @@ provide(UNDO_POPOVER_REQUEST_KEY, undoPopoverRequest); provide(LOG_FOCUS_SEARCH_KEY, logFocusRequest); provide(LAUNCHPAD_OPEN_REQUEST_KEY, launchpadOpenRequest); provide(TOGGLE_GIT_TREE_KEY, () => { showGitTree.value = !showGitTree.value; }); +provide(OPEN_SETTINGS_KEY, (tab) => { settingsInitialTab.value = tab; showSettings.value = true; }); provide("askConfirm", askConfirm); // ─── Multi-repo tabs (lightweight — paths only) ───────── @@ -1124,7 +1126,7 @@ function onPaletteSelectCommit(hash: string) { // ─── Settings panel ───────────────────────────────────── const showSettings = ref(false); -const settingsInitialTab = ref<"general" | "git" | "editor" | "ai" | "logs" | undefined>(undefined); +const settingsInitialTab = ref<"general" | "git" | "editor" | "ai" | "automations" | "logs" | "hooks" | "accounts" | "mcp" | undefined>(undefined); // ─── Error log (in-memory ring buffer, feeds SettingsPanel Logs tab) ─ // Uses the useLogs() singleton composable — no localStorage persistence so a @@ -2035,7 +2037,7 @@ async function onInstallUpdate() { // The user downloads + installs themselves; we close our modal. if (pendingUpdate.value?.installMethod === "manual") { if (pendingUpdate.value.downloadUrl) { - window.open(pendingUpdate.value.downloadUrl, "_blank"); + void openExternalUrl(pendingUpdate.value.downloadUrl); } pendingUpdate.value = null; return; @@ -2092,7 +2094,7 @@ useAppMenu( : info.provider === "bitbucket" ? `https://bitbucket.org/${info.owner}/${info.repo}` : `https://github.com/${info.owner}/${info.repo}`; - window.open(url, "_blank"); + void openExternalUrl(url); }, toggleTheme, checkForUpdates: runUpdateCheck, diff --git a/apps/desktop/src/components/CommitDiffViewer.vue b/apps/desktop/src/components/CommitDiffViewer.vue index bcc64d90..0a6b854d 100644 --- a/apps/desktop/src/components/CommitDiffViewer.vue +++ b/apps/desktop/src/components/CommitDiffViewer.vue @@ -2,6 +2,7 @@ import { ref, computed, watch, onUnmounted, nextTick } from "vue"; import type { GitDiff, GitLogEntry, DiffLine, FolderDiffNode } from "../utils/backend"; import { useI18n } from "../composables/useI18n"; +import { avatarStyle, avatarInitials as initials } from "../composables/useAvatar"; import type { DiffMode } from "../utils/diffMode"; import { detectLanguage, highlightLine } from "../utils/highlight"; import { safeHtml } from "../composables/useSafeHtml"; @@ -75,25 +76,6 @@ function fileName(path: string): string { return path.split("/").pop() ?? path; } -function hueFor(s: string): number { - let h = 0; - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; - return Math.abs(h) % 360; -} - -function avatarStyle(key: string) { - const h = hueFor(key); - const color = `hsl(${h} 70% 55%)`; - return { borderColor: color, color, background: "transparent" }; -} - -function initials(name: string): string { - if (!name) return "?"; - const parts = name.trim().split(/\s+/); - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); -} - function cleanBody(raw: string): string { // Replace literal \n sequences with real newlines, then trim return raw.replace(/\\n/g, "\n").trim(); diff --git a/apps/desktop/src/components/CommitGraph.vue b/apps/desktop/src/components/CommitGraph.vue index a7a7d117..93cfeb0a 100644 --- a/apps/desktop/src/components/CommitGraph.vue +++ b/apps/desktop/src/components/CommitGraph.vue @@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; import type { GitLogEntry, GitBranch } from "../utils/backend"; import { computeDagLayout, parseRefs, type DagLayout, type DagNode } from "../utils/dagLayout"; import { useI18n } from "../composables/useI18n"; +import { avatarStyle, avatarInitials as initials } from "../composables/useAvatar"; import { filterCommitsLocal } from "../composables/useCommitSearch"; const { t } = useI18n(); @@ -653,30 +654,6 @@ function truncate(str: string, limit = 20) { return str.slice(0, limit - 1) + "…"; } -/** Deterministic hue for an avatar from a string (same author → same color). */ -function hueFor(s: string): number { - let h = 0; - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; - return Math.abs(h) % 360; -} - -function avatarStyle(key: string) { - const h = hueFor(key); - const color = `hsl(${h} 70% 55%)`; - return { - borderColor: color, - color: color, - background: "transparent", - }; -} - -function initials(name: string): string { - if (!name) return "?"; - const parts = name.trim().split(/\s+/); - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); -} - function formatDate(raw: string): string { try { const d = new Date(raw); diff --git a/apps/desktop/src/components/CommitLog.vue b/apps/desktop/src/components/CommitLog.vue index 2ce21660..896be1b7 100644 --- a/apps/desktop/src/components/CommitLog.vue +++ b/apps/desktop/src/components/CommitLog.vue @@ -3,6 +3,7 @@ import { computed, ref, watch, inject, nextTick, onMounted, onUnmounted, type Re import type { GitLogEntry, GitBranch } from "../utils/backend"; import { useVirtualizer } from "@tanstack/vue-virtual"; import { useI18n } from "../composables/useI18n"; +import { avatarStyle, avatarInitials as initials } from "../composables/useAvatar"; import { useAIProvider } from "../composables/useAIProvider"; import { useCommitSearch, filterCommitsLocal, type CommitMatch } from "../composables/useCommitSearch"; import { LOG_FOCUS_SEARCH_KEY } from "../composables/branchPickerBridge"; @@ -595,30 +596,6 @@ function truncate(str: string, limit = 20) { return str.slice(0, limit - 1) + "…"; } -/** Deterministic hue for an avatar from a string (same author → same color). */ -function hueFor(s: string): number { - let h = 0; - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; - return Math.abs(h) % 360; -} - -function avatarStyle(key: string) { - const h = hueFor(key); - const color = `hsl(${h} 70% 55%)`; - return { - borderColor: color, - color: color, - background: "transparent", - }; -} - -function initials(name: string): string { - if (!name) return "?"; - const parts = name.trim().split(/\s+/); - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); -} - function relativeDate(isoDate: string): string { const date = new Date(isoDate); const now = new Date(); @@ -639,24 +616,6 @@ function isCurrent(entry: GitLogEntry): boolean { return parseRefBadges(entry.refs).some(b => b.type === "head"); } -function authorInitials(author: string): string { - if (!author) return "?"; - const parts = author.replace(/[^a-zA-Z0-9\s-]/g, " ").split(/[\s-]+/).filter(Boolean); - if (parts.length === 0) return author[0]?.toUpperCase() ?? "?"; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return (parts[0][0] + parts[1][0]).toUpperCase(); -} - -function authorColor(author: string): string { - if (!author) return "hsl(260 65% 55%)"; - let h = 0; - for (let i = 0; i < author.length; i++) { - h = (h * 31 + author.charCodeAt(i)) >>> 0; - } - const hue = h % 360; - return `linear-gradient(135deg, hsl(${hue} 65% 55%), hsl(${(hue + 40) % 360} 70% 45%))`; -} - function abbrevAuthor(author: string): string { if (!author) return ""; const parts = author.trim().split(/\s+/); @@ -749,8 +708,8 @@ function abbrevAuthor(author: string): string { tabindex="0" @keydown.enter="emit('selectCommit', c(vr.index).hashFull)" > -
- {{ authorInitials(c(vr.index).author) }} +
+ {{ initials(c(vr.index).author) }}
@@ -1247,12 +1206,12 @@ function abbrevAuthor(author: string): string { width: 28px; height: 28px; border-radius: 50%; + border: 1.5px solid currentColor; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: var(--font-weight-bold); - color: var(--color-accent-text); flex-shrink: 0; margin-top: 2px; } diff --git a/apps/desktop/src/components/DashboardView.vue b/apps/desktop/src/components/DashboardView.vue index 657b3fe9..e3bcc205 100644 --- a/apps/desktop/src/components/DashboardView.vue +++ b/apps/desktop/src/components/DashboardView.vue @@ -15,6 +15,7 @@ import { } from "../utils/backend"; import type { ViewMode } from "../composables/useGitRepo"; import { useI18n } from "../composables/useI18n"; +import { avatarStyle, avatarInitials as initials } from "../composables/useAvatar"; import { useAIProvider } from "../composables/useAIProvider"; import { useReleaseNotes, latestTag as findLatestTag } from "../composables/useReleaseNotes"; import { renderMarkdown, safeHtml } from "../composables/useSafeHtml"; @@ -330,29 +331,6 @@ function runNextAction() { else if (nextAction.value.view) emit("changeView", nextAction.value.view); } -/** Deterministic hue for an avatar from a string (same author → same color). */ -function hueFor(s: string): number { - let h = 0; - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; - return Math.abs(h) % 360; -} - -function avatarStyle(key: string) { - const h = hueFor(key); - const color = `hsl(${h} 70% 55%)`; - return { - borderColor: color, - color: color, - background: "transparent", - }; -} - -function initials(name: string): string { - if (!name) return "?"; - const parts = name.trim().split(/\s+/); - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); -} /** Detect conventional-commit type for tag colouring. */ function commitType(msg: string): string { diff --git a/apps/desktop/src/components/FileHistoryViewer.vue b/apps/desktop/src/components/FileHistoryViewer.vue index e250bb53..172cf537 100644 --- a/apps/desktop/src/components/FileHistoryViewer.vue +++ b/apps/desktop/src/components/FileHistoryViewer.vue @@ -1,6 +1,7 @@ -
-