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

The flow panel's last merge gates speak the home page's language #9965

Merged opened this

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

Merged

scout/forge:scout/9954-the-flow-panel-s-last-merge-gates-speak → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/9954-the-flow-panel-s-last-merge-gates-speak was merged into main as 8f1ceda.

Review

Sign in to review this change.
3 files changed +94 −12
src/web/pages/boards-gates.ts +8 −10
@@ -10,7 +10,7 @@ import type { Database } from "bun:sqlite";
1010 import { recentGates, type KeptResult } from "../../checks/gate_result.ts";
1111 import { span } from "../../flow/pipeline.ts";
1212 import { html, type Html } from "../html.ts";
13 import { badge, card, row } from "../ui.ts";
13 import { badge, row } from "../ui.ts";
1414
1515 /** The most gates the card lists. */
1616 export const GATES_LISTED = 10;
@@ -68,8 +68,8 @@ function gateRow(l: Listed, now: number): Html {
6868 });
6969 }
7070
71 /** The "Last gates" card. */
72 export function gatesCard(db: Database, owner: boolean, now: number): Html {
71 /** The "Last gates" section: a heading and rows, no card. */
72 export function gatesSection(db: Database, owner: boolean, now: number): Html {
outdated

Principle 7 / clean: the function says card but returns a section. Rename it (e.g. gatesSection) and update flow-panel.ts.

7373 const all: Listed[] = [];
7474 for (const r of repos(db, owner)) {
7575 for (const gate of recentGates(db, r.id, GATES_LISTED)) {
@@ -78,11 +78,9 @@ export function gatesCard(db: Database, owner: boolean, now: number): Html {
7878 }
7979 all.sort((a, b) => (a.gate.finishedAt < b.gate.finishedAt ? 1 : a.gate.finishedAt > b.gate.finishedAt ? -1 : 0));
8080 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 );
81 return html`<section class="settings-block" id="gates"><h2>Last gates</h2>${
82 top.length === 0
83 ? html`<p class="muted">No gate has run yet.</p>`
84 : html`<ul class="list">${top.map((l) => gateRow(l, now))}</ul>`
85 }</section>`;
8886 }
src/web/pages/flow-panel.ts +2 −2
@@ -48,7 +48,7 @@ import { readVerified, verifiedSays, type VerifiedRow } from "../../flow/verifie
4848 import type { App } from "../../app.ts";
4949 import { html, type Html } from "../html.ts";
5050 import { keeperRow } from "../studio/keeper_row.ts";
51 import { gatesCard } from "./boards-gates.ts";
51 import { gatesSection } from "./boards-gates.ts";
5252 import { heldCard } from "./boards-held.ts";
5353 import { badge, card, meter } from "../ui.ts";
5454
@@ -219,7 +219,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
219219 ${hotFilesCard(app, reader)}
220220 ${heldCard(readHeld(app, reader.kind === "owner", now))}
221221 ${queueCard(queuesFor(app, reader, new Date(now), { batches: true }))}
222 ${gatesCard(app.db, reader.kind === "owner", now)}
222 ${gatesSection(app.db, reader.kind === "owner", now)}
223223 ${url.searchParams.get("idle") === "all" && idle.length > 0 ? idleList(idle) : null}
224224 ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`;
225225 }
test/boards-gates-language.test.ts added +84
@@ -0,0 +1,84 @@
1 /** /boards' last merge gates speak the home page's language (#9954): a heading and rows, no card. */
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 } from "../src/app.ts";
7 import { keepGateResult, type GateResult } from "../src/checks/gate_result.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve } from "../src/http/server.ts";
11
12 let dir: string;
13 let app: ReturnType<typeof createApp>;
14 let servers: Awaited<ReturnType<typeof serve>>;
15 let token: string;
16 const ago = (min: number) => new Date(Date.now() - min * 60_000).toISOString();
17 const gate = (over: Partial<GateResult>): GateResult => ({
18 repoId: "r1", runId: "gate-abc", tipOid: "abc", pulls: [1], state: "green", passed: 10, failed: 0, failing: [],
19 startedAt: ago(12), finishedAt: ago(10), ...over,
20 });
21
22 beforeAll(async () => {
23 dir = await mkdtemp(join(tmpdir(), "forge-gates-lang-"));
24 app = createApp({ dataDir: dir, port: 0 });
25 servers = await serve(app);
26 const r = await createUser(app.db, {
27 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
28 password: "a-good-passphrase", adminIfFirst: true,
29 });
30 if (!r.ok) throw new Error(r.error);
31 token = createToken(app.db, r.value.id, "t", "admin").token;
32 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r1', ?1, 'svc', 'public')", [r.value.id]);
33 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r2', ?1, 'hidden', 'private')", [r.value.id]);
34 keepGateResult(app.db, "g1", gate({ pulls: [12] }));
35 keepGateResult(
36 app.db, "g2",
37 gate({ pulls: [15], state: "red", failed: 1, failing: [{ name: "it works", errors: ["boom", "second"] }], finishedAt: ago(5) }),
38 );
39 });
40 afterAll(async () => {
41 servers.stop();
42 app.close();
43 await rm(dir, { recursive: true, force: true });
44 });
45
46 const section = async (as: string | null = token): Promise<string> => {
47 const html = await (await fetch(`${servers.url}/boards`, { headers: as === null ? {} : { authorization: `Bearer ${as}` } })).text();
48 const at = html.indexOf('id="gates"');
49 expect(at).toBeGreaterThan(-1);
50 return html.slice(html.lastIndexOf("<section", at), html.indexOf("</section>", at));
51 };
52
53 test("the last gates are a section under an h2, rows in a .list, no card", async () => {
54 const s = await section();
55 expect(s).toMatch(/^<section[^>]*id="gates"/);
56 expect(s).not.toContain('class="card');
57 expect(s).toContain("<h2>Last gates</h2>");
58 expect(s).toContain('<ul class="list">');
59 expect(s).toContain('href="/alice/svc/pulls/12"');
60 });
61
62 test("a red row names the first failing test and its first error only", async () => {
63 const s = await section();
64 expect(s).toContain("it works — boom");
65 expect(s).not.toContain("second");
66 });
67
68 test("a private repository's gate is listed for the owner (the public one too)", async () => {
69 keepGateResult(app.db, "g4", gate({ repoId: "r2", pulls: [77], finishedAt: ago(1) }));

Only the owner is tested. Add a second user (or unauthenticated fetch) and assert href="/alice/hidden/pulls/77" is absent while /alice/svc/pulls/12 is present, so "only repositories the reader may see" is held on /boards. This test passes on main too, so it proves nothing about the change.

70 expect(await section()).toContain('href="/alice/hidden/pulls/77"');
71 });
72
73 test("/boards shows gates to the forge's owner alone: a plain account or a guest never gets the private gate", async () => {
74 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bobby", email: "[email protected]", password: "a-good-passphrase" });
75 if (!b.ok) throw new Error(b.error);
76 const other = createToken(app.db, b.value.id, "t", "ro").token;
77 for (const who of [other, null]) {
78 const r = await fetch(`${servers.url}/boards`, { redirect: "manual", headers: who === null ? {} : { authorization: `Bearer ${who}` } });
79 const body = await r.text();
80 expect(body).not.toContain("pulls/77");
81 expect(body).not.toContain("alice/hidden");
82 expect(body).not.toContain('id="gates"');
83 }
84 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs