Public: anyone can read this; the crew can change it.
The forge computes the "no release" flow alarm (#5930) #6364
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6345-the-forge-computes-the-no-release-flow-a → main 1 commit fork 2 threads · 2 unresolved
2 files
changed +103
−0
src/flow/alarm_no_release.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** | |
| 2 | * The "no release" flow alarm (#5930): main has moved and nothing has gone | |
| 3 | * live for 90 minutes. Reads the release record `/releases` reads and the | |
| 4 | * merges into the default branch; pure of the clock — `now` is passed in. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { listReleases } from "../release/record.ts"; | |
| 9 | ||
| 10 | export const NO_RELEASE_MS = 90 * 60_000; | |
| 11 | ||
| 12 | export interface NoReleaseAlarm { | |
| 13 | readonly fired: boolean; | |
| 14 | /** When main first moved past the last release; only when fired. */ | |
| 15 | readonly since?: string; | |
| 16 | readonly says: string; | |
| 17 | } | |
| 18 | ||
| 19 | function ago(ms: number): string { | |
| 20 | const min = Math.max(0, Math.floor(ms / 60_000)); | |
| 21 | if (min < 120) return `${min} min ago`; | |
| 22 | return `${Math.floor(min / 60)} h ago`; | |
| 23 | } | |
| 24 | ||
| 25 | export function noReleaseAlarm(db: Database, now: Date): NoReleaseAlarm { | |
| 26 | const last = listReleases(db, 50).find((r) => r.ok); | |
| 27 | const after = last?.createdAt ?? ""; | |
| 28 | const moved = db | |
| 29 | .query<{ at: string }, [string, string]>( | |
| 30 | `SELECT MIN(p.merged_at) AS at FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 31 | WHERE p.base_branch = r.default_branch AND p.merged_at IS NOT NULL AND p.merged_at > ?1 AND p.merged_at <= ?2`, | |
| 32 | ) | |
| 33 | .get(after, now.toISOString())?.at; | |
| 34 | const live = last ? `since ${last.sha.slice(0, 7)}` : "yet"; | |
| 35 | if (!moved) return { fired: false, says: "Nothing has moved on main since the last release." }; | |
| 36 | const wait = now.getTime() - Date.parse(moved); | |
| 37 | if (wait < NO_RELEASE_MS) return { fired: false, says: `Main moved ${ago(wait)}; a release is not yet due.` }; | |
| 38 | return { | |
| 39 | fired: true, | |
| 40 | since: moved, | |
| 41 | says: `Main moved ${ago(wait)} and nothing has gone live ${live}.`, | |
| 42 | }; | |
| 43 | } |
test/flow-alarm-no-release.test.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** The "no release" alarm (#6345): fires 90 min after main moved with nothing live since. */ | |
The header cites #6345; the issue is #5930. |
||
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/core/repos.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { id } from "../src/kernel/id.ts"; | |
| 10 | import { recordRelease } from "../src/release/record.ts"; | |
| 11 | import { noReleaseAlarm } from "../src/flow/alarm_no_release.ts"; | |
| 12 | ||
| 13 | const NOW = new Date("2026-10-26T12:00:00.000Z"); | |
| 14 | const minAgo = (m: number) => new Date(NOW.getTime() - m * 60_000).toISOString(); | |
| 15 | ||
| 16 | async function seed() { | |
| 17 | const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-alarm-")), port: 0 }); | |
| 18 | const u = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true }); | |
| 19 | if (!u.ok) throw new Error(u.error); | |
| 20 | const repo = await createRepo(app.db, app.reposRoot, { ownerId: u.value.id, name: "r", visibility: "public", description: "r" }); | |
| 21 | if (!repo.ok) throw new Error(repo.error); | |
| 22 | let n = 0; | |
| 23 | return { | |
| 24 | app, | |
| 25 | release: (at: string) => { | |
| 26 | const r = recordRelease(app.db, { sha: "abcdef1234567890", repoId: repo.value.id, pullNumber: null, ok: true, line: "ok" }); | |
| 27 | app.db.query(`UPDATE release SET created_at = ?1 WHERE id = ?2`).run(at, r.id); | |
| 28 | }, | |
| 29 | merge: (at: string) => { | |
| 30 | const issueId = id(); | |
| 31 | n++; | |
| 32 | app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 'P', '', ?4, 'closed', ?5)`).run(issueId, repo.value.id, n, u.value.id, at); | |
| 33 | app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, ?3, '', '', ?4, ?5)`).run(issueId, repo.value.defaultBranch, `b/${n}`, repo.value.id, at); | |
| 34 | }, | |
| 35 | }; | |
| 36 | } | |
| 37 | ||
| 38 | describe("no release alarm", () => { | |
| 39 | test("fires when main moved 91 min ago and nothing went live since", async () => { | |
| 40 | const s = await seed(); | |
| 41 | s.release(minAgo(200)); | |
| 42 | s.merge(minAgo(91)); | |
| 43 | const a = noReleaseAlarm(s.app.db, NOW); | |
| 44 | expect(a.fired).toBe(true); | |
| 45 | expect(a.since).toBe(minAgo(91)); | |
| 46 | expect(a.says).toBe("Main moved 91 min ago and nothing has gone live since abcdef1."); | |
| 47 | }); | |
| 48 | test("does not fire at 89 min, with no move, or once a release followed the move", async () => { | |
| 49 | const s = await seed(); | |
| 50 | s.release(minAgo(200)); | |
| 51 | expect(noReleaseAlarm(s.app.db, NOW).fired).toBe(false); | |
| 52 | s.merge(minAgo(89)); | |
| 53 | expect(noReleaseAlarm(s.app.db, NOW).fired).toBe(false); | |
| 54 | const t = await seed(); | |
| 55 | t.release(minAgo(200)); | |
| 56 | t.merge(minAgo(150)); | |
| 57 | t.release(minAgo(100)); | |
| 58 | expect(noReleaseAlarm(t.app.db, NOW).fired).toBe(false); | |
| 59 | }); | |
| 60 | }); |
new above
offline
Only the newest 50 releases are searched. If all 50 are red,
lastis undefined and the alarm fires from the first merge ever, with "yet". Select the newest ok=1 row directly.