Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e1e7697
docs(specs): v3.6.0 PR Review 2.0 target-UX spec (benchmark-driven)
Jul 9, 2026
4f0ac07
docs(plans): v3.6.0 PR Review 2.0 implementation plan (15 tasks, TDD)
Jul 9, 2026
73f1953
feat(pr-review): surface PR head SHA on PullRequestDetail across forges
Jul 9, 2026
63a19f7
perf(pr-review): parse PR diff lazily per selected file
Jul 9, 2026
a785e0a
perf(pr-review): virtualize PR diff line rendering
Jul 9, 2026
4f64acc
perf(pr-review): slim detail revalidation to 3 hot-path calls
Jul 9, 2026
f0abe2c
feat(pr-review): keyboard-first review keymap on the Diff tab
Jul 9, 2026
681f24e
feat(pr-review): client-side viewed-file state with re-push invalidation
Jul 9, 2026
b34bc0f
feat(pr-review): persist and surface the pending review draft
Jul 9, 2026
3c5a2a7
feat(pr-review): dismiss review and request reviewers (GitHub real, h…
Jul 9, 2026
72f2878
refactor(pr-review): unify diff annotations under a LineAnnotation model
Jul 9, 2026
39e8115
feat(pr-review): Review AI settings section (opt-in, threshold, cap, …
Jul 9, 2026
ea96a88
feat(core): export extractImportSources for the PR dependency hop
Jul 9, 2026
5ea7f2d
feat(pr-review): local multi-hop pre-review finding engine
Jul 9, 2026
f524104
feat(pr-review): confidence threshold, cap, and dismissal memory for …
Jul 9, 2026
84b018c
feat(pr-review): bounded, cancellable, headSha-cached pre-review exec…
Jul 9, 2026
c027fa2
feat(pr-review): render and navigate AI pre-review findings
Jul 9, 2026
2162fad
feat(pr-review): AI PR summary on the Info tab
Jul 9, 2026
95bf4bd
refactor(pr-review): unify AI + static + CI signals in useReviewIntel…
Jul 9, 2026
5c118ce
feat(pr-review): complete GitLab review methods (inline batch, review…
Jul 9, 2026
fcd06d0
fix(pr-review): honest Azure comment edit/delete capability, no runti…
Jul 9, 2026
f1d4176
docs(changelog): PR Review 2.0 Unreleased entry + i18n sync
Jul 9, 2026
9b7103d
fix(pr-review): address verifier findings (GitLab dismiss gating, dis…
Jul 9, 2026
c20a95d
Merge branch 'main' into feat/pr-review-2
Jul 10, 2026
b56bc0d
Merge branch 'main' into feat/pr-review-2
Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions apps/desktop/dev-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/commands/azure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}
}

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/commands/bitbucket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}
}

Expand Down
52 changes: 51 additions & 1 deletion apps/desktop/src-tauri/src/commands/gh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> 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<String>) -> 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<PullRequestDetail, String> {
if let Some(tok) = github_api::settings_github_token() {
return github_api::rest_pr_detail(&cwd, number, &tok);
Expand All @@ -648,7 +698,7 @@ fn gh_pr_detail_inner(cwd: String, number: i64) -> Result<PullRequestDetail, Str
let mut cmd = hidden_cmd("gh");
cmd.args([
"pr", "view", &num,
"--json", "number,title,body,state,author,headRefName,baseRefName,isDraft,createdAt,updatedAt,mergedAt,url,additions,deletions,changedFiles,comments,reviewRequests,labels,reviews,mergeable,statusCheckRollup",
"--json", "number,title,body,state,author,headRefName,headRefOid,baseRefName,isDraft,createdAt,updatedAt,mergedAt,url,additions,deletions,changedFiles,comments,reviewRequests,labels,reviews,mergeable,statusCheckRollup",
]);
if let Some(ref nwo) = upstream {
cmd.args(["--repo", nwo]);
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/src-tauri/src/commands/github_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ fn json_to_detail(pr: &serde_json::Value) -> 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"),
}
}

Expand Down Expand Up @@ -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)?;
Expand Down
92 changes: 92 additions & 0 deletions apps/desktop/src-tauri/src/commands/gitlab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
}
}

Expand Down Expand Up @@ -350,6 +356,37 @@ pub(crate) async fn gl_get_mr(cwd: String, iid: i64) -> Result<PullRequestDetail
Ok(detail)
}

/// Get a MR's diff refs (F1, v3.6.0) — `base_sha`/`start_sha`/`head_sha`,
/// required to correctly anchor inline discussion comments (old/new-side
/// positioning) via the Discussions API. Same `glab mr view --output json`
/// fetch pattern as `gl_mr_to_detail`.
#[tauri::command]
pub(crate) async fn gl_mr_diff_refs(cwd: String, iid: i64) -> Result<MrDiffRefs, String> {
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<String, String> {
Expand Down Expand Up @@ -1002,6 +1039,61 @@ pub(crate) async fn gl_reviewer_candidates(cwd: String) -> Result<Vec<ReviewerCa
Ok(candidates)
}

/// Resolve a GitLab username to its numeric member id within the current
/// project — the merge-request update endpoint takes `reviewer_ids`
/// (numeric), not usernames.
fn gl_resolve_member_id(cwd: &str, username: &str) -> Option<i64> {
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[]=<id>` per resolved username.
#[tauri::command]
pub(crate) async fn gl_request_reviewers(cwd: String, iid: i64, usernames: Vec<String>) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let ids: Vec<i64> = 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]
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/git/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading