Public: anyone can read this; the crew can change it.
A pure read picks the crew pulls with no push for 7 days, oldest first, with the note that folds each back into its issue #6541
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6521-a-pure-read-picks-the-crew-pulls-with-no → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +85
−0
src/flow/stale_fold.ts added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | /** | |
| 2 | * Stale fold (#6521): crew pulls with no push for 7 days, oldest first, each | |
| 3 | * with the note that folds it back into its issue. A pure read over plain data; | |
| 4 | * it only proposes, a person closes. Approved pulls are left out. Nothing shows it yet. | |
| 5 | */ | |
| 6 | ||
| 7 | const DAY_MS = 86_400_000; | |
| 8 | const STALE_DAYS = 7; | |
| 9 | ||
| 10 | export interface FoldablePull { | |
| 11 | readonly number: number; | |
| 12 | readonly issueNumber: number | null; | |
| 13 | readonly branch: string; | |
| 14 | readonly lastPushAt: string; | |
| 15 | readonly approved: boolean; | |
| 16 | } | |
| 17 | ||
| 18 | export interface StaleFold extends FoldablePull { | |
| 19 | readonly days: number; | |
| 20 | /** Null when the pull has no issue to fold back into. */ | |
| 21 | readonly note: string | null; | |
| 22 | } | |
| 23 | ||
| 24 | export function staleFold(pulls: readonly FoldablePull[], now: Date): StaleFold[] { | |
| 25 | const out: (StaleFold & { at: number })[] = []; | |
| 26 | for (const p of pulls) { | |
| 27 | if (p.approved) continue; | |
| 28 | const at = Date.parse(p.lastPushAt); | |
| 29 | if (!Number.isFinite(at)) continue; | |
| 30 | const days = Math.floor((now.getTime() - at) / DAY_MS); | |
| 31 | if (days < STALE_DAYS) continue; | |
| 32 | const note = | |
| 33 | p.issueNumber === null | |
| 34 | ? null | |
| 35 | : `Folded back: pull #${p.number} had no push for ${days} days; its branch \`${p.branch}\` keeps the work.`; | |
| 36 | out.push({ ...p, days, note, at }); | |
| 37 | } | |
| 38 | out.sort((a, b) => a.at - b.at || a.number - b.number); | |
| 39 | return out.map(({ at: _at, ...rest }) => rest); | |
| 40 | } |
test/flow-stale-fold.test.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** The stale fold (#6521): pulls with no push for 7 days, oldest first, with the note. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { staleFold, type FoldablePull } from "../src/flow/stale_fold.ts"; | |
| 4 | ||
| 5 | const NOW = new Date("2026-10-26T12:00:00.000Z"); | |
| 6 | const ago = (d: number) => new Date(NOW.getTime() - d * 86_400_000).toISOString(); | |
| 7 | const pull = (number: number, d: number, extra: Partial<FoldablePull> = {}): FoldablePull => ({ | |
| 8 | number, | |
| 9 | issueNumber: number + 1000, | |
| 10 | branch: `scout/${number}`, | |
| 11 | lastPushAt: ago(d), | |
| 12 | approved: false, | |
| 13 | ...extra, | |
| 14 | }); | |
| 15 | ||
| 16 | describe("staleFold", () => { | |
| 17 | test("exactly 7 days is stale, just under is not", () => { | |
| 18 | const got = staleFold([pull(1, 7), pull(2, 7 - 1 / 86_400)], NOW); | |
| 19 | expect(got.map((p) => p.number)).toEqual([1]); | |
| 20 | }); | |
| 21 | ||
| 22 | test("oldest first, ties by number", () => { | |
| 23 | const got = staleFold([pull(3, 8), pull(2, 20), pull(1, 8)], NOW); | |
| 24 | expect(got.map((p) => p.number)).toEqual([2, 1, 3]); | |
| 25 | }); | |
| 26 | ||
| 27 | test("approved pulls are left out", () => { | |
| 28 | expect(staleFold([pull(1, 30, { approved: true }), pull(2, 30)], NOW).map((p) => p.number)).toEqual([2]); | |
| 29 | }); | |
| 30 | ||
| 31 | test("the note's words", () => { | |
| 32 | const [p] = staleFold([pull(5, 9.5)], NOW); | |
| 33 | expect(p?.note).toBe("Folded back: pull #5 had no push for 9 days; its branch `scout/5` keeps the work."); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a pull with no issue is listed with no note", () => { | |
| 37 | const [p] = staleFold([pull(6, 10, { issueNumber: null })], NOW); | |
| 38 | expect(p?.issueNumber).toBeNull(); | |
| 39 | expect(p?.note).toBeNull(); | |
| 40 | }); | |
| 41 | ||
| 42 | test("an unreadable date is skipped", () => { | |
| 43 | expect(staleFold([pull(1, 10, { lastPushAt: "nope" })], NOW)).toEqual([]); | |
| 44 | }); | |
| 45 | }); |
new above
offline
Nit: dropping
atthrough an unused-name destructure is easy to trip a lint on; map from pairs instead if the linter ever flags_at.