From 527880b3617fa4e141e38e1cf540c2f3082155f7 Mon Sep 17 00:00:00 2001 From: repeat98 Date: Sun, 1 Nov 2026 12:18:44 +0100 Subject: [PATCH] Fix: the PR triage bot reads only the newest run of each check A cancelled or failed earlier workflow run sits in its own check suite beside the green rerun, and filter=latest only dedupes within one suite, so the bot would have labelled #1088 needs-work. Keep the newest run per check name (started last, then the higher id) before deciding red. Co-Authored-By: Claude Opus 5.5 --- changelog/2026-11-01-pr-triage-newest-run.md | 1 + scripts/pr-triage.mjs | 14 +++++++++++++- scripts/pr-triage.test.mjs | 10 +++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 changelog/2026-11-01-pr-triage-newest-run.md diff --git a/changelog/2026-11-01-pr-triage-newest-run.md b/changelog/2026-11-01-pr-triage-newest-run.md new file mode 100644 index 000000000..025f9f69c --- /dev/null +++ b/changelog/2026-11-01-pr-triage-newest-run.md @@ -0,0 +1 @@ +- PR triage bot: it now reads only the newest run of each check, so a failed or cancelled earlier run beside its green rerun no longer marks a pull request `needs-work`. The bot stays switched off. diff --git a/scripts/pr-triage.mjs b/scripts/pr-triage.mjs index 575f08c65..20577f6d5 100644 --- a/scripts/pr-triage.mjs +++ b/scripts/pr-triage.mjs @@ -24,6 +24,18 @@ export function decide({ red, labels, labeledAt, headDate, now, queued }) { return actions } +/** The newest run of each check name: a failed or cancelled earlier run beside its green rerun (each rerun + * of a workflow is a new check suite, which `filter=latest` does not merge) must not count. Newest = started + * last (a run not started yet counts as newest), then the higher id. */ +export function newestRuns(runs) { + const newest = new Map(), started = run => Date.parse(run.started_at ?? '') || Infinity + for (const run of runs) { + const kept = newest.get(run.name) + if (!kept || started(run) > started(kept) || (started(run) === started(kept) && run.id > kept.id)) newest.set(run.name, run) + } + return [...newest.values()] +} + /** Contributors without a published module get one module PR reviewed at a time: every newer one waits. */ export function queuedPulls(pulls, registered) { const waiting = new Set(), byAuthor = new Map() @@ -58,7 +70,7 @@ async function main() { const queued = queuedPulls(pulls, registered), now = Date.now() for (const { pr } of pulls) { const runs = await pages(api, '/commits/' + pr.head.sha + '/check-runs?filter=latest', 'check_runs') - const red = runs.some(run => ['failure', 'timed_out', 'startup_failure'].includes(run.conclusion)) + const red = newestRuns(runs).some(run => ['failure', 'timed_out', 'startup_failure'].includes(run.conclusion)) const labeled = (await pages(api, '/issues/' + pr.number + '/events')).filter(event => event.event === 'labeled' && event.label?.name === NEEDS_WORK).at(-1) const actions = decide({ red, labels: pr.labels.map(label => label.name), labeledAt: labeled?.created_at, headDate: (await api('/commits/' + pr.head.sha)).commit.committer.date, now, queued: queued.has(pr.number) }) for (const action of actions) { diff --git a/scripts/pr-triage.test.mjs b/scripts/pr-triage.test.mjs index 4928cf0b4..29519fec2 100644 --- a/scripts/pr-triage.test.mjs +++ b/scripts/pr-triage.test.mjs @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { CLOSE_AFTER_DAYS, NEEDS_WORK, ONE_AT_A_TIME, decide, queuedPulls } from './pr-triage.mjs' +import { CLOSE_AFTER_DAYS, NEEDS_WORK, ONE_AT_A_TIME, decide, newestRuns, queuedPulls } from './pr-triage.mjs' const day = 86400000, now = Date.parse('2026-10-25T12:00:00Z'), ago = days => new Date(now - days * day).toISOString() const base = { red: false, labels: [], labeledAt: undefined, headDate: ago(30), now, queued: false } @@ -17,6 +17,14 @@ describe('PR triage policy', () => { expect(decide({ ...red, labeledAt: ago(CLOSE_AFTER_DAYS), headDate: ago(2) })).toEqual([]) expect(decide({ ...red, labeledAt: undefined })).toEqual([]) }) + it('reads only the newest run of each check, so a green rerun replaces an earlier failure', () => { + const run = (id, name, conclusion, started_at) => ({ id, name, conclusion, started_at }) + const runs = [run(1, 'module-pr-ok', 'failure', ago(1)), run(2, 'module-pr-ok', 'success', ago(0.5)), run(3, 'module-contract', 'cancelled', ago(1)), run(4, 'flag', 'success', ago(1))] + expect(newestRuns(runs).map(r => r.id).sort()).toEqual([2, 3, 4]) + expect(newestRuns([run(5, 'x', 'failure', ago(1)), run(6, 'x', 'success', ago(1))]).map(r => r.id)).toEqual([6]) + expect(newestRuns([run(7, 'x', 'failure', ago(1)), run(8, 'x', null, null)]).map(r => r.id)).toEqual([8]) + expect(newestRuns([run(9, 'x', 'success', ago(1)), run(10, 'x', 'failure', ago(0.5))]).map(r => r.id)).toEqual([10]) + }) it('queues every newer module PR from a contributor without a published module', () => { const pulls = [{ number: 12, author: 'new', module: true }, { number: 10, author: 'new', module: true }, { number: 11, author: 'new', module: false }, { number: 13, author: 'known', module: true }, { number: 14, author: 'known', module: true }] expect([...queuedPulls(pulls, new Set(['known']))]).toEqual([12])