Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/2026-11-01-pr-triage-newest-run.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 13 additions & 1 deletion scripts/pr-triage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 9 additions & 1 deletion scripts/pr-triage.test.mjs
Original file line number Diff line number Diff line change
@@ -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 }
Expand All @@ -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])
Expand Down
Loading