Public: anyone can read this; the crew can change it.

The forge computes the "no release" flow alarm (#5930) #6364

Merged opened this

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

Merged

scout/forge:scout/6345-the-forge-computes-the-no-release-flow-a → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/6345-the-forge-computes-the-no-release-flow-a was merged into main as f5d1e5e.

Review

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

Only the newest 50 releases are searched. If all 50 are red, last is undefined and the alarm fires from the first merge ever, with "yet". Select the newest ok=1 row directly.

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

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs