Skip to content

Commit 9001d9f

Browse files
rubenfiszelclaude
andauthored
fix(pr): stop cancelled CI runs from masking the latest review status (#298)
A codex CI review cancelled when `/review` re-triggers it (concurrency cancel-in-progress) reports conclusion CANCELLED, which summarizeChecks treated as a failure. Combined with the fresh run posting under a different check name, the PR stayed pinned to "failed" and the latest review status was never surfaced. Dedupe the rollup to the most recent entry per check name / status context (latest wins by completion time), drop CANCELLED runs when summarizing (superseded, not a failing verdict), and map CANCELLED to skipped in the per-check detail. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b6562e3 commit 9001d9f

2 files changed

Lines changed: 132 additions & 5 deletions

File tree

backend/src/__tests__/pr.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
import { describe, expect, it } from "bun:test";
22
import { mapWithConcurrency, startSerializedInterval } from "../lib/async";
3-
import { parseReviewComments } from "../services/pr-service";
3+
import {
4+
dedupeLatestChecks,
5+
mapChecks,
6+
parseReviewComments,
7+
summarizeChecks,
8+
} from "../services/pr-service";
9+
10+
// Mirror the GhCheckEntry shape from pr-service (not exported) closely enough
11+
// for these summarizers, which only read the fields referenced below.
12+
function checkRun(over: Record<string, unknown> = {}): any {
13+
return {
14+
__typename: "CheckRun",
15+
name: "codex-review",
16+
status: "COMPLETED",
17+
conclusion: "SUCCESS",
18+
detailsUrl: "https://github.com/o/r/actions/runs/1/job/1",
19+
startedAt: "2026-07-23T09:00:00Z",
20+
completedAt: "2026-07-23T09:05:00Z",
21+
...over,
22+
};
23+
}
424

525
describe("parseReviewComments", () => {
626
it("parses normal review comments", () => {
@@ -82,6 +102,59 @@ describe("parseReviewComments", () => {
82102
});
83103
});
84104

105+
describe("summarizeChecks — cancelled/superseded runs", () => {
106+
it("does not report failed when a codex run was cancelled by a re-trigger", () => {
107+
// Real-world shape: `/review` cancels the auto codex run (concurrency
108+
// cancel-in-progress); the fresh run posts under a different check name.
109+
const checks = [
110+
checkRun({ name: "codex-review", conclusion: "CANCELLED", completedAt: "2026-07-23T09:32:58Z" }),
111+
checkRun({ name: "codex / codex-review", conclusion: "SUCCESS", completedAt: "2026-07-23T09:40:00Z" }),
112+
];
113+
expect(summarizeChecks(checks)).toBe("success");
114+
});
115+
116+
it("latest run of the same check name wins over an earlier cancelled one", () => {
117+
const checks = [
118+
checkRun({ conclusion: "CANCELLED", completedAt: "2026-07-23T09:32:58Z" }),
119+
checkRun({ conclusion: "SUCCESS", startedAt: "2026-07-23T09:33:00Z", completedAt: "2026-07-23T09:40:00Z" }),
120+
];
121+
expect(summarizeChecks(checks)).toBe("success");
122+
});
123+
124+
it("still reports failed for a genuine failing run", () => {
125+
expect(summarizeChecks([checkRun({ conclusion: "FAILURE" })])).toBe("failed");
126+
});
127+
128+
it("reports none when every check is cancelled (no verdict)", () => {
129+
expect(summarizeChecks([checkRun({ conclusion: "CANCELLED" })])).toBe("none");
130+
});
131+
132+
it("treats a still-running check as pending despite the zero completedAt sentinel", () => {
133+
const checks = [
134+
checkRun({ conclusion: "CANCELLED", completedAt: "2026-07-23T09:32:58Z" }),
135+
checkRun({ status: "IN_PROGRESS", conclusion: "", startedAt: "2026-07-23T09:35:00Z", completedAt: "0001-01-01T00:00:00Z" }),
136+
];
137+
expect(summarizeChecks(checks)).toBe("pending");
138+
});
139+
});
140+
141+
describe("dedupeLatestChecks / mapChecks", () => {
142+
it("keeps only the latest entry per check name", () => {
143+
const deduped = dedupeLatestChecks([
144+
checkRun({ conclusion: "CANCELLED", completedAt: "2026-07-23T09:32:58Z" }),
145+
checkRun({ conclusion: "SUCCESS", completedAt: "2026-07-23T09:40:00Z" }),
146+
]);
147+
expect(deduped).toHaveLength(1);
148+
expect((deduped[0] as any).conclusion).toBe("SUCCESS");
149+
});
150+
151+
it("maps a cancelled run to skipped rather than failed", () => {
152+
const mapped = mapChecks([checkRun({ name: "solo", conclusion: "CANCELLED" })]);
153+
expect(mapped).toHaveLength(1);
154+
expect(mapped[0].status).toBe("skipped");
155+
});
156+
});
157+
85158
describe("mapWithConcurrency", () => {
86159
it("maps all items with results in order", async () => {
87160
const items = [1, 2, 3, 4, 5];

backend/src/services/pr-service.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ interface GhCheckRunEntry {
4949
status: GhCheckStatus;
5050
name: string;
5151
detailsUrl: string | null;
52+
startedAt?: string | null;
53+
completedAt?: string | null;
5254
}
5355

5456
// StatusContext entries from external CI (e.g. Vercel)
@@ -57,6 +59,7 @@ interface GhStatusContextEntry {
5759
context: string;
5860
state: "SUCCESS" | "FAILURE" | "PENDING" | "ERROR" | "EXPECTED";
5961
targetUrl: string | null;
62+
createdAt?: string | null;
6063
}
6164

6265
type GhCheckEntry = GhCheckRunEntry | GhStatusContextEntry;
@@ -88,18 +91,68 @@ const etagCache = new Map<string, { etag: string; comments: PrComment[] }>();
8891

8992
// ── Pure helper functions (exported for unit testing) ─────────────────────────
9093

94+
/** Group key for an entry: check-run name, or external status context. */
95+
function checkEntryKey(c: GhCheckEntry): string {
96+
return c.__typename === "StatusContext" ? `status:${c.context}` : `check:${c.name}`;
97+
}
98+
99+
/** Parse an ISO timestamp to epoch ms, 0 when absent/invalid. */
100+
function toEpoch(ts: string | null | undefined): number {
101+
const t = ts ? new Date(ts).getTime() : NaN;
102+
return Number.isNaN(t) ? 0 : t;
103+
}
104+
105+
/**
106+
* Recency of an entry (epoch ms) for latest-wins dedupe. For a check run, use
107+
* the later of startedAt/completedAt: GitHub reports completedAt as a zero
108+
* sentinel ("0001-01-01T00:00:00Z") while still running, so a live run would
109+
* otherwise sort as ancient and lose to an older completed run.
110+
*/
111+
function checkEntryTime(c: GhCheckEntry): number {
112+
if (c.__typename === "StatusContext") return toEpoch(c.createdAt);
113+
return Math.max(toEpoch(c.startedAt), toEpoch(c.completedAt));
114+
}
115+
116+
/** A CANCELLED check-run is a superseded/aborted run, not a failing verdict. */
117+
function isCancelled(c: GhCheckEntry): boolean {
118+
return c.__typename === "CheckRun" && c.conclusion === "CANCELLED";
119+
}
120+
121+
/**
122+
* Collapse the rollup to the most recent entry per check name / status context.
123+
* Re-triggering a workflow (e.g. `/review` re-running codex CI) leaves the prior
124+
* run in the rollup under the same name; keeping only the latest lets the fresh
125+
* result win instead of a stale run masking it. Latest wins by completion time,
126+
* falling back to array order (GitHub returns oldest-first) on ties.
127+
*/
128+
export function dedupeLatestChecks(checks: GhCheckEntry[]): GhCheckEntry[] {
129+
const latest = new Map<string, GhCheckEntry>();
130+
for (const c of checks) {
131+
const key = checkEntryKey(c);
132+
const prev = latest.get(key);
133+
if (!prev || checkEntryTime(c) >= checkEntryTime(prev)) latest.set(key, c);
134+
}
135+
return [...latest.values()];
136+
}
137+
91138
/** Summarize CI check status from a statusCheckRollup array. */
92139
export function summarizeChecks(
93140
checks: GhCheckEntry[] | null,
94141
): PrEntry["ciStatus"] {
95142
if (!checks || checks.length === 0) return "none";
96-
const allDone = checks.every((c) =>
143+
// Drop CANCELLED runs: a codex CI review cancelled when `/review` re-triggers
144+
// it (concurrency cancel-in-progress) reports CANCELLED, which must not mask
145+
// the latest run — otherwise the PR stays "failed" forever. Also dedupe so a
146+
// same-name re-run's fresh result wins over the superseded one.
147+
const relevant = dedupeLatestChecks(checks).filter((c) => !isCancelled(c));
148+
if (relevant.length === 0) return "none";
149+
const allDone = relevant.every((c) =>
97150
c.__typename === "StatusContext"
98151
? c.state !== "PENDING" && c.state !== "EXPECTED"
99152
: c.status === "COMPLETED",
100153
);
101154
if (!allDone) return "pending";
102-
const allPass = checks.every((c) => {
155+
const allPass = relevant.every((c) => {
103156
if (c.__typename === "StatusContext") return c.state === "SUCCESS";
104157
return c.conclusion === "SUCCESS" || c.conclusion === "NEUTRAL" || c.conclusion === "SKIPPED";
105158
});
@@ -124,14 +177,15 @@ export function deriveCheckStatus(check: GhCheckEntry): CiCheck["status"] {
124177
if (check.status !== "COMPLETED") return "pending";
125178
const c = check.conclusion;
126179
if (c === "SUCCESS" || c === "NEUTRAL") return "success";
127-
if (c === "SKIPPED") return "skipped";
180+
// CANCELLED = superseded (e.g. codex CI re-triggered by `/review`); not a failure.
181+
if (c === "SKIPPED" || c === "CANCELLED") return "skipped";
128182
return "failed";
129183
}
130184

131185
/** Map raw GH check entries to typed CiCheck array. */
132186
export function mapChecks(checks: GhCheckEntry[] | null): CiCheck[] {
133187
if (!checks || checks.length === 0) return [];
134-
return checks.map((c) => {
188+
return dedupeLatestChecks(checks).map((c) => {
135189
const name = c.__typename === "StatusContext" ? c.context : c.name;
136190
const url = c.__typename === "StatusContext" ? c.targetUrl : c.detailsUrl;
137191
return {

0 commit comments

Comments
 (0)