Skip to content

Commit 2b3482b

Browse files
arul28claude
andcommitted
fix(prs): address review — canonical rollup outranks raw check rows
CodeRabbit review on #1004. Eleven findings; ten applied, one refuted. The substantive one: a `not_run` rollup can coexist with pending or failing THIRD-PARTY rows, and several surfaces evaluated those raw counts first. A commit nothing verified would report "2 pending checks" or a failing count instead of the honest answer — the same producer-blind claim in a different tense. The canonical verdict now takes precedence in the merge checklist, the merge blockers, and the ade code right pane. Also: * A stale commit whose CI was entirely skipped, with a required context that never reported, stayed `pending` forever: the guard admitted any producer rather than an actual pass. * `fetchCombinedStatus` sat unwrapped in the same Promise.all as the guarded check-runs fetch, so a 403 there still aborted the whole refresh instead of preserving the last-known rollup. * A supplied canonical `checksStatus` is authoritative in the TUI formatter; the row fallback speaks only when no verdict was sent. * iOS bootstrap SQL declares the two ADE-135 columns, so a fresh install has them before cr-sqlite delivers a changeset carrying them. * Fixture and naming corrections: browserMock rows carry real app slugs (an absent slug is CI-eligible by design, which silently defeated the fixture), the denylist test is named for the rule it asserts, the lane summary test is named for the path it actually exercises, and the iOS #988 preview clone no longer points at #559's URL. Refuted: the mock row store's positional read was reported as broken by the new upsert params. It targets a different UPDATE (the projection write, unchanged here) and was already inert for the other one. Its matcher is now specific so the two cannot be confused again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent aff8c99 commit 2b3482b

11 files changed

Lines changed: 98 additions & 32 deletions

File tree

apps/ade-cli/src/tuiClient/components/RightPane.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -235,12 +235,13 @@ export function computeLaneChatCounts(
235235
type LaneDetailsPr = NonNullable<Extract<RightPaneContent, { kind: "lane-details" }>["pr"]>;
236236

237237
function laneDetailsPrChecksLineColor(pr: LaneDetailsPr): string {
238+
// ADE-135: the canonical verdict outranks the row counts. A `not_run` rollup
239+
// coexists with pending/failing THIRD-PARTY rows, so checking those first
240+
// would colour an unverified commit as merely running or red-for-the-wrong-
241+
// reason. An unverified commit is the finding, so it takes attention.
242+
if (pr.checksStatus === "not_run") return theme.color.attention;
238243
if (pr.checksPending > 0) return theme.color.running;
239244
if (pr.checksFailed > 0) return theme.color.error;
240-
// ADE-135: an unverified commit is not a quiet neutral state — it is the
241-
// finding — so it takes the attention colour rather than the muted one a
242-
// clean pass gets.
243-
if (pr.checksStatus === "not_run") return theme.color.attention;
244245
return theme.color.t3;
245246
}
246247

@@ -251,13 +252,6 @@ function laneDetailsPrChipStatus(state: LaneDetailsPr["state"]): "info" | "done"
251252
}
252253

253254
function formatPrActivity(pr: LaneDetailsPr): string {
254-
if (pr.checksPending > 0) {
255-
const done = pr.checksTotal - pr.checksPending;
256-
return `CI running · ${done}/${pr.checksTotal} done`;
257-
}
258-
if (pr.checksFailed > 0) {
259-
return `${pr.checksFailed} check${pr.checksFailed === 1 ? "" : "s"} failing`;
260-
}
261255
// ADE-135: this said "checks passing" for any non-empty row list, which is
262256
// exactly the ticket — on PR #988 three bot rows made `checksTotal` 3 while
263257
// `checksPassed` stayed 0, and the line claimed a pass nothing had earned.
@@ -267,6 +261,14 @@ function formatPrActivity(pr: LaneDetailsPr): string {
267261
? `CI not run · ${pr.checksTotal} check${pr.checksTotal === 1 ? "" : "s"}, none from CI`
268262
: "CI not run";
269263
}
264+
265+
if (pr.checksPending > 0) {
266+
const done = pr.checksTotal - pr.checksPending;
267+
return `CI running · ${done}/${pr.checksTotal} done`;
268+
}
269+
if (pr.checksFailed > 0) {
270+
return `${pr.checksFailed} check${pr.checksFailed === 1 ? "" : "s"} failing`;
271+
}
270272
if (pr.checksPassed > 0) {
271273
return "checks passing";
272274
}

apps/ade-cli/src/tuiClient/rightPaneFormatters.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,11 @@ export function formatPrChecks(value: unknown): string {
377377
const ok = rowRollup.counts.passing;
378378
const fail = rowRollup.counts.failing;
379379
const wait = rowRollup.counts.pending;
380-
const notRun = rollup === "not_run" || rowRollup.status === "not_run";
380+
// A supplied canonical `checksStatus` is authoritative: only the host knows
381+
// about required contexts, the merge box, and the grace window. The row
382+
// fallback speaks only when the payload carried no verdict at all — letting
383+
// it override a supplied `passing` would contradict the contract above.
384+
const notRun = rollup ? rollup === "not_run" : rowRollup.status === "not_run";
381385
const summary = notRun
382386
? `CI: not run${reason ? ` — ${reason}` : ""}`
383387
: [ok ? `${ok} passing` : null, fail ? `${fail} failing` : null, wait ? `${wait} pending` : null]

apps/desktop/src/main/services/prs/prService.test.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,12 @@ function installPullRequestRowStore(db: ReturnType<typeof makeMockDb>, initialRo
432432

433433
db.run.mockImplementation((sql: string, params: unknown[] = []) => {
434434
const text = String(sql);
435-
if (text.includes("update pull_requests")) {
435+
// Matches ONLY the projection update in `applyProjectionToLinkedPrRows`
436+
// (its params end `… head_sha, id, project_id`). The substring used to be
437+
// the bare "update pull_requests", which also matched `upsertRow`'s much
438+
// longer UPDATE and then read two arbitrary columns as the id/project id —
439+
// silently finding no row instead of failing loudly.
440+
if (text.includes("update pull_requests") && text.includes("head_sha = coalesce(?, head_sha)")) {
436441
const prId = params[12];
437442
const projectIdParam = params[13];
438443
const row = rows.find((entry) => entry.id === prId && entry.project_id === projectIdParam);
@@ -843,11 +848,14 @@ describe("prService.getForLane", () => {
843848
});
844849
});
845850

846-
it("does not report a lane PR as fully passed when only bots reported", async () => {
847-
// ADE-135: `checksPassed` counted any `success` row with no producer
848-
// awareness, so a lane whose PR had only CodeRabbit/Vercel checks reported
849-
// N/N — and every consumer inferring a pass from `passed === total`
850-
// (the ade code drawer, the lane rail) painted it green.
851+
it("carries the canonical checks rollup onto every lane PR summary", async () => {
852+
// ADE-135: consumers (the ade code drawer, the lane rail) used to infer a
853+
// pass from `checksPassed === checksTotal`, which is producer-blind. They
854+
// read `checksStatus` now, so it has to survive the trip through
855+
// `listPrsByLane`. This fixture seeds no snapshot rows, so it pins the
856+
// no-checks path where the summary echoes the row's stored verdict; the
857+
// producer-aware COUNTING it delegates to is covered by the
858+
// `rollupPrChecks` suite in shared/prChecksRollup.test.ts.
851859
const lane = makeFakeLane({ id: "lane-bots", branchRef: "refs/heads/bots-feature" });
852860
const service = buildGetForLaneService(lane, [
853861
makePrRow({

apps/desktop/src/main/services/prs/prService.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4779,7 +4779,8 @@ export function createPrService({
47794779
*/
47804780
headActivityAt: string | null;
47814781
/**
4782-
* `bestEffort` turns a 403/rate-limit on /check-runs into `[]`, which is
4782+
* Set when EITHER checks fetch failed. `bestEffort` turns a 403/rate-limit
4783+
* on /check-runs or /commits/{sha}/status into an empty result, which is
47834784
* byte-identical to "this commit has no checks". Recomputing from that
47844785
* would flip a green PR to `not_run` and persist it to every surface,
47854786
* so a failed fetch keeps whatever we last knew.
@@ -4846,7 +4847,7 @@ export function createPrService({
48464847
let checkRunsFetchFailed = false;
48474848
const [combinedStatus, checkRuns, reviews, compare] = shouldFetchLiveStatus
48484849
? await Promise.all([
4849-
headSha ? fetchCombinedStatus(repo, headSha) : Promise.resolve({ state: "", statuses: [] }),
4850+
headSha ? bestEffort("refreshOne.fetchCombinedStatus", fetchCombinedStatus(repo, headSha), { state: "", statuses: [] }, () => { checkRunsFetchFailed = true; }) : Promise.resolve({ state: "", statuses: [] }),
48504851
headSha ? bestEffort("refreshOne.fetchCheckRuns", fetchCheckRuns(repo, headSha), [] as any[], () => { checkRunsFetchFailed = true; }) : Promise.resolve([]),
48514852
bestEffort("refreshOne.fetchReviews", fetchReviews(repo, Number(row.github_pr_number)), []),
48524853
baseSha && headSha ? bestEffort("refreshOne.fetchCompare", fetchCompare(repo, baseSha, headSha), { behindBy: null as number | null }) : Promise.resolve({ behindBy: null as number | null })
@@ -5173,7 +5174,7 @@ export function createPrService({
51735174

51745175
let checkRunsFetchFailed = false;
51755176
const [combinedStatus, checkRuns, reviews, compare, mergeState] = await Promise.all([
5176-
restHeadSha ? fetchCombinedStatus(repo, restHeadSha) : Promise.resolve({ state: "", statuses: [] }),
5177+
restHeadSha ? bestEffort("computeStatus.fetchCombinedStatus", fetchCombinedStatus(repo, restHeadSha), { state: "", statuses: [] }, () => { checkRunsFetchFailed = true; }) : Promise.resolve({ state: "", statuses: [] }),
51775178
restHeadSha ? bestEffort("computeStatus.fetchCheckRuns", fetchCheckRuns(repo, restHeadSha), [] as any[], () => { checkRunsFetchFailed = true; }) : Promise.resolve([]),
51785179
bestEffort("computeStatus.fetchReviews", fetchReviews(repo, prNumber), []),
51795180
baseSha && restHeadSha ? bestEffort("computeStatus.fetchCompare", fetchCompare(repo, baseSha, restHeadSha), { behindBy: null as number | null }) : Promise.resolve({ behindBy: null as number | null }),

apps/desktop/src/renderer/browserMock.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1633,13 +1633,18 @@ const MOCK_CHECKS_BY_PR: Record<string, any[]> = {
16331633
// Three green rows, zero CI: a review bot, a preview deploy, and a comment
16341634
// bot. The rollup calls this "not_run" — the rows are real, the pass is not.
16351635
"pr-5": [
1636+
// Slugs are load-bearing: an ABSENT `appSlug` is treated as CI-eligible
1637+
// (legacy rows carry none), so without them a producer-aware consumer like
1638+
// `groupCheckItems` would file all three under CI and the fixture would
1639+
// stop demonstrating the bug it exists to demonstrate.
16361640
{
16371641
name: "CodeRabbit",
16381642
status: "completed",
16391643
conclusion: "success",
16401644
detailsUrl: "#",
16411645
startedAt: now,
16421646
completedAt: now,
1647+
appSlug: "coderabbitai",
16431648
},
16441649
{
16451650
name: "Vercel — Preview",
@@ -1648,6 +1653,7 @@ const MOCK_CHECKS_BY_PR: Record<string, any[]> = {
16481653
detailsUrl: "#",
16491654
startedAt: now,
16501655
completedAt: now,
1656+
appSlug: "vercel",
16511657
},
16521658
{
16531659
name: "changeset-bot",
@@ -1656,6 +1662,7 @@ const MOCK_CHECKS_BY_PR: Record<string, any[]> = {
16561662
detailsUrl: "#",
16571663
startedAt: now,
16581664
completedAt: now,
1665+
appSlug: "changeset-bot",
16591666
},
16601667
],
16611668
};

apps/desktop/src/renderer/components/prs/shared/prMergeRailUtils.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,32 @@ describe("buildDefaultCommitMessage", () => {
261261
expect(result.body).toBe("");
262262
});
263263

264+
it("reports no-CI rather than a producer-blind pending or failing count", () => {
265+
// A not_run rollup coexists with third-party rows in any state. Reporting
266+
// "1 pending check" there is the same blind claim in a different tense.
267+
for (const conclusion of ["failure", null] as const) {
268+
const items = buildMergeChecklist({
269+
pr: makePr({ checksStatus: "not_run" }),
270+
status: null,
271+
checks: [
272+
{
273+
name: "Vercel",
274+
status: conclusion === null ? "in_progress" : "completed",
275+
conclusion,
276+
detailsUrl: null,
277+
startedAt: null,
278+
completedAt: null,
279+
appSlug: "vercel",
280+
},
281+
],
282+
reviews: [],
283+
});
284+
const row = items.find((item) => item.id === "checks");
285+
expect(row?.label, String(conclusion)).toBe("No CI has run on this commit");
286+
expect(row?.state, String(conclusion)).toBe("neutral");
287+
}
288+
});
289+
264290
it("does not claim all checks passed when nothing verified the commit", () => {
265291
// ADE-135: summarizeChecks is producer-blind, so three third-party
266292
// successes reported passing: 3 and this row said "All 3 checks passed" —

apps/desktop/src/renderer/components/prs/shared/prMergeRailUtils.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,15 @@ export function deriveMergeBlockers(args: {
8989
const checkSummary = summarizeChecks(checks);
9090
const blockersRollup = status?.checksStatus ?? pr.checksStatus;
9191
if (blockersRollup === "not_run") {
92+
// The canonical verdict replaces the row counts rather than sitting beside
93+
// them: when nothing verified the commit, any failing/pending rows are
94+
// third-party, and reporting "2 pending required checks" would be the same
95+
// producer-blind claim in a different tense.
9296
blockers.push({
9397
id: "no-ci",
9498
label: "No CI has run on this commit.",
9599
});
96-
}
97-
if (checkSummary.failing > 0) {
100+
} else if (checkSummary.failing > 0) {
98101
blockers.push({
99102
id: "failing-checks",
100103
label: `${checkSummary.failing} required check${checkSummary.failing === 1 ? "" : "s"} ${checkSummary.failing === 1 ? "is" : "are"} failing.`,
@@ -231,7 +234,17 @@ export function buildMergeChecklist(args: {
231234
// rollup decides the verdict; the counts are still summarizeChecks' job.
232235
const summary = summarizeChecks(checks);
233236
const checksRollup = status?.checksStatus ?? pr.checksStatus;
234-
if (summary.failing > 0) {
237+
if (checksRollup === "not_run") {
238+
// Ordered ahead of the raw counts on purpose: a `not_run` rollup can
239+
// coexist with pending or failing third-party rows, and reporting those
240+
// would put a producer-blind number where the honest answer is "nothing
241+
// verified this commit".
242+
items.push({
243+
id: "checks",
244+
label: "No CI has run on this commit",
245+
state: "neutral",
246+
});
247+
} else if (summary.failing > 0) {
235248
items.push({
236249
id: "checks",
237250
label: `${summary.failing} failing check${summary.failing === 1 ? "" : "s"}`,
@@ -243,12 +256,6 @@ export function buildMergeChecklist(args: {
243256
label: `${summary.pending} pending check${summary.pending === 1 ? "" : "s"}`,
244257
state: "neutral",
245258
});
246-
} else if (checksRollup === "not_run") {
247-
items.push({
248-
id: "checks",
249-
label: "No CI has run on this commit",
250-
state: "neutral",
251-
});
252259
} else if (summary.passing > 0) {
253260
items.push({
254261
id: "checks",

apps/desktop/src/shared/prChecksRollup.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ describe("rollupChecks — producer awareness", () => {
9999
expect(result.status).toBe("not_run");
100100
});
101101

102-
it("recognizes only github-actions as a CI app slug", () => {
102+
it("excludes known non-CI apps and admits everything else", () => {
103103
expect(isCiProducerAppSlug("github-actions")).toBe(true);
104104
expect(isCiProducerAppSlug("GitHub-Actions")).toBe(true);
105105
expect(isCiProducerAppSlug("vercel")).toBe(false);

apps/desktop/src/shared/prChecksRollup.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,11 @@ export function rollupChecks(input: ChecksRollupInput): ChecksRollup {
306306
// simply one GitHub has not registered yet. Calling that "not run" made
307307
// every single push flash a spurious "CI has not run" card before the
308308
// suite appeared.
309-
status: ciProducerCount > 0 || !stale ? "pending" : "not_run",
309+
// `hasPass` keeps a partially-verified commit pending while it waits for
310+
// the missing context. Bare `ciProducerCount > 0` used to do the same,
311+
// which pinned a stale commit whose CI was entirely SKIPPED at `pending`
312+
// forever — producers reported, but nothing verified anything.
313+
status: hasPass || !stale ? "pending" : "not_run",
310314
reason: `${pluralize(missingRequiredContexts.length, "required check has", "required checks have")} not reported${stale ? "" : " yet"}: ${formatContexts(missingRequiredContexts)}.`,
311315
missingRequiredContexts,
312316
};

apps/ios/ADE/Resources/DatabaseBootstrap.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,10 @@ alter table pull_requests add column head_sha text;
475475

476476
alter table pull_requests add column creation_strategy text;
477477
alter table pull_requests add column merged_at text;
478+
-- ADE-135: the desktop rollup replicates these through cr-sqlite, so the column
479+
-- must exist before a changeset carrying it arrives.
480+
alter table pull_requests add column checks_reason text;
481+
alter table pull_requests add column checks_missing_required text;
478482

479483
drop table if exists github_pr_cache;
480484

0 commit comments

Comments
 (0)