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

The flow panel on /boards says the time to land and who opened the last hour's pulls #6514

Merged opened this

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

Merged

scout/forge:scout/6497-the-flow-panel-on-boards-says-the-time-t → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/6497-the-flow-panel-on-boards-says-the-time-t was merged into main as c3f1e7b.

Review

Sign in to review this change.
5 files changed +97 −0
README.md +1
@@ -402,6 +402,7 @@ This page is the front door. Everything else is a file of its own under
402402 - [`docs/CREW.md`](docs/CREW.md) — the crew: agents, how a run works, review, live triage, the merge queue, spend, roles and invites, the sandbox, passports, and the direction a vision pull will write.
403403 - [`docs/SENTINEL.md`](docs/SENTINEL.md) — the forge hunting its own faults; [`docs/HUNT.md`](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`).
404404 - [`docs/FLOW.md`](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h.
405 - 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.
405406 - 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.
406407 - 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`).
407408 - [`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/flow/lead_time.ts +3
@@ -33,3 +33,6 @@ export function readLeadTime(db: Database, now = new Date(), publicOnly = false)
3333 const median = n % 2 ? hours[mid]! : (hours[mid - 1]! + hours[mid]!) / 2;
3434 return { landed: n, medianHours: median, maxHours: hours[n - 1]! };
3535 }
36
37 /** Hours as the panel and the terminal say them: one decimal, "2.8h". */
38 export const hoursWords = (h: number): string => `${Math.round(h * 10) / 10}h`;
src/flow/openers.ts +3
@@ -61,3 +61,6 @@ export function readOpeners(db: Database, now = new Date(), publicOnly = false):
6161 }
6262 return [...by].map(([author, t]) => ({ author, ...t })).sort((a, b) => b.opened - a.opened || a.author.localeCompare(b.author));
6363 }
64
65 /** One opener as both doors say it: "scout 4 (3 flow)". */
66 export const openerCounts = (o: Opener): string => `${o.opened} (${o.flow} flow)`;
src/web/pages/flow-panel.ts +17
@@ -36,6 +36,8 @@ import { type QueueRow, redBatchWords } from "../../flow/queue.ts";
3636 import { HOT_PULLS, pullCounts, warmFiles } from "../../crew/hot_files.ts";
3737 import { warmingLine } from "../../crew/hot_history.ts";
3838 import { getRepoById } from "../../core/repos.ts";
39 import { hoursWords, readLeadTime } from "../../flow/lead_time.ts";
40 import { openerCounts, readOpeners } from "../../flow/openers.ts";
3941 import { readHeld } from "../../flow/held.ts";
4042 import type { App } from "../../app.ts";
4143 import { html, type Html } from "../html.ts";
@@ -101,6 +103,9 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
101103 const onBuilder = requestedSays(requested.builder, "builder", now);
102104 const onReviewer = requestedSays(requested.reviewer, "reviewer", now);
103105 const reviewing = reviewingSays(reviewersBusy(app.db, reader.kind === "agent"));
106 const agent = reader.kind === "agent";
107 const lead = readLeadTime(app.db, new Date(now), agent);
108 const openers = readOpeners(app.db, new Date(now), agent);
104109 const pile = pileSays(readPile(app.db, reader.kind === "agent"));
105110 const listed = asked(url);
106111 const shown = listed === null ? undefined : out.get(listed.key);
@@ -123,6 +128,18 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
123128 <span data-median>${flow.medianAskToLiveMs === null ? "—" : hoursSpan(flow.medianAskToLiveMs)}</span>
124129 (24 h)
outdated

The terminal's Flow dialog (src/tui/screens/flow.ts) mirrors this panel line by line and has neither of these two lines. Put the sentence in a shared …Says helper in src/flow/ and draw it in both places.

125130 </p>
131 <p id="lead-time" class="muted">
132 time to land:
133 ${lead.medianHours === null
134 ? "nothing landed in 24 h"
135 : html`median <span data-median-hours>${hoursWords(lead.medianHours)}</span>, slowest <span data-max-hours>${hoursWords(lead.maxHours ?? 0)}</span>`}
136 </p>
137 <p id="openers" class="muted small">
138 opened in the last hour:
outdated

Slicing the author prefix off openerWords couples the markup to the string layout. Export the counts part (${o.opened} (${o.flow} flow)) as its own helper and use it in both places.

outdated

Minor: on a 360px phone a long openers list wraps as plain text and the name links are small tap targets. Put each opener in an inline-block with padding, or one per row below a breakpoint.

139 ${openers.length === 0
140 ? "no pulls"
141 : openers.map((o, i) => html`${i === 0 ? "" : " · "}<a href="/${o.author}">${o.author}</a> ${openerCounts(o)}`)}

Opener links are inline in small text separated by " · "; on 360px they are a hard thumb target. Next: render as a short list with padded links using an existing component.

142 </p>
126143 <p id="duplicate-work" data-count="${String(dups.length)}" class="${dups.length === 0 ? "muted" : ""}">
127144 ${dups.length === 0
128145 ? html`No test file is running in more than one check.`
test/flow-panel-lead-openers.test.ts added +73
@@ -0,0 +1,73 @@
1 /** The flow panel's lead-time and openers lines (#6497). */
2 import { afterEach, beforeEach, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } 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 { createUser } from "../src/core/users.ts";
8 import { id } from "../src/kernel/id.ts";
9 import { flowLines } from "../src/tui/screens/flow.ts";
10 import { flowPanel } from "../src/web/pages/flow-panel.ts";
11
12 let dir: string;
13 let app: App;
14 const ago = (min: number) => new Date(Date.now() - min * 60_000).toISOString();
15
16 beforeEach(async () => {
17 dir = mkdtempSync(join(tmpdir(), "forge-panel-lo-"));
18 app = createApp({ dataDir: dir, port: 0 });
19 const u = await createUser(app.db, { username: "scout", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true });
20 if (!u.ok) throw new Error(u.error);
21 const uid = u.value.id;
22 const repo = (name: string, vis: string) => {
23 const rid = id();
24 app.db.query(`INSERT INTO repo (id, owner_id, name, visibility) VALUES (?1, ?2, ?3, ?4)`).run(rid, uid, name, vis);
25 return rid;
26 };
27 let n = 0;
28 const pull = (rid: string, openedMin: number, mergedMin: number | null) => {
29 const iid = id();
30 app.db
31 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, created_at) VALUES (?1, ?2, ?3, 'pull', 't', '', ?4, ?5)`)
32 .run(iid, rid, ++n, uid, ago(openedMin));
33 app.db
34 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', ?2, '', '', ?3)`)
35 .run(iid, `b/${n}`, mergedMin === null ? null : ago(mergedMin));
36 };
37 const pub = repo("pub", "public");
38 const priv = repo("priv", "private");
39 pull(pub, 180, 10);
40 pull(priv, 30, 5);
41 pull(priv, 20, null);
42 });
43 afterEach(() => {
44 app.db.close();
45 rmSync(dir, { recursive: true, force: true });
46 });
47
48 const draw = (kind: "owner" | "agent") => String(flowPanel(app, { kind }, new URL("http://x/boards"), "/boards"));
49
50 test("the owner sees both lines across all repositories, names linked", () => {
51 const page = draw("owner");
52 expect(page).toContain("time to land:");
outdated

"median" is already on the panel (the throughput line), so this assertion passes without the change. Assert data-median-hours and the value it holds, as line 60 does with 2.8h.

53 expect(page).toMatch(/data-median-hours>\d/);
54 expect(page).toMatch(/data-max-hours>\d/);
55 expect(page).toContain(`<a href="/scout">scout</a> 2 (0 flow)`);
56 });
57
58 test("a non-admin reads public repositories alone", () => {
59 const page = draw("agent");
60 expect(page).toMatch(/opened in the last hour:\s+no pulls/);
61 expect(page).toContain("data-median-hours>2.8h<");
62 expect(page).toContain("data-max-hours>2.8h<");
63 expect(page).not.toContain("(0 flow)");
64 });
65
66 test("the terminal's Flow dialog says both lines too, public-only for an agent", () => {
67 const owner = flowLines(app.db, new Date(), []).join(" ");
68 expect(owner).toContain("Time to land: median");
69 expect(owner).toContain("scout: 2 pulls, 0 for flow");
70 const agent = flowLines(app.db, new Date(), [], { queues: [] }).join(" ");
71 expect(agent).toContain("Time to land: median 2.8 h, slowest 2.8 h");
72 expect(agent).toContain("Opened in the last hour: no pulls");
73 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs