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

The forge computes the "drifting pull" flow alarm: an open pull's base is 25 landings or 6 h behind main #6400

Merged opened this

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

Merged

scout/forge:scout/6390-the-forge-computes-the-drifting-pull-flo → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/6390-the-forge-computes-the-drifting-pull-flo was merged into main as cc6849e.

Review

Sign in to review this change.
2 files changed +219 −0
src/flow/alarm_drift.ts added +91
@@ -0,0 +1,91 @@
1 /**
2 * The "drifting pull" alarm (#6390): an open pull whose base is more than 25
3 * landings, or 6 h, behind its repository's main. Rebase early, while it is
4 * cheap. A landing is one first-parent commit on main past the merge base of
5 * the pull's head and main (`base_oid` is the tip at opening and never moves,
6 * so a rebased pull would stay named); the hours run from the first of them. Merged and closed pulls never count.
7 */
8
9 import type { App } from "../app.ts";
10 import { runGit } from "../git/env.ts";
11 import { isSafeOid, isSafeRef } from "../git/guard.ts";
12 import { repoDir } from "../git/repos.ts";
13 import { span } from "./pipeline.ts";
14
15 export const DRIFT_LANDINGS = 25;
16 export const DRIFT_MS = 6 * 3_600_000;
17 const NAMED = 3;
18
19 export interface Drift {
20 readonly number: number;
21 readonly landings: number;
22 /** When main first moved past the pull's base, epoch ms; null when it has not. */
23 readonly since: number | null;
24 }
25
26 export interface DriftAlarm {
27 readonly fired: boolean;
28 readonly since?: string;
29 readonly says: string;
30 }
31
32 interface Row {
33 number: number;
34 base_branch: string;
35 head_oid: string;
36 owner: string;
37 name: string;
38 }
39
40 /** Landings on `branch` past where `headOid` forked from it and when the first happened, read from git; zero when unreadable. */
41 export async function readDrift(dir: string, branch: string, headOid: string): Promise<Omit<Drift, "number">> {
42 const none = { landings: 0, since: null };
43 if (!isSafeOid(headOid) || !isSafeRef(branch)) return none;
outdated

High: baseOid is the base tip from when the pull was opened, and nothing updates it on a rebase or a head push. Count from git merge-base <head_oid> refs/heads/<branch> instead, otherwise rebasing never clears the alarm.

44 const tip = `refs/heads/${branch}`;
45 const fork = await runGit(["merge-base", headOid, tip], { cwd: dir });
46 const base = fork.ok ? fork.value.trim() : "";
47 if (!isSafeOid(base)) return none;
48 const out = await runGit(["log", "--first-parent", "--format=%ct", `${base}..${tip}`], { cwd: dir });
49 if (!out.ok) return none;
50 const times = out.value.split("\n").filter(Boolean);
51 const first = Number(times[times.length - 1]);
52 return { landings: times.length, since: times.length > 0 && Number.isFinite(first) ? first * 1000 : null };
53 }
54
55 const behind = (d: Drift, now: number): number => (d.since === null ? 0 : now - d.since);
56 const drifting = (d: Drift, now: number): boolean => d.landings > DRIFT_LANDINGS || behind(d, now) > DRIFT_MS;
57
58 /** The alarm from the measured pulls: pure, so a test names `now` and the drift. */
59 export function driftAlarm(drifts: readonly Drift[], now: number): DriftAlarm {
60 const hot = drifts.filter((d) => drifting(d, now));
61 if (hot.length === 0) return { fired: false, says: "No open pull is drifting from main." };
62 const worst = [...hot].sort((a, b) => b.landings - a.landings || behind(b, now) - behind(a, now) || a.number - b.number);
63 const named = worst.slice(0, NAMED).map((d) =>
64 d.landings > DRIFT_LANDINGS
65 ? `#${d.number} is ${d.landings} landings behind main`
66 : `#${d.number} is ${span(behind(d, now))} behind`,
67 );
68 const more = hot.length > NAMED ? `; and ${hot.length - NAMED} more` : "";
69 const since = Math.min(...hot.map((d) => d.since ?? now));
70 return { fired: true, since: new Date(since).toISOString(), says: `${named.join("; ")}${more}.` };
71 }
72
73 /** Reads every open pull's drift from its repository's main and answers the alarm. */
74 export async function alarmDrift(app: App, now: Date | number = Date.now()): Promise<DriftAlarm> {
75 const at = typeof now === "number" ? now : now.getTime();
76 const rows = app.db
77 .query<Row, []>(
78 `SELECT i.number, p.base_branch, p.head_oid, o.username AS owner, r.name
79 FROM issue i JOIN pull p ON p.issue_id = i.id
80 JOIN repo r ON r.id = i.repo_id JOIN user o ON o.id = r.owner_id
81 WHERE i.kind = 'pull' AND i.state = 'open' AND p.merged_at IS NULL`,
82 )
83 .all();
84 const drifts = await Promise.all(
85 rows.map(async (r) => ({
86 number: r.number,
87 ...(await readDrift(repoDir(app.reposRoot, r.owner, r.name), r.base_branch, r.head_oid)),
88 })),
89 );
90 return driftAlarm(drifts, at);
91 }
test/flow-alarm-drift.test.ts added +128
@@ -0,0 +1,128 @@
1 /** The drifting-pull alarm: 26 landings fires, 25 does not; merged or closed pulls never count. */
2 import { afterAll, beforeAll, 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 { createApp, type App } from "../src/app.ts";
7 import { commitToBranch } from "../src/actions/repos.ts";
8 import { setIssueState } from "../src/core/issues.ts";
9 import { createPull } from "../src/core/pulls.ts";
10 import { createRepo } from "../src/core/repos.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { alarmDrift, driftAlarm } from "../src/flow/alarm_drift.ts";
13 import { repoDir } from "../src/git/repos.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 const NOW = Date.UTC(2026, 9, 26, 12);
19 const HOUR = 3_600_000;
20 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
21 if (!r.ok) throw new Error(JSON.stringify(r.error));
22 return r.value;
23 };
24
25 test("by landings: more than 25 fires, 25 does not", () => {
26 const at = (landings: number) => driftAlarm([{ number: 7, landings, since: NOW - 60_000 }], NOW);
27 expect(at(25).fired).toBe(false);
28 const fired = at(26);
29 expect(fired.fired).toBe(true);
30 expect(fired.says).toBe("#7 is 26 landings behind main.");
31 });
32
33 test("by hours: past 6 h fires; says the three most behind", () => {
34 const a = driftAlarm(
35 [
36 { number: 1, landings: 2, since: NOW - 6 * HOUR },
37 { number: 2, landings: 2, since: NOW - 7 * HOUR },
38 { number: 3, landings: 41, since: NOW - HOUR },
39 { number: 4, landings: 30, since: NOW - HOUR },
40 { number: 5, landings: 26, since: NOW - HOUR },
41 ],
42 NOW,
43 );
44 expect(a.fired).toBe(true);
45 expect(a.says).toBe("#3 is 41 landings behind main; #4 is 30 landings behind main; #5 is 26 landings behind main; and 1 more.");
46 expect(a.since).toBe(new Date(NOW - 7 * HOUR).toISOString());
47 const h = driftAlarm([{ number: 2, landings: 2, since: NOW - 7 * HOUR }], NOW);
48 expect(h.says).toBe("#2 is 7h behind.");
49 expect(driftAlarm([{ number: 1, landings: 2, since: NOW - 6 * HOUR }], NOW).fired).toBe(false);
50 expect(driftAlarm([], NOW)).toEqual({ fired: false, says: "No open pull is drifting from main." });
51 });
52
53 let dataDir: string;
54 let app: App;
55
56 beforeAll(async () => {
57 dataDir = await mkdtemp(join(tmpdir(), "forge-drift-"));
58 app = createApp({ dataDir, port: 0 });
59 });
60 afterAll(async () => {
61 await rm(dataDir, { recursive: true, force: true });
62 });
63
64 test("read from git: landings past a pull's base, open pulls only", async () => {
65 const ada = ok(
66 await createUser(app.db, { origin: "seed", trusted: true, username: "ada", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }),
67 );
68 const repo = ok(await createRepo(app.db, app.reposRoot, { ownerId: ada.id, name: "svc", visibility: "public" }));
69 const dir = repoDir(app.reposRoot, ada.username, repo.name);
70 const land = async (n: number) => {
71 for (let i = 0; i < n; i++)
72 ok(await commitToBranch(app, ada, repo, { message: `m${i}`, files: [{ path: `f${Math.random()}.txt`, content: "x\n" }] }));
73 };
74 await land(1);
75 const branch = (b: string) =>
76 commitToBranch(app, ada, repo, { message: b, branch: b, files: [{ path: `${b}.txt`, content: "x\n" }] } as any);
77 ok(await branch("one"));
78 ok(await branch("two"));
79 const open = ok(await createPull(app.db, dir, { repoId: repo.id, authorId: ada.id, title: "one", baseBranch: "main", headBranch: "one" }));
80 const shut = ok(await createPull(app.db, dir, { repoId: repo.id, authorId: ada.id, title: "two", baseBranch: "main", headBranch: "two" }));
81 await land(25);
82 expect((await alarmDrift(app, new Date())).fired).toBe(false);
83 await land(1);
84 const fired = await alarmDrift(app, new Date());
85 expect(fired.fired).toBe(true);
86 expect(fired.says).toContain(`#${open.number} is 26 landings`);
87 const both = await alarmDrift(app, new Date(Date.now()));
88 expect(both.says).toBe(`#${open.number} is 26 landings behind main; #${shut.number} is 26 landings behind main.`);
89 setIssueState(app.db, shut.id, "closed");

Add the two cases the issue names that are missing here: a merged pull never counts, and a pull whose head was rebased onto the current main stops counting. The rebase case fails against the code as it is.

90 const one = await alarmDrift(app, new Date(Date.now()));
91 expect(one.says).toBe(`#${open.number} is 26 landings behind main.`);
92 setIssueState(app.db, open.id, "closed");
93 });
94
95 test("a rebased head stops counting; a merged pull never counts", async () => {
96 const ada = ok(
97 await createUser(app.db, { origin: "seed", trusted: true, username: "bo", email: "[email protected]", password: "a-good-passphrase" }),
98 );
99 const repo = ok(await createRepo(app.db, app.reposRoot, { ownerId: ada.id, name: "rb", visibility: "public" }));
100 const dir = repoDir(app.reposRoot, ada.username, repo.name);
101 const git = async (...args: string[]) => {
102 const p = Bun.spawn(["git", ...args], {
103 cwd: dir,
104 stdout: "pipe",
105 stderr: "ignore",
106 env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" },
107 });
108 return (await new Response(p.stdout).text()).trim();
109 };
110 ok(await commitToBranch(app, ada, repo, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] }));
111 ok(await commitToBranch(app, ada, repo, { message: "w", branch: "work", files: [{ path: "w.txt", content: "w\n" }] } as any));
112 const pull = ok(await createPull(app.db, dir, { repoId: repo.id, authorId: ada.id, title: "w", baseBranch: "main", headBranch: "work" }));
113 for (let i = 0; i < 26; i++)
114 ok(await commitToBranch(app, ada, repo, { message: `m${i}`, files: [{ path: `g${i}.txt`, content: "x\n" }] }));
115 expect((await alarmDrift(app, new Date())).fired).toBe(true);
116 // Merge main into the head and tell the pull, as a push does.
117 const merged = await git("commit-tree", "main^{tree}", "-p", "work", "-p", "main", "-m", "merge main");
118 expect(merged).toMatch(/^[0-9a-f]{40}$/);
119 await git("update-ref", "refs/heads/work", merged);
120 app.db.query("UPDATE pull SET head_oid = ?1 WHERE issue_id = ?2").run(merged, pull.id);
121 expect((await alarmDrift(app, new Date())).fired).toBe(false);
122 // Behind again, but merged: never counts.
123 for (let i = 0; i < 26; i++)
124 ok(await commitToBranch(app, ada, repo, { message: `n${i}`, files: [{ path: `h${i}.txt`, content: "x\n" }] }));
125 expect((await alarmDrift(app, new Date())).fired).toBe(true);
126 app.db.query("UPDATE pull SET merged_at = ?1 WHERE issue_id = ?2").run(new Date().toISOString(), pull.id);
127 expect((await alarmDrift(app, new Date())).fired).toBe(false);
128 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs