diff --git a/CHANGELOG.md b/CHANGELOG.md index bda27993..71d4af2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **PR Review 2.0** — a chantier of composable, keyboard-first review upgrades: + - **GitHub-standard review keymap** — `J`/`K` next/previous hunk, `⇧J`/`⇧K` next/previous file, `V` toggle viewed, `⇧V` hide viewed files, `T` filter files, `C` comment on the current hunk, `N`/`P` next/previous AI finding, `⌘Enter`/`Ctrl+Enter` submit review. Documented in Help → Keyboard Shortcuts. + - **Viewed-file state** — mark files as reviewed; hidden-viewed-files view to focus on what's left. Invalidated at the whole-PR level when the head SHA changes (a re-push resets viewed state honestly, rather than silently keeping stale marks). + - **Pending review surfaced** — draft review comments persist across closing/reopening the PR detail (survives navigating away) and are visible as a running count before submission. + - **Dismiss review / request reviewers** — GitHub gains both actions from the Info tab; forges that don't support one (e.g. GitLab has no direct review-dismissal equivalent) hide the action instead of throwing. + - **Local, opt-in AI pre-review pipeline** — a multi-hop pass (diff → import/dependency extraction → file history → LLM findings) surfaces confidence-scored, line-anchored findings with a cap and per-finding dismissal memory. Zero calls when the setting is off; a PR switch aborts any in-flight run. Configurable under Settings → Git → Review AI. + - **PR summary block** — a generated one-paragraph summary of the PR's intent and risk, shown above the diff, regeneratable on demand. + - **Unified `LineAnnotation` model** — CI check-run annotations, AI findings, and static heuristic flags now render through one merged gutter-overlay stream instead of three separate code paths. + - **GitLab real review completion** — inline batch review comments anchor correctly via a dedicated diff-refs lookup, `listReviews` reflects approvals and changes-requested verdicts, and file review history reports real per-path comment counts. - **Pre-commit secrets scanner** — a zero-network, local scanner over the staged diff's added lines, detecting AWS/GCP/Azure/GitHub/GitLab/Slack/Stripe/OpenAI/Anthropic tokens, RSA/OpenSSH/EC/PGP private-key headers, JWTs, and high-entropy literals (Shannon entropy, tunable threshold). Non-blocking in-app: an orange badge in the commit area opens a findings modal (redacted excerpts only, per-finding dismiss, per-pattern ignore), and committing with active findings shows a confirm the user can always bypass. Extensible per-repo via `.gitwandrc` `secrets.patterns[]` / `secrets.ignore[]`. Dual-implemented (Rust `regex` crate for the desktop app, a pure TypeScript mirror in `@gitwand/core` for `pnpm dev:web` and the CLI) and locked in sync by a parity test. Also ships `gitwand scan [--json] [--strict]` in the CLI and an opt-in, blocking pre-commit hook installer (Settings → Hooks) that shells out to it — always bypassable with `git commit --no-verify`. +### Changed + +- **PR detail open now costs 3 forge calls on the hot path (was 6)** — revalidation on reopening an already-cached PR detail was slimmed to only the calls that can actually have changed. +- **PR diff parsing is lazy, per-file** — a PR with many changed files no longer parses every file's hunks up front; only the file currently open is parsed, cached by path. +- **Diff line rendering is virtualized** — large diffs (thousands of lines) keep a bounded DOM instead of rendering every line, fixing scroll/interaction lag on big files. + +### Fixed + +- **Azure comment edit/delete no longer risks a runtime crash** — `updateComment`/`deleteComment` are unsupported on Azure DevOps for now; the UI hides the edit/delete affordance instead of calling into a forge method that throws. + ## [3.4.0] - 2026-07-08 ### Added diff --git a/apps/desktop/dev-server.mjs b/apps/desktop/dev-server.mjs index be4ca044..a3fd76bd 100644 --- a/apps/desktop/dev-server.mjs +++ b/apps/desktop/dev-server.mjs @@ -3234,6 +3234,7 @@ async function handleRequest(req, res) { reviewers: (pr.requested_reviewers ?? []).map((r) => r.login ?? ""), mergeable: pr.mergeable_state ?? "unknown", checks_status: "", + head_sha: pr.head?.sha ?? "", }); } catch (err) { return jsonResponse(req, res, { error: err.stderr?.toString() || err.message }, 500); @@ -4450,6 +4451,64 @@ async function handleRequest(req, res) { } } + // POST /api/gh-dismiss-review { cwd, number, reviewId, message } (B4, v3.6.0) + if (url.pathname === "/api/gh-dismiss-review" && req.method === "POST") { + try { + const { cwd, number, reviewId, message } = await readBody(req); + if (!cwd || !number || !reviewId) return jsonResponse(req, res, { error: "Missing cwd, number, or reviewId" }, 400); + 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 fetch(`https://api.github.com/repos/${nwo}/pulls/${number}/reviews/${reviewId}/dismissals`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + body: JSON.stringify({ message: message || "", event: "DISMISS" }), + }); + if (!resp.ok) { + const body = await resp.text().catch(() => ""); + return jsonResponse(req, res, { error: `GitHub API ${resp.status}${body ? `: ${body}` : ""}` }, 500); + } + return jsonResponse(req, res, { ok: true }); + } catch (err) { + return jsonResponse(req, res, { error: err.stderr?.toString() || err.message }, 500); + } + } + + // POST /api/gh-request-reviewers { cwd, number, logins } (B4, v3.6.0) + if (url.pathname === "/api/gh-request-reviewers" && req.method === "POST") { + try { + const { cwd, number, logins } = await readBody(req); + if (!cwd || !number || !Array.isArray(logins)) return jsonResponse(req, res, { error: "Missing cwd, number, or logins" }, 400); + 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 fetch(`https://api.github.com/repos/${nwo}/pulls/${number}/requested_reviewers`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + body: JSON.stringify({ reviewers: logins }), + }); + if (!resp.ok) { + const body = await resp.text().catch(() => ""); + return jsonResponse(req, res, { error: `GitHub API ${resp.status}${body ? `: ${body}` : ""}` }, 500); + } + return jsonResponse(req, res, { ok: true }); + } catch (err) { + return jsonResponse(req, res, { error: err.stderr?.toString() || err.message }, 500); + } + } + // POST /api/git-interactive-rebase { cwd, base, todoLines } // Starts an interactive rebase with a custom todo list. // Writes a temp file and uses GIT_SEQUENCE_EDITOR to inject it. diff --git a/apps/desktop/src-tauri/src/commands/azure.rs b/apps/desktop/src-tauri/src/commands/azure.rs index b57aab11..4eaf81e5 100644 --- a/apps/desktop/src-tauri/src/commands/azure.rs +++ b/apps/desktop/src-tauri/src/commands/azure.rs @@ -586,6 +586,7 @@ fn json_to_detail(r: &AzureRepo, pr: &serde_json::Value) -> PullRequestDetail { // Azure merge permission needs the Security/Permissions API — not // cheaply available here. Unknown ⇒ UI gates on errors only. can_merge: None, + head_sha: jnested(pr, "lastMergeSourceCommit", "commitId"), } } diff --git a/apps/desktop/src-tauri/src/commands/bitbucket.rs b/apps/desktop/src-tauri/src/commands/bitbucket.rs index a9c337d6..3ea7e3d9 100644 --- a/apps/desktop/src-tauri/src/commands/bitbucket.rs +++ b/apps/desktop/src-tauri/src/commands/bitbucket.rs @@ -381,6 +381,7 @@ fn bb_pr_to_detail(pr: &serde_json::Value) -> PullRequestDetail { // Bitbucket merge permission needs a separate privileges call — // unknown ⇒ UI gates on errors only. can_merge: None, + head_sha: jdeep(pr, "source", "commit", "hash"), } } diff --git a/apps/desktop/src-tauri/src/commands/gh.rs b/apps/desktop/src-tauri/src/commands/gh.rs index 0fa45592..6a20224c 100644 --- a/apps/desktop/src-tauri/src/commands/gh.rs +++ b/apps/desktop/src-tauri/src/commands/gh.rs @@ -639,6 +639,56 @@ pub(crate) async fn gh_pr_ready(cwd: String, number: i64) -> Result<(), String> .map_err(|e| e.to_string())? } +fn gh_dismiss_review_inner(cwd: String, number: i64, review_id: i64, message: String) -> Result<(), String> { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_dismiss_review(&cwd, number, review_id, &message, &tok); + } + let nwo = gh_fork_upstream(&cwd).unwrap_or_else(|| "{owner}/{repo}".to_string()); + let path = format!("repos/{}/pulls/{}/reviews/{}/dismissals", nwo, number, review_id); + gh_api_write(&cwd, "PUT", &path, &[("message", message.as_str()), ("event", "DISMISS")])?; + Ok(()) +} + +/// Dismiss a submitted review (B4, v3.6.0). +#[tauri::command] +pub(crate) async fn gh_dismiss_review(cwd: String, number: i64, review_id: i64, message: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || gh_dismiss_review_inner(cwd, number, review_id, message)) + .await + .map_err(|e| e.to_string())? +} + +fn gh_request_reviewers_inner(cwd: String, number: i64, logins: Vec) -> Result<(), String> { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_request_reviewers(&cwd, number, &logins, &tok); + } + let nwo = gh_fork_upstream(&cwd).unwrap_or_else(|| "{owner}/{repo}".to_string()); + let path = format!("repos/{}/pulls/{}/requested_reviewers", nwo, number); + let mut cmd = hidden_cmd("gh"); + cmd.args(["api", "-X", "POST", &path]); + for login in &logins { + cmd.args(["-f", &format!("reviewers[]={}", login)]); + } + let output = cmd + .current_dir(&cwd) + .output() + .map_err(|e| format!("gh api request reviewers: {}", e))?; + if !output.status.success() { + return Err(format!( + "gh api request reviewers failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + Ok(()) +} + +/// Request reviewers on an existing PR (B4, v3.6.0). +#[tauri::command] +pub(crate) async fn gh_request_reviewers(cwd: String, number: i64, logins: Vec) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || gh_request_reviewers_inner(cwd, number, logins)) + .await + .map_err(|e| e.to_string())? +} + fn gh_pr_detail_inner(cwd: String, number: i64) -> Result { if let Some(tok) = github_api::settings_github_token() { return github_api::rest_pr_detail(&cwd, number, &tok); @@ -648,7 +698,7 @@ fn gh_pr_detail_inner(cwd: String, number: i64) -> Result PullRequestDetail { // 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, + head_sha: jnested(pr, "head", "sha"), } } @@ -1150,6 +1151,35 @@ pub(crate) fn rest_merge_pr(cwd: &str, number: i64, method: &str, token: &str) - Ok(()) } +/// Dismiss a submitted review (B4, v3.6.0). +pub(crate) fn rest_dismiss_review( + cwd: &str, + number: i64, + review_id: i64, + message: &str, + token: &str, +) -> Result<(), String> { + let (repo, _pr) = get_pr_json(cwd, number, token)?; + let url = format!("{}/repos/{}/pulls/{}/reviews/{}/dismissals", API_BASE, repo, number, review_id); + let payload = serde_json::json!({ "message": message, "event": "DISMISS" }); + api_json("PUT", &url, token, Some(&payload.to_string()))?; + Ok(()) +} + +/// Request reviewers on an existing PR (B4, v3.6.0). +pub(crate) fn rest_request_reviewers( + cwd: &str, + number: i64, + logins: &[String], + token: &str, +) -> Result<(), String> { + let (repo, _pr) = get_pr_json(cwd, number, token)?; + let url = format!("{}/repos/{}/pulls/{}/requested_reviewers", API_BASE, repo, number); + let payload = serde_json::json!({ "reviewers": logins }); + api_json("POST", &url, token, Some(&payload.to_string()))?; + 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)?; diff --git a/apps/desktop/src-tauri/src/commands/gitlab.rs b/apps/desktop/src-tauri/src/commands/gitlab.rs index b09f0439..19a7ce91 100644 --- a/apps/desktop/src-tauri/src/commands/gitlab.rs +++ b/apps/desktop/src-tauri/src/commands/gitlab.rs @@ -188,6 +188,12 @@ fn gl_mr_to_detail(mr: &serde_json::Value) -> PullRequestDetail { .get("user") .and_then(|u| u.get("can_merge")) .and_then(|b| b.as_bool()), + head_sha: mr + .get("diff_refs") + .and_then(|d| d.get("head_sha")) + .and_then(|s| s.as_str()) + .map(String::from) + .unwrap_or_else(|| js(mr, "sha")), } } @@ -350,6 +356,37 @@ pub(crate) async fn gl_get_mr(cwd: String, iid: i64) -> Result Result { + let output = hidden_cmd("glab") + .args(["mr", "view", &iid.to_string(), "--output", "json"]) + .current_dir(&cwd) + .output() + .map_err(|e| format!("glab mr view: {}", e))?; + if !output.status.success() { + return Err(format!( + "glab mr view failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let mr: serde_json::Value = serde_json::from_str(stdout.trim()) + .map_err(|e| format!("Failed to parse glab mr view output: {}", e))?; + let refs = mr.get("diff_refs").cloned().unwrap_or(serde_json::Value::Null); + let base_sha = js(&refs, "base_sha"); + let start_sha = js(&refs, "start_sha"); + let head_sha = js(&refs, "head_sha"); + Ok(MrDiffRefs { + base_sha: base_sha.clone(), + start_sha: if start_sha.is_empty() { base_sha } else { start_sha }, + head_sha: if head_sha.is_empty() { js(&mr, "sha") } else { head_sha }, + }) +} + /// Get the unified diff of a MR using `glab mr diff`. #[tauri::command] pub(crate) async fn gl_mr_diff(cwd: String, iid: i64) -> Result { @@ -1002,6 +1039,61 @@ pub(crate) async fn gl_reviewer_candidates(cwd: String) -> Result Option { + let output = hidden_cmd("glab") + .args(["api", &format!("projects/:fullpath/members/all?query={}", username)]) + .current_dir(cwd) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + let arr: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?; + arr.as_array()? + .iter() + .find(|m| m.get("username").and_then(|v| v.as_str()) == Some(username)) + .and_then(|m| m.get("id")) + .and_then(|v| v.as_i64()) +} + +/// Request reviewers on an existing MR (B4, v3.6.0) — `PUT +/// /merge_requests/:iid` with `reviewer_ids[]=` per resolved username. +#[tauri::command] +pub(crate) async fn gl_request_reviewers(cwd: String, iid: i64, usernames: Vec) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let ids: Vec = usernames + .iter() + .filter_map(|u| gl_resolve_member_id(&cwd, u)) + .collect(); + if ids.is_empty() { + return Err("Could not resolve any reviewer username to a GitLab project member.".to_string()); + } + let endpoint = format!("projects/:fullpath/merge_requests/{}", iid); + let mut cmd = hidden_cmd("glab"); + cmd.args(["api", "-X", "PUT", &endpoint]); + for id in &ids { + cmd.args(["-f", &format!("reviewer_ids[]={}", id)]); + } + let output = cmd + .current_dir(&cwd) + .output() + .map_err(|e| format!("glab api request reviewers: {}", e))?; + if !output.status.success() { + return Err(format!( + "glab api request reviewers failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + /// List branch names for the project via `glab api`. Paginated at 100/page, /// deduped, case-insensitively sorted. Non-fatal on failure (returns partial). #[tauri::command] diff --git a/apps/desktop/src-tauri/src/git/parse.rs b/apps/desktop/src-tauri/src/git/parse.rs index ea303bf5..0ee46447 100644 --- a/apps/desktop/src-tauri/src/git/parse.rs +++ b/apps/desktop/src-tauri/src/git/parse.rs @@ -435,6 +435,7 @@ pub(crate) fn gh_pr_detail_raw_to_detail(r: GhPrDetailRaw) -> PullRequestDetail // 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, + head_sha: r.head_ref_oid, } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ca8e43af..04985912 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -433,6 +433,8 @@ pub fn run() { commands::gh::gh_add_reaction, commands::gh::gh_delete_reaction, commands::gh::gh_pr_ready, + commands::gh::gh_dismiss_review, + commands::gh::gh_request_reviewers, commands::gh::gh_fork_info, commands::github_api::github_device_start, commands::github_api::github_device_poll, @@ -533,6 +535,7 @@ pub fn run() { commands::gitlab::gl_list_issues, commands::gitlab::gl_mr_count, commands::gitlab::gl_get_mr, + commands::gitlab::gl_mr_diff_refs, commands::gitlab::gl_mr_diff, commands::gitlab::gl_mr_pipelines, commands::gitlab::gl_mr_annotations, @@ -548,6 +551,7 @@ pub fn run() { commands::gitlab::gl_list_reviews, commands::gitlab::gl_current_user, commands::gitlab::gl_reviewer_candidates, + commands::gitlab::gl_request_reviewers, commands::gitlab::gl_branches, commands::gitlab::gl_mr_files, commands::gitlab::gl_mr_create_discussion, diff --git a/apps/desktop/src-tauri/src/types.rs b/apps/desktop/src-tauri/src/types.rs index 97868fd0..fa61aa83 100644 --- a/apps/desktop/src-tauri/src/types.rs +++ b/apps/desktop/src-tauri/src/types.rs @@ -411,6 +411,8 @@ pub struct GhPrDetailRaw { pub author: GhPrAuthor, #[serde(rename = "headRefName")] pub head_ref_name: String, + #[serde(rename = "headRefOid", default)] + pub head_ref_oid: String, #[serde(rename = "baseRefName")] pub base_ref_name: String, #[serde(rename = "isDraft")] @@ -516,6 +518,10 @@ pub struct PullRequestDetail { /// disable the merge button on an unknown permission. #[serde(default)] pub can_merge: Option, + /// The PR/MR's current head commit SHA. Empty when a forge can't + /// cheaply provide one (callers must treat "" as "unknown", v3.6.0). + #[serde(default)] + pub head_sha: String, } // ─── Fork / PR target info ───────────────────────────────────────── @@ -533,6 +539,16 @@ pub struct ForkInfo { pub parent: String, } +/// GitLab MR diff refs (F1, v3.6.0) — the three SHAs needed to correctly +/// position an inline discussion comment via the Discussions API. +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MrDiffRefs { + pub base_sha: String, + pub start_sha: String, + pub head_sha: String, +} + // ─── GitHub OAuth device flow ────────────────────────────────────── /// Returned by `github_device_start` — the user-facing code + the diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 6def1041..31a9785b 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -3278,7 +3278,8 @@ onUnmounted(() => { + @navigate-commit="(hash) => { selectCommit(hash); viewMode = 'history'; }" + @open-help="showHelp = true" />