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

The terminal's Flow dialog says the time to land and who opened the last hour's pulls #6513

Merged opened this

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

Merged

scout/forge:scout/6496-the-terminal-s-flow-dialog-says-the-time → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/6496-the-terminal-s-flow-dialog-says-the-time was merged into main as ce756ed.

Review

Sign in to review this change.
2 files changed +104 −0
src/tui/screens/flow.ts +23
@@ -20,6 +20,8 @@ import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../
2020 import { pileSays, readPile } from "../../flow/pile.ts";
2121 import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts";
2222 import { firedAlarms } from "../../flow/alarms.ts";
23 import { readLeadTime, type LeadTime } from "../../flow/lead_time.ts";
24 import { readOpeners, type Opener } from "../../flow/openers.ts";
2325 import { readVitals, vitalsLine } from "../../flow/vitals.ts";
2426 import { readQueues, redBatchWords } from "../../flow/queue.ts";
2527 import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts";
@@ -70,6 +72,8 @@ export function flowLines(
7072 ...wrap(throughputSays(flow), inner),
7173 "",
7274 ...wrap(pileSays(readPile(db, agent !== undefined)), inner),
75 ...wrap(leadTimeSays(readLeadTime(db, now, agent !== undefined)), inner),
76 ...openerLines(readOpeners(db, now, agent !== undefined), inner),
7377 "",
7478 `${pad("Out", 12)} ${pad("last hour", 9, "right")} ${pad("24 h", 5, "right")}`,
7579 ...flow.stages.map(stageOut).map(
@@ -97,6 +101,25 @@ export function flowLines(
97101 ];
98102 }
99103
104 /** Hours, or minutes under one hour, so a landing never reads as 0 h. */

Nit: the two new lines sit directly under the pile sentence without a blank line; fine in a narrow dialog, but check it reads as its own group on an 80-column terminal.

105 const hours = (h: number) =>
106 h < 1 ? `${Math.max(1, Math.round(h * 60))} min` : `${Math.round(h * 10) / 10} h`;
107
108 /** The time to land, in words; with no landing in the day, it says so. */
109 export function leadTimeSays(t: LeadTime): string {
110 if (t.landed === 0 || t.medianHours === null || t.maxHours === null) return "Time to land: nothing landed in the last 24 h.";
outdated

The cap of five is in the issue's done list, but no test reaches it. Seed six authors and assert that five opener lines are drawn.

111 return `Time to land: median ${hours(t.medianHours)}, slowest ${hours(t.maxHours)} (24 h)`;
112 }
113
114 /** One line per opener of the last hour, at most five; with none, a line that says so. */
115 export function openerLines(rows: readonly Opener[], inner: number): string[] {
116 if (rows.length === 0) return ["Opened in the last hour: no pulls."];
117 const lines = rows
118 .slice(0, 5)
119 .map((o) => truncate(`${o.author}: ${o.opened} ${o.opened === 1 ? "pull" : "pulls"}, ${o.flow} for flow`, inner));
120 return rows.length > 5 ? [...lines, `and ${rows.length - 5} more`] : lines;
121 }
122
100123 /** One line per autonomous repository: its name, then `startable=N` and each filter holding any. */
101124 export function heldLines(rows: readonly HeldRow[], inner: number): string[] {
102125 if (rows.length === 0) return ["Ready, and why it waits: no repository runs on its own."];
test/tui-flow-lead.test.ts added +81
@@ -0,0 +1,81 @@
1 /** The Flow dialog says the time to land and who opened the last hour's pulls (#6496). */
2 import { describe, 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 } from "../src/app.ts";
7 import { createRepo } from "../src/core/repos.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { id } from "../src/kernel/id.ts";
10 import { flowLines } from "../src/tui/screens/flow.ts";
11
12 const NOW = new Date("2026-10-26T12:00:00.000Z");
13 const ago = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString();
14 const text = (db: Parameters<typeof flowLines>[0]) => flowLines(db, NOW, [], undefined, []).join("\n");
15
16 async function seed() {
17 const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-tuiflow-")), port: 0 });
18 const u = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true });
19 if (!u.ok) throw new Error(u.error);
20 const mk = async (name: string, visibility: "public" | "private") => {
21 const x = await createRepo(app.db, app.reposRoot, { ownerId: u.value.id, name, visibility, description: name });
22 if (!x.ok) throw new Error(x.error);
23 return x;
24 };
25 const r = await mk("r", "public");
26 const priv = await mk("priv", "private");
27 let n = 0;
28 const pull = (opened: number, merged: string | null, body = "", repo = r, author = u.value.id) => {
29 const issueId = id();
30 n++;
31 app.db.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)`).run(issueId, repo.value.id, n, body, author, ago(opened));
32 app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, ?3, '', '', ?4, ?5)`).run(issueId, repo.value.defaultBranch, `b/${n}`, repo.value.id, merged);
33 };
34 const user = async (name: string) => {
35 const x = await createUser(app.db, { username: name, email: `${name}@forge.test`, password: "a-good-passphrase", origin: "seed", trusted: true });
36 if (!x.ok) throw new Error(x.error);
37 return x.value.id;
38 };
39 return { app, pull, priv, user };
40 }
41
42 describe("flow dialog lead time and openers", () => {
43 test("a seeded database draws both lines", async () => {
44 const s = await seed();
45 s.pull(5, ago(1)); // 4 h
46 s.pull(10, ago(2)); // 8 h
47 s.pull(0.5, null);
48 const t = text(s.app.db);
49 expect(t).toContain("Time to land: median 6 h, slowest 8 h (24 h)");
50 expect(t).toContain("alice: 1 pull, 0 for flow");
51 });
52
53 test("with no landings and no openers it says so in words", async () => {
54 const s = await seed();
55 const t = text(s.app.db);
56 expect(t).toContain("nothing landed in the last 24 h");
57 expect(t).toContain("Opened in the last hour: no pulls.");
58 expect(t).not.toContain("0 h");
59 });
60
61 test("an agent reader does not see a private repository's author or lead time", async () => {
62 const s = await seed();
63 const bob = await s.user("bobsecret");
64 s.pull(0.5, ago(0.25), "", s.priv, bob);
65 const agent = flowLines(s.app.db, NOW, [], { queues: [] }, []).join("\n");
66 expect(agent).toContain("Opened in the last hour: no pulls.");
67 expect(agent).toContain("nothing landed in the last 24 h");
68 expect(agent).not.toContain("bobsecret");
69 const admin = text(s.app.db);
70 expect(admin).toContain("bobsecret: 1 pull, 0 for flow");
71 expect(admin).toContain("Time to land: median 15 min, slowest 15 min (24 h)");
72 });
73
74 test("six openers draw five lines", async () => {
75 const s = await seed();
76 for (const name of ["u1", "u2", "u3", "u4", "u5", "u6"]) s.pull(0.5, null, "", undefined, await s.user(name));
77 const lines = text(s.app.db).split("\n").filter((l) => /^u\d: 1 pull, 0 for flow$/.test(l));
78 expect(lines).toHaveLength(5);
79 expect(text(s.app.db)).toContain("and 1 more");
80 });
81 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs