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

A hot-file hold names the open pulls on that file, oldest first, on the map and in the Flow panel #7491

Merged opened this

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

Merged

scout/forge:scout/7423-a-hot-file-hold-names-the-open-pulls-on → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/7423-a-hot-file-hold-names-the-open-pulls-on was merged into main as 165adac.

Review

Sign in to review this change.
10 files changed +186 −26
README.md +1
@@ -411,6 +411,7 @@ This page is the front door. Everything else is a file of its own under
411411 - Events at the terminal: `v` on the About card (`I`) lists the repository's events newest first, 50 at a time; `n` loads the next page, `esc` goes back; it reads once on opening and does not refresh while open. The audience is `GET …/events`'s and MCP `events`' (a webhook's URL and an app's variable names are the admins').
412412 - Why it is not moving: MCP `flow` with `{ repo }`, `{ repo, issue }` or `{ repo, pull }` (a writer's) answers the first gate that holds it (`flow_only`, `ceiling`, `hot_file`, `blocked_by`, `later_gate`, `no_builder`, `no_headroom`, `red_check`, `waiting_review`) with the action that clears it, or `state: closed`/`merged` when nothing is left to move; a pull is read against the head git has now. The terminal's About card (`I`) carries the repository's answer in a **Moving** row for a writer (`src/flow/why.ts`).
413413 - [`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.
414 - A ready issue held on a hot file names the open pulls on that file, oldest first, at most 8: `held.pulls` in `GET …/map` and the MCP `map` tool, links after the sentence in the flow panel's "Ready, and why it waits" card, and `#N` lines in the terminal Flow dialog. Read from the pull files the picker caches (`src/crew/hot_files.ts`), per reader: a sealed pull is named and counted for the owner only.
414415 - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [`docs/FLOW.md`](docs/FLOW.md).
415416 - Under it, the panel says the crew's build yield over 24 h (runs finished, pushed, landed; `src/flow/build_yield.ts`) and the median hours to first review (`src/flow/review_latency.ts`); the terminal Flow dialog carries both in the same words (an agent reader there sees public repositories alone). `/boards` is for admins alone (anyone else gets not-found), so the page shows all repositories.
416417 - 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.
docs/design/areas/boards.md +6
@@ -9,6 +9,12 @@ repositories the viewer administers and shows when there are none, because it
99 holds the form. A person who administers a repository but is not a forge
1010 admin sees `/boards` as this card alone.
1111
12 ## Pulls on a held issue (`.held-pulls`)
13
14 In the held card, a hot-file hold lists the open pulls on that file after its
15 sentence. `.held-pulls` is a wrapping flex line; each `<a>` is a 44px-high tap
16 target with 8px of padding. Defined in `src/web/style/components.ts`.
17
1218 ## Verified after landing (#7786)
1319
1420 The card under the Pipeline table (`id="verified"`) adds no class: it is a `card()` with an `<h2>`, one `<p>` per repository in the default text colour, the repository and its run as plain `<a>` links, `small` for the run link. It is server-rendered, needs no JavaScript, and has no accent of its own.
src/crew/held.ts +9 −4
@@ -47,7 +47,7 @@ import { flowOnlyHolds } from "./flow_only.ts";
4747 import { interruptionOf } from "./interrupted.ts";
4848 import { PARK_STRIKES, parkedCount } from "./parks.ts";
4949 import { heldFiles, type FileHold } from "./file_holds.ts";
50 import { expectedFiles, hotFiles, hotOverlap, relievedFiles } from "./hot_files.ts";
50 import { expectedFiles, hotFiles, hotOverlap, pullsTouching, relievedFiles } from "./hot_files.ts";
5151 import { coveredByRedFix } from "./red_fix.ts";
5252
5353 export const HOLDS = [
@@ -257,6 +257,8 @@ export function stuckOf(held: Held): Stuck | undefined {
257257 export interface MapHold {
258258 readonly by: HoldName;
259259 readonly detail: string;
260 /** For a hot file: the open pulls that touch it, oldest first, at most 8. */
261 readonly pulls?: readonly number[];
260262 }
261263
262264 const HOLD_WORDS: Readonly<Record<HoldName, string>> = {
@@ -289,6 +291,7 @@ export function mapHolds(
289291 for (const name of HOLDS) {
290292 for (const issue of held.by[name]) {
291293 let detail = HOLD_WORDS[name];
294 let pulls: number[] | undefined;
292295 if (name === "hot_file") {
293296 const pin = held.pinned.get(issue.number);
294297 const top = hotOverlap(issue, hot)[0];
@@ -296,17 +299,19 @@ export function mapHolds(
296299 if (pin !== undefined) detail = `${pin.path} is held by an admin`;
297300 else if (top === undefined && rel !== undefined)
298301 detail = `waits: ${rel.path} is already being relieved by #${rel.by}`;
299 else if (top !== undefined)
302 else if (top !== undefined) {
300303 detail = `${top.path} is in ${top.pulls} open ${top.pulls === 1 ? "pull" : "pulls"}`;
304 pulls = pullsTouching(repo.id, top.path, admit);
305 }
301306 }
302 holds.set(issue.number, { by: name, detail });
307 holds.set(issue.number, { by: name, detail, ...(pulls === undefined ? {} : { pulls }) });
303308 }
304309 }
305310 return { holds, startable: held.startable.map((r) => r.issue.number) };
306311 }
307312
308313 /** The pulls a viewer of `scope` may see: all with the sealed scope, else the unsealed and their own. */
309 function admits(app: App, repo: Repo, scope: { sealed?: true; sealedBy?: string }) {
314 export function admits(app: App, repo: Repo, scope: { sealed?: true; sealedBy?: string }) {
310315 if (scope.sealed === true) return () => true;
311316 return (n: number): boolean => {
312317 const row = sealedRowByNumber(app.db, repo.id, n);
src/crew/hot_files.ts +15
@@ -139,6 +139,21 @@ export function pullCounts(
139139 return counts;
140140 }
141141
142 /** The most pulls a hold names. */
143 export const PULLS_NAMED = 8;
144
145 /** The numbers of the open pulls that touch `path`, oldest first, at most `PULLS_NAMED`. Memory only. */
146 export function pullsTouching(
147 repoId: string,
148 path: string,
149 admit: (pullNumber: number) => boolean = () => true,
150 ): number[] {
151 const out: number[] = [];
152 for (const { files, number } of touched.get(repoId)?.values() ?? [])
153 if (files.length <= WIDE_PULL && admit(number) && files.includes(path)) out.push(number);
154 return out.sort((a, b) => a - b).slice(0, PULLS_NAMED);
155 }
156
142157 /** Every file of `repo` that `HOT_PULLS` or more open pulls touch, with how many. */
143158 export function hotFiles(
144159 repoId: string,
src/flow/held.ts +31 −18
@@ -9,9 +9,9 @@
99 */
1010
1111 import type { App } from "../app.ts";
12 import { listAutonomousRepos } from "../core/repos.ts";
12 import { listAutonomousRepos, type Repo } from "../core/repos.ts";
1313 import { smallShareWords } from "../crew/autonomy.ts";
14 import { heldCounts, heldNow, type HoldName } from "../crew/held.ts";
14 import { admits, heldCounts, heldNow, mapHolds, type Held, type HoldName } from "../crew/held.ts";
1515
1616 export interface HeldRow {
1717 /** `owner/name`. */
@@ -19,33 +19,46 @@ export interface HeldRow {
1919 readonly public: boolean;
2020 readonly startable: number;
2121 readonly counts: Readonly<Record<HoldName, number>>;
22 /** Issues a hot file holds, with the open pulls on that file, oldest first. */
23 readonly hot: readonly { readonly number: number; readonly detail: string; readonly pulls: readonly number[] }[];
2224 /** `1/2`: small issues in flight of the builder slots they may hold (#7571). */
2325 readonly small: string;
2426 }
2527
28 /** The most hot-file holds a row lists. */
29 const HOT_ROWS = 5;
30
2631 /** How long the rows are kept. */
2732 export const HELD_TTL_MS = 5_000;
2833
29 const kept = new WeakMap<App, { at: number; rows: readonly HeldRow[] }>();
34 const kept = new WeakMap<App, { at: number; repos: readonly { repo: Repo; held: Held }[] }>();
3035
31 /** Each autonomous repository's ready list, sorted by what holds it; private ones for the owner only. */
36 /**
37 * Each autonomous repository's ready list, sorted by what holds it; private ones for the owner only.
38 * The counts are the same for every reader. The pulls a hot file holds are resolved per reader on
39 * every call, from memory: a sealed pull is named, and counted, for the owner only.
40 */
3241 export function readHeld(app: App, owner: boolean, now = Date.now()): HeldRow[] {
3342 let entry = kept.get(app);
3443 if (entry === undefined || now - entry.at >= HELD_TTL_MS || now < entry.at) {
35 entry = {
36 at: now,
37 rows: listAutonomousRepos(app.db).map((repo) => {
38 const held = heldNow(app, repo);
39 return {
40 repo: `${repo.ownerName}/${repo.name}`,
41 public: repo.visibility === "public",
42 startable: held.startable.length,
43 counts: heldCounts(held),
44 small: smallShareWords(app, repo),
45 };
46 }),
47 };
44 entry = { at: now, repos: listAutonomousRepos(app.db).map((repo) => ({ repo, held: heldNow(app, repo) })) };
4845 kept.set(app, entry);
4946 }
outdated

mapHolds(repo, held) has no admit, and these rows are cached and shared across viewers. Sealed pull numbers leak to guests on public repos. Resolve the pulls per viewer. Also drop the h.pulls! below.

50 return entry.rows.filter((r) => owner || r.public);
47 return entry.repos
48 .filter(({ repo }) => owner || repo.visibility === "public")
49 .map(({ repo, held }) => {
50 const { holds } = mapHolds(repo, held, admits(app, repo, owner ? { sealed: true } : {}));
51 return {
52 repo: `${repo.ownerName}/${repo.name}`,
53 public: repo.visibility === "public",
54 startable: held.startable.length,
55 counts: heldCounts(held),
56 small: smallShareWords(app, repo),
57 hot: [...holds].flatMap(([number, h]) =>
58 h.by === "hot_file" && h.pulls !== undefined && h.pulls.length > 0
59 ? [{ number, detail: h.detail, pulls: h.pulls }]
60 : [],
61 ).slice(0, HOT_ROWS),
62 };
63 });
5164 }
src/tui/screens/flow.ts +6 −3
@@ -209,8 +209,8 @@ export function heldLines(rows: readonly HeldRow[], inner: number): string[] {
209209 if (rows.length === 0) return ["Ready, and why it waits: no repository runs on its own."];
210210 return [
211211 "Ready, and why it waits:",
212 ...rows.flatMap((r) =>
213 wrap(
212 ...rows.flatMap((r) => [
213 ...wrap(
214214 [
215215 r.repo,
216216 `startable=${r.startable}`,
@@ -219,7 +219,10 @@ export function heldLines(rows: readonly HeldRow[], inner: number): string[] {
219219 ].join(" "),
220220 inner,
221221 ),
222 ),
222 ...r.hot.flatMap((h) =>
223 wrap(` #${h.number} waits: ${h.detail}: ${h.pulls.map((n) => `#${n}`).join(" ")}`, inner),
224 ),
225 ]),
223226 ];
224227 }
225228
src/web/pages/boards-held.ts +11
@@ -55,6 +55,17 @@ export function heldCard(rows: readonly HeldRow[]): Html {
5555 <td>${heldCount("startable", r.startable)} ${startableMeter(r)}</td>
5656 <td>
5757 ${HOLDS.filter((h) => r.counts[h] > 0).map((h) => html`${heldCount(h, r.counts[h])} `)}
58 ${r.hot.length === 0
59 ? null
60 : html`<ul class="small" data-held-hot>
61 ${r.hot.map(
62 (h) =>
63 html`<li data-held-hot-issue="${String(h.number)}">
64 #${String(h.number)} waits: ${h.detail}
65 <span class="held-pulls">${h.pulls.map((n) => html`<a class="mono" href="/${r.repo}/pulls/${String(n)}">#${String(n)}</a>`)}</span>
outdated

Links are bare inline anchors separated by spaces; they are hard to hit with a thumb at 360px. Give them a gap/padding via a named layout class or token.

66 </li>`,
67 )}
68 </ul>`}
5869 </td>
5970 </tr>`,
6071 )}
src/web/style/components.ts +3
@@ -338,6 +338,9 @@ a.sha:hover { color: var(--accent); }
338338 /* Cards stacked in normal flow space themselves. Inside a container that
339339 already manages its own spacing with gap, that margin would double it. */
340340 .card + .card { margin-top: 12px; }
341 /* The open pulls a held issue waits on: a wrapping line of touch-sized links. */
342 .held-pulls { display: flex; flex-wrap: wrap; gap: 0 4px; align-items: center; }
343 .held-pulls a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 8px; }
341344 :is(.stack, .split, .timeline) > .card + .card { margin-top: 0; }
342345 .card-head {
343346 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
test/held-hot-pulls.test.ts added +102
@@ -0,0 +1,102 @@
1 /** A hot-file hold names the open pulls on that file, oldest first, in the map answer and the held card (#7491). */
outdated

Header cites #7423, but this is #7491. Add a guest/sealed-pull test and a terminal test.

2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createIssue } from "../src/core/issues.ts";
9 import { PULLS_NAMED, pullsTouching, rememberPullFiles } from "../src/crew/hot_files.ts";
10 import { readHeld } from "../src/flow/held.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { heldLines } from "../src/tui/screens/flow.ts";
13 import { heldCard } from "../src/web/pages/boards-held.ts";
14 import { seedAdmin } from "./helpers/admin.ts";
15
16 const SEED = [
17 { number: 31, files: ["src/hot.ts"] },
18 { number: 12, files: ["src/hot.ts", "a.ts"] },
19 { number: 20, files: ["src/hot.ts"] },
20 { number: 25, files: ["src/hot.ts"] },
21 { number: 26, files: ["src/hot.ts"] },
22 { number: 40, files: ["other.ts"] },
23 ];
24
25 let dataDir = "";
26 let app: App, servers: Servers, repoId = "", issueNo = 0;
27
28 beforeAll(async () => {
29 dataDir = await mkdtemp(join(tmpdir(), "forge-heldpulls-"));
30 app = createApp({ dataDir, port: 0 });
31 servers = await serve(app);
32 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
33 if (!admin.ok) throw new Error(admin.error);
34 const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" });
35 if (!made.ok) throw new Error(String(made.error));
36 repoId = made.value.id;
37 app.db.run("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2 WHERE id = ?1", [repoId, admin.value.id]);
38 rememberPullFiles(repoId, SEED);
39 const r = createIssue(app.db, { repoId, authorId: admin.value.id, title: "Add a flag to src/hot.ts", body: "", sealed: false });
40 if (!r.ok) throw new Error(r.error);
41 issueNo = r.value.number;
42 });
43
44 afterAll(async () => {
45 rememberPullFiles(repoId, []);
46 servers.stop?.();
47 app.close();
48 await rm(dataDir, { recursive: true, force: true });
49 });
50
51 test("pullsTouching lists pull numbers oldest first and stops at eight", () => {
52 expect(pullsTouching(repoId, "src/hot.ts")).toEqual([12, 20, 25, 26, 31]);
53 rememberPullFiles(repoId, Array.from({ length: 12 }, (_, i) => ({ number: 50 - i, files: ["x.ts"] })));
54 const many = pullsTouching(repoId, "x.ts");
55 expect(many).toHaveLength(PULLS_NAMED);
56 expect(many[0]).toBe(39);
57 rememberPullFiles(repoId, SEED);
58 });
59
60 test("the map answer carries the pulls on a held issue, oldest first", async () => {
61 const body = (await (await fetch(`${servers.url}/api/v1/repos/alice/notes/map`)).json()) as {
62 ready: { number: number; held: { by: string; detail: string; pulls: number[] } | null }[];
63 };
64 const held = body.ready.find((r) => r.number === issueNo)!.held!;
65 expect(held.by).toBe("hot_file");
66 expect(held.detail).toBe("src/hot.ts is in 5 open pulls");
67 expect(held.pulls).toEqual([12, 20, 25, 26, 31]);
68 });
69
70 test("the held card links them after the sentence, in order", () => {
71 const page = String(heldCard(readHeld(app, true, Date.now() + 60_000)));
72 expect(page).toContain(`data-held-hot-issue="${issueNo}"`);
73 const at = (n: number) => page.indexOf(`href="/alice/notes/pulls/${n}"`);
74 expect(page.indexOf("src/hot.ts is in 5 open pulls")).toBeGreaterThan(-1);
75 expect(at(12)).toBeGreaterThan(page.indexOf("src/hot.ts is in 5 open pulls"));
76 expect([12, 20, 25, 26, 31].map(at)).toEqual([...[12, 20, 25, 26, 31].map(at)].sort((a, b) => a - b));
77 expect(at(40)).toBe(-1);
78 });
79
80 test("the terminal's Flow lines name the pulls too", () => {
81 const lines = heldLines(readHeld(app, true, Date.now() + 120_000), 200).join("\n");
82 expect(lines).toContain(`#${issueNo} waits: src/hot.ts is in 5 open pulls: #12 #20 #25 #26 #31`);
83 });
84
85 test("a guest is told neither the number of a sealed pull nor a count that includes it", () => {
86 app.db.run(
87 "INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, sealed) VALUES ('sp', ?1, 26, 'pull', 's', '', (SELECT id FROM user LIMIT 1), 'open', 1)",
88 [repoId],
89 );
90 rememberPullFiles(repoId, [...SEED, { number: 27, files: ["src/hot.ts"] }]);
91 const now = Date.now() + 300_000;
92 const guest = String(heldCard(readHeld(app, false, now)));
93 expect(guest).toContain("src/hot.ts is in 5 open pulls");
94 expect(guest).not.toContain("/pulls/26");
95 expect(guest).not.toContain("#26");
96 expect(heldLines(readHeld(app, false, now), 200).join("\n")).not.toContain("#26");
97 const owner = String(heldCard(readHeld(app, true, now)));
98 expect(owner).toContain("src/hot.ts is in 6 open pulls");
99 expect(owner).toContain("/pulls/26");
100 rememberPullFiles(repoId, SEED);
101 app.db.run("DELETE FROM issue WHERE id = 'sp'");
102 });
test/held-relieves-map.test.ts +2 −1
@@ -109,7 +109,8 @@ test("the three planned bodies are held by nothing on their own hot files; a pla
109109 expect(heldOf(map, tui.number)).toBeNull();
110110 expect(heldOf(map, cli.number)).toBeNull();
111111 expect(heldOf(map, mcp.number)).toBeNull();
112 expect(heldOf(map, plain.number)).toEqual({ by: "hot_file", detail: `src/tui/forge.ts is in ${HOT_PULLS + 1} open pulls` });
112 expect(heldOf(map, plain.number)).toMatchObject({ by: "hot_file", detail: `src/tui/forge.ts is in ${HOT_PULLS + 1} open pulls` });
113 expect((heldOf(map, plain.number) as { pulls?: number[] } | undefined)?.pulls).toHaveLength(HOT_PULLS + 1);
113114 expect(map.startable.sort((a, b) => a - b)).toEqual([tui.number, cli.number, mcp.number]);
114115 }
115116 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs