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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6496-the-terminal-s-flow-dialog-says-the-time → main 3 commits fork 5 threads · 5 unresolved
2 files
changed +104
−0
src/tui/screens/flow.ts +23
| @@ -20,6 +20,8 @@ import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../ | ||
| 20 | 20 | import { pileSays, readPile } from "../../flow/pile.ts"; |
| 21 | 21 | import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts"; |
| 22 | 22 | 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"; | |
| 23 | 25 | import { readVitals, vitalsLine } from "../../flow/vitals.ts"; |
| 24 | 26 | import { readQueues, redBatchWords } from "../../flow/queue.ts"; |
| 25 | 27 | import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts"; |
| @@ -70,6 +72,8 @@ export function flowLines( | ||
| 70 | 72 | ...wrap(throughputSays(flow), inner), |
| 71 | 73 | "", |
| 72 | 74 | ...wrap(pileSays(readPile(db, agent !== undefined)), inner), |
| 75 | ...wrap(leadTimeSays(readLeadTime(db, now, agent !== undefined)), inner), | |
| 76 | ...openerLines(readOpeners(db, now, agent !== undefined), inner), | |
| 73 | 77 | "", |
| 74 | 78 | `${pad("Out", 12)} ${pad("last hour", 9, "right")} ${pad("24 h", 5, "right")}`, |
| 75 | 79 | ...flow.stages.map(stageOut).map( |
| @@ -97,6 +101,25 @@ export function flowLines( | ||
| 97 | 101 | ]; |
| 98 | 102 | } |
| 99 | 103 | |
| 104 | /** Hours, or minutes under one hour, so a landing never reads as 0 h. */ | |
| 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 | ||
| 100 | 123 | /** One line per autonomous repository: its name, then `startable=N` and each filter holding any. */ |
| 101 | 124 | export function heldLines(rows: readonly HeldRow[], inner: number): string[] { |
| 102 | 125 | 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 | }); |
new above
offline
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.