Skip to content

Commit 3419c72

Browse files
author
Laurent Guitton
committed
feat(desktop): inline CI check annotations across the three forges
1 parent adeb086 commit 3419c72

25 files changed

Lines changed: 846 additions & 23 deletions

CHANGELOG.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,31 @@ All notable changes to GitWand will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased]
8+
## [2.18.0] - 2026-06-12
9+
10+
v2.18 brings the CI back to the code: check-run annotations now overlay the PR diff — the exact line that failed the linter or typecheck, right where you review — across all three forges. GitHub Copilot CLI also joins the AI-provider lineup.
911

1012
### Added
1113

14+
- **Inline CI check annotations** — check-run annotations are fetched per PR and anchored to the diff:
15+
- **Gutter icons** (❌ failure, ⚠ warning, ℹ notice) on affected lines in the PR inline diff, with a subtle colored edge on annotated rows. Hovering shows a tooltip with the annotation title, message, and the check that produced it. Multi-line annotations flag their whole range (capped at 20 lines); when several annotations hit the same line, the worst level wins the icon.
16+
- **"N annotations" badge in the CI tab** — check-runs that produced annotations show a clickable badge that jumps straight to the diff.
17+
- **Per-file ⚠ count in the diff file sidebar** — spot which files the CI flagged before opening them.
18+
- **All three forges**: GitHub (check-runs annotations API), GitLab (`artifacts:reports:codequality` — Code Climate severities mapped to failure/warning/notice), Bitbucket (Reports API annotations). Everything is non-fatal: a repo with no checks, an expired report, or a forge with nothing to offer simply shows no annotations.
19+
- **Lazy by design** — annotations are fetched once per PR, the first time the Diff or CI tab is opened; never during PR-list browsing.
1220
- **GitHub Copilot CLI as an AI provider**`copilot-cli` joins `claude-code-cli`, `codex-cli`, and `opencode-cli` in the `AIProvider` union. It piggybacks on the user's locally-installed `copilot` binary and their GitHub Copilot subscription — no API key required. Detection mirrors the existing CLI providers (binary discovery across PATH + common install locations) and it appears in Settings → AI with the same status/re-detect pattern. Prompts run one-shot via `copilot -p` (model selectable via the per-provider model picker, free-text since Copilot has no enumeration command). Tool permissions are deliberately restricted (`--deny-tool=shell`, `--deny-tool=write`, `--no-ask-user`, and `COPILOT_ALLOW_ALL` stripped from the child env) so Copilot only produces text and cannot edit files, run shell commands, or block on interactive prompts.
1321

22+
### Changed
23+
24+
- **Sidebar quick actions trimmed** — the Changes and History quick actions were removed from the repo sidebar (#39); both remain reachable from their primary surfaces (commit area, Git Tree).
25+
1426
### Technical
1527

28+
- New forge-agnostic type `CIAnnotation { check_name, path, start_line, end_line, level, title, message }` (`types.rs`, mirrored as camelCase in `backend-pr.ts` with a shared `mapAnnotation` normalizer — unknown levels degrade to `notice`).
29+
- Three new Rust commands following the per-forge pattern: `gh_check_annotations` (head SHA → `commits/{sha}/check-runs``check-runs/{id}/annotations`, capped at 20 annotated runs), `gl_mr_annotations` (MR pipelines → jobs with a `codequality` artifact → `gl-code-quality-report.json`), `bb_pr_annotations` (commit Reports → per-report annotations). Registered in `lib.rs`; GitHub path mirrored in `dev-server.mjs` (`/api/gh-check-annotations`); TS wrappers in `backend-pr.ts` / `backend-gitlab.ts` / `backend-bitbucket.ts`.
30+
- `ForgeProvider.getCheckAnnotations()` added to the forge contract and implemented by the three providers. `usePrPanel` gains `prAnnotations` (lazy, one flight per PR), `annotationCountByCheck`, and `annotationsByFile`.
31+
- Gutter rendering in `PrInlineDiff` is anchored on `newLineNo` (annotations target the head commit); CSS-only hover tooltip, no new dependency.
32+
- i18n: `pr.annotations.{badge,badgeTooltip}` across all five locales; unit tests in `v2.18-ci-annotations.test.ts`.
1633
- New Rust commands `detect_copilot_cli` and `copilot_cli_prompt` (`copilot --no-color --deny-tool=shell --deny-tool=write --no-ask-user [--model …] -p <prompt>`), plus a `CopilotCliInfo` type; registered in `lib.rs`. Mirrored across all three layers: `commands/ai.rs`, `dev-server.mjs`, and the `backend-ai.ts` wrapper.
1734
- `useAIProvider` adds `copilot-cli` to `CLI_AGENT_PROVIDERS`, dispatches it in `suggest()` / `rawPrompt()`, and forwards the per-provider model. `SettingsPanel` gains the provider option, detection state, and status block.
1835
- i18n: `aiProviderCopilotCli`, `aiProviderCopilotCliNotFound`, `aiCopilotCliDetectedHint`, `aiCopilotCliInfoBox` across all five locales (en, fr, es, pt-BR, zh-CN).
@@ -984,7 +1001,11 @@ Design-system foundations — the app header and every overlay now ride on a sha
9841001
- CI pipeline via GitHub Actions (Node 18, 20, 22)
9851002
- 28 tests covering all patterns + real-world scenarios (package.json, Laravel routes, Vue SFC, CSS, .env files)
9861003

987-
[Unreleased]: https://github.com/devlint/GitWand/compare/v2.15.0...HEAD
1004+
[Unreleased]: https://github.com/devlint/GitWand/compare/v2.18.0...HEAD
1005+
[2.18.0]: https://github.com/devlint/GitWand/compare/v2.17.0...v2.18.0
1006+
[2.17.0]: https://github.com/devlint/GitWand/compare/v2.16.0...v2.17.0
1007+
[2.16.0]: https://github.com/devlint/GitWand/compare/v2.15.1...v2.16.0
1008+
[2.15.1]: https://github.com/devlint/GitWand/compare/v2.15.0...v2.15.1
9881009
[2.15.0]: https://github.com/devlint/GitWand/compare/v2.14.0...v2.15.0
9891010
[2.14.0]: https://github.com/devlint/GitWand/compare/v2.13.0...v2.14.0
9901011
[2.13.0]: https://github.com/devlint/GitWand/compare/v2.12.0...v2.13.0

ROADMAP.md

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,6 @@
66

77
## What's Next
88

9-
### v2.18.0 — Inline CI Check Annotations
10-
11-
Overlay check-run annotations in the diff — the exact line that failed the linter or typecheck, right where you need it in the review.
12-
13-
**Backend**
14-
15-
- GitHub: `GET /repos/{owner}/{repo}/check-runs/{check_run_id}/annotations`
16-
- GitLab: pipeline job trace + `artifacts:reports:codequality` (common JSON format)
17-
- Bitbucket: `/commit/{commit}/statuses` enriched with Pipelines annotations
18-
- New type `CIAnnotation { path, line, level: "failure"|"warning"|"notice", title, message }`
19-
20-
**UI — DiffViewer**
21-
22-
- Gutter icons (❌ failure, ⚠ warning, ℹ notice) on affected lines
23-
- On hover: tooltip with `title` + `message` — same pattern as inline comments
24-
- In the CI tab: check-runs with annotations show a clickable "N annotations" badge
25-
26-
---
27-
289
### v2.19.0 — Scratch worktree + extended Conflict Predictor
2910

3011
_Inspired by GitSquid. A natural extension of the GitWand engine and Worktree first-class (v2.7)._
@@ -153,6 +134,7 @@ Positioning: neither "yet another Git GUI" nor an IDE. A first-class Git navigat
153134
154135
| Version | Highlights |
155136
|---------|-----------|
137+
| **v2.18.0** | Inline CI Check Annotations — check-run annotations overlaid in the PR diff across the three forges (GitHub check-runs API, GitLab `artifacts:reports:codequality`, Bitbucket Reports API), gutter icons ❌/⚠/ℹ with hover tooltip, clickable "N annotations" badge in the CI tab, per-file ⚠ count in the diff sidebar, forge-agnostic `CIAnnotation` type + `ForgeProvider.getCheckAnnotations()`, lazy one-shot fetch per PR; Copilot CLI as a fourth AI provider (text-only sandbox) |
156138
| **v2.17.0** | opencode provider + per-CLI model picker — `opencode-cli` as a first-class AI provider (`opencode run`, binary discovery, Settings status), second model select under the provider picker for the three CLI agents (opencode enumerates via `opencode models`, Claude Code aliases, Codex free-text), `aiModelByProvider` persisted per provider, `--model` threaded through all three CLIs |
157139
| **v2.16.0** | PR Activity Notifications — background Launchpad poller, zero-network snapshot diff (`useLaunchpadNotifications`) for CI flips / review requests / new comments / merge-close, native OS notifications via `tauri-plugin-notification` (background-only), Settings granularity (All · Reviews & comments · CI failures only · None) + "by people" bot filter, enriched `workspace_prs_all` (CI/review/comment fields) |
158140
| **v2.15.1** | Git Tree polish & quick actions — Force push (branch context menu + protected-trunk/diverged-remote guard), Quick Stash `⌘⇧,` (instant, AI label) + pending badge in the commit area, Submodules in the Git Tree (branch-picker section, per-commit pointed-SHA badge, click-to-navigate) |

apps/desktop/dev-server.mjs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2535,6 +2535,53 @@ async function handleRequest(req, res) {
25352535
}
25362536
}
25372537

2538+
// GET /api/gh-check-annotations?cwd=<path>&number=<n> (v2.18)
2539+
if (url.pathname === "/api/gh-check-annotations" && req.method === "GET") {
2540+
const cwd = url.searchParams.get("cwd");
2541+
const number = url.searchParams.get("number");
2542+
if (!cwd || !number) return jsonResponse(req, res, { error: "Missing cwd or number" }, 400);
2543+
try {
2544+
const token = getGithubToken();
2545+
if (!token) return jsonResponse(req, res, { error: "No GitHub token" }, 401);
2546+
const nwo = getRepoNwo(resolve(cwd));
2547+
if (!nwo) return jsonResponse(req, res, { error: "Could not determine GitHub repo" }, 400);
2548+
// PR head SHA
2549+
const prResp = await githubFetch(`/repos/${nwo}/pulls/${number}`, token);
2550+
if (!prResp.ok) return jsonResponse(req, res, { error: `GitHub API ${prResp.status}` }, 500);
2551+
const pr = await prResp.json();
2552+
const sha = pr.head?.sha;
2553+
if (!sha) return jsonResponse(req, res, []);
2554+
// Check runs with annotation counts
2555+
const checksResp = await githubFetch(`/repos/${nwo}/commits/${sha}/check-runs?per_page=100`, token);
2556+
if (!checksResp.ok) return jsonResponse(req, res, { error: `GitHub API ${checksResp.status}` }, 500);
2557+
const data = await checksResp.json();
2558+
const annotated = (data.check_runs ?? [])
2559+
.filter((c) => (c.output?.annotations_count ?? 0) > 0)
2560+
.slice(0, 20); // mirror the Rust MAX_ANNOTATED_RUNS cap
2561+
const annotations = [];
2562+
for (const run of annotated) {
2563+
const annResp = await githubFetch(`/repos/${nwo}/check-runs/${run.id}/annotations?per_page=100`, token);
2564+
if (!annResp.ok) continue; // annotations expired — skip run
2565+
const items = await annResp.json();
2566+
for (const a of items ?? []) {
2567+
annotations.push({
2568+
check_name: run.name ?? "",
2569+
path: a.path ?? "",
2570+
start_line: a.start_line ?? 0,
2571+
end_line: Math.max(a.end_line ?? 0, a.start_line ?? 0),
2572+
level: a.annotation_level === "failure" || a.annotation_level === "warning"
2573+
? a.annotation_level : "notice",
2574+
title: a.title ?? "",
2575+
message: a.message ?? "",
2576+
});
2577+
}
2578+
}
2579+
return jsonResponse(req, res, annotations);
2580+
} catch (err) {
2581+
return jsonResponse(req, res, { error: err.stderr?.toString() || err.message }, 500);
2582+
}
2583+
}
2584+
25382585
// ─── PR Review Comments ────────────────────────────────
25392586

25402587
// GET /api/gh-pr-comments?cwd=<path>&number=<n>
@@ -4731,6 +4778,7 @@ server.listen(PORT, "127.0.0.1", () => {
47314778
console.log(` GET /api/gh-pr-detail?cwd=<path>&number=<n>`);
47324779
console.log(` GET /api/gh-pr-diff?cwd=<path>&number=<n>`);
47334780
console.log(` GET /api/gh-pr-checks?cwd=<path>&number=<n>`);
4781+
console.log(` GET /api/gh-check-annotations?cwd=<path>&number=<n>`);
47344782
console.log(` GET /api/pr-files?repo=<path>&pr=<n>`);
47354783
console.log(` GET /api/git-remote-info?cwd=<path>`);
47364784
console.log(` GET /api/git-merge-base?cwd=<path>&ref1=<ref>&ref2=<ref>`);

apps/desktop/src-tauri/src/commands/bitbucket.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,95 @@ pub(crate) fn bb_pr_ci_checks(cwd: String, pr_id: i64) -> Result<Vec<CICheck>, S
908908
Ok(checks)
909909
}
910910

911+
/// Get report annotations for a PR (v2.18 — Inline CI Check Annotations).
912+
///
913+
/// Uses the Bitbucket Reports API:
914+
/// 1. PR → head commit SHA
915+
/// 2. `GET /commit/{sha}/reports` → reports created by Pipelines / linters
916+
/// 3. `GET /commit/{sha}/reports/{id}/annotations` per report
917+
///
918+
/// Severity mapping: CRITICAL|HIGH → failure, MEDIUM → warning, LOW → notice.
919+
/// Non-fatal everywhere: no reports → `[]`.
920+
#[tauri::command]
921+
pub(crate) fn bb_pr_annotations(cwd: String, pr_id: i64) -> Result<Vec<CIAnnotation>, String> {
922+
let (workspace, slug) = parse_workspace_slug(&cwd)?;
923+
let (username, app_password) = get_bb_creds(&cwd)?;
924+
let auth = basic_auth_header(&username, &app_password);
925+
926+
// 1. Head commit SHA.
927+
let pr_url = format!("{}/pullrequests/{}", repo_api(&workspace, &slug), pr_id);
928+
let pr = bb_curl("GET", &pr_url, None, &auth)?;
929+
let head_sha = jdeep(&pr, "source", "commit", "hash");
930+
if head_sha.is_empty() {
931+
return Ok(Vec::new());
932+
}
933+
934+
// 2. Reports on that commit.
935+
let reports_url = format!(
936+
"{}/commit/{}/reports?pagelen=20",
937+
repo_api(&workspace, &slug),
938+
head_sha
939+
);
940+
let resp = bb_curl("GET", &reports_url, None, &auth).unwrap_or(serde_json::Value::Null);
941+
let reports = resp
942+
.get("values")
943+
.and_then(|v| v.as_array())
944+
.cloned()
945+
.unwrap_or_default();
946+
947+
// 3. Annotations per report.
948+
let mut annotations = Vec::new();
949+
for report in &reports {
950+
let report_id = js(report, "uuid");
951+
if report_id.is_empty() {
952+
continue;
953+
}
954+
let report_title = js(report, "title");
955+
let ann_url = format!(
956+
"{}/commit/{}/reports/{}/annotations?pagelen=100",
957+
repo_api(&workspace, &slug),
958+
head_sha,
959+
report_id
960+
);
961+
let resp = match bb_curl("GET", &ann_url, None, &auth) {
962+
Ok(v) => v,
963+
Err(_) => continue,
964+
};
965+
let values = resp
966+
.get("values")
967+
.and_then(|v| v.as_array())
968+
.cloned()
969+
.unwrap_or_default();
970+
971+
for a in &values {
972+
let path = js(a, "path");
973+
let line = ji(a, "line");
974+
if path.is_empty() || line == 0 {
975+
continue; // commit-level annotation — nothing to anchor in the diff
976+
}
977+
let level = match js(a, "severity").as_str() {
978+
"CRITICAL" | "HIGH" => "failure",
979+
"MEDIUM" => "warning",
980+
_ => "notice", // LOW + unknown
981+
};
982+
annotations.push(CIAnnotation {
983+
check_name: report_title.clone(),
984+
path,
985+
start_line: line,
986+
end_line: line,
987+
level: level.to_string(),
988+
title: js(a, "summary"),
989+
message: {
990+
let d = js(a, "details");
991+
if d.is_empty() { js(a, "summary") } else { d }
992+
},
993+
});
994+
}
995+
}
996+
997+
Ok(annotations)
998+
}
999+
9111000
/// Convert a "Draft: …" Bitbucket PR to ready-for-review by stripping the prefix.
9121001
///
9131002
/// Bitbucket has no native draft concept — the convention is a "Draft: " title

apps/desktop/src-tauri/src/commands/gh.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,3 +519,115 @@ pub(crate) fn gh_pr_checks(cwd: String, number: i64) -> Result<Vec<CICheck>, Str
519519

520520
Ok(checks)
521521
}
522+
523+
/// Get check-run annotations for a PR (v2.18 — Inline CI Check Annotations).
524+
///
525+
/// Flow:
526+
/// 1. `gh pr view --json headRefOid` → head commit SHA
527+
/// 2. `gh api repos/{owner}/{repo}/commits/{sha}/check-runs` → runs with
528+
/// `output.annotations_count` (gh resolves `{owner}/{repo}` from cwd)
529+
/// 3. For each run with annotations: `gh api .../check-runs/{id}/annotations`
530+
///
531+
/// Non-fatal everywhere: a repo without checks/annotations returns `[]`.
532+
#[tauri::command]
533+
pub(crate) fn gh_check_annotations(cwd: String, number: i64) -> Result<Vec<CIAnnotation>, String> {
534+
// 1. Head SHA of the PR.
535+
let output = hidden_cmd("gh")
536+
.args(["pr", "view", &number.to_string(), "--json", "headRefOid"])
537+
.current_dir(&cwd)
538+
.output()
539+
.map_err(|e| format!("gh pr view: {}", e))?;
540+
if !output.status.success() {
541+
return Ok(Vec::new());
542+
}
543+
let stdout = String::from_utf8_lossy(&output.stdout);
544+
let sha = match extract_json_string(stdout.trim(), "headRefOid") {
545+
Some(s) if !s.is_empty() => s,
546+
_ => return Ok(Vec::new()),
547+
};
548+
549+
// 2. Check-runs for that commit (id + annotations_count).
550+
let output = hidden_cmd("gh")
551+
.args([
552+
"api",
553+
&format!("repos/{{owner}}/{{repo}}/commits/{}/check-runs?per_page=100", sha),
554+
])
555+
.current_dir(&cwd)
556+
.output()
557+
.map_err(|e| format!("gh api check-runs: {}", e))?;
558+
if !output.status.success() {
559+
return Ok(Vec::new());
560+
}
561+
let stdout = String::from_utf8_lossy(&output.stdout);
562+
let parsed: serde_json::Value =
563+
serde_json::from_str(stdout.trim()).unwrap_or(serde_json::Value::Null);
564+
let runs = parsed
565+
.get("check_runs")
566+
.and_then(|v| v.as_array())
567+
.cloned()
568+
.unwrap_or_default();
569+
570+
// 3. Fetch annotations for runs that have any. Cap the number of runs
571+
// queried so a pathological CI setup can't fan out dozens of API calls.
572+
const MAX_ANNOTATED_RUNS: usize = 20;
573+
let mut annotations = Vec::new();
574+
for run in runs
575+
.iter()
576+
.filter(|r| {
577+
r.get("output")
578+
.and_then(|o| o.get("annotations_count"))
579+
.and_then(|c| c.as_i64())
580+
.unwrap_or(0)
581+
> 0
582+
})
583+
.take(MAX_ANNOTATED_RUNS)
584+
{
585+
let run_id = match run.get("id").and_then(|v| v.as_i64()) {
586+
Some(id) => id,
587+
None => continue,
588+
};
589+
let check_name = run
590+
.get("name")
591+
.and_then(|v| v.as_str())
592+
.unwrap_or("")
593+
.to_string();
594+
595+
let output = hidden_cmd("gh")
596+
.args([
597+
"api",
598+
&format!("repos/{{owner}}/{{repo}}/check-runs/{}/annotations?per_page=100", run_id),
599+
])
600+
.current_dir(&cwd)
601+
.output();
602+
let output = match output {
603+
Ok(o) if o.status.success() => o,
604+
_ => continue, // annotations gone (e.g. expired) — skip this run
605+
};
606+
let stdout = String::from_utf8_lossy(&output.stdout);
607+
let arr: serde_json::Value =
608+
serde_json::from_str(stdout.trim()).unwrap_or(serde_json::Value::Array(vec![]));
609+
let Some(items) = arr.as_array() else { continue };
610+
611+
for a in items {
612+
let s = |k: &str| a.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
613+
let n = |k: &str| a.get(k).and_then(|v| v.as_i64()).unwrap_or(0);
614+
// GitHub levels are already our canonical set.
615+
let level = match s("annotation_level").as_str() {
616+
"failure" => "failure",
617+
"warning" => "warning",
618+
_ => "notice",
619+
};
620+
annotations.push(CIAnnotation {
621+
check_name: check_name.clone(),
622+
path: s("path"),
623+
start_line: n("start_line"),
624+
end_line: n("end_line").max(n("start_line")),
625+
level: level.to_string(),
626+
title: s("title"),
627+
message: s("message"),
628+
});
629+
}
630+
}
631+
632+
Ok(annotations)
633+
}

0 commit comments

Comments
 (0)