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

The map is the owner's roadmap: pillars in order, what's building, what landed, what's next, and when each finishes #10306

Merged opened this in answer to

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

Merged

scout/forge:scout/10303-the-map-is-the-owner-s-roadmap-pillars-i → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/10303-the-map-is-the-owner-s-roadmap-pillars-i was merged into main as 6987dae.

Review

Sign in to review this change.
21 files changed +987 −12
docs/PLANNING.md +5
@@ -99,6 +99,11 @@ closed per day drawn as bars. They share a chart and not an axis, because
9999 a level and a flow are different units: the line says whether the plan is
100100 converging, the bars say whether anything is moving.
101101
102 ## The roadmap
103
104 `/:owner/:name/map` opens on the roadmap (#10303), `?view=graph` is the
105 drawing. How to read it and where else it is: [docs/features/roadmap.md](features/roadmap.md).
106
102107 The same three lists are data at `GET /api/v1/repos/:owner/:name/map`,
103108 which is what an agent reads before it starts (`issues` and `edges` hold only open issues; `?done=1`, or `done: true` on MCP `map`, brings the closed ones back; `counts.done` always says how many) and what a repository in
104109 autonomous mode reads to pick the next thing. Both carry where the crew
docs/design/areas/roadmap.md added +14
@@ -0,0 +1,14 @@
1 # Roadmap (the map's first view, #10303)

Both doors: add src/tui/ screen for the roadmap and its how-to as docs/features/roadmap.md, not in PLANNING.md.

2
3 The pillars in order, one `row()` each, folded (`fold`): the current pillar
4 open, the rest closed. Inside a pillar, groups of rows (Building, Up next,
5 Waiting on a pull, Blocked, Done this week). Needs you is a list of rows.
6 No border, no card, no progress bar; the pace is one sentence.
7
8 New classes, besides the live region `.rm`:
9
10 - `.rm-group` (with its `h3`): a muted small heading over a group of rows
11 inside a folded pillar; no existing component names a group of rows.
12 - `.rm-moved`: the "moved" marker after a title, muted (never accent: it is
13 not interactive).
14 - `.rm-pace`: the one-sentence pace line (a plain paragraph).
docs/features/roadmap.md added +26
@@ -0,0 +1,26 @@
1 # The roadmap (#10303)
2
3 `/:owner/:name/map` opens on the roadmap for a repository that names pillars:
4 one sentence of pace (landed today and in 7 days, and when the current pillar
5 finishes, or that it has no date yet), a Needs you list that appears only when
6 something waits on a writer (a held pull, an umbrella with nothing open in
7 front of it), then the pillars in order, Later last. The current pillar is
8 open; the rest fold. A pillar shows Building, Up next, Waiting on a pull,
9 Blocked and Done this week.
10
11 A pillar's ETA is its open work over its 7-day merge rate (`mergedByPillar`);
12 with no merges there is no date. The drawing stays at `?view=graph` (and
13 `?done=1`); a repository without pillars still opens on the drawing. The page
14 is a live region, and an HttpOnly cookie of the last two visits marks what
15 moved since the previous one.
16
17 Reads follow the reader: a guest sees only what is public, sealed issues stay
18 sealed, and Needs you is shown to writers only.
19
20 ## Where it is
21
22 - Page: `/:owner/:name/map`.
23 - API: `GET /api/v1/repos/:owner/:name/roadmap` (404 without pillars).
24 - MCP: `map` with `roadmap: true`.
25 - Terminal: the palette command "Roadmap of a repository" (`owner/name`);
26 `j`/`k` scroll, `esc` closes. Same model, `src/web/roadmap.ts`.
src/contract/openapi/routes/getApiV1ReposOwnerNameRoadmap.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "get", path: `${REPO}/roadmap`, summary: "Read the roadmap: the repository's pillars in order, what is building, what landed, what is next and when each finishes; 404 where the repository names no pillars", auth: false };
src/contract/openapi/routes/index.ts +1
@@ -152,6 +152,7 @@ export { route as getApiV1ReposOwnerNamePullsNumberReviews } from "./getApiV1Rep
152152 export { route as getApiV1ReposOwnerNamePullsNumberThreads } from "./getApiV1ReposOwnerNamePullsNumberThreads.ts";
153153 export { route as getApiV1ReposOwnerNameQueueGates } from "./getApiV1ReposOwnerNameQueueGates.ts";
154154 export { route as getApiV1ReposOwnerNameReports } from "./getApiV1ReposOwnerNameReports.ts";
155 export { route as getApiV1ReposOwnerNameRoadmap } from "./getApiV1ReposOwnerNameRoadmap.ts";
155156 export { route as getApiV1ReposOwnerNameRuns } from "./getApiV1ReposOwnerNameRuns.ts";
156157 export { route as getApiV1ReposOwnerNameRunsId } from "./getApiV1ReposOwnerNameRunsId.ts";
157158 export { route as getApiV1ReposOwnerNameRunsIdLog } from "./getApiV1ReposOwnerNameRunsIdLog.ts";
src/http/api.ts +20
@@ -242,6 +242,7 @@ import {
242242 import { forReader } from "../crew/handover.ts";
243243 import { globalMapJson } from "../web/pages/global-map.ts";
244244 import { mapJson, marksFor, withoutDone } from "../web/pages/map.ts";
245 import { roadmapOf } from "../web/roadmap.ts";
245246 import { cachedMap } from "../web/map_snapshot.ts";
246247 import { scopeKey } from "../web/map_cache.ts";
247248 import {
@@ -2364,6 +2365,25 @@ export function apiRoutes(router: Router<Handler>): void {
23642365 }
23652366 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
23662367 });
2368 // The roadmap (#10303): the pillars in order, what is building, what landed,
2369 // what is next and when each finishes. The page's own model; 404 where the
2370 // repository names no pillars.
2371 router.add("GET", "/api/v1/repos/:owner/:name/roadmap", (ctx) => {
2372 const repo = loadRepo(ctx);
2373 if (!repo) return notFound();
2374 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
2375 const road = timePhase(ctx.request, "render", () =>
2376 roadmapOf(ctx.app.db, repo, ctx.auth.user),
2377 );
2378 if (road === undefined) return notFound();
2379 return new Response(JSON.stringify(road), {
2380 headers: {
2381 "content-type": "application/json",
2382 "cache-control": "private, no-cache",
2383 vary: "authorization, cookie",
2384 },
2385 });
2386 });
23672387 // What people asked of the crew in the repositories the caller can write —
23682388 // by default what has nothing merged yet, oldest first — and what became of each (core/asked.ts, the /asked page).
23692389 router.add("GET", "/api/v1/asked", (ctx) => {
src/http/mcp/tools/map.ts +7 −2
@@ -4,18 +4,19 @@ import { mapHeldFor } from "../../../crew/held.ts";
44 import { pillarView } from "../../../core/pillars.ts";
55 import { askToLiveFor } from "../../../web/ask_to_live.ts";
66 import { listActiveRuns } from "../../../core/runs.ts";
7 import { roadmapOf } from "../../../web/roadmap.ts";
78 import { mapJson, marksFor, withoutDone } from "../../../web/pages/map.ts";
89 import { putIssueFirst } from "../../../actions/first.ts";
910 import { bad, good, type Tool, APP_ARG, planRepo, isAnswer, issueArg } from "../shared.ts";
1011
1112 export const tool: Tool = {
1213 name: "map",
13 doc: "an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`, `done` to list closed issues too; `first: <number>` puts that ready issue first, for writers: a reader is forbidden, a closed or blocked issue invalid); draws the `ui://forge/map` card",
14 doc: "an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`, `done` to list closed issues too; `roadmap` for the pillars in order with ETAs; `first: <number>` puts that ready issue first, for writers: a reader is forbidden, a closed or blocked issue invalid); draws the `ui://forge/map` card",
1415 description:
1516 "An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first.",
1617 inputSchema: {
1718 type: "object",
18 properties: { app: APP_ARG, done: { type: "boolean" }, first: { type: "integer", minimum: 1 } },
19 properties: { app: APP_ARG, done: { type: "boolean" }, roadmap: { type: "boolean" }, first: { type: "integer", minimum: 1 } },
1920 required: ["app"],
2021 },
2122 writes: true,
@@ -29,6 +30,10 @@ export const tool: Tool = {
2930 const put = putIssueFirst(ctx.app, user, repo, issue.number);
3031 if (!put.ok) return bad(put.error.kind === "not_found" ? "no_such_issue" : put.error.kind, put.error.message);
3132 }
33 if (args["roadmap"] === true) {
34 const road = roadmapOf(ctx.app.db, repo, user);
35 return road === undefined ? bad("no_pillars", "this app names no pillars") : good({ ...road });
36 }
3237 const scope = sealedScope(ctx.app.db, repo, user);
3338 const graph = repoGraph(ctx.app.db, repo.id, { ...scope });
3439 const pillars = pillarView(ctx.app.db, repo);
src/tui/forge.ts +6
@@ -16,6 +16,7 @@ import { canRead, getRepo, type Repo } from "../core/repos.ts";
1616 import { sourceFor } from "../view/source.ts";
1717 import { fleetCommand } from "./screens/fleet.ts";
1818 import { packsCommand } from "./screens/packs.ts";
19 import { roadmapScreen } from "./screens/roadmap.ts";
1920 import { App, type Ctx, type Screen } from "./app/app.ts";
2021 import type { Command, PaletteItem, PaletteSearch } from "./app/palette.ts";
2122 import { G } from "./term/screen.ts";
@@ -126,6 +127,11 @@ const REPO_VERBS: readonly {
126127 title: "Held by the Later gate",
127128 open: (f, ctx, repo) => heldScreen(f, ctx, repo),
128129 },
130 {
131 id: "roadmap",
132 title: "Roadmap of a repository",
133 open: (f, ctx, repo) => roadmapScreen(f, ctx, repo),
134 },
129135 {
130136 id: "pulls",
131137 title: "Pull requests in a repository",
src/tui/screens/roadmap.ts added +73
@@ -0,0 +1,73 @@
1 /**
2 * The roadmap in the terminal (#10303): the same model the map page, the API
3 * and the MCP read (`web/roadmap.ts`), as scrolling text lines. The reader is
4 * the session's person, so a guest sees what is public and only a writer
5 * gets Needs you.
6 */
7
8 import type { Repo } from "../../core/repos.ts";
9 import { paceSentence, roadmapOf, type Roadmap, type RoadmapIssue } from "../../web/roadmap.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import type { Session } from "../session.ts";
13 import { style } from "../term/style.ts";
14 import { wrap } from "../term/width.ts";
15 import { Scroll } from "./common.ts";
16
17 const issueLine = (i: RoadmapIssue): string =>
18 ` #${i.number} ${i.title}${i.pull === undefined ? "" : ` (pull #${i.pull})`}${i.blockedBy === undefined ? "" : ` (waits for #${i.blockedBy.number})`}${i.moved ? " (moved)" : ""}`;
19
20 /** The roadmap as text lines, wrapped to `width`. */
21 export function roadmapLines(road: Roadmap, width = 80): string[] {
22 const w = Math.max(20, width);
23 const out = [...wrap(paceSentence(road), w), ""];
24 if (road.needsYou.length > 0) {
25 out.push("Needs you");
26 for (const n of road.needsYou) out.push(...wrap(` ${n.text} (${n.pull ? "pull" : "issue"} #${n.number}: ${n.action})`, w));
27 out.push("");
28 }
29 for (const p of road.pillars) {
30 const state = p.state === "done" ? "done" : p.total === 0 ? "nothing yet" : `${p.done} of ${p.total} done`;
31 out.push(`${p.name} ${state}${p.state === "current" ? ` ${p.etaAt === null ? "no date yet" : `around ${p.etaAt.slice(0, 10)}`}` : ""}`);
32 if (p.state === "future") continue;
33 const g = p.groups;
34 for (const [title, rows] of [
35 ["Building", g.building],
36 ["Up next", g.upNext],
37 ["Waiting on a pull", g.waiting],
38 ["Blocked", g.blocked],
39 ["Done this week", g.doneThisWeek],
40 ] as const) {
41 if (rows.length === 0) continue;
42 out.push(` ${title}`);
43 for (const i of rows) out.push(issueLine(i));
44 }
45 }
46 return out;
47 }
48
49 export function roadmapScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
50 const scroll = new Scroll();
51 let lines: string[] = [];
52 return {
53 title: `Roadmap · ${repo.ownerName}/${repo.name}`,
54 keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")],
55 render(area, s) {
56 const c = ctx.theme.c;
57 const road = roadmapOf(f.app.db, repo, f.user);
58 lines = road === undefined ? ["This repository names no pillars."] : roadmapLines(road, area.w - 4);
59 const from = scroll.window(area.h, lines.length);
60 for (let i = 0; i < area.h; i++) {
61 const line = lines[from + i];
62 if (line !== undefined) s.text(area.x + 1, area.y + i, line, style(c.fg, c.bg), area.x + area.w);
63 }
64 },
65 key(e) {
66 if (e.key === "esc" || e.key === "q") {
67 ctx.pop();
68 return true;
69 }
70 return scroll.key(e, lines.length);
71 },
72 };
73 }
src/web/pages/map.ts +77 −1
@@ -75,6 +75,10 @@ import {
7575 type PillarView,
7676 } from "../../core/pillars.ts";
7777 import { askToLiveFor } from "../ask_to_live.ts";
78 import { roadmapOf } from "../roadmap.ts";
79 import { roadmapBody, roadmapCss } from "./roadmap.ts";
80 import { cookieValue } from "../../http/context.ts";
81 import { secureFor } from "../../http/cookie_secure.ts";
7882
7983 /** Where a card's own link goes: dev's issue page, by default. */
8084 const devIssueHref = (base: string, number: number): string =>
@@ -1378,11 +1382,83 @@ function openOnly(graph: Graph): Graph {
13781382 };
13791383 }
13801384
1381 /** GET /:owner/:name/map (`?done=1` draws the closed issues too) */
1385 /** A visit older than this is a new visit: what moved since is marked. */
1386 const VISIT_MS = 30 * 60_000;
1387
1388 /**
1389 * When the reader last looked, from their cookie `prev~cur`: `cur` is this
1390 * visit's start, `prev` the one before it. A live swap (a fetch, not a
1391 * navigation) never moves them, so what is marked stays marked.
1392 */
1393 export function lastLooked(
1394 header: string | undefined,
1395 now: Date,
1396 navigating: boolean,
1397 ): { since: string | undefined; cookie: string | undefined } {
1398 const [prev, cur] = (header ?? "").split("~");
1399 const valid = (t: string | undefined): string | undefined =>
1400 t !== undefined && /^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(t) ? t : undefined;
1401 let p = valid(prev);
1402 let c = valid(cur);
1403 if (!navigating) return { since: p ?? c, cookie: undefined };
1404 if (c === undefined) c = now.toISOString();
1405 else if (now.getTime() - Date.parse(c) > VISIT_MS) {
1406 p = c;
1407 c = now.toISOString();
1408 }
1409 return { since: p, cookie: `${p ?? ""}~${c}` };
1410 }
1411
1412 /** GET /:owner/:name/map (`?done=1` draws the closed issues too; `?view=graph` is the drawing) */
13821413 export async function mapPage(ctx: Ctx): Promise<Response> {
13831414 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
13841415 const repo = loadRepo(ctx);
13851416 if (!repo) return notFoundPage(ctx);
1417 if (
1418 ctx.url.searchParams.get("view") !== "graph" &&
1419 ctx.url.searchParams.get("done") !== "1"
1420 ) {
1421 const name = `rm_${repo.ownerName}_${repo.name}`.replace(/[^A-Za-z0-9_]/g, "_");
1422 const mode = ctx.request.headers.get("sec-fetch-mode");
1423 const seen = lastLooked(
1424 cookieValue(ctx.request, name),
1425 new Date(),
1426 mode === null || mode === "navigate",
1427 );
1428 const road = roadmapOf(ctx.app.db, repo, ctx.auth.user, {
1429 ...(seen.since === undefined ? {} : { since: seen.since }),
1430 });
1431 if (road !== undefined) {
1432 const base = repoBase(repo);
1433 const response = page({
1434 ctx,
1435 title: `Roadmap · ${repo.name}`,
1436 crumbs: crumbsFor(repo),
1437 palette: repoPalette(ctx, repo),
1438 body: html`${repoHeader(ctx, repo, "map")}
1439 <div class="page-head mt">
1440 <h1>Roadmap</h1>
1441 <p class="muted small"><a href="${base}/map?view=graph">The graph</a></p>
1442 </div>
1443 <div
1444 class="rm"
1445 id="roadmap-live"
1446 data-live="${LIVE_KINDS}"
1447 data-live-repo="${repo.ownerName}/${repo.name}"
1448 >
1449 ${roadmapBody(base, road)}
1450 </div>`,
1451 styleCss: roadmapCss,
1452 scriptJs: liveRegionsJs,
1453 });
1454 if (seen.cookie !== undefined)
1455 response.headers.append(
1456 "set-cookie",
1457 `${name}=${seen.cookie}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly${secureFor(ctx.url, ctx.app.config.url) ? "; Secure" : ""}`,
1458 );
1459 return response;
1460 }
1461 }
13861462 const db = ctx.app.db;
13871463 const studio = studioRecord(ctx, repo) !== undefined;
13881464 const showDone = ctx.url.searchParams.get("done") === "1";
src/web/pages/roadmap.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * The roadmap view of the map (#10303): the pillars in the order the crew
3 * works them, one row each (the `row()` component, folded). One pace
4 * sentence, what waits on the owner, then the pillars: the current one open,
5 * the rest folded. A tap opens a pillar in place (a `<details>`, so no script);
6 * the drawing stays one link away at `?view=graph`. A live region: the page
7 * is swapped under the reader without losing their place or an open fold.
8 */
9
10 import { html, type Html } from "../html.ts";
11 import { avatar, row } from "../ui.ts";
12 import { finishWords, paceSentence } from "../roadmap.ts";
13 import type {
14 NeedsYou,
15 Roadmap,
16 RoadmapIssue,
17 RoadmapPillar,
18 } from "../roadmap.ts";
19
20 export const roadmapCss = /* css */ `
21 .rm { display: grid; gap: 24px; margin-top: 4px; }
22 .rm-pace { margin: 0; }
23 .rm-group h3 { margin: 12px 0 4px; font-size: 13px; color: var(--muted); font-weight: 600; }
24 .rm-moved { margin-left: 6px; font-size: 11.5px; color: var(--muted); }
25 `;
26
27 function needs(base: string, rows: readonly NeedsYou[]): Html | null {
28 if (rows.length === 0) return null;
29 return html`<section id="rm-needs" aria-label="Needs you">
30 <h2 class="small muted">Needs you</h2>
31 <ul class="list">
32 ${rows.map((r) =>
33 row({
34 title: r.text,
35 href: `${base}/${r.pull ? "pulls" : "issues"}/${r.number}`,
36 trail: html`${r.action}`,
37 }),
38 )}
outdated

Design principle 5: --accent is for interaction alone; the 'moved' marker is not interactive. Use var(--muted).

39 </ul>
40 </section>`;
41 }
42
43 function issueRow(base: string, i: RoadmapIssue): Html {
44 const bits: string[] = [];
45 if (i.pull !== undefined) bits.push(`pull #${i.pull}`);
46 if (i.blockedBy !== undefined) bits.push(`waits for #${i.blockedBy.number} ${i.blockedBy.title}`);
47 return row({
48 ...(i.assignee === null ? {} : { lead: avatar(i.assignee) }),
49 title: html`#${i.number} ${i.title}${i.moved ? html`<span class="rm-moved">moved</span>` : null}`,
50 href: `${base}/${i.pull === undefined ? "issues" : "pulls"}/${i.pull ?? i.number}`,
51 ...(bits.length === 0 ? {} : { meta: html`${bits.join(" · ")}` }),
52 });
53 }
54
55 function group(base: string, title: string, rows: readonly RoadmapIssue[]): Html | null {
56 if (rows.length === 0) return null;
57 return html`<section class="rm-group" data-group="${title}">
58 <h3>${title} · ${rows.length}</h3>
59 <ul class="list">${rows.map((i) => issueRow(base, i))}</ul>
60 </section>`;
61 }
62
63 function meta(p: RoadmapPillar): string {
64 if (p.state === "done") return "done";
65 if (p.total === 0) return "nothing yet";
66 return `${p.done} of ${p.total} done${p.state === "current" ? ` · ${finishWords(p.etaAt)}` : ""}`;
67 }
68
69 function pillarRow(base: string, p: RoadmapPillar): Html {
70 const g = p.groups;
71 const landed =
72 g.doneThisWeek.length === 0 && p.lastLanding !== null && p.state !== "future"
73 ? [p.lastLanding]
74 : [];
75 return row({
76 id: `pillar-${p.short.toLowerCase()}`,
77 title: html`${p.name}${p.movedCount > 0 ? html`<span class="rm-moved">${p.movedCount} moved</span>` : null}`,
78 meta: html`${meta(p)}`,
79 state: p.state === "done" ? "closed" : "open",
80 data: { pillar: p.state },
81 fold: {
82 id: `pillar-${p.short.toLowerCase()}-fold`,
83 open: p.state === "current",
84 body: html`${group(base, "Building", g.building)}${group(base, "Up next", g.upNext)}${group(base, "Waiting on a pull", g.waiting)}${group(base, "Blocked", g.blocked)}${group(base, "Done this week", g.doneThisWeek)}${group(base, "Last landed", landed)}
85 <p class="small muted">${p.umbrella === null ? null : html`<a href="${base}/issues/${p.umbrella}">The pillar's issue</a> · `}<a href="${base}/map?view=graph">Draw the graph</a></p>`,
86 },
87 });
88 }
89
90 /** The region's inner markup; the live swap replaces exactly this. */
91 export function roadmapBody(base: string, road: Roadmap): Html {
92 return html`<p class="rm-pace" id="rm-pace">${paceSentence(road)}</p>${needs(base, road.needsYou)}
93 <ul class="list" id="rm-pillars">${road.pillars.map((p) => pillarRow(base, p))}</ul>`;
94 }
src/web/roadmap.ts added +294
@@ -0,0 +1,294 @@
1 /**
2 * The roadmap (#10303): a repository's pillars in the order the crew works
3 * them, as plain data. What is building, what landed, what is next and when
4 * each pillar should finish. One model for the map page, the API and the MCP.
5 *
6 * Order, membership and served-by come from `core/pillars.ts`; the pace from
7 * `mergedByPillar`. A pillar's ETA is its open work over its seven-day merge
8 * rate, or none where nothing merged (a rate of zero projects no date).
9 */
10
11 import type { Database } from "bun:sqlite";
12 import {
13 idleFurniture,
14 readyNow,
15 repoGraph,
16 type Graph,
17 } from "../core/dependencies.ts";
18 import type { Issue } from "../core/issues.ts";
19 import {
20 LATER,
21 mergedByPillar,
22 pillarsOf,
23 PILLAR_DAYS,
24 servedBy,
25 } from "../core/pillars.ts";
26 import { canWrite, type Repo } from "../core/repos.ts";
27 import { listActiveRuns } from "../core/runs.ts";
28 import { sealedScope } from "../core/sealed.ts";
29 import type { User } from "../core/users.ts";
30 import { proposedWork } from "../crew/direct.ts";
31 import { marksFor, wordFor, type Marks } from "./pages/map.ts";
32
33 /** An issue as the roadmap names it. */
34 export interface RoadmapIssue {
35 readonly number: number;
36 readonly title: string;
37 readonly assignee: string | null;
38 /** The pull that waits for a person, where there is one. */
39 readonly pull?: number;
40 /** For a blocked issue: the open issue in front of it. */
41 readonly blockedBy?: { readonly number: number; readonly title: string };
42 /** When it last moved. */
43 readonly at: string;
44 /** Moved since the reader's previous visit. */
45 readonly moved: boolean;
46 }
47
48 export type PillarState = "done" | "current" | "future";
49
50 export interface RoadmapPillar {
51 /** `P3`, or `Later`. */
52 readonly short: string;
53 /** `P3 Cloud`, or `Later`. */
54 readonly name: string;
55 /** The umbrella's issue number, where there is one. */
56 readonly umbrella: number | null;
57 readonly state: PillarState;
58 /** Done and counted issues; dropped, split and parked closes are neither. */
59 readonly done: number;
60 readonly total: number;
61 /** Pulls merged for it in the last seven days. */
62 readonly merged: number;
63 readonly building: readonly RoadmapIssue[];
64 readonly next: RoadmapIssue | null;
65 readonly lastLanding: RoadmapIssue | null;
66 /** Days to finish at the seven-day rate; null with no rate or nothing open. */
67 readonly etaDays: number | null;
68 readonly etaAt: string | null;
69 readonly groups: {
70 readonly building: readonly RoadmapIssue[];
71 readonly upNext: readonly RoadmapIssue[];
72 readonly waiting: readonly RoadmapIssue[];
73 readonly blocked: readonly RoadmapIssue[];
74 readonly doneThisWeek: readonly RoadmapIssue[];
75 };
76 /** Rows that moved since the reader last looked. */
77 readonly movedCount: number;
78 }
79
80 export interface NeedsYou {
81 readonly kind: "hold" | "close";
82 readonly text: string;
83 readonly action: string;
84 /** Issue number the action opens. */
85 readonly number: number;
86 readonly pull: boolean;
87 }
88
89 export interface Roadmap {
90 readonly generatedAt: string;
91 readonly pace: {
92 readonly landed24h: number;
93 readonly landed7d: number;
94 readonly days: number;
95 readonly current: string | null;
96 readonly etaDays: number | null;
97 readonly etaAt: string | null;
98 };
99 readonly needsYou: readonly NeedsYou[];
100 /** Pillars in order; Later, when the repository has one, is last. */
101 readonly pillars: readonly RoadmapPillar[];
102 }
103
104 const DAY = 86_400_000;
105 const NEXT_MAX = 5;
106 const WEEK_MAX = 8;
107
108 /** Days to clear `open` issues at `merged` pulls in `PILLAR_DAYS` days. */
109 export function etaDays(open: number, merged: number): number | null {
110 if (open <= 0 || merged <= 0) return null;
111 return Math.max(1, Math.ceil((open * PILLAR_DAYS) / merged));
112 }
113
114 /** The repository's roadmap, or undefined where it names no pillars. */
115 export function roadmapOf(
116 db: Database,
117 repo: Repo,
118 user: User | undefined,
119 options: { now?: Date; since?: string } = {},
120 ): Roadmap | undefined {
121 const now = options.now ?? new Date();
122 const since = options.since ?? "9999";
123 const set = pillarsOf(db, repo);
124 if (set.pillars.length === 0) return undefined;
125 const served = servedBy(db, set);
126 const scope = sealedScope(db, repo, user);
127 const graph: Graph = repoGraph(db, repo.id, { ...scope, pillars: set, lean: true });
128 const marks: Marks = marksFor(db, repo.id, graph, listActiveRuns(db, [repo.id]));
129 const ready = readyNow(graph);
130 const readySet = new Set(ready.map((r) => r.issue.id));
131 const readyOrder = new Map(ready.map((r, n) => [r.issue.id, n]));
132 const weekAgo = new Date(now.getTime() - PILLAR_DAYS * DAY).toISOString();
133 const dayAgo = new Date(now.getTime() - DAY).toISOString();
134 const week = mergedByPillar(db, repo.id, set, served, weekAgo);
135 const day = mergedByPillar(db, repo.id, set, served, dayAgo);
136 const sum = (rows: readonly { merged: number }[]): number =>
137 rows.reduce((n, r) => n + r.merged, 0);
138
139 const pulls = proposedWork(db, repo);
140 const byId = new Map(graph.issues.map((i) => [i.id, i]));
141 const blocker = new Map<string, Issue>();
142 for (const edge of graph.edges) {
143 const b = byId.get(edge.blockedById);
144 if (b !== undefined && b.state === "open" && !blocker.has(edge.issueId))
145 blocker.set(edge.issueId, b);
146 }
147
148 const pullOf = new Map<string, number>();
149 for (const [number, pull] of pulls) {
150 const issue = graph.issues.find((i) => i.number === number);
151 if (issue !== undefined) pullOf.set(issue.id, pull.number);
152 }
153 const item = (i: Issue, extra: Partial<RoadmapIssue> = {}): RoadmapIssue => {
154 const at = i.state === "closed" ? (i.closedAt ?? i.updatedAt) : i.updatedAt;
155 return {
156 number: i.number,
157 title: i.title,
158 assignee: i.assigneeName,
159 at,
160 moved: at > since,
161 ...extra,
162 };
163 };
164
165 const names = [...set.pillars.map((p) => p.name)];
166 const later = set.gate !== undefined || set.later !== undefined;
167 if (later) names.push(LATER);
168 const rows = names.map((name, n): RoadmapPillar => {
169 const pillar = set.pillars[n];
170 const issues = graph.issues.filter(
171 (i) => !graph.structural.has(i.id) && served.get(i.id) === name,
172 );
173 const open = issues.filter((i) => i.state === "open");
174 const closed = issues.filter(
175 (i) => i.state === "closed" && (i.closeReason === null || i.closeReason === "done"),
176 );
177 const live = open.filter((i) => (!marks.parked.has(i.id) && !marks.deferred.has(i.id)) || name === LATER);
178 const wordOf = (i: Issue) => wordFor(i, readySet, marks);
179 const building = open.filter((i) => wordOf(i) === "working").map((i) => item(i));
180 const upNext = open
181 .filter((i) => wordOf(i) === "ready" && i.assigneeName === null)
182 .sort((a, b) => (readyOrder.get(a.id) ?? 0) - (readyOrder.get(b.id) ?? 0))
183 .map((i) => item(i));
184 const waiting = open
185 .filter((i) => ["waiting", "held"].includes(wordOf(i)))
186 .map((i) => item(i, pullOf.has(i.id) ? { pull: pullOf.get(i.id)! } : {}));
187 const blocked = open
188 .filter((i) => wordOf(i) === "blocked")
189 .map((i) => {
190 const b = blocker.get(i.id);
191 return item(i, b === undefined ? {} : { blockedBy: { number: b.number, title: b.title } });
192 });
193 const doneThisWeek = closed
194 .filter((i) => (i.closedAt ?? "") >= weekAgo)
195 .sort((a, b) => (b.closedAt ?? "").localeCompare(a.closedAt ?? ""))
196 .map((i) => item(i));
197 const last = [...closed].sort((a, b) => (b.closedAt ?? "").localeCompare(a.closedAt ?? ""))[0];
198 const merged = week.find((w) => w.pillar === name)?.merged ?? 0;
199 const eta = etaDays(live.length, merged);
200 const total = closed.length + open.length;
201 const all = [...building, ...upNext, ...waiting, ...blocked, ...doneThisWeek];
202 return {
203 short: pillar?.short ?? LATER,
204 name,
205 umbrella: pillar?.umbrella.number ?? set.later?.number ?? null,
206 state: total > 0 && open.length === 0 ? "done" : "future",
207 done: closed.length,
208 total,
209 merged,
210 building,
211 next: upNext[0] ?? null,
212 lastLanding: last === undefined ? null : item(last),
213 etaDays: eta,
214 etaAt: eta === null ? null : new Date(now.getTime() + eta * DAY).toISOString(),
215 groups: {
216 building,
217 upNext: upNext.slice(0, NEXT_MAX),
218 waiting,
219 blocked,
220 doneThisWeek: doneThisWeek.slice(0, WEEK_MAX),
221 },
222 movedCount: all.filter((r) => r.moved).length,
223 };
224 });
225 // The current pillar is the first, in order, with open work; Later is never current.
226 const cur = rows.findIndex((r) => r.name !== LATER && r.state !== "done" && r.total > r.done);
227 const pillars = rows.map((r, n) => (n === cur ? { ...r, state: "current" as const } : r));
228 const current = pillars[cur];
229
230 const needsYou: NeedsYou[] = [];
231 if (user !== undefined && user.kind !== "agent" && canWrite(db, repo, user)) {
232 const held = db
233 .query<{ number: number; title: string; by: string; why: string }, [string, number, string]>(
234 `SELECT issue.number, issue.title, user.username AS by, pull_hold.why
235 FROM pull_hold JOIN issue ON issue.id = pull_hold.issue_id
236 JOIN user ON user.id = pull_hold.held_by
237 WHERE issue.repo_id = ?1 AND issue.kind = 'pull' AND issue.state = 'open'
238 AND pull_hold.released_at IS NULL
239 AND (?2 = 1 OR issue.sealed = 0 OR issue.author_id = ?3)
240 ORDER BY pull_hold.held_at LIMIT 20`,
241 )
242 .all(repo.id, scope.sealed === true ? 1 : 0, scope.sealedBy ?? "");
243 for (const hold of held)
244 needsYou.push({
245 kind: "hold",
246 text: `${hold.by} holds #${hold.number} ${hold.title}: ${hold.why}`,
247 action: "Open the pull",
248 number: hold.number,
249 pull: true,
250 });
251 for (const furniture of idleFurniture(graph))
252 needsYou.push({
253 kind: "close",
254 text: `#${furniture.number} ${furniture.title.split(":")[0]} has nothing open in front of it`,
255 action: "Close it or add work",
256 number: furniture.number,
257 pull: false,
258 });
259 }
260 return {
261 generatedAt: now.toISOString(),
262 pace: {
263 landed24h: sum(day),
264 landed7d: sum(week),
265 days: PILLAR_DAYS,
266 current: current?.name ?? null,
267 etaDays: current?.etaDays ?? null,
268 etaAt: current?.etaAt ?? null,
269 },
270 needsYou,
271 pillars,
272 };
273 }
274
275 const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
276
277 /** `Nov 20`, in UTC, from an ISO time. */
278 export function shortDate(iso: string): string {
279 const d = new Date(iso);
280 return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
281 }
282
283 /** `finishes around Nov 20`, or `has no date yet` where there is no rate. */
284 export function finishWords(etaAt: string | null): string {
285 return etaAt === null ? "has no date yet" : `finishes around ${shortDate(etaAt)}`;
286 }
287
288 /** The pace as sentences: what landed, then the current pillar's finish. */
289 export function paceSentence(road: Roadmap): string {
290 const p = road.pace;
291 const landed = `Landed ${p.landed24h} today and ${p.landed7d} in ${p.days} days.`;
292 if (p.current === null) return `${landed} Every pillar is done.`;
293 return `${landed} ${p.current} ${finishWords(p.etaAt)}.`;
294 }
test/limbs.test.ts +1 −1
@@ -449,7 +449,7 @@ describe("limb actions", () => {
449449 word: "deferred",
450450 deferredBecause: through,
451451 });
452 const mapPage = await (await get("alice", "/alice/engine/map")).text();
452 const mapPage = await (await get("alice", "/alice/engine/map?view=graph")).text();
453453 expect(mapPage).toContain(
454454 `aria-label="#${extra.number} One home: the footer — deferred: ${through}"`,
455455 );
test/map-speed.test.ts +1 −1
@@ -151,7 +151,7 @@ afterAll(() => {
151151
152152 test("the map of a forge the size of trevato/forge reads in under 300 ms, each step named", async () => {
153153 const hit = async () => {
154 const res = await fetch(`${servers.url}/keeper/forge/map`, { headers: { cookie: f.strangerCookie } });
154 const res = await fetch(`${servers.url}/keeper/forge/map?view=graph`, { headers: { cookie: f.strangerCookie } });
155155 await res.text();
156156 expect(res.status).toBe(200);
157157 const phases = new Map<string, number>();
test/milestones.test.ts +1 −1
@@ -416,7 +416,7 @@ describe("milestones", () => {
416416 });
417417
418418 test("one tap from the map, the board and the issue itself", async () => {
419 const map = await (await get("alice", "/alice/engine/map")).text();
419 const map = await (await get("alice", "/alice/engine/map?view=graph")).text();
420420 expect(map).toContain('href="/alice/engine/milestones"');
421421 expect(map).toContain(`href="/alice/engine/milestones/${trust.number}"`);
422422 const board = await (await get("alice", "/board")).text();
test/palette-roster.test.ts +4
@@ -56,6 +56,7 @@ test("a site-admin person is offered the whole roster", () => {
5656 ["repo", "Open a repository", "owner/name"],
5757 ["issues", "Issues in a repository", "owner/name"],
5858 ["held", "Held by the Later gate", "owner/name"],
59 ["roadmap", "Roadmap of a repository", "owner/name"],
5960 ["pulls", "Pull requests in a repository", "owner/name"],
6061 ["milestones", "Milestones in a repository", "owner/name"],
6162 ["next-up", "Next up in a repository", "owner/name"],
@@ -171,6 +172,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
171172 ["repo", "Open a repository", "owner/name"],
172173 ["issues", "Issues in a repository", "owner/name"],
173174 ["held", "Held by the Later gate", "owner/name"],
175 ["roadmap", "Roadmap of a repository", "owner/name"],
174176 ["pulls", "Pull requests in a repository", "owner/name"],
175177 ["milestones", "Milestones in a repository", "owner/name"],
176178 ["next-up", "Next up in a repository", "owner/name"],
@@ -243,6 +245,7 @@ test("a member person is offered the signed-in roster without the admin entries"
243245 ["repo", "Open a repository", "owner/name"],
244246 ["issues", "Issues in a repository", "owner/name"],
245247 ["held", "Held by the Later gate", "owner/name"],
248 ["roadmap", "Roadmap of a repository", "owner/name"],
246249 ["pulls", "Pull requests in a repository", "owner/name"],
247250 ["milestones", "Milestones in a repository", "owner/name"],
248251 ["next-up", "Next up in a repository", "owner/name"],
@@ -308,6 +311,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
308311 ["repo", "Open a repository", "owner/name"],
309312 ["issues", "Issues in a repository", "owner/name"],
310313 ["held", "Held by the Later gate", "owner/name"],
314 ["roadmap", "Roadmap of a repository", "owner/name"],
311315 ["pulls", "Pull requests in a repository", "owner/name"],
312316 ["milestones", "Milestones in a repository", "owner/name"],
313317 ["next-up", "Next up in a repository", "owner/name"],
test/pillars.test.ts +5 −5
@@ -391,7 +391,7 @@ describe("pillars", () => {
391391 expect(mcp.value.pillars).toEqual(data.pillars);
392392 expect(mcp.value.mergedByPillar).toEqual(data.mergedByPillar);
393393
394 const page = await get("/alice/engine/map");
394 const page = await get("/alice/engine/map?view=graph");
395395 expect(page).toContain('id="map-pillars"');
396396 // Each pillar opens its milestone (#731).
397397 expect(page).toMatch(
@@ -576,7 +576,7 @@ describe("pillars", () => {
576576 limbs.value.limbs.find((l: any) => l.number === feed.number).askToLive,
577577 ).toBeNull();
578578
579 const page = await get("/alice/engine/map");
579 const page = await get("/alice/engine/map?view=graph");
580580 expect(page).toContain("8.5h median");
581581 expect(page).toContain(`#${sandbox.number}`);
582582 expect(page).toMatch(/P1 Feed[\s\S]*?ask to live\s+—/);
@@ -610,7 +610,7 @@ describe("pillars", () => {
610610 seal(1);
611611 try {
612612 const sealedLink = `/issues/${sandbox.number}"`;
613 const guest = await (await fetch(`${base}/alice/engine/map`)).text();
613 const guest = await (await fetch(`${base}/alice/engine/map?view=graph`)).text();
614614 expect(guest).toContain("5h median");
615615 expect(guest).not.toContain(sealedLink);
616616 const data = await asBob("/api/v1/repos/alice/engine/map");
@@ -626,7 +626,7 @@ describe("pillars", () => {
626626 limbs.limbs.find((l: any) => l.number === trust.number).askToLive.slowest.number,
627627 ).toBe(parts[0]!.number);
628628 // A writer still sees it.
629 expect(await get("/alice/engine/map")).toContain(sealedLink);
629 expect(await get("/alice/engine/map?view=graph")).toContain(sealedLink);
630630 expect(
631631 (await api("/api/v1/repos/alice/engine/map")).mergedByPillar.counts[0].askToLive
632632 .slowest.number,
@@ -749,7 +749,7 @@ describe("pillars", () => {
749749 "the plan's own: a person closes it, the crew never picks it",
750750 });
751751 expect(data.counts).toMatchObject({ ready: 2, parked: 3 });
752 const page = await get("/alice/bare/map");
752 const page = await get("/alice/bare/map?view=graph");
753753 const listed = page.slice(
754754 page.indexOf("<h2>Ready now</h2>"),
755755 page.indexOf("</section>", page.indexOf("<h2>Ready now</h2>")),
test/plan-pillars.test.ts +1 −1
@@ -243,7 +243,7 @@ describe("a plan says what each issue is for", () => {
243243 ).headers.get("set-cookie") ?? ""
244244 ).split(";")[0]!;
245245 const page = await (
246 await fetch(`${base}/alice/forge/map`, { headers: { cookie } })
246 await fetch(`${base}/alice/forge/map?view=graph`, { headers: { cookie } })
247247 ).text();
248248 const card = (title: string) =>
249249 page.match(
test/roadmap-tui.test.ts added +106
@@ -0,0 +1,106 @@
1 /** The roadmap in the terminal (#10303): lines from the model, gated like the page. */
2 import { afterAll, beforeAll, describe, 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 { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { addDependency } from "../src/core/dependencies.ts";
9 import { getRepo } from "../src/core/repos.ts";
10 import { setHold } from "../src/core/holds.ts";
11 import { createIssue } from "../src/core/issues.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import { roadmapLines } from "../src/tui/screens/roadmap.ts";
15 import type { TuiIdentity } from "../src/tui/session.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { roadmapOf } from "../src/web/roadmap.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { settleApp } from "./helpers/tui.ts";
20
21 quietLogs();
22
23 describe("roadmap in the terminal", () => {
24 let dir: string;
25 let forge: App;
26 let ada: User;
27 let eve: User;
28
29 function boot(user: User | undefined) {
30 const terminal = headlessTerminal(120, 32);
31 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", ...(user ? { user } : {}) };
32 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
33 app.start();
34 const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
35 return {
36 text: () => app.screen.toText(),
37 async open(spec: string) {
38 await settleApp(app);
39 app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
40 await settleApp(app);
41 for (const ch of "Roadmap of") key(ch);
42 await settleApp(app);
43 key("enter");
44 await settleApp(app);
45 for (const ch of spec) key(ch);
46 key("enter");
47 await settleApp(app);
48 await settleApp(app);
49 },
50 stop: () => app.stop(),
51 };
52 }
53
54 beforeAll(async () => {
55 dir = await mkdtemp(join(tmpdir(), "forge-roadmap-tui-"));
56 forge = createApp({ dataDir: dir, port: 0 });
57 const make = async (username: string, adminIfFirst: boolean) => {
58 const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst });
59 if (!made.ok) throw new Error(made.error);
60 return made.value;
61 };
62 ada = await make("ada", true);
63 eve = await make("eve", false);
64 const made = await createRepo(forge, ada, { name: "road", visibility: "public" });
65 if (!made.ok) throw new Error(String(made.error));
66 const mk = (title: string, kind?: "pull") => {
67 const i = createIssue(forge.db, { repoId: made.value.id, authorId: ada.id, title, body: "", ...(kind ? { kind } : {}) });
68 if (!i.ok) throw new Error(i.error);
69 return i.value;
70 };
71 const umbrella = mk("P0 Trust: contained");
72 const work = mk("Startable thing");
73 if (!addDependency(forge.db, umbrella, work, ada.id).ok) throw new Error("dep");
74 setHold(forge.db, mk("Held pull", "pull").id, ada.id, "look twice");
75 }, 30_000);
76 afterAll(async () => {
77 forge?.close();
78 await rm(dir, { recursive: true, force: true });
79 });
80
81 test("a writer's screen names the pillar, its work and what waits on them", async () => {
82 const t = boot(ada);
83 await t.open("ada/road");
84 const text = t.text();
85 expect(text).toContain("P0 Trust");
86 expect(text).toContain("Startable thing");
87 expect(text).toContain("Needs you");
88 expect(text).toContain("look twice");
89 t.stop();
90 });
91
92 test("a reader's screen has the pillars and no Needs you", async () => {
93 const t = boot(eve);
94 await t.open("ada/road");
95 expect(t.text()).toContain("Startable thing");
96 expect(t.text()).not.toContain("Needs you");
97 t.stop();
98 });
99
100 test("lines come from the model", () => {
101 const repo = getRepo(forge.db, "ada", "road")!;
102 const lines = roadmapLines(roadmapOf(forge.db, repo, ada)!, 80);
103 expect(lines[0]).toContain("P0 Trust has no date yet.");
104 expect(lines.some((l) => l.includes("Up next"))).toBe(true);
105 });
106 });
test/roadmap.test.ts added +247
@@ -0,0 +1,247 @@
1 /** The roadmap (#10303): pillars in order, folding, the ETA from a known rate, needs-you, the live region. */
2 import { afterAll, beforeAll, describe, 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 { addDependency } from "../src/core/dependencies.ts";
9 import { setHold } from "../src/core/holds.ts";
10 import { createIssue, getIssue, setIssueState, type Issue } from "../src/core/issues.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { promote } from "./helpers/members.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { secureFor } from "../src/http/cookie_secure.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { etaDays, paceSentence, roadmapOf } from "../src/web/roadmap.ts";
17 import { lastLooked } from "../src/web/pages/map.ts";
18
19 describe("the roadmap", () => {
20 let dir: string;
21 let app: App;
22 let servers: Servers;
23 let alice: User;
24 let rw: string;
25 let repo: Awaited<ReturnType<typeof makeRepo>>;
26 const makeRepo = async () => {
27 const made = await createRepo(app, alice, { name: "forge", visibility: "private" });
28 if (!made.ok) throw new Error(String(made.error));
29 return made.value;
30 };
31 const open = (title: string): Issue => {
32 const made = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title, body: "" });
33 if (!made.ok) throw new Error(made.error);
34 return made.value;
35 };
36 const wait = (blocked: Issue, blocker: Issue) => {
37 if (!addDependency(app.db, blocked, blocker, alice.id).ok) throw new Error("dep");
38 };
39 let p0: Issue, p1: Issue, p2: Issue, a: Issue, b: Issue, c: Issue;
40
41 beforeAll(async () => {
42 dir = await mkdtemp(join(tmpdir(), "forge-roadmap-"));
43 app = createApp({ dataDir: dir, port: 0 });
44 servers = await serve(app);
45 const u = await createUser(app.db, {
46 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
47 password: "a-good-passphrase", adminIfFirst: true,
48 });
49 if (!u.ok) throw new Error(u.error);
50 alice = u.value;
51 rw = createToken(app.db, alice.id, "rw", "rw").token;
52 repo = await makeRepo();
53 p1 = open("P1 Feed: one front door");
54 p0 = open("P0 Trust: agents contained");
55 p2 = open("P2 Cloud: somewhere else");
56 a = open("Done work");
57 b = open("Startable work");
58 c = open("Follow-up work");
59 wait(p0, a);
60 wait(p0, b);
61 wait(p0, c);
62 wait(c, b);
63 setIssueState(app.db, a.id, "closed");
64 }, 30_000);
65
66 afterAll(async () => {
67 servers.stop();
68 app.close();
69 await rm(dir, { recursive: true, force: true });
70 });
71
72 test("the ETA is the open work over the seven-day rate, none without a rate", () => {
73 expect(etaDays(6, 3)).toBe(14);
74 expect(etaDays(1, 7)).toBe(1);
75 expect(etaDays(5, 0)).toBeNull();
76 expect(etaDays(0, 4)).toBeNull();
77 });
78
79 test("pillars come in order by number; the first with open work is current; the next startable is named", () => {
80 const road = roadmapOf(app.db, repo, alice)!;
81 expect(road.pillars.map((p) => p.short)).toEqual(["P0", "P1", "P2"]);
82 expect(road.pillars.map((p) => p.state)).toEqual(["current", "future", "future"]);
83 const cur = road.pillars[0]!;
84 expect([cur.done, cur.total]).toEqual([1, 3]);
85 expect(cur.next?.title).toBe("Startable work");
86 expect(cur.lastLanding?.title).toBe("Done work");
87 expect(cur.groups.blocked.map((i) => i.title)).toEqual(["Follow-up work"]);
88 expect(cur.groups.blocked[0]!.blockedBy?.title).toBe("Startable work");
89 expect(cur.groups.doneThisWeek.map((i) => i.title)).toEqual(["Done work"]);
90 expect(road.pace.current).toBe("P0 Trust");
91 expect(road.pace.etaDays).toBeNull();
92 });
93
94 test("a pillar with nothing in front of it needs the owner, and stops needing him once it has work", () => {
95 expect(roadmapOf(app.db, repo, alice)!.needsYou.map((n) => n.number)).toEqual(
96 expect.arrayContaining([p1.number, p2.number]),
97 );
98 wait(p1, open("Feed work"));
99 wait(p2, open("Cloud work"));
100 expect(roadmapOf(app.db, repo, alice)!.needsYou).toEqual([]);
101 });
102
103 test("a finished pillar folds to done and the next becomes current", () => {
104 setIssueState(app.db, b.id, "closed");
105 setIssueState(app.db, c.id, "closed");
106 const road = roadmapOf(app.db, repo, alice)!;
107 expect(road.pillars.map((p) => p.state)).toEqual(["done", "current", "future"]);
108 expect(road.pace.current).toBe("P1 Feed");
109 });
110
111 test("the page draws it folded, the API and the graph view answer, and moves mark since the last visit", async () => {
112 const headers = { authorization: `Bearer ${rw}` };
113 const page = await (await fetch(`${servers.url}/alice/forge/map`, { headers })).text();
114 expect(page).toContain('id="roadmap-live"');
115 expect(page).toContain("data-live=");
116 expect(page).toMatch(/<details id="pillar-p0-fold"\s*>/);
117 expect(page).toMatch(/<details id="pillar-p1-fold"\s+open/);
118 expect(page.indexOf("pillar-p0")).toBeLessThan(page.indexOf("pillar-p1"));
119 expect(page.indexOf("pillar-p1")).toBeLessThan(page.indexOf("pillar-p2"));
120 const api = (await (await fetch(`${servers.url}/api/v1/repos/alice/forge/roadmap`, { headers })).json()) as any;
121 expect(api.pillars.map((p: any) => p.short)).toEqual(["P0", "P1", "P2"]);
122 const graph = await (await fetch(`${servers.url}/alice/forge/map?view=graph`, { headers })).text();
123 expect(graph).not.toContain('id="roadmap-live"');
124 // The live update: a landing changes the rows the next fetch of the page draws.
125 const before = await (await fetch(`${servers.url}/alice/forge/map`, { headers })).text();
126 const feedWork = roadmapOf(app.db, repo, alice)!.pillars[1]!.groups.upNext[0]!;
127 setIssueState(app.db, getIssue(app.db, repo.id, feedWork.number)!.id, "closed");
128 const after = await (await fetch(`${servers.url}/alice/forge/map`, { headers })).text();
129 expect(after).not.toBe(before);
130 expect(after).toMatch(/id="pillar-p1"[^>]*data-pillar="done"/);
131 expect(before).toMatch(/id="pillar-p1"[^>]*data-pillar="current"/);
132 });
133
134 test("lastLooked keeps the previous visit across a live fetch and rolls it over after half an hour", () => {
135 const t0 = new Date("2026-11-09T10:00:00.000Z");
136 const first = lastLooked(undefined, t0, true);
137 expect(first.since).toBeUndefined();
138 expect(first.cookie).toBe(`~${t0.toISOString()}`);
139 const later = new Date(t0.getTime() + 3_600_000);
140 const second = lastLooked(first.cookie, later, true);
141 expect(second.since).toBe(t0.toISOString());
142 const fetched = lastLooked(second.cookie, later, false);
143 expect(fetched.since).toBe(t0.toISOString());
144 expect(fetched.cookie).toBeUndefined();
145 expect(lastLooked("junk~also", t0, true).since).toBeUndefined();
146 });
147
148 test("the pace is plain sentences: a date where there is a rate, 'no date yet' where there is none", () => {
149 const road = roadmapOf(app.db, repo, alice, { now: new Date("2026-11-09T00:00:00Z") })!;
150 expect(paceSentence(road)).toBe("Landed 0 today and 0 in 7 days. P2 Cloud has no date yet.");
151 const rated = { ...road, pace: { ...road.pace, etaDays: 11, etaAt: "2026-11-20T00:00:00.000Z" } };
152 expect(paceSentence(rated)).toContain("P2 Cloud finishes around Nov 20.");
153 expect(paceSentence({ ...road, pace: { ...road.pace, current: null } })).toContain("Every pillar is done.");
154 });
155
156 describe("on a public repository with a held pull and a sealed one", () => {
157 let pub: Awaited<ReturnType<typeof makeRepo>>;
158 let carol: User;
159 let held: Issue;
160 beforeAll(async () => {
161 const mk = async (name: string) => {
162 const made = await createRepo(app, alice, { name, visibility: "public" });
163 if (!made.ok) throw new Error(String(made.error));
164 return made.value;
165 };
166 pub = await mk("pub");
167 await mk("plain");
168 const c = await createUser(app.db, {
169 origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase",
170 });
171 if (!c.ok) throw new Error(c.error);
172 carol = c.value;
173 promote(app.db, carol);
174 const mkIssue = (title: string, extra: { kind?: "pull"; sealed?: boolean } = {}): Issue => {
175 const made = createIssue(app.db, { repoId: pub.id, authorId: alice.id, title, body: "", ...extra });
176 if (!made.ok) throw new Error(made.error);
177 return made.value;
178 };
179 const umbrella = mkIssue("P0 Trust: contained");
180 const work = mkIssue("Public work");
181 wait(umbrella, work);
182 held = mkIssue("Visible pull", { kind: "pull" });
183 setHold(app.db, held.id, alice.id, "second look");
184 const sealed = mkIssue("Sealed pull secret-title", { kind: "pull", sealed: true });
185 setHold(app.db, sealed.id, alice.id, "sealed why");
186 });
187
188 test("a writer is told about a held pull", () => {
189 const road = roadmapOf(app.db, pub, alice)!;
190 expect(road.needsYou.map((n) => n.number)).toContain(held.number);
191 expect(road.needsYou.find((n) => n.number === held.number)).toMatchObject({ kind: "hold", pull: true });
192 });
193
194 test("a guest and a reader get no Needs you and never a sealed title", () => {
195 for (const reader of [undefined, carol]) {
196 const road = roadmapOf(app.db, pub, reader)!;
197 expect(road.needsYou).toEqual([]);
198 expect(JSON.stringify(road)).not.toContain("secret-title");
199 }
200 });
201
202 test("the API answers a guest on a public repository and 404s one without pillars", async () => {
203 const ok = await fetch(`${servers.url}/api/v1/repos/alice/pub/roadmap`);
204 expect(ok.status).toBe(200);
205 const body = await ok.text();
206 expect(body).not.toContain("secret-title");
207 expect(JSON.parse(body).needsYou).toEqual([]);
208 expect((await fetch(`${servers.url}/api/v1/repos/alice/plain/roadmap`)).status).toBe(404);
209 });
210
211 test("MCP map with roadmap: true returns the model; an app without pillars says so", async () => {
212 const call = async (app_: string) => {
213 const res = await fetch(`${servers.url}/mcp`, {
214 method: "POST",
215 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${rw}` },
216 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "map", arguments: { app: app_, roadmap: true } } }),
217 });
218 return (await res.json()) as any;
219 };
220 const good = await call("alice/pub");
221 expect(good.result.isError).not.toBe(true);
222 expect(JSON.parse(good.result.content[0].text).pillars[0].short).toBe("P0");
223 const none = await call("alice/plain");
224 expect(none.result.isError).toBe(true);
225 expect(none.result.content[0].text).toContain("no pillars");
226 });
227
228 test("the default map of a repository without pillars still draws the graph", async () => {
229 const page = await (await fetch(`${servers.url}/alice/plain/map`)).text();
230 expect(page).not.toContain('id="roadmap-live"');
231 expect(page).toContain("map-svg");
232 });
233
234 test("the last-looked cookie is HttpOnly and named for the repository; not Secure over http", async () => {
235 const res = await fetch(`${servers.url}/alice/pub/map`);
236 const cookie = res.headers.get("set-cookie") ?? "";
237 expect(cookie).toMatch(/^rm_alice_pub=~\d{4}-/);
238 expect(cookie).toContain("HttpOnly");
239 expect(cookie).toContain("SameSite=Lax");
240 expect(cookie).not.toContain("Secure");
241 expect(secureFor(new URL("https://forge.example/"), undefined)).toBe(true);
242 // A live fetch (not a navigation) never sets it.
243 const live = await fetch(`${servers.url}/alice/pub/map`, { headers: { "sec-fetch-mode": "cors" } });
244 expect(live.headers.get("set-cookie")).toBeNull();
245 });
246 });
247 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs