Public: anyone can read this; the crew can change it.
The boards' flow panel lists the hot files: each file 3 or more open pulls touch, with its count, and the ones at the build limit marked #6433
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6420-the-boards-flow-panel-lists-the-hot-file → main 1 commit fork
3 files
changed +137
−1
src/crew/hot_files.ts +22
| @@ -112,6 +112,28 @@ export function hotFiles(repoId: string): Map<string, number> { | ||
| 112 | 112 | return counts; |
| 113 | 113 | } |
| 114 | 114 | |
| 115 | /** The flow panel lists a file this many open pulls touch, held or not. */ | |
| 116 | export const WARM_PULLS = 3; | |
| 117 | ||
| 118 | /** Every repository with pulls learned, and each file `WARM_PULLS` or more of its open pulls touch. Memory only: never git. */ | |
| 119 | export function warmFiles(): { repoId: string; path: string; pulls: number }[] { | |
| 120 | const out: { repoId: string; path: string; pulls: number }[] = []; | |
| 121 | for (const [repoId, pulls] of touched) { | |
| 122 | const counts = new Map<string, number>(); | |
| 123 | for (const { files } of pulls.values()) { | |
| 124 | if (files.length > WIDE_PULL) continue; | |
| 125 | for (const file of files) counts.set(file, (counts.get(file) ?? 0) + 1); | |
| 126 | } | |
| 127 | for (const [path, n] of counts) if (n >= WARM_PULLS) out.push({ repoId, path, pulls: n }); | |
| 128 | } | |
| 129 | return out; | |
| 130 | } | |
| 131 | ||
| 132 | /** Put what git said about `repoId`'s open pulls into the cache, as `learnPullFiles` would; for tests. */ | |
| 133 | export function rememberPullFiles(repoId: string, pulls: readonly { number: number; files: readonly string[] }[]): void { | |
| 134 | touched.set(repoId, new Map(pulls.map((p) => [`pull-${p.number}`, { at: `seeded-${p.number}`, ...p }]))); | |
| 135 | } | |
| 136 | ||
| 115 | 137 | /** An open pull too wide to count toward heat, with the files its diff touches. */ |
| 116 | 138 | export interface WidePull { |
| 117 | 139 | readonly number: number; |
src/web/pages/flow-panel.ts +41 −1
| @@ -32,11 +32,13 @@ import { | ||
| 32 | 32 | type StageThroughput, |
| 33 | 33 | } from "../../flow/throughput.ts"; |
| 34 | 34 | import { type QueueRow, redBatchWords } from "../../flow/queue.ts"; |
| 35 | import { HOT_PULLS, warmFiles } from "../../crew/hot_files.ts"; | |
| 36 | import { getRepoById } from "../../core/repos.ts"; | |
| 35 | 37 | import { readHeld } from "../../flow/held.ts"; |
| 36 | 38 | import type { App } from "../../app.ts"; |
| 37 | 39 | import { html, type Html } from "../html.ts"; |
| 38 | 40 | import { heldCard } from "./boards-held.ts"; |
| 39 | import { badge, card } from "../ui.ts"; | |
| 41 | import { badge, card, meter } from "../ui.ts"; | |
| 40 | 42 | |
| 41 | 43 | /** What `?out=` and `?hour=` ask to list, checked: an unknown stage or hour lists nothing. */ |
| 42 | 44 | function asked(url: URL): { key: PipelineKey; hour: number | null } | null { |
| @@ -167,6 +169,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 167 | 169 | </div>`, |
| 168 | 170 | { head: html`<h2>Pipeline</h2><a class="muted small" href="/settings/capacity">Capacity</a>`, id: "pipeline", pad: false }, |
| 169 | 171 | )} |
| 172 | ${hotFilesCard(app, reader)} | |
| 170 | 173 | ${heldCard(readHeld(app, reader.kind === "owner", now))} |
| 171 | 174 | ${queueCard(queuesFor(app, reader, new Date(now), { batches: true }))} |
| 172 | 175 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; |
| @@ -238,3 +241,40 @@ function outList(t: StageThroughput, hour: number | null, now: number): Html { | ||
| 238 | 241 | { id: "out" }, |
| 239 | 242 | ); |
| 240 | 243 | } |
| 244 | ||
| 245 | /** The most files the panel lists. */ | |
| 246 | const HOT_LISTED = 10; | |
| 247 | ||
| 248 | /** Files several open pulls touch, most-touched first, from what `hot_files.ts` holds in memory. */ | |
| 249 | function hotFilesCard(app: App, reader: Reader): Html { | |
| 250 | const rows: { repo: string; path: string; pulls: number }[] = []; | |
| 251 | const repos = new Map<string, { name: string; ok: boolean } | undefined>(); | |
| 252 | for (const w of warmFiles()) { | |
| 253 | if (!repos.has(w.repoId)) { | |
| 254 | const r = getRepoById(app.db, w.repoId); | |
| 255 | repos.set(w.repoId, r && { name: `${r.ownerName}/${r.name}`, ok: reader.kind === "owner" || r.visibility === "public" }); | |
| 256 | } | |
| 257 | const repo = repos.get(w.repoId); | |
| 258 | if (repo?.ok) rows.push({ repo: repo.name, path: w.path, pulls: w.pulls }); | |
| 259 | } | |
| 260 | rows.sort((a, b) => b.pulls - a.pulls || a.path.localeCompare(b.path) || a.repo.localeCompare(b.repo)); | |
| 261 | const top = rows.slice(0, HOT_LISTED); | |
| 262 | const max = Math.max(1, ...top.map((r) => r.pulls)); | |
| 263 | return card( | |
| 264 | html`<h2>Hot files</h2> | |
| 265 | ${top.length === 0 | |
| 266 | ? html`<p class="muted">No file is touched by 3 or more open pulls.</p>` | |
| 267 | : html`<ul> | |
| 268 | ${top.map( | |
| 269 | (r) => | |
| 270 | html`<li data-hot-file="${r.path}"> | |
| 271 | <span class="mono">${r.path}</span> <span class="muted small">${r.repo}</span> | |
| 272 | <span class="mono" data-pulls>${String(r.pulls)}</span> open pulls | |
| 273 | ${r.pulls >= HOT_PULLS ? badge("builds held", { tone: "danger" }) : null} | |
| 274 | ${meter((r.pulls / max) * 100, `${r.pulls} open pulls touch ${r.path}`)} | |
| 275 | </li>`, | |
| 276 | )} | |
| 277 | </ul>`}`, | |
| 278 | { id: "hot-files" }, | |
| 279 | ); | |
| 280 | } |
test/flow-hot-files.test.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** The flow panel's "Hot files" (#6420): most-touched first, capped at 10, "builds held" at HOT_PULLS, private files for the owner only. */ | |
| 2 | ||
| 3 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { rememberPullFiles } from "../src/crew/hot_files.ts"; | |
| 11 | import { flowPanel } from "../src/web/pages/flow-panel.ts"; | |
| 12 | ||
| 13 | describe("flow panel hot files", () => { | |
| 14 | let dataDir: string; | |
| 15 | let app: App; | |
| 16 | let repoId: string; | |
| 17 | ||
| 18 | const draw = (kind: "owner" | "agent") => | |
| 19 | String(flowPanel(app, { kind }, new URL("http://x/boards"), "/boards")); | |
| 20 | const section = (page: string) => page.slice(page.indexOf('id="hot-files"'), page.indexOf('id="hot-files"') + 6000); | |
| 21 | ||
| 22 | beforeEach(async () => { | |
| 23 | dataDir = await mkdtemp(join(tmpdir(), "forge-hot-files-")); | |
| 24 | app = createApp({ dataDir, port: 0 }); | |
| 25 | const u = await createUser(app.db, { | |
| 26 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 27 | password: "a-good-passphrase", adminIfFirst: true, | |
| 28 | }); | |
| 29 | if (!u.ok) throw new Error(u.error); | |
| 30 | const made = await createRepo(app, u.value, { name: "shop" }); | |
| 31 | if (!made.ok) throw new Error(String(made.error)); | |
| 32 | repoId = made.value.id; | |
| 33 | }); | |
| 34 | afterEach(async () => { | |
| 35 | rememberPullFiles(repoId, []); | |
| 36 | app.db.close(); | |
| 37 | await rm(dataDir, { recursive: true, force: true }); | |
| 38 | }); | |
| 39 | ||
| 40 | test("with nothing learned it says so", () => { | |
| 41 | expect(draw("owner")).toContain("No file is touched by 3 or more open pulls"); | |
| 42 | }); | |
| 43 | ||
| 44 | test("twelve files: most-touched first, ten shown, held ones marked", () => { | |
| 45 | // Pull i touches file f when i < counts[f], so file f has counts[f] pulls. | |
| 46 | const counts = [7, 6, 5, 5, 4, 4, 3, 3, 3, 3, 3, 3]; | |
| 47 | const pulls = Array.from({ length: 7 }, (_, i) => ({ | |
| 48 | number: i + 1, | |
| 49 | files: counts.flatMap((c, f) => (i < c ? [`src/f${String(f).padStart(2, "0")}.ts`] : [])), | |
| 50 | })); | |
| 51 | rememberPullFiles(repoId, pulls); | |
| 52 | const page = section(draw("owner")); | |
| 53 | const listed = [...page.matchAll(/data-hot-file="([^"]+)"/g)].map((m) => m[1]); | |
| 54 | expect(listed).toHaveLength(10); | |
| 55 | expect(listed.slice(0, 4)).toEqual(["src/f00.ts", "src/f01.ts", "src/f02.ts", "src/f03.ts"]); | |
| 56 | expect(listed).not.toContain("src/f10.ts"); | |
| 57 | expect(page.match(/builds held/g)).toHaveLength(4); | |
| 58 | expect(page).not.toContain("No file is touched"); | |
| 59 | }); | |
| 60 | ||
| 61 | test("a file below three pulls is not listed", () => { | |
| 62 | rememberPullFiles(repoId, [1, 2].map((number) => ({ number, files: ["a/b.ts"] }))); | |
| 63 | expect(draw("owner")).toContain("No file is touched by 3 or more open pulls"); | |
| 64 | }); | |
| 65 | ||
| 66 | test("a private repository's files are for the owner only", () => { | |
| 67 | rememberPullFiles(repoId, [1, 2, 3].map((number) => ({ number, files: ["secret/plan.ts"] }))); | |
| 68 | app.db.query("UPDATE repo SET visibility = 'private' WHERE id = ?1").run(repoId); | |
| 69 | expect(draw("owner")).toContain("secret/plan.ts"); | |
| 70 | expect(draw("agent")).not.toContain("secret/plan.ts"); | |
| 71 | app.db.query("UPDATE repo SET visibility = 'public' WHERE id = ?1").run(repoId); | |
| 72 | expect(draw("agent")).toContain("secret/plan.ts"); | |
| 73 | }); | |
| 74 | }); |
new above
offline