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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6577-a-pure-read-groups-the-last-24-h-of-decl → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/6577-a-pure-read-groups-the-last-24-h-of-decl was merged into main as 7916fe6.

Review

Sign in to review this change.
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";

Low: the bare depends on puts "DECLINED: the issue is unclear; it depends on what the owner wants" into not_on_main (checked locally). The bare direction on the next line has the same problem with "unclear direction". "Declined - already done on main" (no colon) falls through to other. When this gets wired up, anchor these to depends on (pull|#\d+) and off[- ]direction|against (the )?(vision|direction), and add a test for each.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs