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

/flow lists approved pulls waiting on the merge queue, stale crew pulls to fold back, and work a crew fork never proposed #11466

Merged opened this

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

Merged

mason/forge:mason/11455-flow-lists-approved-pulls-waiting-on-the → main 5 commits fork 3 threads · 3 unresolved

  • Merged. mason/11455-flow-lists-approved-pulls-waiting-on-the was merged into main as 5d6f47b.

Review

Sign in to review this change.
10 files changed +428 −11
AGENTS.md +1 −1
@@ -15,7 +15,7 @@ A new read of a table that grows (`event`, `issue`, `run`, `comment`) names the
1515
1616 A new `/settings…` page route goes in `src/http/settings_routes.ts` (`settingsRoutes`), not `src/http/server.ts`.
1717
18 State shared between modules through process memory, not the database, is named here. `src/perf/views_report.ts`: `readVisits` holds each scope and window's visits for 15 seconds (dropped by `flushViews`), so a page and its probe share one read; `src/perf/views_store.ts`: the view buffer, flushed every 10 seconds. The traffic sources view's terminal door is `src/tui/screens/trafficview.ts` (palette entry for admins, per-app command for a repository's admins). `src/runs/held.ts`: each dispatcher pass (`src/crew/dispatch.ts`, `pump`) records which queued agent runs it held and why (valve, pace, limit), and vitals (`src/kernel/vitals.ts`) leaves those out of `queue:agent`. A record older than `HELD_FRESH_MS` is ignored, so a stopped dispatcher cannot hide its queue. `src/runs/stoppable.ts`: the checks in flight in this process by run id (`src/checks/service.ts` `start` tracks each), so a person's cancel (`cancelRepoRun`) aborts the check and its process group ends with it; agent runs keep `src/crew/running.ts`. `src/apps/egress_refused.ts`: refused-host counts per repository, flushed to disk every 5 seconds (`flushRefused` forces it). `src/spawn/usage.ts`: the last disk reading of each child's data directory (30 s), and a cell child's last reading of its own cgroup CPU (`selfUse`, #10853), from which the next reading's cores are taken; `forgetSelfCpu` drops it.
18 State shared between modules through process memory, not the database, is named here. `src/perf/views_report.ts`: `readVisits` holds each scope and window's visits for 15 seconds (dropped by `flushViews`), so a page and its probe share one read; `src/perf/views_store.ts`: the view buffer, flushed every 10 seconds. The traffic sources view's terminal door is `src/tui/screens/trafficview.ts` (palette entry for admins, per-app command for a repository's admins). `src/runs/held.ts`: each dispatcher pass (`src/crew/dispatch.ts`, `pump`) records which queued agent runs it held and why (valve, pace, limit), and vitals (`src/kernel/vitals.ts`) leaves those out of `queue:agent`. A record older than `HELD_FRESH_MS` is ignored, so a stopped dispatcher cannot hide its queue. `src/runs/stoppable.ts`: the checks in flight in this process by run id (`src/checks/service.ts` `start` tracks each), so a person's cancel (`cancelRepoRun`) aborts the check and its process group ends with it; agent runs keep `src/crew/running.ts`. `src/apps/egress_refused.ts`: refused-host counts per repository, flushed to disk every 5 seconds (`flushRefused` forces it). `src/spawn/usage.ts`: the last disk reading of each child's data directory (30 s), and a cell child's last reading of its own cgroup CPU (`selfUse`, #10853), from which the next reading's cores are taken; `forgetSelfCpu` drops it. `src/web/pages/flow-waiting.ts`: the unproposed-work scan (it reads git for every crew fork) is held for 60 seconds (`forgetUnproposed` drops it), so /flow and the terminal screen share one scan.
1919
2020 `src/perf/loop_worst.ts`: `loopWorst`, the worst event-loop lag of the last 10 s in ten one-second slots, fed by one unref'd 50 ms timer that the first `serve` starts (`startLoopWorst`), and read by every response's `lag;dur` and by `/healthz` asked for JSON (`loopLagMs`). Tests listing a request's phases read `requestTiming` (`test/helpers/request-timing.ts`), which leaves `lag` out.
2121
docs/features/flow-waiting.md added +13
@@ -0,0 +1,13 @@
1 # Flow: waiting

Both doors: add the terminal screen and describe it here.

2
3 `/flow` (a forge admin who is a person) shows three quiet places work waits, each with its one action. A section with nothing to say draws nothing; when all three are quiet there is no card.
4
5 - **Approved, waiting on the merge queue** — per repository, how many approved pulls wait and how long the oldest has (`src/flow/approved_wait.ts`). Action: open the oldest pull.
6 - **Stale crew pulls** — crew pulls with no push for 7 days, oldest first (`src/flow/stale_fold.ts`), each with the note that folds it back into its issue. It only proposes; a person closes.
7 - **Work a crew fork never proposed** — a crew fork's branch 6 h old or more that heads no pull (`src/flow/alarm_unproposed.ts`), once, with a link to the branch. Branches are read with the branches page's helpers; no branch name reaches a shell.
8
9 Terminal: the palette entry "Pulls to land" (`src/tui/screens/waiting.ts`, for a forge admin who is a person, offered nothing to anyone else) reads the same `readWaiting` and lists the same three sections as text lines; a quiet lane says "Nothing waiting."
10
11 Cost: the unproposed-work scan reads git for every crew fork, so `readWaiting` holds its result for 60 seconds per process; the other two sections are plain queries and are read each time. Stale pulls carry their repository, so two repositories with the same pull number and branch stay apart.
12
13 Code: `src/web/pages/flow-waiting.ts` (reads and draws), mounted by one line in `src/web/pages/flow.ts`. Test: `test/flow-waiting.test.ts`.
scripts/unreached-allow.json −3
@@ -1,15 +1,12 @@
11 [
22 "src/crew/governor_log.ts",
33 "src/design/first_five_minutes.ts",
4 "src/flow/alarm_unproposed.ts",
5 "src/flow/approved_wait.ts",
64 "src/flow/clog_words.ts",
75 "src/flow/conflict_pairs.ts",
86 "src/flow/handovers.ts",
97 "src/flow/rebase_first.ts",
108 "src/flow/review_rounds.ts",
119 "src/flow/shared_red.ts",
12 "src/flow/stale_fold.ts",
1310 "src/perf/page_budget.ts",
1411 "src/spawn/pod.ts",
1512 "src/tui/gallery.ts",
src/flow/stale_fold.ts +6 −5
@@ -21,8 +21,9 @@ export interface StaleFold extends FoldablePull {
2121 readonly note: string | null;
2222 }
2323
24 export function staleFold(pulls: readonly FoldablePull[], now: Date): StaleFold[] {
25 const out: (StaleFold & { at: number })[] = [];
24 /** Any extra fields on a pull (its repository, say) ride through to its fold. */
25 export function staleFold<T extends FoldablePull>(pulls: readonly T[], now: Date): (T & StaleFold)[] {
26 const out: { at: number; fold: T & StaleFold }[] = [];
2627 for (const p of pulls) {
2728 if (p.approved) continue;
2829 const at = Date.parse(p.lastPushAt);
@@ -33,8 +34,8 @@ export function staleFold(pulls: readonly FoldablePull[], now: Date): StaleFold[
3334 p.issueNumber === null
3435 ? null
3536 : `Folded back: pull #${p.number} had no push for ${days} days; its branch \`${p.branch}\` keeps the work.`;
36 out.push({ ...p, days, note, at });
37 out.push({ at, fold: { ...p, days, note } });
3738 }
38 out.sort((a, b) => a.at - b.at || a.number - b.number);
39 return out.map(({ at: _at, ...rest }) => rest);
39 out.sort((a, b) => a.at - b.at || a.fold.number - b.fold.number);
40 return out.map((x) => x.fold);
4041 }
src/tui/screens/entries.ts +2
@@ -56,6 +56,7 @@ import { entry as plans } from "./plans.ts";
5656 import { entry as economics } from "./economics.ts";
5757 import { entry as money } from "./money.ts";
5858 import { entry as loopStalls } from "./loopstalls.ts";
59 import { entry as waiting } from "./waiting.ts";
5960 import { entry as slowPagesEntry } from "./slowpages.ts";
6061 import { entry as maintenance } from "./maintenance.ts";
6162 import { entry as maintainRuns } from "./maintainruns.ts";
@@ -118,6 +119,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
118119 homePins,
119120 system,
120121 loopStalls,
122 waiting,
121123 slowPagesEntry,
122124 telemetry,
123125 resilience,
src/tui/screens/waiting.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * /flow's waiting section in the terminal (#11455): the web page's three
3 * lists, read by the same `readWaiting`, gated the same way: a forge admin who
4 * is a person; anyone else is offered nothing and, were the screen reached,
5 * sees no rows. A section with nothing to say draws nothing. Read-only.
6 */
7
8 import { readWaiting, type Waiting } from "../../web/pages/flow-waiting.ts";
9 import type { Ctx, Screen } from "../app/app.ts";
10 import { binding } from "../app/keys.ts";
11 import type { Session } from "../session.ts";
12 import { style } from "../term/style.ts";
13 import { Scroll, drawLines, hitScroll } from "./common.ts";
14 import type { PaletteEntry } from "./entries.ts";
15
16 const mayRead = (session: Session): boolean => session.user?.isAdmin === true && session.user.kind !== "agent";
17 const hours = (h: number): string => (h >= 48 ? `${String(Math.floor(h / 24))} d` : `${String(h)} h`);
18 const plural = (n: number, one: string): string => `${String(n)} ${n === 1 ? one : `${one}s`}`;
19
20 /** The sections as text lines, headings first; empty for anyone the gate refuses or when all three are quiet. */
21 export function waitingLines(session: Session, w: Waiting): string[] {
22 if (!mayRead(session)) return [];
23 const out: string[] = [];
24 const section = (title: string, rows: string[]) => {
25 if (rows.length === 0) return;
26 if (out.length > 0) out.push("");
27 out.push(title, ...rows);
28 };
29 section(
30 "Approved, waiting on the merge queue",
31 w.approved.map((a) => ` ${a.repo}: ${plural(a.waiting, "approved pull")} waiting; the oldest, #${String(a.oldest)}, for ${hours(a.oldestHours)}`),
32 );
33 section(
34 "Stale crew pulls",
35 w.stale.flatMap((s) => [
36 ` ${s.repo} #${String(s.number)}: ${plural(s.days, "day")} without a push; a person closes it`,
37 s.note === null ? " No issue to fold back into." : ` Fold back into #${String(s.issueNumber)}: ${s.note}`,
38 ]),
39 );
40 section(
41 "Work a crew fork never proposed",
42 w.unproposed.branches.map((b) => ` ${b.repo}: ${b.branch}, ${hours(Math.floor(b.ageMs / 3_600_000))} old, no pull`),
43 );
44 return out;
45 }
46
47 export function waitingScreen(session: Session, ctx: Ctx, w: Waiting): Screen {
48 const scroll = new Scroll();
49 const lines = waitingLines(session, w);
50 const all = lines.length === 0 ? ["Nothing waiting."] : lines;
51 return {
52 title: "Pulls to land",
53 keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")],
54 render(area, s) {
55 const c = ctx.theme.c;
56 const spans = all.map((text) => [{ text, style: style(text.startsWith(" ") || text === "Nothing waiting." ? c.mutedFg : c.fg, c.bg) }]);
57 drawLines(s, area, c, scroll, spans);
58 hitScroll(ctx, area, scroll, all.length);
59 },
60 key(e) {
61 if (scroll.key(e, all.length)) return true;
62 if (e.key === "esc" || e.key === "q") {
63 ctx.pop();
64 return true;
65 }
66 return false;
67 },
68 };
69 }
70
71 export const entry: PaletteEntry = {
72 id: "pulls-to-land",
73 title: "Pulls to land",
74 hint: "approved pulls, stale crew pulls, unproposed work",
75 after: ["flow", "boards"],
76 offered: mayRead,
77 run: (session, ctx) => {
78 void readWaiting(session.app.db, session.app.reposRoot, new Date()).then((w) => ctx.push(waitingScreen(session, ctx, w)));
79 },
80 };
src/web/pages/flow-waiting.ts added +126
@@ -0,0 +1,126 @@
1 /**
2 * /flow's "waiting" section (#11455): three quiet places work sits unseen, each
3 * with its one action. Approved pulls waiting on the merge queue (`approvedWait`),
4 * crew pulls with no push for a week (`staleFold`, which only proposes the note;
5 * a person closes) and crew-fork branches that head no pull (`alarmUnproposed`).
6 * A section with nothing to say draws nothing. Reads are plain rows into the
7 * pure modules; no branch name reaches a shell.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { closesFromBody } from "../../core/pulls.ts";
12 import { alarmUnproposed, type UnproposedAlarm } from "../../flow/alarm_unproposed.ts";
13 import { approvedWait, type ApprovedRow, type ApprovedWait } from "../../flow/approved_wait.ts";
14 import { staleFold, type StaleFold } from "../../flow/stale_fold.ts";
15 import { html, type Html } from "../html.ts";
16 import { card, icon, row } from "../ui.ts";
17
18 export interface Waiting {
19 readonly approved: readonly ApprovedWait[];
20 readonly stale: readonly (StaleFold & { readonly repo: string })[];
21 readonly unproposed: UnproposedAlarm;
22 }
23
24 /** Open, unlanded pulls whose latest verdict is an approval, with when it came. */
25 function approvedRows(db: Database): ApprovedRow[] {
26 return db
27 .query<{ repo: string; number: number; at: string }, []>(
28 `SELECT ro.username || '/' || r.name AS repo, i.number AS number, a.at AS at
29 FROM pull p
30 JOIN issue i ON i.id = p.issue_id
31 JOIN repo r ON r.id = i.repo_id
32 JOIN user ro ON ro.id = r.owner_id
33 JOIN (SELECT issue_id, MAX(created_at) AS at FROM review WHERE state = 'approved' GROUP BY issue_id) a ON a.issue_id = i.id
34 WHERE i.state = 'open' AND p.merged_at IS NULL
35 AND NOT EXISTS (SELECT 1 FROM review c WHERE c.issue_id = i.id AND c.state = 'changes_requested' AND c.created_at > a.at)`,
36 )
37 .all()
38 .map((x) => ({ repo: x.repo, number: x.number, approvedAt: x.at }));
39 }
40
41 /** Open crew pulls with the time of their newest push (their opening when none), and whether they are approved. */
42 function crewPulls(db: Database): { repo: string; pull: Parameters<typeof staleFold>[0][number] }[] {
43 return db
44 .query<{ repo: string; number: number; branch: string; body: string; moved: string; approved: number }, []>(
45 `SELECT ro.username || '/' || r.name AS repo, i.number AS number, p.head_branch AS branch, i.body AS body,
46 COALESCE((SELECT MAX(e.created_at) FROM event e
47 WHERE e.repo_id = i.repo_id AND e.kind = 'pull.pushed'
48 AND json_extract(e.payload, '$.number') = i.number + 0), i.created_at) AS moved,
49 EXISTS (SELECT 1 FROM review a WHERE a.issue_id = i.id AND a.state = 'approved'
50 AND NOT EXISTS (SELECT 1 FROM review c WHERE c.issue_id = i.id AND c.state = 'changes_requested' AND c.created_at > a.created_at)) AS approved
51 FROM pull p
52 JOIN issue i ON i.id = p.issue_id AND i.state = 'open' AND i.kind = 'pull'
53 JOIN repo r ON r.id = i.repo_id
54 JOIN user ro ON ro.id = r.owner_id
55 JOIN user u ON u.id = i.author_id AND u.is_agent = 1
56 WHERE p.merged_at IS NULL`,
57 )
58 .all()
59 .map((x) => ({
60 repo: x.repo,
61 pull: { number: x.number, issueNumber: closesFromBody(x.body)[0] ?? null, branch: x.branch, lastPushAt: x.moved, approved: x.approved === 1 },
62 }));
63 }
64
65 export async function readWaiting(db: Database, reposRoot: string, now: Date): Promise<Waiting> {
66 const pulls = crewPulls(db);

Collides across repositories: pull number + branch is not unique forge-wide. Key by repo as well.

67 return {
68 approved: approvedWait(approvedRows(db), now),
69 stale: staleFold(pulls.map((p) => ({ ...p.pull, repo: p.repo })), now),
70 unproposed: await unproposedCached(db, reposRoot, now),
71 };
72 }
73
74 /** How long the branch scan is reused: it reads git for every crew fork, /flow must answer in milliseconds. */
75 export const UNPROPOSED_TTL_MS = 60_000;
76 let scanned: { root: string; at: number; value: UnproposedAlarm } | undefined;
77
78 /** The unproposed-work scan, at most once a minute per process (a branch is hours old before it counts). */
79 async function unproposedCached(db: Database, reposRoot: string, now: Date): Promise<UnproposedAlarm> {
80 const hit = scanned;
81 if (hit !== undefined && hit.root === reposRoot && now.getTime() - hit.at >= 0 && now.getTime() - hit.at < UNPROPOSED_TTL_MS) return hit.value;
82 const value = await alarmUnproposed(db, reposRoot, now);
83 scanned = { root: reposRoot, at: now.getTime(), value };
84 return value;
85 }
86
87 /** Drops the held scan (tests). */
88 export function forgetUnproposed(): void {
89 scanned = undefined;
90 }
91
92 const hours = (h: number): string => (h >= 48 ? `${String(Math.floor(h / 24))} d` : `${String(h)} h`);
93 const plural = (n: number, one: string): string => `${String(n)} ${n === 1 ? one : `${one}s`}`;
94
95 /** The section; empty html when all three are quiet. */
96 export function waitingSection(w: Waiting): Html {
97 const parts: Html[] = [];
98 if (w.approved.length > 0) {
99 parts.push(html`<h2>Approved, waiting on the merge queue</h2>
100 <ul class="list mb" id="flow-approved">${w.approved.map((a) => row({
outdated

Principle 1: the paragraph restates the section title and row note. One sentence is enough.

101 lead: icon("check"),
102 title: html`<a href="/${a.repo}/pulls/${String(a.oldest)}">${a.repo}</a>
103 <span class="muted small">${plural(a.waiting, "approved pull")} waiting; the oldest, #${String(a.oldest)}, for ${hours(a.oldestHours)}</span>`,
104 }))}</ul>`);
105 }
106 if (w.stale.length > 0) {
107 parts.push(html`<h2>Stale crew pulls</h2>
108 <ul class="list mb" id="flow-stale">${w.stale.map((s) => row({
109 lead: icon("clock"),
110 title: html`<a href="/${s.repo}/pulls/${String(s.number)}">${s.repo} #${String(s.number)}</a>
111 <span class="muted small">${plural(s.days, "day")} without a push; a person closes it</span>`,
112 meta: s.note === null
113 ? html`<span class="muted small">No issue to fold back into.</span>`
114 : html`<span class="small"><a href="/${s.repo}/issues/${String(s.issueNumber)}">Fold back into #${String(s.issueNumber)}</a>: <code>${s.note}</code></span>`,
115 }))}</ul>`);
116 }
117 if (w.unproposed.fires) {
118 parts.push(html`<h2>Work a crew fork never proposed</h2>
119 <ul class="list mb" id="flow-unproposed">${w.unproposed.branches.map((b) => row({
120 lead: icon("alert"),
121 title: html`<a class="mono" href="/${b.repo}/tree/${encodeURIComponent(b.branch)}">${b.repo}: ${b.branch}</a>
122 <span class="muted small">${hours(Math.floor(b.ageMs / 3_600_000))} old, no pull</span>`,
123 }))}</ul>`);
124 }
125 return parts.length === 0 ? html`` : card(html`${parts}`, { extra: "mb", attrs: html`id="flow-waiting"` });
126 }
src/web/pages/flow.ts +3 −1
@@ -20,6 +20,7 @@ import { field, readForm } from "../form.ts";
2020 import { html, when } from "../html.ts";
2121 import { flashTo, page } from "../layout.ts";
2222 import { btn, icon, row, time } from "../ui.ts";
23 import { readWaiting, waitingSection } from "./flow-waiting.ts";
2324
2425 const money = (usd: number): string => `$${usd.toFixed(2)}`;
2526 const plural = (n: number, one: string, many = `${one}s`): string => `${String(n)} ${n === 1 ? one : many}`;
@@ -87,7 +88,7 @@ export function backupRow(alarm: BackupAlarm) {
8788 }
8889
8990 // GET /flow
90 export function flowPage(ctx: Ctx): Response {
91 export async function flowPage(ctx: Ctx): Promise<Response> {
9192 if (ctx.url.search !== "") return new Response(null, { status: 301, headers: { location: `/boards${ctx.url.search}` } });
9293 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
9394 const user = ctx.auth.user;
@@ -97,6 +98,7 @@ export function flowPage(ctx: Ctx): Response {
9798 const body = html`<div class="page-head"><h1>Flow</h1>${btn("Boards", { href: "/boards", variant: "ghost" })}</div>
9899 <p class="mb">Money flows from the left to shipped on the right, as wide as this week's real spend. Every repository has a valve: drag it to give the repository a share of the week, all the way down to shut. The crew does exactly what the valves say, from the next tick, and nothing but you turns one.</p>
99100 ${backupRow(alarmBackup(ctx.app.config.dataDir, new Date()))}
101 ${waitingSection(await readWaiting(ctx.app.db, ctx.app.reposRoot, new Date()))}
100102 ${pipesSvg(p)}
101103 <p class="muted small">${money(p.usd)} spent in the last 7 days; ${plural(p.shipped, "pull")} shipped.</p>
102104 ${p.valves.length === 0
test/flow-waiting.test.ts added +195
@@ -0,0 +1,195 @@
1 /** /flow's waiting section (#11455): one test per section on the page's HTML; quiet draws nothing. */
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 { createRepo, forkRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createUser, type User } from "../src/core/users.ts";
9 import type { Repo } from "../src/core/repos.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { id } from "../src/kernel/id.ts";
12 import { repoDir } from "../src/git/repos.ts";
13 import { forgetUnproposed, readWaiting } from "../src/web/pages/flow-waiting.ts";
14 import { waitingLines } from "../src/tui/screens/waiting.ts";
15 import type { Session } from "../src/tui/session.ts";
16
17 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
18 if (!r.ok) throw new Error(JSON.stringify(r.error));
19 return r.value;
20 };
21 const ago = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString();
22
23 let dataDir: string;
24 let app: App;
25 let servers: Servers;
26 let cookie = "";
27 let repoId = "";
28 let aliceId = "";
29 let botId = "";
30 let upstream: Repo;
31 let bot: User;
32 let alice: User;
33 let n = 0;
34
35 const page = async () => (await fetch(`${servers.url}/flow`, { headers: { cookie } })).text();
36 const git = async (cwd: string, args: string[], date: string, stdin?: string): Promise<string> => {
37 const p = Bun.spawn(["git", ...args], {
38 cwd,
39 stdin: stdin === undefined ? "ignore" : new Blob([stdin]),
40 stdout: "pipe",
41 stderr: "pipe",
42 env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "[email protected]", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "[email protected]", GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date },
43 });
44 const out = await new Response(p.stdout).text();
45 if ((await p.exited) !== 0) throw new Error(await new Response(p.stderr).text());
46 return out.trim();
47 };
48 const pull = (author: string, body: string, createdAt: string, repo = repoId, number?: number, branch?: string): { issueId: string; number: number } => {
49 const issueId = id();
50 n += 1;
51 const num = number ?? 100 + n;
52 app.db
53 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 'P', ?4, ?5, 'open', ?6)`)
54 .run(issueId, repo, num, body, author, createdAt);
55 app.db
56 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, '', ?3, ?4)`)
57 .run(issueId, branch ?? `br-${String(n)}`, `oid-${String(n)}`, repo);
58 return { issueId, number: num };
59 };
60
61 beforeAll(async () => {
62 dataDir = await mkdtemp(join(tmpdir(), "forge-waiting-"));
63 app = createApp({ dataDir, port: 0 });
64 servers = await serve(app);
65 alice = must(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }));
66 aliceId = alice.id;
67 bot = must(await createUser(app.db, { origin: "seed", trusted: true, username: "bot", email: "[email protected]", agent: true }));
68 botId = bot.id;
69 upstream = must(await createRepo(app, alice, { name: "app", visibility: "public" }));
70 repoId = upstream.id;
71 const res = await fetch(`${servers.url}/signin`, {
72 method: "POST",
73 redirect: "manual",
74 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
75 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
76 });
77 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
78 }, 60_000);
79
80 afterAll(async () => {
81 servers.stop();
82 app.close();
83 await rm(dataDir, { recursive: true, force: true });
84 });
85
86 test("a quiet lane draws no waiting card", async () => {
87 const html = await page();
88 expect(html).not.toContain('id="flow-waiting"');
89 expect(html).not.toContain("flow-approved");
90 expect(html).not.toContain("flow-stale");
91 expect(html).not.toContain("flow-unproposed");
92 });
93
94 test("an approval followed by changes requested keeps the lane quiet", async () => {
95 const p = pull(botId, "", ago(5));
96 app.db.query(`INSERT INTO review (id, issue_id, author_id, state, created_at) VALUES (?1, ?2, ?3, 'approved', ?4)`).run(id(), p.issueId, aliceId, ago(4));
97 app.db.query(`INSERT INTO review (id, issue_id, author_id, state, created_at) VALUES (?1, ?2, ?3, 'changes_requested', ?4)`).run(id(), p.issueId, aliceId, ago(3));
98 expect((await readWaiting(app.db, app.reposRoot, new Date())).approved).toEqual([]);
99 expect(await page()).not.toContain("flow-approved");
100 app.db.query("DELETE FROM review WHERE issue_id = ?1").run(p.issueId);
101 app.db.query("DELETE FROM pull WHERE issue_id = ?1").run(p.issueId);
102 app.db.query("DELETE FROM issue WHERE id = ?1").run(p.issueId);
103 });
104
105 test("approved pulls waiting are counted per repository with the oldest", async () => {
106 for (const h of [3, 30]) {
107 const p = pull(botId, "", ago(50));
108 app.db.query(`INSERT INTO review (id, issue_id, author_id, state, created_at) VALUES (?1, ?2, ?3, 'approved', ?4)`).run(id(), p.issueId, aliceId, ago(h));
109 }
110 const html = await page();
111 expect(html).toContain('id="flow-approved"');
112 expect(html).toContain("2 approved pulls waiting");
113 expect(html).toContain("alice/app");
114 expect(html).toContain("for 30 h");
115 });
116
117 test("a stale crew pull shows the note that folds it back, and a fresh one does not", async () => {
118 const stale = pull(botId, "Closes #7", ago(24 * 9));
119 pull(botId, "Closes #8", ago(2));
120 const html = await page();
121 expect(html).toContain('id="flow-stale"');
122 expect(html).toContain(`/alice/app/pulls/${String(stale.number)}`);
123 expect(html).toContain("Folded back: pull #" + String(stale.number) + " had no push for 9 days");
124 expect(html).toContain("/alice/app/issues/7");
125 expect(html).not.toContain("/alice/app/issues/8");
126 });
127
128 test("a crew fork branch that heads no pull is shown once with a link to the branch", async () => {
129 const up = repoDir(app.reposRoot, "alice", "app");
130 const empty = await git(up, ["hash-object", "-t", "tree", "-w", "--stdin"], ago(40), "");
131 const main = await git(up, ["commit-tree", empty, "-m", "root"], ago(40));
132 await git(up, ["update-ref", `refs/heads/${upstream.defaultBranch}`, main], ago(40));
133 must(await forkRepo(app, bot, upstream));
134 const dir = repoDir(app.reposRoot, "bot", "app");
135 const tree = await git(dir, ["rev-parse", `${main}^{tree}`], ago(7));
136 const oid = await git(dir, ["commit-tree", tree, "-p", main, "-m", "lonely"], ago(7));
137 await git(dir, ["update-ref", "refs/heads/lonely", oid], ago(7));
138 forgetUnproposed();
139 const html = await page();
140 expect(html).toContain('id="flow-unproposed"');
141 expect(html.split('href="/bot/app/tree/lonely"').length).toBe(2);
142 });
143
144 test("the unproposed scan is held for a minute, then read again", async () => {
145 forgetUnproposed();
146 const t = new Date();
147 expect((await readWaiting(app.db, app.reposRoot, t)).unproposed.fires).toBe(true);
148 const dir = repoDir(app.reposRoot, "bot", "app");
149 await git(dir, ["update-ref", "-d", "refs/heads/lonely"], ago(1));
150 expect((await readWaiting(app.db, app.reposRoot, new Date(t.getTime() + 5_000))).unproposed.fires).toBe(true);
151 expect((await readWaiting(app.db, app.reposRoot, new Date(t.getTime() + 61_000))).unproposed.fires).toBe(false);
152 });
153
154 test("a stale pull keeps its own repository when two repositories share a number and a branch", async () => {
155 const other = must(await createRepo(app, alice, { name: "second", visibility: "public" }));
156 pull(botId, "Closes #1", ago(24 * 20), repoId, 900, "same-branch");
157 pull(botId, "Closes #1", ago(24 * 10), other.id, 900, "same-branch");
158 const w = await readWaiting(app.db, app.reposRoot, new Date());
159 const mine = w.stale.filter((s) => s.number === 900);
160 expect(mine.map((s) => s.repo).sort()).toEqual(["alice/app", "alice/second"]);
161 expect(mine.find((s) => s.repo === "alice/app")?.days).toBe(20);
162 expect(mine.find((s) => s.repo === "alice/second")?.days).toBe(10);
163 });
164
165 test("a non-admin, a guest and a crew member get no waiting card on the page", async () => {
166 const bob = must(await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }));
167 expect(bob.isAdmin).toBe(false);
168 const res = await fetch(`${servers.url}/signin`, {
169 method: "POST",
170 redirect: "manual",
171 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
172 body: new URLSearchParams({ username: "bob", password: "a-good-passphrase" }),
173 });
174 const bobCookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
175 for (const headers of [{ cookie: bobCookie }, {}]) {
176 const r = await fetch(`${servers.url}/flow`, { headers, redirect: "manual" });
177 const text = await r.text();
178 expect(text).not.toContain("flow-waiting");
179 expect(text).not.toContain("Stale crew pulls");
180 expect(text).not.toContain("Approved, waiting");
181 }
182 });
183
184 test("the terminal lists the three sections for an admin only", async () => {
185 const w = await readWaiting(app.db, app.reposRoot, new Date());
186 const as = (user: User | undefined) => ({ user }) as unknown as Session;
187 const lines = waitingLines(as(alice), w);
188 expect(lines).toContain("Approved, waiting on the merge queue");
189 expect(lines).toContain("Stale crew pulls");
190 expect(waitingLines(as(bot), w)).toEqual([]);
191 expect(waitingLines(as(undefined), w)).toEqual([]);
192 const bob = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'bob'").get();
193 expect(bob).not.toBeNull();
194 expect(waitingLines({ user: { ...alice, isAdmin: false } } as unknown as Session, w)).toEqual([]);
195 });
test/palette-roster.test.ts +2 −1
@@ -111,10 +111,11 @@ test("a site-admin person is offered the whole roster", () => {
111111 ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"],
112112 ["flow", "Flow", "the pipeline's bottleneck and throughput"],
113113 ["system", "System", "the machine now: CPU, memory, disks, processes"],
114 ["pulls-to-land", "Pulls to land", "approved pulls, stale crew pulls, unproposed work"],
114115 ["valves", "Valves", "the week's budget as a valve per repository; turn one and the crew obeys"],
115 ["stalls", "Main-thread stalls", "the last hour's stall ms by route"],
116116 ["disks", "Disks", "machines' disk readings: 30-day write rate and projected rated-TBW date"],
117117 ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"],
118 ["stalls", "Main-thread stalls", "the last hour's stall ms by route"],
118119 ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"],
119120 ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"],
120121 ["maintenance", "Maintenance", "prepare the forge for a power-off, check the drain, or end maintenance"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs