Public: anyone can read this; the crew can change it.
A pure read groups the last 24 h of declined build runs by reason, so planning mistakes show as a count #6590
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6577-a-pure-read-groups-the-last-24-h-of-decl → main 3 commits fork 1 thread · 1 unresolved
2 files
changed +139
−0
src/flow/declines.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | /** | |
| 2 | * Planning mistakes, counted (#6577): of the build runs that ended in the 24 | |
| 3 | * hours up to `now`, those that declined their issue, grouped by why. A run | |
| 4 | * declined when its summary starts "Declined:". Pure: rows in, counts out. | |
| 5 | * `done_on_main` is "Declined: already done on main"; `not_on_main` names | |
| 6 | * code or a pull that is not on main yet; `off_direction` says the work is | |
| 7 | * against the vision or direction; everything else declined is `other`. | |
| 8 | * A run ending exactly 24 h ago is in; one in the future is out. | |
| 9 | */ | |
| 10 | ||
| 11 | const DAY_MS = 86_400_000; | |
| 12 | const SHOWN = 3; | |
| 13 | ||
| 14 | export type DeclineReason = "done_on_main" | "not_on_main" | "off_direction" | "other"; | |
| 15 | ||
| 16 | export interface DeclineRow { | |
| 17 | readonly issue: number; | |
| 18 | readonly endedAt: string; | |
| 19 | readonly summary: string; | |
| 20 | } | |
| 21 | ||
| 22 | export interface DeclineGroup { | |
| 23 | readonly count: number; | |
| 24 | /** Issues that declined, before the cap; `issues` shows the first few. */ | |
| 25 | readonly distinct: number; | |
| 26 | readonly issues: readonly number[]; | |
| 27 | } | |
| 28 | ||
| 29 | export type Declines = Readonly<Record<DeclineReason, DeclineGroup>>; | |
| 30 | ||
| 31 | const REASONS: readonly DeclineReason[] = ["done_on_main", "not_on_main", "off_direction", "other"]; | |
| 32 | ||
| 33 | export function declineReason(summary: string): DeclineReason | null { | |
| 34 | const s = summary.trimStart(); | |
| 35 | if (!/^declined\b/i.test(s)) return null; | |
| 36 | if (/^declined:?\s+already done on main/i.test(s)) return "done_on_main"; | |
| 37 | if (/\bnot (yet )?(on|in|landed (on|in)|merged (to|into|on|in)) main\b|\bhas not (landed|merged)\b|\bnot (yet )?(landed|merged)\b|\bdepends on\b/i.test(s)) return "not_on_main"; | |
| 38 | if (/\boff[- ]direction\b|\bdirection\b|\bvision\b/i.test(s)) return "off_direction"; | |
| 39 | return "other"; | |
| 40 | } | |
| 41 | ||
| 42 | export function declines(rows: readonly DeclineRow[], now: Date): Declines { | |
| 43 | const to = now.getTime(); | |
| 44 | const from = to - DAY_MS; | |
| 45 | const found: Record<DeclineReason, number[]> = { done_on_main: [], not_on_main: [], off_direction: [], other: [] }; | |
| 46 | for (const row of rows) { | |
| 47 | const at = Date.parse(row.endedAt); | |
| 48 | if (!(at >= from && at <= to)) continue; | |
| 49 | const reason = declineReason(row.summary); | |
| 50 | if (!reason) continue; | |
| 51 | found[reason].push(row.issue); | |
| 52 | } | |
| 53 | const out = {} as Record<DeclineReason, DeclineGroup>; | |
| 54 | for (const r of REASONS) { | |
| 55 | const issues = [...new Set(found[r])]; | |
| 56 | out[r] = { count: found[r].length, distinct: issues.length, issues: issues.slice(0, SHOWN) }; | |
| 57 | } | |
| 58 | return out; | |
| 59 | } | |
| 60 | ||
| 61 | const LABELS: Readonly<Record<DeclineReason, string>> = { | |
| 62 | done_on_main: "already on main", | |
| 63 | not_on_main: "not on main yet", | |
| 64 | off_direction: "off direction", | |
| 65 | other: "other", | |
| 66 | }; | |
| 67 | ||
| 68 | /** One line, empty when nothing was declined: `Declined in 24 h: 3 already on main (#1, #2), 1 other (#9)`. */ | |
| 69 | export function declinesSays(result: Declines): string { | |
| 70 | const parts = REASONS.filter((r) => result[r].count > 0).map((r) => { | |
| 71 | const g = result[r]; | |
| 72 | const more = g.distinct > g.issues.length ? ", …" : ""; | |
| 73 | return `${g.count} ${LABELS[r]} (${g.issues.map((n) => `#${n}`).join(", ")}${more})`; | |
| 74 | }); | |
| 75 | return parts.length ? `Declined in 24 h: ${parts.join(", ")}` : ""; | |
| 76 | } |
test/flow-declines.test.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** Declined build runs grouped by reason (#6577). */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { declines, declinesSays } from "../src/flow/declines.ts"; | |
| 4 | ||
| 5 | const NOW = new Date("2026-10-26T12:00:00.000Z"); | |
| 6 | const ago = (min: number) => new Date(NOW.getTime() - min * 60_000).toISOString(); | |
| 7 | const row = (issue: number, summary: string, min = 10) => ({ issue, endedAt: ago(min), summary }); | |
| 8 | ||
| 9 | describe("declines", () => { | |
| 10 | test("each reason", () => { | |
| 11 | const r = declines( | |
| 12 | [ | |
| 13 | row(1, "Declined: already done on main in abc123"), | |
| 14 | row(2, "DECLINED: already done on main"), | |
| 15 | row(3, "Declined: depends on pull #5 which has not landed"), | |
| 16 | row(4, "Declined: the code is not on main yet"), | |
| 17 | row(5, "Declined: against docs/VISION.md, off direction"), | |
| 18 | row(6, "Declined: the task is unclear"), | |
| 19 | row(7, "Shipped the thing"), | |
| 20 | ], | |
| 21 | NOW, | |
| 22 | ); | |
| 23 | expect(r.done_on_main).toEqual({ count: 2, distinct: 2, issues: [1, 2] }); | |
| 24 | expect(r.not_on_main).toEqual({ count: 2, distinct: 2, issues: [3, 4] }); | |
| 25 | expect(r.off_direction).toEqual({ count: 1, distinct: 1, issues: [5] }); | |
| 26 | expect(r.other).toEqual({ count: 1, distinct: 1, issues: [6] }); | |
| 27 | }); | |
| 28 | ||
| 29 | test("window edge: exactly 24 h is in, older and future are out", () => { | |
| 30 | const r = declines( | |
| 31 | [row(1, "Declined: x", 1440), row(2, "Declined: x", 1441), row(3, "Declined: x", -1), row(4, "Declined: x", 0)], | |
| 32 | NOW, | |
| 33 | ); | |
| 34 | expect(r.other.issues).toEqual([1, 4]); | |
| 35 | expect(r.other.count).toBe(2); | |
| 36 | }); | |
| 37 | ||
| 38 | test("unparseable time is ignored", () => { | |
| 39 | expect(declines([{ issue: 1, endedAt: "nope", summary: "Declined: x" }], NOW).other.count).toBe(0); | |
| 40 | }); | |
| 41 | ||
| 42 | test("caps the numbers at 3 but counts all", () => { | |
| 43 | const rows = [1, 2, 3, 4, 5].map((n) => row(n, "Declined: already done on main")); | |
| 44 | const r = declines(rows, NOW); | |
| 45 | expect(r.done_on_main.count).toBe(5); | |
| 46 | expect(r.done_on_main.issues).toEqual([1, 2, 3]); | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 50 | describe("declinesSays", () => { | |
| 51 | test("empty when none", () => { | |
| 52 | expect(declinesSays(declines([], NOW))).toBe(""); | |
| 53 | }); | |
| 54 | test("one line", () => { | |
| 55 | const rows = [1, 2, 3, 4].map((n) => row(n, "Declined: already done on main")); | |
| 56 | rows.push(row(9, "Declined: unclear")); | |
| 57 | expect(declinesSays(declines(rows, NOW))).toBe("Declined in 24 h: 4 already on main (#1, #2, #3, …), 1 other (#9)"); | |
| 58 | }); | |
| 59 | test("one issue declining twice shows no ellipsis", () => { | |
| 60 | const rows = [row(7, "Declined: already done on main"), row(7, "Declined: already done on main", 20)]; | |
| 61 | expect(declinesSays(declines(rows, NOW))).toBe("Declined in 24 h: 2 already on main (#7)"); | |
| 62 | }); | |
| 63 | }); |
new above
offline
Low: the bare
depends onputs "DECLINED: the issue is unclear; it depends on what the owner wants" intonot_on_main(checked locally). The baredirectionon the next line has the same problem with "unclear direction". "Declined - already done on main" (no colon) falls through toother. When this gets wired up, anchor these todepends on (pull|#\d+)andoff[- ]direction|against (the )?(vision|direction), and add a test for each.