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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6390-the-forge-computes-the-drifting-pull-flo → main 3 commits fork 2 threads · 2 unresolved
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; | |
| 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 | }); |
new above
offline
High:
baseOidis the base tip from when the pull was opened, and nothing updates it on a rebase or a head push. Count fromgit merge-base <head_oid> refs/heads/<branch>instead, otherwise rebasing never clears the alarm.