diff --git a/docs/2-handbook/swarm/ui.md b/docs/2-handbook/swarm/ui.md index 1fdd74d5..36ca935d 100644 --- a/docs/2-handbook/swarm/ui.md +++ b/docs/2-handbook/swarm/ui.md @@ -605,8 +605,9 @@ and base branch, and what a reviewer or merger filters on: decision, on the PR's current head, neither stale nor dismissed, and from a reviewer whose approval the rule accepts. A reviewer whose latest review asks for changes shows **changes requested**. -- **has all approvals**: the approvals meet the rule. A PR into a branch with no - protection rule has nothing to approve, so it has them all. +- **approved**: the approvals meet the rule. A PR into a branch with no + protection rule has nothing to approve, so it counts as approved. +- **diff**: the lines the PR adds and removes (`+120 −8`), as the forge counts them. - **conflicts**: the forge can't merge the PR as it stands. - **protected base**: the base branch has a protection rule. A stacked PR targets a feature branch, so filtering this to **no** lists them. diff --git a/frontend/packages/swarm-ui/src/pages/PrReportPage.tsx b/frontend/packages/swarm-ui/src/pages/PrReportPage.tsx index 9994e32b..0d8d1f1a 100644 --- a/frontend/packages/swarm-ui/src/pages/PrReportPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/PrReportPage.tsx @@ -24,6 +24,8 @@ import { TableSkeleton } from "../ui/table/TableSkeleton.js"; import { ALL_REPOS, repoOptions } from "./issue-report-repo.js"; import { approvalsText, + diffSize, + diffText, hasAllApprovals, prReportUrl, yesNo, @@ -124,7 +126,7 @@ const COLUMNS: TableColumn[] = [ }, { key: "approved", - header: "has all approvals", + header: "approved", render: (r) => hasAllApprovals(r) ? : "no", sortBy: (r) => Number(hasAllApprovals(r)), @@ -156,6 +158,12 @@ const COLUMNS: TableColumn[] = [ filterValues: (r) => [r.ci ?? "none"], filterMode: "multiselect", }, + { + key: "diff", + header: "diff", + render: (r) => diffText(r), + sortBy: (r) => diffSize(r), + }, { key: "updated", header: "updated", diff --git a/frontend/packages/swarm-ui/src/pages/pr-report.ts b/frontend/packages/swarm-ui/src/pages/pr-report.ts index 6c11415f..6b8e8556 100644 --- a/frontend/packages/swarm-ui/src/pages/pr-report.ts +++ b/frontend/packages/swarm-ui/src/pages/pr-report.ts @@ -17,6 +17,9 @@ export interface PrReportRow { head_branch: string; created_unix: number | null; updated_unix: number | null; + /** Lines the PR adds and removes. */ + additions: number | null; + deletions: number | null; /** The forge's draft flag. */ wip: boolean; has_conflicts: boolean; @@ -52,6 +55,18 @@ export function hasAllApprovals(row: PrReportRow): boolean { return row.approvals >= row.required_approvals; } +/** The diff size cell: lines added and removed, `+120 −8`. A count the forge + * did not give reads as a dash. */ +export function diffText(row: PrReportRow): string { + if (row.additions === null && row.deletions === null) return "—"; + return `+${row.additions ?? 0} −${row.deletions ?? 0}`; +} + +/** What the diff column sorts on: lines changed in all. */ +export function diffSize(row: PrReportRow): number { + return (row.additions ?? 0) + (row.deletions ?? 0); +} + /** The approvals cell: how many of the rule's approvals the PR has, or a dash * when its base branch asks for none. */ export function approvalsText(row: PrReportRow): string { diff --git a/frontend/packages/swarm-ui/test/pr-report.test.mjs b/frontend/packages/swarm-ui/test/pr-report.test.mjs index 64fe22c6..a39648a2 100644 --- a/frontend/packages/swarm-ui/test/pr-report.test.mjs +++ b/frontend/packages/swarm-ui/test/pr-report.test.mjs @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { approvalsText, + diffSize, + diffText, hasAllApprovals, prReportUrl, yesNo, @@ -52,6 +54,15 @@ test("an unprotected base, or a rule asking for none, shows only what it has", ( ); }); +test("the diff reads as lines added and removed, and sorts on both", () => { + assert.equal(diffText({ additions: 120, deletions: 8 }), "+120 −8"); + assert.equal(diffText({ additions: 0, deletions: 3 }), "+0 −3"); + assert.equal(diffText({ additions: 5, deletions: null }), "+5 −0"); + assert.equal(diffText({ additions: null, deletions: null }), "—"); + assert.equal(diffSize({ additions: 120, deletions: 8 }), 128); + assert.equal(diffSize({ additions: null, deletions: null }), 0); +}); + test("a PR has all approvals when it meets the rule, and an unprotected base always does", () => { assert.equal(hasAllApprovals(row({})), false); assert.equal(hasAllApprovals(row({ approvals: 2 })), true); diff --git a/swarm-controller/src/forge/pr_report.rs b/swarm-controller/src/forge/pr_report.rs index 801572a8..3e59af59 100644 --- a/swarm-controller/src/forge/pr_report.rs +++ b/swarm-controller/src/forge/pr_report.rs @@ -41,6 +41,9 @@ pub struct PrReportRow { pub head_branch: String, pub created_unix: Option, pub updated_unix: Option, + /// Lines the PR adds and removes, as the forge counts them. + pub additions: Option, + pub deletions: Option, /// The forge's draft flag (setting a `WIP:` title is what sets it). pub wip: bool, /// The forge cannot merge the PR as it is: its head conflicts with the @@ -200,6 +203,8 @@ fn row( head_branch: head.r#ref.clone().unwrap_or_default(), created_unix: pr.created_at.as_ref().map(|t| t.unix_timestamp()), updated_unix: pr.updated_at.as_ref().map(|t| t.unix_timestamp()), + additions: pr.additions, + deletions: pr.deletions, wip: pr.draft.unwrap_or(false), has_conflicts: pr.mergeable == Some(false), targets_protected_branch: protection.is_some(), @@ -483,6 +488,8 @@ mod tests { "head": { "ref": "iris/thing", "sha": "h1" }, "created_at": "2026-11-07T08:00:00Z", "updated_at": "2026-11-07T09:00:00Z", + "additions": 120, + "deletions": 8, })) .expect("a pull request"); let status: CombinedStatus = serde_json::from_value(serde_json::json!({ @@ -504,6 +511,7 @@ mod tests { assert_eq!(r.author.as_deref(), Some("iris")); assert_eq!(r.requested_reviewers, ["argus"]); assert_eq!(r.updated_unix, Some(1_794_042_000)); + assert_eq!((r.additions, r.deletions), (Some(120), Some(8))); // The same PR into a branch no rule names has nothing to approve. let r = row("o/r", &pr, &[], &reviews, &status).expect("a row");