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

The Flow panel lists the merge queue's last gates: the pulls in each, green or red, and the first failing test by name #7517

Merged opened this

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

Merged

scout/forge:scout/7508-the-flow-panel-lists-the-merge-queue-s-l → main 2 commits fork

  • Merged. scout/7508-the-flow-panel-lists-the-merge-queue-s-l was merged into main as 5d1e952.

Review

Sign in to review this change.
4 files changed +180 −0
README.md +1
@@ -422,6 +422,7 @@ This page is the front door. Everything else is a file of its own under
422422 - 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.
423423 - 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.
424424 - 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.
425 - The flow panel on `/boards` has a Last gates card: the newest ten merge-queue gates, each with its pulls linked, green, red or cut off, and the first failing test with its first error line (or the reason a gate was cut off). A gate carrying a sealed pull shows `a sealed pull` and the verdict alone; a non-admin reader sees public repositories only (`src/web/pages/boards-gates.ts`, the same `recentGates` read as MCP `queue_gates`).
425426 - 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`).
426427 - The off-box snapshot mirror is set from three doors with one set of rules (`src/actions/mirror.ts`): Settings → Backups, MCP `mirror_set` (`set` or `forget`), and the terminal's Resilience screen (a forge admin who is a person; an admin agent only reads). The screen shows `Mirror: <place>` or `Mirror: none.`, `M` seals it with five prompts (endpoint, bucket, region, access key, secret key; the keys are masked as typed) where a blank keeps what was sealed, and `F` forgets it after a confirm. A key is never read back or drawn.
427428 - [`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.
src/web/pages/boards-gates.ts added +88
@@ -0,0 +1,88 @@
1 /**
2 * The merge queue's last gates (#7508), for the flow panel: the newest ten
3 * across the repositories the reader may see, one row each with when, the
4 * pulls, the verdict, and on red the first failing test and its first error
5 * line. Reads `recentGates` once per repository; a sealed pull is never
6 * named by title, only as `a sealed pull`.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { recentGates, type KeptResult } from "../../checks/gate_result.ts";
11 import { span } from "../../flow/pipeline.ts";
12 import { html, type Html } from "../html.ts";
13 import { badge, card, row } from "../ui.ts";
14
15 /** The most gates the card lists. */
16 export const GATES_LISTED = 10;
17
18 interface Listed {
19 readonly repo: string;
20 readonly gate: KeptResult;
21 readonly sealed: ReadonlySet<number>;
22 }
23
24 /** Repositories with a kept gate that the reader may see: all for the owner, public ones otherwise. */
25 function repos(db: Database, owner: boolean): { id: string; name: string }[] {
26 return db
27 .query<{ id: string; name: string }, []>(
28 `SELECT r.id, u.username || '/' || r.name AS name FROM repo r JOIN user u ON u.id = r.owner_id
29 WHERE r.id IN (SELECT repo_id FROM gate_result)${owner ? "" : " AND r.visibility = 'public'"}
30 ORDER BY name`,
31 )
32 .all();
33 }
34
35 function sealedPulls(db: Database, repoId: string, numbers: readonly number[]): Set<number> {
36 const out = new Set<number>();
37 for (const n of numbers) {
38 const row = db.query<{ s: number }, [string, number]>("SELECT sealed AS s FROM issue WHERE repo_id = ?1 AND number = ?2").get(repoId, n);
39 if (row?.s) out.add(n);
40 }
41 return out;
42 }
43
44 const verdictWord = (g: KeptResult): string => (g.state === "cut_off" ? "cut off" : g.state);
45
46 /** What the verdict adds: a red gate's first failing test and error line, a cut-off gate's reason; nothing when a pull in it is sealed. */
47 function detail(l: Listed): string | null {
48 if (l.sealed.size > 0) return null;
49 const { gate } = l;
50 if (gate.state === "red") {
51 const first = gate.failing[0];
52 if (first === undefined) return gate.reason;
53 return first.errors[0] === undefined ? first.name : `${first.name} — ${first.errors[0]}`;
54 }
55 return gate.state === "cut_off" ? gate.reason : null;
56 }
57
58 function gateRow(l: Listed, now: number): Html {
59 const { gate, repo } = l;
60 const more = detail(l);
61 const tone = gate.state === "green" ? "success" : gate.state === "red" ? "danger" : "default";
62 return row({
63 data: { gate: gate.state },
64 title: html`${gate.pulls.map((n) =>
65 l.sealed.has(n) ? html`<span class="muted">a sealed pull</span> ` : html`<a href="/${repo}/pulls/${String(n)}">#${String(n)}</a> `,
66 )}${badge(verdictWord(gate), { tone })}`,
67 meta: html`${span(Math.max(0, now - Date.parse(gate.finishedAt)))} ago · ${repo}${more === null ? null : html` · <span data-detail title="${more}">${more}</span>`}`,
68 });
69 }
70
71 /** The "Last gates" card. */
72 export function gatesCard(db: Database, owner: boolean, now: number): Html {
73 const all: Listed[] = [];
74 for (const r of repos(db, owner)) {
75 for (const gate of recentGates(db, r.id, GATES_LISTED)) {
76 all.push({ repo: r.name, gate, sealed: sealedPulls(db, r.id, gate.pulls) });
77 }
78 }
79 all.sort((a, b) => (a.gate.finishedAt < b.gate.finishedAt ? 1 : a.gate.finishedAt > b.gate.finishedAt ? -1 : 0));
80 const top = all.slice(0, GATES_LISTED);
81 return card(
82 html`<h2>Last gates</h2>
83 ${top.length === 0
84 ? html`<p class="muted">No gate has run yet.</p>`
85 : html`<ul class="list">${top.map((l) => gateRow(l, now))}</ul>`}`,
86 { id: "gates" },
87 );
88 }
src/web/pages/flow-panel.ts +2
@@ -45,6 +45,7 @@ import { openerCounts } from "../../flow/openers.ts";
4545 import { readHeld } from "../../flow/held.ts";
4646 import type { App } from "../../app.ts";
4747 import { html, type Html } from "../html.ts";
48 import { gatesCard } from "./boards-gates.ts";
4849 import { heldCard } from "./boards-held.ts";
4950 import { badge, card, meter } from "../ui.ts";
5051
@@ -213,6 +214,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
213214 ${hotFilesCard(app, reader)}
214215 ${heldCard(readHeld(app, reader.kind === "owner", now))}
215216 ${queueCard(queuesFor(app, reader, new Date(now), { batches: true }))}
217 ${gatesCard(app.db, reader.kind === "owner", now)}
216218 ${url.searchParams.get("idle") === "all" && idle.length > 0 ? idleList(idle) : null}
217219 ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`;
218220 }
test/flow-panel-gates.test.ts added +89
@@ -0,0 +1,89 @@
1 /** The flow panel's "Last gates" card (#7508): both verdicts, pull links, the failing test, sealed and private pulls. */
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, type GateResult } from "../src/checks/gate_result.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { flowPanel } from "../src/web/pages/flow-panel.ts";
10
11 const ago = (min: number) => new Date(Date.now() - min * 60_000).toISOString();
12 let app: App;
13 beforeAll(async () => {
14 app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "panel-gates-")), port: 0 });
15 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
16 if (!u.ok) throw new Error(u.error);
17 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r1', ?1, 'svc', 'public')", [u.value.id]);
18 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r2', ?1, 'hidden', 'private')", [u.value.id]);
19 app.db.run(
20 "INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, sealed) VALUES ('i1', 'r1', 14, 'pull', 'SECRET TITLE', '', ?1, 1)",
21 [u.value.id],
22 );
23 });
24 afterAll(() => app.close?.());
25
26 const gate = (over: Partial<GateResult>): GateResult => ({
27 repoId: "r1", runId: "gate-abc", tipOid: "abc", pulls: [1], state: "green", passed: 10, failed: 0, failing: [],
28 startedAt: ago(12), finishedAt: ago(10), ...over,
29 });
30 const draw = (kind: "owner" | "agent") => String(flowPanel(app, { kind }, new URL("http://x/boards"), "/boards"));
31
32 test("a green and a red gate: rows, pull links, the failing test and its first error, escaped", () => {
33 keepGateResult(app.db, "g1", gate({ pulls: [12, 13] }));
34 keepGateResult(
35 app.db, "g2",
36 gate({ pulls: [15], state: "red", failed: 1, failing: [{ name: "a <b> works", errors: ["boom <script>", "second"] }], finishedAt: ago(5) }),
37 );
38 const out = draw("agent");
39 expect(out).toContain('id="gates"');
40 expect(out).toContain('href="/alice/svc/pulls/12"');
41 expect(out).toContain('href="/alice/svc/pulls/13"');
42 expect(out).toContain('href="/alice/svc/pulls/15"');
43 expect(out).toContain('data-gate="green"');
44 expect(out).toContain('data-gate="red"');
45 expect(out).toContain("a &lt;b&gt; works");
46 expect(out).toContain("boom &lt;script&gt;");
47 expect(out).not.toContain("second");
48 });
49
50 test("a sealed pull is never named or linked; a private repository's gates are the owner's alone", () => {
51 keepGateResult(app.db, "g3", gate({ pulls: [14], finishedAt: ago(3) }));
52 keepGateResult(app.db, "g4", gate({ repoId: "r2", pulls: [77], finishedAt: ago(1) }));
53 const agent = draw("agent");
54 expect(agent).toContain("a sealed pull");
55 expect(agent).not.toContain("pulls/14");
56 expect(agent).not.toContain("SECRET TITLE");
57 expect(agent).not.toContain("pulls/77");
58 expect(draw("owner")).toContain('href="/alice/hidden/pulls/77"');
59 });
60
61 test("a red or cut-off gate with a sealed pull shows the verdict alone", () => {
62 keepGateResult(app.db, "g5", gate({ pulls: [14, 16], state: "red", failed: 1, failing: [{ name: "leaky test name", errors: ["leaky error"] }], finishedAt: ago(2) }));
63 keepGateResult(app.db, "g6", gate({ pulls: [14], state: "cut_off", reason: "leaky reason", finishedAt: ago(2) }));
64 const out = draw("owner");
65 expect(out).not.toContain("leaky");
66 expect(out).toContain('data-gate="red"');
67 });
68
69 test("a cut-off gate shows its reason, escaped", () => {
70 keepGateResult(app.db, "g7", gate({ pulls: [40], state: "cut_off", reason: "slot <gone>", finishedAt: ago(1) }));
71 const out = draw("owner");
72 expect(out).toContain("cut off");
73 expect(out).toContain("slot &lt;gone&gt;");
74 });
75
76 test("the card follows the reader: a non-owner reader never gets a private repository gate", () => {
77 keepGateResult(app.db, "g8", gate({ repoId: "r2", pulls: [88], state: "red", failed: 1, failing: [{ name: "private-only test", errors: [] }], finishedAt: ago(0.5) }));
78 expect(draw("owner")).toContain("private-only test");
79 const other = draw("agent");
80 expect(other).not.toContain("private-only test");
81 expect(other).not.toContain("pulls/88");
82 });
83
84 test("only the newest ten are listed", () => {
85 for (let i = 0; i < 12; i++) keepGateResult(app.db, `h${i}`, gate({ pulls: [100 + i], finishedAt: ago(-i - 1) }));
86 const out = draw("owner");
87 expect(out.match(/data-gate=/g)?.length).toBe(10);
88 expect(out).not.toContain("No gate has run yet");
89 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs