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

The terminal's Flow dialog lists each repository's last merge-queue gates with their verdict and first failing test, as MCP `queue_gates` does #7327

Merged opened this

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

Merged

mason/forge:mason/7320-the-terminal-s-flow-dialog-lists-each-re → main 3 commits fork 4 threads · 4 unresolved

  • Merged. mason/7320-the-terminal-s-flow-dialog-lists-each-re was merged into main as 00b095b.

Review

Sign in to review this change.
3 files changed +153 −4
README.md +1
@@ -375,6 +375,7 @@ This page is the front door. Everything else is a file of its own under
375375 - Under it, the panel says the crew's build yield over 24 h (runs finished, pushed, landed; `src/flow/build_yield.ts`) and the median hours to first review (`src/flow/review_latency.ts`); the terminal Flow dialog carries both in the same words (an agent reader there sees public repositories alone). `/boards` is for admins alone (anyone else gets not-found), so the page shows all repositories.
376376 - The flow panel on `/boards` also says the time to land (median and slowest of the last 24 h, `src/flow/lead_time.ts`) and who opened the last hour's pulls (names linked, `src/flow/openers.ts`); non-admins read public repositories alone. The terminal Flow dialog carries the same two facts in its own words.
377377 - The flow panel's Hot files card and the terminal Flow dialog also name files that gained open pulls over the last hour ("warming: src/a.ts 2 → 4 pulls", at most 5; `src/flow/hot_trend.ts`). The hour-old counts are samples kept in memory by `src/crew/hot_history.ts`, so after a restart nothing is named until a half-hour-old sample exists; an agent sees public repositories only. The MCP `flow` tool does not carry it yet.
378 - Under each queued repository's merge-queue line, the terminal Flow dialog lists its last five gates, newest first: the pulls carried, `green`, `red` or `cut off`, how long ago, and for a red gate the first failing test with its file, `flaky` when two gates sharing no pull failed it (`src/checks/gate_result.ts`, as MCP `queue_gates` reads them); a repository with none kept says `no gates yet`, and an agent reader sees public repositories only.
378379 - The flow panel on `/boards` and the terminal Flow dialog say how the open crew pulls sort into piles (red, conflicted, changes asked, approved and waiting, awaiting review), each pull counted once in the first pile it fits (`src/flow/pile.ts`).
379380 - [`docs/PLANNING.md`](docs/PLANNING.md) — how issues depend on issues: the edge, the map, and what "ready now" means to a person and to the crew.
380381 - [`docs/FLEET.md`](docs/FLEET.md) — one change across many repositories, runs or issues: a query, a dry-run plan of every row before → after, a receipt, and undo.
src/tui/screens/flow.ts +50 −4
@@ -5,7 +5,10 @@
55 * `flow/pipeline.ts` and `flow/throughput.ts`, the same the page and the
66 * MCP `flow` tool read. Under them, each repository's merge queue (#2036):
77 * queued, gating, gates in flight, landed in the last hour and the median
8 * minutes from approval to landing (`flow/queue.ts`); then the
8 * minutes from approval to landing (`flow/queue.ts`), and under each its
9 * last five gates (#7320, `checks/gate_result.ts`, as MCP `queue_gates`
10 * reads them): the pulls carried, the verdict, how long ago, and for a red
11 * one the first failing test by name and file, `flaky` when it is; then the
912 * conflicted pulls, the idle crew pulls (#6479) and the pulls with
1013 * changes requested waiting on a builder and on a reviewer (`flow/requested.ts`, #2172); then each
1114 * autonomous repository's ready list, startable beside the count per
@@ -14,9 +17,11 @@
1417 */
1518
1619 import type { Database } from "bun:sqlite";
20 import { recentGates, type KeptResult } from "../../checks/gate_result.ts";
1721 import { HOLDS } from "../../crew/held.ts";
1822 import { pullCounts } from "../../crew/hot_files.ts";
outdated

Keep imports in path order: feed/rank.ts goes after crew/ imports.

1923 import { warmingLine } from "../../crew/hot_history.ts";
24 import { ago } from "../../feed/rank.ts";
2025 import type { HeldRow } from "../../flow/held.ts";
2126 import { conflictedSays, rebaseLaneSays } from "../../flow/conflicted.ts";
2227 import { IDLE_LIMIT, type IdlePull } from "../../flow/idle_pulls.ts";
@@ -73,6 +78,7 @@ export function flowLines(
7378 ].flatMap((says) => (says === null ? [] : ["", ...wrap(says, inner)]));
7479 const warming = warmingLine(db, now.getTime(), pullCounts, agent !== undefined);
7580 const reviewing = reviewingSays(fig.reviewing);
81 const ids = repoIds(db, queues.map((q) => q.repo));
7682 return [
7783 ...wrap(verdict(stages, now.getTime()), inner),
7884 "",
@@ -99,10 +105,13 @@ export function flowLines(
99105 `${pad("Merge queue", 15)} ${pad("queued", 6, "right")} ${pad("gating", 6, "right")} ${pad("gates", 5, "right")} ${pad("landed 1h", 9, "right")} ${pad("to land, min", 12, "right")}`,
100106 ...(queues.length === 0
101107 ? ["Nothing queued, gating or landed in the last hour."]
102 : queues.map(
103 (q) =>
108 : queues.flatMap((q) => {
109 const id = ids.get(q.repo);
110 return [
104111 `${pad(truncate(q.repo, 15), 15)} ${pad(String(q.queued), 6, "right")} ${pad(String(q.gating), 6, "right")} ${pad(String(q.gates), 5, "right")} ${pad(String(q.landedHour), 9, "right")} ${pad(q.approvedToLanded === null ? "—" : String(q.approvedToLanded), 12, "right")}`,
105 )),
112 ...gateLines(id === undefined ? [] : recentGates(db, id, GATES_SHOWN), now.getTime(), inner),

This is right: gates are read only for the queue rows this reader was given, so an agent sees public repositories only. Please pin it with a test: put a private repository's gate in the db, pass agent queues holding only the public row, and expect no line that names the private repository or its pulls.

113 ];
114 })),
106115 ...(red.length === 0
107116 ? []
108117 : wrap(`Red batches, last hour: ${red.map((q) => `${q.repo} ${redBatchWords(q)}`).join(", ")}.`, inner)),
@@ -118,6 +127,43 @@ export function flowLines(
118127 ];
119128 }
120129
130 /** Gates shown under each repository's queue line. */
131 const GATES_SHOWN = 5;
132
133 /** Each `owner/name` the queue lists, to its repository id: one query. */
134 function repoIds(db: Database, names: readonly string[]): Map<string, string> {
135 if (names.length === 0) return new Map();
136 const rows = db
137 .query<{ id: string; full: string }, string[]>(
138 `SELECT r.id, u.username || '/' || r.name AS full
139 FROM repo r JOIN user u ON u.id = r.owner_id
140 WHERE u.username || '/' || r.name IN (${names.map(() => "?").join(",")})`,
141 )
142 .all(...names);
143 return new Map(rows.map((r) => [r.full, r.id]));
144 }
145
146 const STATE_WORD: Record<KeptResult["state"], string> = { green: "green", red: "red", cut_off: "cut off" };

Wrap this doc comment; the last line runs far past the file's width.

147
148 /**
149 * One indented line per kept gate, newest first: its pulls, its verdict,
150 * how long ago it finished, and for a red one the first failing test with
151 * its file, `flaky` when two gates sharing no pull failed it; `no gates
152 * yet` when none is kept.
153 */
154 export function gateLines(gates: readonly KeptResult[], now: number, inner: number): string[] {
155 if (gates.length === 0) return [" no gates yet"];
156 return gates.map((g) => {
157 const pulls = g.pulls.length === 0 ? "no pulls" : g.pulls.map((n) => `#${n}`).join(" ");
158 const first = g.state === "red" ? g.failing[0] : undefined;
159 const test =
160 first === undefined
161 ? ""
162 : ` · ${first.name}${first.file === undefined ? "" : ` (${first.file})`}${first.flaky ? " flaky" : ""}`;
163 return truncate(` ${pulls} ${STATE_WORD[g.state]} · ${ago(Date.parse(g.finishedAt), now)}${test}`, inner);
164 });
165 }
166
121167 /** Idle pulls shown before `+N more`. */
122168 const IDLE_SHOWN = 5;
123169
test/tui-flow-gates.test.ts added +102
@@ -0,0 +1,102 @@
1 /** The Flow dialog lists each queued repository's last gates (#7320): verdict, how long ago, a red one's first failing test, `flaky` when it is. */
2 import { afterAll, beforeAll, 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, type App } from "../src/app.ts";
7 import { keepGateResult, recentGates, type GateResult } from "../src/checks/gate_result.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { flowLines, gateLines } from "../src/tui/screens/flow.ts";
10
11 const NOW = new Date("2026-10-27T12:00:00Z");
12 const at = (min: number) => new Date(NOW.getTime() - min * 60_000).toISOString();
13
14 let app: App;
15 beforeAll(async () => {
16 app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "tui-flow-gates-")), port: 0 });
17 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
18 if (!u.ok) throw new Error(u.error);
19 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r1', ?1, 'svc', 'public')", [u.value.id]);
20 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r2', ?1, 'quiet', 'public')", [u.value.id]);
21 });
22 afterAll(() => app.close?.());
23
24 const gate = (over: Partial<GateResult>): GateResult => ({
25 repoId: "r1",
26 runId: "gate-abc",
27 tipOid: "abc",
28 pulls: [1],
29 state: "green",
30 passed: 10,
31 failed: 0,
32 failing: [],
33 startedAt: at(12),
34 finishedAt: at(10),
35 ...over,
36 });
37
38 test("one green and one red gate: both lines, the red one names its failing test and file", () => {
39 keepGateResult(app.db, "g1", gate({ pulls: [12, 13] }), NOW);
40 keepGateResult(
41 app.db,
42 "g2",
43 gate({ pulls: [14], state: "red", reason: "tests", passed: 9, failed: 1, failing: [{ name: "a > b", file: "test/a.test.ts", errors: ["boom"] }], finishedAt: at(5) }),
44 NOW,
45 );
46 const queues = [
47 { repo: "alice/svc", queued: 1, gating: 0, gates: 0, landedHour: 0, approvedToLanded: null },
48 { repo: "alice/quiet", queued: 0, gating: 0, gates: 0, landedHour: 1, approvedToLanded: null },
49 ];
50 const lines = flowLines(app.db, NOW, [], { queues }, []);
51 const svc = lines.findIndex((l) => l.startsWith("alice/svc"));
52 expect(lines[svc + 1]).toBe(" #14 red · 5 minutes ago · a > b (test/a.test.ts)");
53 expect(lines[svc + 2]).toBe(" #12 #13 green · 10 minutes ago");
54 expect(lines[svc + 3]!.startsWith("alice/quiet")).toBe(true);
55 expect(lines[svc + 4]).toBe(" no gates yet");
56 });
57
58 test("a test red in two gates that share no pull is marked flaky; cut off reads as two words", () => {
59 keepGateResult(
60 app.db,
61 "g3",
62 gate({ pulls: [20], state: "red", failed: 1, failing: [{ name: "a > b", file: "test/a.test.ts", errors: [] }], finishedAt: at(2) }),
63 NOW,
64 );
65 keepGateResult(app.db, "g4", gate({ pulls: [21], state: "cut_off", finishedAt: at(1) }), NOW);
66 const lines = gateLines(recentGates(app.db, "r1", 5), NOW.getTime(), 58);
67 expect(lines).toEqual([
68 " #21 cut off · just now",
69 " #20 red · 2 minutes ago · a > b (test/a.test.ts) flaky",
70 " #14 red · 5 minutes ago · a > b (test/a.test.ts) flaky",
71 " #12 #13 green · 10 minutes ago",
72 ]);
73 });
74
75 const svcRow = { repo: "alice/svc", queued: 1, gating: 0, gates: 0, landedHour: 0, approvedToLanded: null };
outdated

This calls recentGates(..., 5) directly, so it tests gate_result.ts, not the dialog. Change GATES_SHOWN to 20 and this still passes. Instead, go through flowLines with six or more gates kept, and count the indented lines under the alice/svc row.

76 const quietRow = { repo: "alice/quiet", queued: 0, gating: 0, gates: 0, landedHour: 1, approvedToLanded: null };
77
78 test("the dialog shows at most five gates a repository", () => {
79 for (let i = 0; i < 4; i++) keepGateResult(app.db, `g9${i}`, gate({ pulls: [30 + i] }), NOW);
80 expect(recentGates(app.db, "r1", 100).length).toBeGreaterThan(5);
81 const lines = flowLines(app.db, NOW, [], { queues: [svcRow, quietRow] }, []);
82 const svc = lines.findIndex((l) => l.startsWith("alice/svc"));
83 const quiet = lines.findIndex((l) => l.startsWith("alice/quiet"));
84 expect(quiet - svc - 1).toBe(5);
85 expect(lines.slice(svc + 1, quiet).every((l) => l.startsWith(" #"))).toBe(true);
86 });
87
88 test("an agent's dialog lists gates only for the queue rows it was handed: a private repository's never show", () => {
89 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r3', (SELECT owner_id FROM repo WHERE id = 'r1'), 'secret', 'private')");
90 keepGateResult(app.db, "g99", gate({ repoId: "r3", pulls: [777], state: "red", failed: 1, failing: [{ name: "hidden > test", file: "test/hidden.test.ts", errors: [] }] }), NOW);
91 const text = flowLines(app.db, NOW, [], { queues: [svcRow] }, []).join("\n");
92 expect(text).toContain("alice/svc");
93 expect(text).not.toContain("secret");
94 expect(text).not.toContain("#777");
95 expect(text).not.toContain("hidden");
96 });
97
98 test("nothing queued: the dialog says so and lists no gates", () => {
99 const lines = flowLines(app.db, NOW, [], { queues: [] }, []);
100 expect(lines).toContain("Nothing queued, gating or landed in the last hour.");
101 expect(lines).not.toContain(" no gates yet");
102 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs