@@ -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
6265type 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. */
92139export 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. */
132186export 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