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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10303-the-map-is-the-owner-s-roadmap-pillars-i → main 5 commits fork 2 threads · 2 unresolved
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 | ||
| 99 | 99 | a level and a flow are different units: the line says whether the plan is |
| 100 | 100 | converging, the bars say whether anything is moving. |
| 101 | 101 | |
| 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: [](features/roadmap.md). | |
| 106 | ||
| 102 | 107 | The same three lists are data at `GET /api/v1/repos/:owner/:name/map`, |
| 103 | 108 | 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 |
| 104 | 109 | 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) | |
| 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 | ||
| 152 | 152 | export { route as getApiV1ReposOwnerNamePullsNumberThreads } from "./getApiV1ReposOwnerNamePullsNumberThreads.ts"; |
| 153 | 153 | export { route as getApiV1ReposOwnerNameQueueGates } from "./getApiV1ReposOwnerNameQueueGates.ts"; |
| 154 | 154 | export { route as getApiV1ReposOwnerNameReports } from "./getApiV1ReposOwnerNameReports.ts"; |
| 155 | export { route as getApiV1ReposOwnerNameRoadmap } from "./getApiV1ReposOwnerNameRoadmap.ts"; | |
| 155 | 156 | export { route as getApiV1ReposOwnerNameRuns } from "./getApiV1ReposOwnerNameRuns.ts"; |
| 156 | 157 | export { route as getApiV1ReposOwnerNameRunsId } from "./getApiV1ReposOwnerNameRunsId.ts"; |
| 157 | 158 | export { route as getApiV1ReposOwnerNameRunsIdLog } from "./getApiV1ReposOwnerNameRunsIdLog.ts"; |
src/http/api.ts +20
| @@ -242,6 +242,7 @@ import { | ||
| 242 | 242 | import { forReader } from "../crew/handover.ts"; |
| 243 | 243 | import { globalMapJson } from "../web/pages/global-map.ts"; |
| 244 | 244 | import { mapJson, marksFor, withoutDone } from "../web/pages/map.ts"; |
| 245 | import { roadmapOf } from "../web/roadmap.ts"; | |
| 245 | 246 | import { cachedMap } from "../web/map_snapshot.ts"; |
| 246 | 247 | import { scopeKey } from "../web/map_cache.ts"; |
| 247 | 248 | import { |
| @@ -2364,6 +2365,25 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2364 | 2365 | } |
| 2365 | 2366 | return new Response(text, { headers: { "content-type": "application/json", ...headers } }); |
| 2366 | 2367 | }); |
| 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 | }); | |
| 2367 | 2387 | // What people asked of the crew in the repositories the caller can write — |
| 2368 | 2388 | // by default what has nothing merged yet, oldest first — and what became of each (core/asked.ts, the /asked page). |
| 2369 | 2389 | 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"; | ||
| 4 | 4 | import { pillarView } from "../../../core/pillars.ts"; |
| 5 | 5 | import { askToLiveFor } from "../../../web/ask_to_live.ts"; |
| 6 | 6 | import { listActiveRuns } from "../../../core/runs.ts"; |
| 7 | import { roadmapOf } from "../../../web/roadmap.ts"; | |
| 7 | 8 | import { mapJson, marksFor, withoutDone } from "../../../web/pages/map.ts"; |
| 8 | 9 | import { putIssueFirst } from "../../../actions/first.ts"; |
| 9 | 10 | import { bad, good, type Tool, APP_ARG, planRepo, isAnswer, issueArg } from "../shared.ts"; |
| 10 | 11 | |
| 11 | 12 | export const tool: Tool = { |
| 12 | 13 | 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", | |
| 14 | 15 | description: |
| 15 | 16 | "An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first.", |
| 16 | 17 | inputSchema: { |
| 17 | 18 | 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 } }, | |
| 19 | 20 | required: ["app"], |
| 20 | 21 | }, |
| 21 | 22 | writes: true, |
| @@ -29,6 +30,10 @@ export const tool: Tool = { | ||
| 29 | 30 | const put = putIssueFirst(ctx.app, user, repo, issue.number); |
| 30 | 31 | if (!put.ok) return bad(put.error.kind === "not_found" ? "no_such_issue" : put.error.kind, put.error.message); |
| 31 | 32 | } |
| 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 | } | |
| 32 | 37 | const scope = sealedScope(ctx.app.db, repo, user); |
| 33 | 38 | const graph = repoGraph(ctx.app.db, repo.id, { ...scope }); |
| 34 | 39 | 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"; | ||
| 16 | 16 | import { sourceFor } from "../view/source.ts"; |
| 17 | 17 | import { fleetCommand } from "./screens/fleet.ts"; |
| 18 | 18 | import { packsCommand } from "./screens/packs.ts"; |
| 19 | import { roadmapScreen } from "./screens/roadmap.ts"; | |
| 19 | 20 | import { App, type Ctx, type Screen } from "./app/app.ts"; |
| 20 | 21 | import type { Command, PaletteItem, PaletteSearch } from "./app/palette.ts"; |
| 21 | 22 | import { G } from "./term/screen.ts"; |
| @@ -126,6 +127,11 @@ const REPO_VERBS: readonly { | ||
| 126 | 127 | title: "Held by the Later gate", |
| 127 | 128 | open: (f, ctx, repo) => heldScreen(f, ctx, repo), |
| 128 | 129 | }, |
| 130 | { | |
| 131 | id: "roadmap", | |
| 132 | title: "Roadmap of a repository", | |
| 133 | open: (f, ctx, repo) => roadmapScreen(f, ctx, repo), | |
| 134 | }, | |
| 129 | 135 | { |
| 130 | 136 | id: "pulls", |
| 131 | 137 | 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 { | ||
| 75 | 75 | type PillarView, |
| 76 | 76 | } from "../../core/pillars.ts"; |
| 77 | 77 | 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"; | |
| 78 | 82 | |
| 79 | 83 | /** Where a card's own link goes: dev's issue page, by default. */ |
| 80 | 84 | const devIssueHref = (base: string, number: number): string => |
| @@ -1378,11 +1382,83 @@ function openOnly(graph: Graph): Graph { | ||
| 1378 | 1382 | }; |
| 1379 | 1383 | } |
| 1380 | 1384 | |
| 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) */ | |
| 1382 | 1413 | export async function mapPage(ctx: Ctx): Promise<Response> { |
| 1383 | 1414 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 1384 | 1415 | const repo = loadRepo(ctx); |
| 1385 | 1416 | 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 | } | |
| 1386 | 1462 | const db = ctx.app.db; |
| 1387 | 1463 | const studio = studioRecord(ctx, repo) !== undefined; |
| 1388 | 1464 | 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/cookie-flags.test.ts +1
| @@ -45,6 +45,7 @@ const UNREACHED: Record<string, { sites: number; why: string }> = { | ||
| 45 | 45 | "src/web/pages/claim.ts": { sites: 1, why: "claiming the first admin seat needs a fresh forge with a claim link; same sessionCookie() as the walked doors" }, |
| 46 | 46 | "src/web/pages/first-admin.ts": { sites: 1, why: "first-admin recovery needs a forge with no admin; same sessionCookie() as the walked doors" }, |
| 47 | 47 | "src/web/pages/signup-passkey.ts": { sites: 2, why: "sign-up by passkey needs an invite; same sessionCookie() as the walked doors" }, |
| 48 | "src/web/pages/map.ts": { sites: 1, why: "the map's last-looked cookie holds two timestamps, HttpOnly and Lax, and grants nothing; covered by test/roadmap.test.ts (name, HttpOnly, SameSite, no Secure over http)" }, | |
| 48 | 49 | "src/web/pages/everyday.ts": { sites: 2, why: "the admin/everyday switch needs a paired account; same sessionCookie() as the walked doors" }, |
| 49 | 50 | }; |
| 50 | 51 |
test/limbs.test.ts +1 −1
| @@ -449,7 +449,7 @@ describe("limb actions", () => { | ||
| 449 | 449 | word: "deferred", |
| 450 | 450 | deferredBecause: through, |
| 451 | 451 | }); |
| 452 | const mapPage = await (await get("alice", "/alice/engine/map")).text(); | |
| 452 | const mapPage = await (await get("alice", "/alice/engine/map?view=graph")).text(); | |
| 453 | 453 | expect(mapPage).toContain( |
| 454 | 454 | `aria-label="#${extra.number} One home: the footer — deferred: ${through}"`, |
| 455 | 455 | ); |
test/map-speed.test.ts +1 −1
| @@ -151,7 +151,7 @@ afterAll(() => { | ||
| 151 | 151 | |
| 152 | 152 | test("the map of a forge the size of trevato/forge reads in under 300 ms, each step named", async () => { |
| 153 | 153 | 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 } }); | |
| 155 | 155 | await res.text(); |
| 156 | 156 | expect(res.status).toBe(200); |
| 157 | 157 | const phases = new Map<string, number>(); |
test/milestones.test.ts +1 −1
| @@ -416,7 +416,7 @@ describe("milestones", () => { | ||
| 416 | 416 | }); |
| 417 | 417 | |
| 418 | 418 | 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(); | |
| 420 | 420 | expect(map).toContain('href="/alice/engine/milestones"'); |
| 421 | 421 | expect(map).toContain(`href="/alice/engine/milestones/${trust.number}"`); |
| 422 | 422 | 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", () => { | ||
| 56 | 56 | ["repo", "Open a repository", "owner/name"], |
| 57 | 57 | ["issues", "Issues in a repository", "owner/name"], |
| 58 | 58 | ["held", "Held by the Later gate", "owner/name"], |
| 59 | ["roadmap", "Roadmap of a repository", "owner/name"], | |
| 59 | 60 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 60 | 61 | ["milestones", "Milestones in a repository", "owner/name"], |
| 61 | 62 | ["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", | ||
| 171 | 172 | ["repo", "Open a repository", "owner/name"], |
| 172 | 173 | ["issues", "Issues in a repository", "owner/name"], |
| 173 | 174 | ["held", "Held by the Later gate", "owner/name"], |
| 175 | ["roadmap", "Roadmap of a repository", "owner/name"], | |
| 174 | 176 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 175 | 177 | ["milestones", "Milestones in a repository", "owner/name"], |
| 176 | 178 | ["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" | ||
| 243 | 245 | ["repo", "Open a repository", "owner/name"], |
| 244 | 246 | ["issues", "Issues in a repository", "owner/name"], |
| 245 | 247 | ["held", "Held by the Later gate", "owner/name"], |
| 248 | ["roadmap", "Roadmap of a repository", "owner/name"], | |
| 246 | 249 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 247 | 250 | ["milestones", "Milestones in a repository", "owner/name"], |
| 248 | 251 | ["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", () => { | ||
| 308 | 311 | ["repo", "Open a repository", "owner/name"], |
| 309 | 312 | ["issues", "Issues in a repository", "owner/name"], |
| 310 | 313 | ["held", "Held by the Later gate", "owner/name"], |
| 314 | ["roadmap", "Roadmap of a repository", "owner/name"], | |
| 311 | 315 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 312 | 316 | ["milestones", "Milestones in a repository", "owner/name"], |
| 313 | 317 | ["next-up", "Next up in a repository", "owner/name"], |
test/pillars.test.ts +5 −5
| @@ -391,7 +391,7 @@ describe("pillars", () => { | ||
| 391 | 391 | expect(mcp.value.pillars).toEqual(data.pillars); |
| 392 | 392 | expect(mcp.value.mergedByPillar).toEqual(data.mergedByPillar); |
| 393 | 393 | |
| 394 | const page = await get("/alice/engine/map"); | |
| 394 | const page = await get("/alice/engine/map?view=graph"); | |
| 395 | 395 | expect(page).toContain('id="map-pillars"'); |
| 396 | 396 | // Each pillar opens its milestone (#731). |
| 397 | 397 | expect(page).toMatch( |
| @@ -576,7 +576,7 @@ describe("pillars", () => { | ||
| 576 | 576 | limbs.value.limbs.find((l: any) => l.number === feed.number).askToLive, |
| 577 | 577 | ).toBeNull(); |
| 578 | 578 | |
| 579 | const page = await get("/alice/engine/map"); | |
| 579 | const page = await get("/alice/engine/map?view=graph"); | |
| 580 | 580 | expect(page).toContain("8.5h median"); |
| 581 | 581 | expect(page).toContain(`#${sandbox.number}`); |
| 582 | 582 | expect(page).toMatch(/P1 Feed[\s\S]*?ask to live\s+—/); |
| @@ -610,7 +610,7 @@ describe("pillars", () => { | ||
| 610 | 610 | seal(1); |
| 611 | 611 | try { |
| 612 | 612 | 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(); | |
| 614 | 614 | expect(guest).toContain("5h median"); |
| 615 | 615 | expect(guest).not.toContain(sealedLink); |
| 616 | 616 | const data = await asBob("/api/v1/repos/alice/engine/map"); |
| @@ -626,7 +626,7 @@ describe("pillars", () => { | ||
| 626 | 626 | limbs.limbs.find((l: any) => l.number === trust.number).askToLive.slowest.number, |
| 627 | 627 | ).toBe(parts[0]!.number); |
| 628 | 628 | // 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); | |
| 630 | 630 | expect( |
| 631 | 631 | (await api("/api/v1/repos/alice/engine/map")).mergedByPillar.counts[0].askToLive |
| 632 | 632 | .slowest.number, |
| @@ -749,7 +749,7 @@ describe("pillars", () => { | ||
| 749 | 749 | "the plan's own: a person closes it, the crew never picks it", |
| 750 | 750 | }); |
| 751 | 751 | 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"); | |
| 753 | 753 | const listed = page.slice( |
| 754 | 754 | page.indexOf("<h2>Ready now</h2>"), |
| 755 | 755 | 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", () => { | ||
| 243 | 243 | ).headers.get("set-cookie") ?? "" |
| 244 | 244 | ).split(";")[0]!; |
| 245 | 245 | const page = await ( |
| 246 | await fetch(`${base}/alice/forge/map`, { headers: { cookie } }) | |
| 246 | await fetch(`${base}/alice/forge/map?view=graph`, { headers: { cookie } }) | |
| 247 | 247 | ).text(); |
| 248 | 248 | const card = (title: string) => |
| 249 | 249 | 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 | }); |
Both doors: add src/tui/ screen for the roadmap and its how-to as docs/features/roadmap.md, not in PLANNING.md.