Public: anyone can read this; the crew can change it.
The forge computes the "unproposed work" flow alarm: a crew fork's branch has sat 6 h with no open pull #6401
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6391-the-forge-computes-the-unproposed-work-f → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +206
−0
src/flow/alarm_unproposed.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** | |
| 2 | * The "unproposed work" alarm (#5930): a branch in a crew member's fork has | |
| 3 | * a head that is not on the fork's default branch, heads no open pull, and | |
| 4 | * is 6 h old or more. Branches are read through the helpers the branches | |
| 5 | * page uses; no branch name reaches a shell. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { listAllForks } from "../core/repos.ts"; | |
| 10 | import { listBranches } from "../git/branches.ts"; | |
| 11 | import { repoDir } from "../git/repos.ts"; | |
| 12 | import { commitLog } from "../git/read.ts"; | |
| 13 | ||
| 14 | const HOUR_MS = 3_600_000; | |
| 15 | export const UNPROPOSED_AFTER_MS = 6 * HOUR_MS; | |
| 16 | const SAYS_MAX = 3; | |
| 17 | ||
| 18 | export interface UnproposedBranch { | |
| 19 | readonly repo: string; | |
| 20 | readonly owner: string; | |
| 21 | readonly branch: string; | |
| 22 | readonly oid: string; | |
| 23 | readonly ageMs: number; | |
| 24 | } | |
| 25 | ||
| 26 | export interface UnproposedAlarm { | |
| 27 | readonly fires: boolean; | |
| 28 | readonly says: string; | |
| 29 | /** Oldest first. */ | |
| 30 | readonly branches: readonly UnproposedBranch[]; | |
| 31 | } | |
| 32 | ||
| 33 | function age(ms: number): string { | |
| 34 | const h = Math.floor(ms / HOUR_MS); | |
| 35 | return h >= 48 ? `${Math.floor(h / 24)} d` : `${h} h`; | |
| 36 | } | |
| 37 | ||
| 38 | export async function alarmUnproposed( | |
| 39 | db: Database, | |
| 40 | reposRoot: string, | |
| 41 | now: Date, | |
| 42 | ): Promise<UnproposedAlarm> { | |
| 43 | const crew = new Set( | |
| 44 | db | |
| 45 | .query<{ id: string }, []>("SELECT id FROM user WHERE is_agent = 1") | |
| 46 | .all() | |
| 47 | .map((u) => u.id), | |
| 48 | ); | |
| 49 | const proposed = new Set( | |
| 50 | db | |
| 51 | .query<{ head_repo_id: string | null; head_branch: string; head_oid: string }, []>( | |
| 52 | `SELECT pull.head_repo_id, pull.head_branch, pull.head_oid | |
| 53 | FROM pull JOIN issue ON issue.id = pull.issue_id | |
| 54 | WHERE issue.state = 'open' AND pull.merged_at IS NULL`, | |
| 55 | ) | |
| 56 | .all() | |
| 57 | .flatMap((p) => [`${p.head_repo_id}\0${p.head_branch}`, `oid\0${p.head_oid}`]), | |
| 58 | ); | |
| 59 | const found: UnproposedBranch[] = []; | |
| 60 | for (const fork of listAllForks(db)) { | |
| 61 | if (!crew.has(fork.ownerId) || fork.archivedAt !== null) continue; | |
| 62 | const dir = repoDir(reposRoot, fork.ownerName, fork.name); | |
| 63 | const listing = await listBranches(dir, fork.defaultBranch); | |
| 64 | if (!listing.ok) continue; | |
| 65 | for (const b of listing.value.branches) { | |
| 66 | if (b.isDefault || b.ahead === 0) continue; | |
| 67 | if (proposed.has(`${fork.id}\0${b.name}`) || proposed.has(`oid\0${b.oid}`)) continue; | |
| 68 | const log = await commitLog(dir, b.oid, { limit: 1 }); | |
| 69 | const date = log.ok ? log.value[0]?.date : undefined; | |
| 70 | const ageMs = date === undefined ? NaN : now.getTime() - Date.parse(date); | |
| 71 | if (!(ageMs >= UNPROPOSED_AFTER_MS)) continue; | |
| 72 | found.push({ | |
| 73 | repo: `${fork.ownerName}/${fork.name}`, | |
| 74 | owner: fork.ownerName, | |
| 75 | branch: b.name, | |
| 76 | oid: b.oid, | |
| 77 | ageMs, | |
| 78 | }); | |
| 79 | } | |
| 80 | } | |
| 81 | found.sort((a, b) => b.ageMs - a.ageMs); | |
| 82 | if (found.length === 0) return { fires: false, says: "", branches: [] }; | |
| 83 | const oldest = found | |
| 84 | .slice(0, SAYS_MAX) | |
| 85 | .map((b) => `${b.owner}'s ${b.branch} (${age(b.ageMs)})`) | |
| 86 | .join(", "); | |
| 87 | const more = found.length > SAYS_MAX ? ` and ${found.length - SAYS_MAX} more` : ""; | |
| 88 | return { | |
| 89 | fires: true, | |
| 90 | says: `Unproposed work: ${found.length} crew ${found.length === 1 ? "branch has" : "branches have"} sat 6 h or more with no open pull: ${oldest}${more}.`, | |
| 91 | branches: found, | |
| 92 | }; | |
| 93 | } |
test/flow-alarm-unproposed.test.ts added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | /** The unproposed-work alarm (#6391): `now` is passed in; nothing reads the wall clock. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { id } from "../src/kernel/id.ts"; | |
| 11 | import { repoDir } from "../src/git/repos.ts"; | |
| 12 | import { alarmUnproposed } from "../src/flow/alarm_unproposed.ts"; | |
| 13 | ||
| 14 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 15 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 16 | return r.value; | |
| 17 | }; | |
| 18 | const NOW = new Date("2026-10-26T12:00:00Z"); | |
| 19 | const ago = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString(); | |
| 20 | ||
| 21 | describe("alarm_unproposed", () => { | |
| 22 | let dataDir: string; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | let forkId: string; | |
| 26 | let dir: string; | |
| 27 | let main: string; | |
| 28 | let authorId: string; | |
| 29 | ||
| 30 | const git = async (args: string[], date?: string, stdin?: string): Promise<string> => { | |
| 31 | const p = Bun.spawn(["git", ...args], { | |
| 32 | cwd: dir, | |
| 33 | stdin: stdin === undefined ? "ignore" : new Blob([stdin]), | |
| 34 | stdout: "pipe", | |
| 35 | stderr: "pipe", | |
| 36 | env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "[email protected]", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "[email protected]", ...(date ? { GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date } : {}) }, | |
| 37 | }); | |
| 38 | const out = await new Response(p.stdout).text(); | |
| 39 | if ((await p.exited) !== 0) throw new Error(await new Response(p.stderr).text()); | |
| 40 | return out.trim(); | |
| 41 | }; | |
| 42 | /** A commit on top of main, aged `h` hours, held by `refs/heads/<name>`. */ | |
| 43 | const branch = async (name: string, h: number): Promise<string> => { | |
| 44 | const tree = await git(["rev-parse", `${main}^{tree}`]); | |
| 45 | const oid = await git(["commit-tree", tree, "-p", main, "-m", name], ago(h)); | |
| 46 | await git(["update-ref", `refs/heads/${name}`, oid]); | |
| 47 | return oid; | |
| 48 | }; | |
| 49 | ||
| 50 | beforeAll(async () => { | |
| 51 | dataDir = await mkdtemp(join(tmpdir(), "forge-alarm-unproposed-")); | |
| 52 | app = createApp({ dataDir, port: 0 }); | |
| 53 | servers = await serve(app); | |
| 54 | const alice = must(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true })); | |
| 55 | authorId = alice.id; | |
| 56 | const bot = must(await createUser(app.db, { origin: "seed", trusted: true, username: "bot", email: "[email protected]", agent: true })); | |
| 57 | const upstream = must(await createRepo(app, alice, { name: "app", visibility: "public" })); | |
| 58 | dir = repoDir(app.reposRoot, "alice", "app"); | |
| 59 | const empty = await git(["hash-object", "-t", "tree", "-w", "--stdin"], undefined, ""); | |
| 60 | main = await git(["commit-tree", empty, "-m", "root"], ago(40)); | |
| 61 | await git(["update-ref", `refs/heads/${upstream.defaultBranch}`, main]); | |
| 62 | const fork = must(await forkRepo(app, bot, upstream)); | |
| 63 | forkId = fork.id; | |
| 64 | dir = repoDir(app.reposRoot, "bot", "app"); | |
| 65 | }, 60_000); | |
| 66 | ||
| 67 | afterAll(async () => { | |
| 68 | servers.stop(); | |
| 69 | app.close(); | |
| 70 | await rm(dataDir, { recursive: true, force: true }); | |
| 71 | }); | |
| 72 | ||
| 73 | const openPull = (head: string, state: string, merged: string | null) => { | |
| 74 | const issueId = id(); | |
| 75 | const n = (app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM issue").get()?.n ?? 0) + 1; | |
| 76 | app.db | |
| 77 | .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 'P', '', ?4, ?5, ?6)`) | |
| 78 | .run(issueId, forkId, n, authorId, state, ago(1)); | |
| 79 | app.db | |
| 80 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, 'main', ?2, '', '', ?3, ?4)`) | |
| 81 | .run(issueId, head, forkId, merged); | |
| 82 | }; | |
| 83 | ||
| 84 | test("quiet when no crew branch is waiting", async () => { | |
| 85 | const a = await alarmUnproposed(app.db, app.reposRoot, NOW); | |
| 86 | expect(a.fires).toBe(false); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a 7 h branch with no pull fires and is named; the others do not", async () => { | |
| 90 | await branch("lonely", 7); | |
| 91 | await branch("fresh", 2); | |
| 92 | await branch("proposed", 9); | |
| 93 | openPull("proposed", "open", null); | |
| 94 | const tree = await git(["rev-parse", `${main}^{tree}`]); | |
| 95 | const merged = await git(["commit-tree", tree, "-p", main, "-m", "m"], ago(10)); | |
| 96 | await git(["update-ref", `refs/heads/${(await git(["symbolic-ref", "--short", "HEAD"]))}`, merged]); | |
| 97 | await git(["update-ref", "refs/heads/landed", merged]); | |
| 98 | ||
| 99 | const a = await alarmUnproposed(app.db, app.reposRoot, NOW); | |
| 100 | expect(a.fires).toBe(true); | |
| 101 | expect(a.branches.map((b) => b.branch)).toEqual(["lonely"]); | |
| 102 | expect(a.says).toContain("bot's lonely (7 h)"); | |
| 103 | }); | |
| 104 | ||
| 105 | test("says names the oldest three with owner and age", async () => { | |
| 106 | await branch("older", 30); | |
| 107 | await branch("old", 12); | |
| 108 | await branch("oldish", 8); | |
| 109 | const a = await alarmUnproposed(app.db, app.reposRoot, NOW); | |
| 110 | expect(a.branches.map((b) => b.branch)).toEqual(["older", "old", "oldish", "lonely"]); | |
| 111 | expect(a.says).toContain("bot's older (30 h), bot's old (12 h), bot's oldish (8 h) and 1 more"); | |
| 112 | }); | |
| 113 | }); |
new above
offline
No caller yet: wire this into the alarm line and an MCP read next, with a test through that door.