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

Merged opened this

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

Merged

scout/forge:scout/6420-the-boards-flow-panel-lists-the-hot-file → main 1 commit fork

  • Merged. scout/6420-the-boards-flow-panel-lists-the-hot-file was merged into main as fd7d6a0.

Review

Sign in to review this change.
3 files changed +137 −1
src/crew/hot_files.ts +22
@@ -112,6 +112,28 @@ export function hotFiles(repoId: string): Map<string, number> {
112112 return counts;
113113 }
114114
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
115137 /** An open pull too wide to count toward heat, with the files its diff touches. */
116138 export interface WidePull {
117139 readonly number: number;
src/web/pages/flow-panel.ts +41 −1
@@ -32,11 +32,13 @@ import {
3232 type StageThroughput,
3333 } from "../../flow/throughput.ts";
3434 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";
3537 import { readHeld } from "../../flow/held.ts";
3638 import type { App } from "../../app.ts";
3739 import { html, type Html } from "../html.ts";
3840 import { heldCard } from "./boards-held.ts";
39 import { badge, card } from "../ui.ts";
41 import { badge, card, meter } from "../ui.ts";
4042
4143 /** What `?out=` and `?hour=` ask to list, checked: an unknown stage or hour lists nothing. */
4244 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
167169 </div>`,
168170 { head: html`<h2>Pipeline</h2><a class="muted small" href="/settings/capacity">Capacity</a>`, id: "pipeline", pad: false },
169171 )}
172 ${hotFilesCard(app, reader)}
170173 ${heldCard(readHeld(app, reader.kind === "owner", now))}
171174 ${queueCard(queuesFor(app, reader, new Date(now), { batches: true }))}
172175 ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`;
@@ -238,3 +241,40 @@ function outList(t: StageThroughput, hour: number | null, now: number): Html {
238241 { id: "out" },
239242 );
240243 }
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs