Public: anyone can read this; the crew can change it.
A pure read says, per repository, how many approved pulls wait for the merge queue and how long the oldest has waited #6563
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6552-a-pure-read-says-per-repository-how-many → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +83
−0
src/flow/approved_wait.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | /** | |
| 2 | * The approved wait per repository (#6552, for #5930): how many approved pulls | |
| 3 | * wait for the merge queue and how long the oldest has waited. Pure: rows in, | |
| 4 | * rows out; `now` is passed in. | |
| 5 | */ | |
| 6 | ||
| 7 | const HOUR_MS = 3_600_000; | |
| 8 | ||
| 9 | export interface ApprovedRow { | |
| 10 | readonly repo: string; | |
| 11 | readonly number: number; | |
| 12 | readonly approvedAt: string; | |
| 13 | } | |
| 14 | ||
| 15 | export interface ApprovedWait { | |
| 16 | readonly repo: string; | |
| 17 | readonly waiting: number; | |
| 18 | /** Hours the oldest has waited, one decimal; a future approval reads 0. */ | |
| 19 | readonly oldestHours: number; | |
| 20 | /** The number of the pull that has waited longest. */ | |
| 21 | readonly oldest: number; | |
| 22 | } | |
| 23 | ||
| 24 | export function approvedWait(rows: readonly ApprovedRow[], now: Date): ApprovedWait[] { | |
| 25 | const by = new Map<string, { waiting: number; at: number; number: number }>(); | |
| 26 | for (const row of rows) { | |
| 27 | const at = Date.parse(row.approvedAt); | |
| 28 | if (Number.isNaN(at)) continue; | |
| 29 | const cur = by.get(row.repo); | |
| 30 | if (!cur) by.set(row.repo, { waiting: 1, at, number: row.number }); | |
| 31 | else { | |
| 32 | cur.waiting += 1; | |
| 33 | if (at < cur.at || (at === cur.at && row.number < cur.number)) { | |
| 34 | cur.at = at; | |
| 35 | cur.number = row.number; | |
| 36 | } | |
| 37 | } | |
| 38 | } | |
| 39 | return [...by] | |
| 40 | .map(([repo, v]) => ({ | |
| 41 | repo, | |
| 42 | waiting: v.waiting, | |
| 43 | oldestHours: Math.round(Math.max(0, (now.getTime() - v.at) / HOUR_MS) * 10) / 10, | |
| 44 | oldest: v.number, | |
| 45 | })) | |
| 46 | .sort((a, b) => b.oldestHours - a.oldestHours || b.waiting - a.waiting || a.repo.localeCompare(b.repo)); | |
| 47 | } |
test/flow-approved-wait.test.ts added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { approvedWait } from "../src/flow/approved_wait.ts"; | |
| 3 | ||
| 4 | const now = new Date("2026-10-26T12:00:00Z"); | |
| 5 | const ago = (h: number) => new Date(now.getTime() - h * 3_600_000).toISOString(); | |
| 6 | ||
| 7 | test("one repository counts its pulls and names the oldest", () => { | |
| 8 | const out = approvedWait([{ repo: "a", number: 1, approvedAt: ago(1) }, { repo: "a", number: 2, approvedAt: ago(5) }], now); | |
| 9 | expect(out).toEqual([{ repo: "a", waiting: 2, oldestHours: 5, oldest: 2 }]); | |
| 10 | }); | |
| 11 | ||
| 12 | test("several repositories are separate, longest wait first", () => { | |
| 13 | const out = approvedWait( | |
| 14 | [ | |
| 15 | { repo: "a", number: 1, approvedAt: ago(2) }, | |
| 16 | { repo: "b", number: 7, approvedAt: ago(30) }, | |
| 17 | { repo: "c", number: 3, approvedAt: ago(10) }, | |
| 18 | ], | |
| 19 | now, | |
| 20 | ); | |
| 21 | expect(out.map((r) => r.repo)).toEqual(["b", "c", "a"]); | |
| 22 | }); | |
| 23 | ||
| 24 | test("nothing approved reads empty", () => { | |
| 25 | expect(approvedWait([], now)).toEqual([]); | |
| 26 | }); | |
| 27 | ||
| 28 | test("hours round to one decimal", () => { | |
| 29 | const out = approvedWait([{ repo: "a", number: 1, approvedAt: ago(1.26) }], now); | |
| 30 | expect(out[0]!.oldestHours).toBe(1.3); | |
| 31 | }); | |
| 32 | ||
| 33 | test("an approval in the future reads 0", () => { | |
| 34 | const out = approvedWait([{ repo: "a", number: 1, approvedAt: ago(-3) }], now); | |
| 35 | expect(out[0]!.oldestHours).toBe(0); | |
| 36 | }); |
new above
offline
The tie-break (same time, lower number wins) and the NaN skip on line 27 have no test. Add one each.