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

A repository's map draws only open issues by default, and a "Show done" link draws the rest: trevato/forge's /map under 400 KB #6266

Merged opened this

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

Merged

scout/forge:scout/6174-a-repository-s-map-draws-only-open-issue → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/6174-a-repository-s-map-draws-only-open-issue was merged into main as 72c55d4.

Review

Sign in to review this change.
6 files changed +151 −12
README.md +2 −1
@@ -1549,7 +1549,8 @@ for both pages, warm and cold. The content-addressed assets answer a
15491549 revalidation with `304`.
15501550
15511551 `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and
1552 what "ready now" means to a person and to the crew. The map also shows each
1552 what "ready now" means to a person and to the crew. The map draws open
1553 issues by default; `?done=1` ("Show done") draws the closed ones on an edge too. The map also shows each
15531554 pillar's ask-to-live: the median hours from an issue opening to its change live
15541555 in production, over the last 7 days (also in the map and milestones API and MCP).
15551556 It reads the `deploy_pull` record a production deploy writes when it finishes,
docs/PLANNING.md +6 −2
@@ -31,8 +31,12 @@ change, so hooks and the crew see it.
3131
3232 ## The map
3333
34 `/:owner/:name/map` draws the repository's open issues, and any closed one
35 still on an edge, laid out by what waits for what. Each issue is a card one
34 `/:owner/:name/map` draws the repository's open issues, laid out by what
35 waits for what. Closed issues are not drawn unless the page is asked for
36 `?done=1` ("Show done" beside the counts; "Open only" returns). Ready now,
37 Blocked on, the critical path, the marks, the counts and the pillar rows are
38 still computed on the whole graph, so a closed blocker still frees what it
39 held. The layout cache keys on the graph drawn, so each filter has its own. Each issue is a card one
3640 level below everything it is blocked by, so the top edge is what is ready
3741 now. Within a level, cards are ordered to keep the curves short and
3842 uncrossed: a barycenter pass sorts each level by where its blockers sit,
docs/design/areas/map.md +1
@@ -23,4 +23,5 @@ is `data-state` on `.map-node`, not a class.
2323 - `.map-edge` — one dependency edge between two cards.
2424 - `.map-edge-crit` — an edge on the critical path, in the accent.
2525 - `.map-edge-done` — an edge with a closed issue at either end, faded.
26 - `.map-toggle` — the link beside the counts that swaps between "Show done" (`?done=1`) and "Open only"; padded, 44px tall under `pointer: coarse`. It is a link, not a badge.
2627 - `.map-ask` — a pillar row's lead-time figures in the side column, inline, with the hover title saying what they measure.
src/web/pages/map.ts +37 −7
@@ -930,7 +930,10 @@ export function countWords(words: readonly Word[]): Record<Word, number> {
930930 return out;
931931 }
932932
933 function countsLine(counts: Record<Word, number>): Html {
933 function countsLine(
934 counts: Record<Word, number>,
935 toggle?: { readonly base: string; readonly showDone: boolean },
936 ): Html {
934937 const always = new Set<Word>(["ready", "working", "blocked", "done"]);
935938 const tone: Partial<Record<Word, string>> = {
936939 ready: "success",
@@ -945,6 +948,13 @@ function countsLine(counts: Record<Word, number>): Html {
945948 (w) =>
946949 html`<span class="badge" data-variant="${tone[w] ?? "secondary"}" data-state="${w}">${w} <b>${counts[w]}</b></span> `,
947950 )}
951 ${toggle === undefined || counts.done === 0
952 ? null
953 : html`<a
954 class="map-toggle"
955 href="${toggle.showDone ? toggle.base : `${toggle.base}?done=1`}"
956 >${toggle.showDone ? "Open only" : "Show done"}</a
957 >`}
948958 </span>`;
949959 }
950960
@@ -1168,6 +1178,8 @@ export const mapCss = /* css */ `
11681178
11691179 /* ---- beside the drawing ---- */
11701180 .map-ask { display: inline; }
1181 .map-toggle { display: inline-block; padding: 2px 8px; font-size: 12.5px; }
1182 @media (pointer: coarse) { .map-toggle { display: inline-flex; align-items: center; min-height: 44px; padding: 0 12px; } }
11711183
11721184 /* ---- lanes: §5 list rows that fold ---- */
11731185 #lanes { margin-top: 16px; }
@@ -1349,15 +1361,32 @@ const studioEmpty = (base: string): MapEmpty => ({
13491361 action: btn("Say one", { href: `${base}#tell`, variant: "primary" }),
13501362 });
13511363
1352 /** GET /:owner/:name/map */
1364 /**
1365 * The graph as the page draws it by default: open issues and the edges
1366 * between them. Readiness is judged on the whole graph, not on this.
1367 */
1368 function openOnly(graph: Graph): Graph {
1369 const issues = graph.issues.filter((i) => i.state === "open");
1370 const kept = new Set(issues.map((i) => i.id));
1371 return {
1372 ...graph,
1373 issues,
1374 edges: graph.edges.filter(
1375 (e) => kept.has(e.blockedById) && kept.has(e.issueId),
1376 ),
1377 };
1378 }
1379
1380 /** GET /:owner/:name/map (`?done=1` draws the closed issues too) */
13531381 export async function mapPage(ctx: Ctx): Promise<Response> {
13541382 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
13551383 const repo = loadRepo(ctx);
13561384 if (!repo) return notFoundPage(ctx);
13571385 const db = ctx.app.db;
13581386 const studio = studioRecord(ctx, repo) !== undefined;
1387 const showDone = ctx.url.searchParams.get("done") === "1";
13591388 // The read: graph, marks and pillars, one phase; `render` is the page's own.
1360 const { graph, ready, blocked, critical, placed, marks, readySet, counts, pillars, ask } =
1389 const { shown, graph, ready, blocked, critical, placed, marks, readySet, counts, pillars, ask } =
13611390 timePhase(ctx.request, "map", () => {
13621391 const graph = repoGraph(db, repo.id, {
13631392 ...sealedScope(db, repo, ctx.auth.user),
@@ -1365,7 +1394,8 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
13651394 const ready = readyNow(graph);
13661395 const blocked = blockedOn(graph);
13671396 const critical = criticalPath(graph);
1368 const placed = cachedLayout(graph, layout);
1397 const shown = showDone ? graph : openOnly(graph);
1398 const placed = cachedLayout(shown, layout);
13691399 const marks = marksFor(db, repo.id, graph, listActiveRuns(db, [repo.id]));
13701400 const readySet = new Set(ready.map((r) => r.issue.id));
13711401 const counts = countWords(
@@ -1373,7 +1403,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
13731403 );
13741404 const pillars = pillarView(db, repo);
13751405 const ask = askToLiveFor(ctx.app, repo, pillars, ctx.auth.user);
1376 return { graph, ready, blocked, critical, placed, marks, readySet, counts, pillars, ask };
1406 return { shown, graph, ready, blocked, critical, placed, marks, readySet, counts, pillars, ask };
13771407 });
13781408 const rendering = performance.now();
13791409 const body = html`${repoHeader(ctx, repo, "map")}
@@ -1392,13 +1422,13 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
13921422 data-live-repo="${repo.ownerName}/${repo.name}"
13931423 >
13941424 <div class="map-main">
1395 ${drawMap(repo, graph, placed, emphasisFor(ready, critical, marks), {
1425 ${drawMap(repo, shown, placed, emphasisFor(ready, critical, marks), {
13961426 id: "map-canvas",
13971427 cap: 1,
13981428 issueHref: tellHref,
13991429 ...(studio ? { empty: studioEmpty(repoBase(repo)) } : {}),
14001430 })}
1401 ${placed.nodes.length === 0 ? null : countsLine(counts)}
1431 ${placed.nodes.length === 0 && counts.done === 0 ? null : countsLine(counts, { base: `${repoBase(repo)}/map`, showDone })}
14021432 </div>
14031433 <aside class="stack">
14041434 ${pillars === undefined
test/map-page.test.ts added +94
@@ -0,0 +1,94 @@
1 /** `/:owner/:name/map` draws open issues; `?done=1` draws the closed ones too (#6174). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { openIssue } from "../src/actions/issues.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { addDependency } from "../src/core/dependencies.ts";
10 import { setIssueState } from "../src/core/issues.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 const CLOSED = 2000;
18 const OPEN = 300;
19 let root = "";
20 let app: App;
21 let servers: Servers;
22 let cookie = "";
23
24 beforeAll(async () => {
25 root = mkdtempSync(join(tmpdir(), "forge-map-page-"));
26 app = createApp({ dataDir: root, port: 0 });
27 const u = await createUser(app.db, {
28 origin: "seed",
29 trusted: true,
30 username: "alice",
31 email: "[email protected]",
32 password: "a-good-passphrase",
33 adminIfFirst: true,
34 });
35 if (!u.ok) throw new Error(u.error);
36 const alice = u.value;
37 const made = await createRepo(app, alice, { name: "big", visibility: "private" });
38 if (!made.ok) throw new Error(made.error);
39 const repo = made.value;
40 const open = [];
41 for (let i = 0; i < OPEN; i++) {
42 const o = await openIssue(app, alice, repo, { title: `Open work ${i}` });
43 if (!o.ok) throw new Error(o.error);
44 open.push(o.value);
45 }
46 for (let i = 0; i < CLOSED; i++) {
47 const c = await openIssue(app, alice, repo, { title: `Closed work ${i}` });
48 if (!c.ok) throw new Error(c.error);
49 const dep = addDependency(app.db, open[i % OPEN]!, c.value, alice.id);
50 if (!dep.ok) throw new Error(dep.error);
51 setIssueState(app.db, c.value.id, "closed");
52 }
53 servers = await serve(app);
54 const res = await fetch(`${servers.url}/signin`, {
55 method: "POST",
56 headers: { origin: servers.url },
57 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
58 redirect: "manual",
59 });
60 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
61 }, 120_000);
62 afterAll(() => {
63 servers.stop();
64 app.close();
65 rmSync(root, { recursive: true, force: true });
66 });
67
68 const get = async (path: string): Promise<string> => {
69 const res = await fetch(`${servers.url}${path}`, { headers: { cookie } });
70 expect(res.status).toBe(200);
71 return res.text();
72 };
73
74 test("the default map draws only open issues, under 400 KB, with a Show done link", async () => {
75 const body = await get("/alice/big/map");
76 expect(body).toContain("Open work 0");
77 expect(body).not.toContain("Closed work");
78 expect(Buffer.byteLength(body)).toBeLessThan(400 * 1024);
79 expect(body).toContain(`<span class="badge" data-variant="outline" data-state="done">done <b>${CLOSED}</b></span>`);
80 expect(body).toMatch(/<a\s+class="map-toggle"\s+href="\/alice\/big\/map\?done=1"\s*>Show done/);
81 expect(body).not.toContain("Open only");
82 });
83
84 test("a closed blocker still frees what it held", async () => {
85 const body = await get("/alice/big/map");
86 expect(body).toMatch(/ready\s*<b>300<\/b>/);
87 });
88
89 test("?done=1 draws the closed issues and offers Open only", async () => {
90 const body = await get("/alice/big/map?done=1");
91 expect(body).toContain("Closed work 1999");

Trailing whitespace after the semicolon; remove it.

92 expect(body).toMatch(/<a\s+class="map-toggle"\s+href="\/alice\/big\/map"\s*>Open only/);
93 expect(body).not.toContain("Show done");
94 });
test/map.test.ts +11 −2
@@ -347,7 +347,16 @@ describe("the map, per app and across the forge", () => {
347347 expect(card("Extract renderer")).toContain('class="map-breath"');
348348 expect(card("Scene graph")).toContain('data-state="blocked"');
349349 expect(card("Write the docs")).toContain('data-state="ready"');
350 expect(card("Old bootstrap")).toContain('data-state="done"');
350 // Done cards are drawn only on `?done=1` (#6174).
351 expect(card("Old bootstrap")).toBe("");
352 const withDone = await (
353 await fetch(`${base}/alice/engine/map?done=1`, { headers: { cookie } })
354 ).text();
355 expect(
356 withDone.match(
357 /<a[^>]*class="map-node"[^>]*>(?:(?!<\/a>)[\s\S])*Old bootstrap/,
358 )?.[0],
359 ).toContain('data-state="done"');
351360 expect(card("Input mapping")).toContain('data-state="ready"');
352361 expect(card("Physics step")).toContain('data-state="waiting"');
353362 expect(card("Shader hot reload")).toContain('data-state="parked"');
@@ -357,7 +366,7 @@ describe("the map, per app and across the forge", () => {
357366 // The chain 1 → 2 is the critical path, in the accent.
358367 expect(page).toContain('class="map-edge map-edge-crit"');
359368 // The edge from a done issue recedes with it.
360 expect(page).toContain('class="map-edge map-edge-done"');
369 expect(withDone).toContain('class="map-edge map-edge-done"');
361370 // The line under the drawing counts the words.
362371 expect(page).toMatch(
363372 /data-state="ready"[^>]*>ready <b>2<\/b><\/span>/,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs