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

A chapter is a version with an end: a named list that is finished, tagged and closed, while the next one waits its turn #11660

Merged opened this in answer to

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

Merged

mason/forge:mason/11603-a-chapter-is-a-version-with-an-end-a-nam → main 5 commits fork 4 threads · 4 unresolved

  • Merged. mason/11603-a-chapter-is-a-version-with-an-end-a-nam was merged into main as bffba22.

Review

Sign in to review this change.
26 files changed +1561 −3
AGENTS.md +1 −1
@@ -15,7 +15,7 @@ A new read of a table that grows (`event`, `issue`, `run`, `comment`) names the
1515
1616 A new `/settings…` page route goes in `src/http/settings_routes.ts` (`settingsRoutes`), not `src/http/server.ts`.
1717
18 State shared between modules through process memory, not the database, is named here. `src/perf/views_report.ts`: `readVisits` holds each scope and window's visits for 15 seconds (dropped by `flushViews`), so a page and its probe share one read; `src/perf/views_store.ts`: the view buffer, flushed every 10 seconds. The traffic sources view's terminal door is `src/tui/screens/trafficview.ts` (palette entry for admins, per-app command for a repository's admins). The schedules' terminal door is `src/tui/screens/schedules.ts` (per-app palette command while the repository is open; `r` runs, `p` pauses or resumes, writers only; `scheduleRows`/`steerSchedule` give the page, API, MCP tool and terminal the same rows and gates). `src/runs/held.ts`: each dispatcher pass (`src/crew/dispatch.ts`, `pump`) records which queued agent runs it held and why (valve, pace, limit), and vitals (`src/kernel/vitals.ts`) leaves those out of `queue:agent`. A record older than `HELD_FRESH_MS` is ignored, so a stopped dispatcher cannot hide its queue. `src/runs/stoppable.ts`: the checks in flight in this process by run id (`src/checks/service.ts` `start` tracks each), so a person's cancel (`cancelRepoRun`) aborts the check and its process group ends with it; agent runs keep `src/crew/running.ts`. `src/apps/egress_refused.ts`: refused-host counts per repository, flushed to disk every 5 seconds (`flushRefused` forces it). `src/spawn/usage.ts`: the last disk reading of each child's data directory (30 s), and a cell child's last reading of its own cgroup CPU (`selfUse`, #10853), from which the next reading's cores are taken; `forgetSelfCpu` drops it. `src/web/pages/flow-waiting.ts`: the unproposed-work scan (it reads git for every crew fork) is held for 60 seconds (`forgetUnproposed` drops it), so /flow and the terminal screen share one scan.
18 State shared between modules through process memory, not the database, is named here. `src/perf/views_report.ts`: `readVisits` holds each scope and window's visits for 15 seconds (dropped by `flushViews`), so a page and its probe share one read; `src/perf/views_store.ts`: the view buffer, flushed every 10 seconds. The traffic sources view's terminal door is `src/tui/screens/trafficview.ts` (palette entry for admins, per-app command for a repository's admins). The schedules' terminal door is `src/tui/screens/schedules.ts` (per-app palette command while the repository is open; `r` runs, `p` pauses or resumes, writers only; `scheduleRows`/`steerSchedule` give the page, API, MCP tool and terminal the same rows and gates). `src/core/chapters.ts`: a closed chapter's item list is kept per chapter id (200 at most; `forgetFrozenItems` drops them), since closed is forever. `src/runs/held.ts`: each dispatcher pass (`src/crew/dispatch.ts`, `pump`) records which queued agent runs it held and why (valve, pace, limit), and vitals (`src/kernel/vitals.ts`) leaves those out of `queue:agent`. A record older than `HELD_FRESH_MS` is ignored, so a stopped dispatcher cannot hide its queue. `src/runs/stoppable.ts`: the checks in flight in this process by run id (`src/checks/service.ts` `start` tracks each), so a person's cancel (`cancelRepoRun`) aborts the check and its process group ends with it; agent runs keep `src/crew/running.ts`. `src/apps/egress_refused.ts`: refused-host counts per repository, flushed to disk every 5 seconds (`flushRefused` forces it). `src/spawn/usage.ts`: the last disk reading of each child's data directory (30 s), and a cell child's last reading of its own cgroup CPU (`selfUse`, #10853), from which the next reading's cores are taken; `forgetSelfCpu` drops it. `src/web/pages/flow-waiting.ts`: the unproposed-work scan (it reads git for every crew fork) is held for 60 seconds (`forgetUnproposed` drops it), so /flow and the terminal screen share one scan.
1919
2020 `src/perf/loop_worst.ts`: `loopWorst`, the worst event-loop lag of the last 10 s in ten one-second slots, fed by one unref'd 50 ms timer that the first `serve` starts (`startLoopWorst`), and read by every response's `lag;dur` and by `/healthz` asked for JSON (`loopLagMs`). Tests listing a request's phases read `requestTiming` (`test/helpers/request-timing.ts`), which leaves `lag` out.
2121
docs/features/chapters.md added +14
@@ -0,0 +1,14 @@
1 # Chapters: a version with an end
2
3 A chapter is a named list of a repository's issues that is finished, tagged and closed (#11603). A name (`v1.0.0`; it becomes the git tag), one sentence of what done means, and the issues in it.
4
5 - **One open, any number drafted.** A repository has at most one open chapter. Later ones (`v2`, `v3`) are drafted after it and filled with issues that wait.
6 - **While one is open the crew builds only what is in it.** The dispatcher (`queueAgentRun`, through `chapterHold` in `src/core/chapters.ts`) starts no builder or reviewer run on an issue outside it. The planner may split a chapter's issue (the parts stay in the chapter) and may open an issue that says `Defect of #N` for an item of the chapter (it joins the chapter); every other issue it opens waits in the next chapter, or, with none drafted, behind the Later gate. Adding or removing an issue is the owner's act, recorded as `chapter.item_added` / `chapter.item_removed`.
7 - **The list being crossed off.** `/<owner>/<name>/chapters/<name>`: done items struck through with the day they went live, what is building, what is left, and the date it finishes at the pace since it opened. An item is done when it is live and proven (a merged pull that closes it and a passing production release proof), never when the pull merged.
8 - **Closing the book.** When the last item is done, or when the owner presses **Ship it**, the forge tags the default branch with the chapter's name, writes the notes from what landed (grouped, with who built what and what it cost), moves what is left to the next chapter and marks the chapter closed. The chapter's page then says it is finished: what shipped, how long it took, what it cost. The crew then rests on that repository until a chapter is opened; nothing starts by itself.
9 - **Closed is forever.** A closed chapter stays readable at its own address and cannot be reopened or changed. A fix for something it shipped goes in a patch chapter (`v1.0.1`), opened with one click from the closed chapter's page.
10 - **No chapter, no change.** A repository that never drafted one flows continuously, as before.
11
12 Doors: the page (`/chapters`, linked from `/queue`), the terminal (`src/tui/screens/chapters.ts`, the palette's "Chapters" while an app is open: `n` drafts, `o` opens, `s` ships, `p` patches, `enter` shows the list where `a` adds an issue and `x` takes one out) and the API (`GET`/`POST /api/v1/repos/:owner/:name/chapters`, `POST …/chapters/:chapter/:verb` with `open`, `add`, `remove`, `ship`, `patch`). Only a person who administers the repository acts; an agent is refused.
13
14 A closed chapter's list is frozen, so `itemsOf` keeps it in process memory (at most 200, `forgetFrozenItems` drops them); an open chapter's list is one pass over the repository's pulls and one grouped read of its runs, however many items it holds, so the anonymous page and API read stay cheap.
src/actions/chapters.ts added +189
@@ -0,0 +1,189 @@
1 /**
2 * The owner's acts on chapters (#11603): draft, open, fill, close the book, patch. One door for the
3 * page and the API. Only a human admin of the repository acts (an agent never adds scope: the crew
4 * proposes, a person decides); each change is an event, so the queue shows who moved what.
5 * The rules are in `core/chapters.ts`, the notes' words in `core/chapter_notes.ts`.
6 */
7
8 import type { App } from "../app.ts";
9 import { chapterNotes, daysBetween } from "../core/chapter_notes.ts";
10 import {
11 draftChapter,
12 getChapter,
13 itemsOf,
14 nextChapterOf,
15 nextName,
16 openChapterOf,
17 patchName,
18 placeNewIssue,
19 putIssue,
20 startChapter,
21 takeIssueOut,
22 type Chapter,
23 type ChapterError,
24 } from "../core/chapters.ts";
25 import { addDependency } from "../core/dependencies.ts";
26 import { getIssue, labelsFor, type Issue } from "../core/issues.ts";
27 import { laterGate } from "../crew/later.ts";
28 import { canAdmin, type Repo } from "../core/repos.ts";
29 import type { User } from "../core/users.ts";
30 import { runGit } from "../git/env.ts";
31 import { repoDir } from "../git/repos.ts";
32 import { err, ok, type Result } from "../kernel/result.ts";
33
34 export type ChapterActError = ChapterError | "forbidden" | "no_issue" | "unfinished" | "no_branch" | "tag_exists" | "git";
35
36 /** A person who administers the repository, never an agent. */
37 function owner(app: App, actor: User | undefined, repo: Repo): actor is User {
38 return actor !== undefined && actor.kind !== "agent" && canAdmin(app.db, repo, actor);
39 }
40
41 export function draft(app: App, actor: User | undefined, repo: Repo, input: { name: string; means?: string }): Result<Chapter, ChapterActError> {
42 if (!owner(app, actor, repo)) return err("forbidden");
43 const made = draftChapter(app.db, repo.id, input, actor.id);
44 if (made.ok) app.bus.append({ kind: "chapter.drafted", repoId: repo.id, actorId: actor.id, payload: { name: made.value.name } });
45 return made;
46 }
47
48 export function open(app: App, actor: User | undefined, repo: Repo, name: string, now = new Date()): Result<Chapter, ChapterActError> {
49 if (!owner(app, actor, repo)) return err("forbidden");
50 const chapter = getChapter(app.db, repo.id, name);
51 if (chapter === undefined) return err("not_found");
52 const started = startChapter(app.db, chapter, now);
53 if (started.ok) app.bus.append({ kind: "chapter.opened", repoId: repo.id, actorId: actor.id, payload: { name } });
54 return started;
55 }
56
57 /** Add an issue to a chapter, or take it out (`add` false): the owner's act, recorded. */
58 export function fill(app: App, actor: User | undefined, repo: Repo, name: string, number: number, add: boolean): Result<void, ChapterActError> {
59 if (!owner(app, actor, repo)) return err("forbidden");
60 const chapter = getChapter(app.db, repo.id, name);
61 if (chapter === undefined) return err("not_found");
62 const issue = getIssue(app.db, repo.id, number);
63 if (issue === undefined || issue.kind !== "issue") return err("no_issue");
64 const done: Result<void, ChapterActError> = add
65 ? putIssue(app.db, chapter, issue.id, actor.id)
66 : takeIssueOut(app.db, chapter, issue.id)
67 ? ok(undefined)
68 : err("not_found");
69 if (done.ok) app.bus.append({ kind: add ? "chapter.item_added" : "chapter.item_removed", repoId: repo.id, actorId: actor.id, payload: { name, number } });
70 return done;
71 }
72
73 /**
74 * Close the book: tag the default branch's tip with the chapter's name, write the notes from what
75 * landed, move what is left (only with `ship`) to the next chapter and mark the chapter closed.
76 * Nothing starts afterwards: with no chapter open the crew rests (`chapterHold`).
77 */
78 export async function close(
79 app: App,
80 actor: User | undefined,
81 repo: Repo,
82 name: string,
83 ship: boolean,
84 now = new Date(),
85 ): Promise<Result<Chapter, ChapterActError>> {
86 if (!owner(app, actor, repo)) return err("forbidden");
87 const chapter = getChapter(app.db, repo.id, name);
88 if (chapter === undefined) return err("not_found");
89 if (chapter.state === "closed") return err("closed");
90 if (chapter.state !== "open") return err("not_draft");
91 const items = itemsOf(app.db, chapter);
92 const left = items.filter((i) => i.status !== "done");
93 if (left.length > 0 && !ship) return err("unfinished");
94
95 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
96 const tipped = await runGit(["rev-parse", "--verify", "--quiet", `refs/heads/${repo.defaultBranch}^{commit}`], { cwd: dir });
97 if (!tipped.ok) return err("no_branch");
98 const tip = tipped.value.trim();
99 const ref = `refs/tags/${chapter.name}`;
100 if ((await runGit(["rev-parse", "--verify", "--quiet", ref], { cwd: dir })).ok) return err("tag_exists");
101 if (!(await runGit(["update-ref", ref, tip, ""], { cwd: dir })).ok) return err("git");
102
103 const closedAt = now.toISOString();
104 const landed = items
105 .filter((i) => i.status === "done")
106 .map((item) => ({ item, fix: labelsFor(app.db, item.issueId).some((l) => l.name.toLowerCase() === "bug") }));
107 const costUsd = Math.round(landed.reduce((s, l) => s + l.item.costUsd, 0) * 1e4) / 1e4;
108 // The tag was created only if absent, so it is ours: whatever stops the book closing takes it back,
109 // or a retry would answer `tag_exists` for good.
110 let next: Chapter | undefined;
111 try {
112 app.db.transaction(() => {
113 // What is left moves on to the next chapter, drafted when there is none.
114 next = left.length === 0 ? undefined : nextChapterOf(app.db, repo.id);
115 if (left.length > 0 && next === undefined) {
116 const made = draftChapter(app.db, repo.id, { name: nextName(chapter.name) }, actor.id);
117 next = made.ok ? made.value : undefined;
118 }
119 const notes = chapterNotes(chapter.name, chapter.means, landed, left, next?.name, {
120 opened: chapter.openedAt ?? closedAt,
121 closed: closedAt,
122 days: daysBetween(chapter.openedAt ?? closedAt, closedAt),
123 doneCount: landed.length,
124 costUsd,
125 });
126 if (next !== undefined) for (const i of left) putIssue(app.db, next, i.issueId, actor.id);
127 const closed = app.db
128 .query("UPDATE chapter SET state = 'closed', closed_at = ?2, tag_oid = ?3, notes = ?4, cost_usd = ?5 WHERE id = ?1 AND state = 'open'")
129 .run(chapter.id, closedAt, tip, notes, costUsd);
130 if (closed.changes !== 1) throw new Error("chapter_not_open");
131 }).immediate();
132 } catch {
133 await runGit(["update-ref", "-d", ref, tip], { cwd: dir });
134 return err(getChapter(app.db, repo.id, name)?.state === "closed" ? "closed" : "git");
135 }
136 app.bus.append({ kind: "chapter.closed", repoId: repo.id, actorId: actor.id, payload: { name, tag: chapter.name, shipped: landed.length, moved: left.length, ...(next === undefined ? {} : { into: next.name }) } });
137 return ok(getChapter(app.db, repo.id, name)!);
138 }
139
140 /** Open a patch chapter for a closed one, with one click: drafted and opened together. */
141 export function patch(app: App, actor: User | undefined, repo: Repo, closedName: string, now = new Date()): Result<Chapter, ChapterActError> {
142 if (!owner(app, actor, repo)) return err("forbidden");
143 const closed = getChapter(app.db, repo.id, closedName);
144 if (closed === undefined) return err("not_found");
145 if (closed.state !== "closed") return err("not_draft");
146 if (openChapterOf(app.db, repo.id) !== undefined) return err("already_open");
147 let name = patchName(closed.name);
148 while (getChapter(app.db, repo.id, name) !== undefined) name = patchName(name);
149 const made = draftChapter(app.db, repo.id, { name, means: `Fixes for what ${closed.name} shipped.`, patchOf: closed.id }, actor.id);
150 if (!made.ok) return made;
151 const started = startChapter(app.db, made.value, now);
152 if (started.ok) app.bus.append({ kind: "chapter.opened", repoId: repo.id, actorId: actor.id, payload: { name, patchOf: closed.name } });
153 return started;
154 }
155
156 /**
157 * A planner's new issue while a chapter is open: a defect of a chapter item joins the chapter; any
158 * other waits in the next chapter, or, with none drafted, behind the Later gate where the owner
159 * sees it queued. The planner never adds scope to the open chapter.
160 */
161 export function keepWithinChapter(app: App, repo: Repo, issue: Issue, planner: User): void {
162 const placed = placeNewIssue(app.db, repo.id, issue.id, issue.body);
163 if (placed === undefined) return;
164 if (placed === "later") {
165 const gate = laterGate(app.db, repo.id);
166 const gateIssue = gate === undefined ? undefined : getIssue(app.db, repo.id, gate);
167 if (gateIssue !== undefined) addDependency(app.db, issue, gateIssue, planner.id);
168 return;
169 }
170 putIssue(app.db, placed.chapter, issue.id, planner.id);
171 app.bus.append({ kind: "chapter.item_added", repoId: repo.id, actorId: planner.id, payload: { name: placed.chapter.name, number: issue.number, scope: placed.scope } });
172 }
173
174 /** What each refusal says, in the words every door uses. */
175 export const CHAPTER_WORDS: Readonly<Record<ChapterActError, string>> = {
176 invalid_name: "A chapter's name is letters, digits, dots, dashes and underscores, at most 40 (it becomes the git tag).",
177 means_too_long: "What done means is one sentence, at most 300 characters.",
178 exists: "A chapter with that name already exists.",
179 already_open: "Another chapter is already open: a repository has one open chapter at a time.",
180 not_draft: "That chapter is not open for this.",
181 closed: "A closed chapter cannot be changed or reopened; open a patch chapter instead.",
182 not_found: "No such chapter, or nothing to do.",
183 forbidden: "Only a person who administers this repository opens, fills and closes chapters.",
184 no_issue: "No such issue here.",
185 unfinished: "Items are still open. Ship the chapter to move them to the next one and close the book.",
186 no_branch: "The default branch has no commit to tag yet.",
187 tag_exists: "A git tag with that name already exists.",
188 git: "The tag could not be written.",
189 };
src/actions/issues.ts +9 −1
@@ -25,6 +25,8 @@ import type { App } from "../app.ts";
2525 import { adoptResolution } from "../core/trusted_sources.ts";
2626 import { crewRole, isCrew } from "../core/agents.ts";
2727 import { pausedUntil } from "../core/planner_pause.ts";
28 import { keepWithinChapter } from "./chapters.ts";
29 import { inheritChapter } from "../core/chapters.ts";
2830 import { moveBlockersToChildren } from "../core/dependencies.ts";
2931 import {
3032 addComment as insertComment,
@@ -118,6 +120,9 @@ export function openIssue(
118120 sealed: input.sealed === true,
119121 });
120122 if (!created.ok) return created;
123 // A chapter is open (#11603): the planner's issue waits in the next chapter, never in this one.
124 if (user.value.kind === "agent" && crewRole(app.db, repo.id, user.value.id) === "planner")
125 keepWithinChapter(app, repo, created.value, user.value);
121126 app.bus.append({
122127 kind: "issue.opened",
123128 repoId: repo.id,
@@ -522,8 +527,11 @@ export function setIssueState(
522527 return written;
523528 })();
524529 if (!changed) return ok(getIssueById(app.db, issue.id) ?? issue);
525 if (children.length > 0)
530 if (children.length > 0) {
526531 moveBlockersToChildren(app.db, issue, children, user.value.id);
532 // The parts of a split stay in the chapter the issue was in (#11603).
533 inheritChapter(app.db, issue, children, user.value.id);
534 }
527535 app.bus.append({
528536 kind: `${issue.kind}.${state === "closed" ? "closed" : "reopened"}`,
529537 repoId: repo.id,
src/contract/openapi/routes/getApiV1ReposOwnerNameChapters.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}/chapters`, summary: "The repository's chapters (a version with an end): the open one, the drafts waiting after it and the closed ones, each with its name, what done means, state, and its list of issues (done only when live and proven, building, left), progress and the date it finishes at the current pace; a closed chapter carries its tag, notes, cost and the day it closed", auth: false, role: "anonymous" };
src/contract/openapi/routes/index.ts +3
@@ -134,6 +134,7 @@ export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameB
134134 export { route as getApiV1ReposOwnerNameBuildsLabelCart } from "./getApiV1ReposOwnerNameBuildsLabelCart.ts";
135135 export { route as getApiV1ReposOwnerNameBuildsLabelShots } from "./getApiV1ReposOwnerNameBuildsLabelShots.ts";
136136 export { route as getApiV1ReposOwnerNameBuildsLabelShotsLooks } from "./getApiV1ReposOwnerNameBuildsLabelShotsLooks.ts";
137 export { route as getApiV1ReposOwnerNameChapters } from "./getApiV1ReposOwnerNameChapters.ts";
137138 export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts";
138139 export { route as getApiV1ReposOwnerNameCommitsOid } from "./getApiV1ReposOwnerNameCommitsOid.ts";
139140 export { route as getApiV1ReposOwnerNameCommitsOidCheck } from "./getApiV1ReposOwnerNameCommitsOidCheck.ts";
@@ -317,6 +318,8 @@ export { route as postApiV1ReposOwnerNameBuildsLabelShots } from "./postApiV1Rep
317318 export { route as postApiV1ReposOwnerNameBuildsLabelShotsFile } from "./postApiV1ReposOwnerNameBuildsLabelShotsFile.ts";
318319 export { route as postApiV1ReposOwnerNameBuildsLabelShotsLooks } from "./postApiV1ReposOwnerNameBuildsLabelShotsLooks.ts";
319320 export { route as postApiV1ReposOwnerNameBuildsPreview } from "./postApiV1ReposOwnerNameBuildsPreview.ts";
321 export { route as postApiV1ReposOwnerNameChapters } from "./postApiV1ReposOwnerNameChapters.ts";
322 export { route as postApiV1ReposOwnerNameChaptersChapterVerb } from "./postApiV1ReposOwnerNameChaptersChapterVerb.ts";
320323 export { route as postApiV1ReposOwnerNameChecksRecheck } from "./postApiV1ReposOwnerNameChecksRecheck.ts";
321324 export { route as postApiV1ReposOwnerNameContents } from "./postApiV1ReposOwnerNameContents.ts";
322325 export { route as postApiV1ReposOwnerNameContribute } from "./postApiV1ReposOwnerNameContribute.ts";
src/contract/openapi/routes/postApiV1ReposOwnerNameChapters.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "post", path: `${REPO}/chapters`, summary: "Draft a chapter: `name` (the git tag it will carry, at most 40 characters) and `means`, one sentence of what done means. Only a person who administers the repository", auth: true, role: "member", body: { name: "string" } };
src/contract/openapi/routes/postApiV1ReposOwnerNameChaptersChapterVerb.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "post", path: `${REPO}/chapters/{chapter}/{verb}`, summary: "Act on a chapter. `verb` is `open` (the repository's one open chapter, from now), `add` or `remove` (`number`: an issue; the owner's act, recorded), `ship` (tag the default branch with the chapter's name, write its notes from what landed, move what is left to the next chapter and close it) or `patch` (open a patch chapter for a closed one). Only a person who administers the repository; a closed chapter cannot be reopened", auth: true, role: "member" };
src/contract/tags.ts +1
@@ -17,6 +17,7 @@ const ALIAS: Readonly<Record<string, string>> = {
1717 contribute: "forks",
1818 queue: "gates",
1919 growth: "issues",
20 chapters: "issues",
2021 checks: "pulls",
2122 conversation: "conversations",
2223 "conversation-confirms": "conversations",
src/core/chapter_notes.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * A closed chapter's notes (#11603), written from what landed: plain sentences, grouped, each with
3 * who built it and what it cost, then what was moved on. Pure text from plain data; no model.
4 */
5
6 import type { Item } from "./chapters.ts";
7
8 export interface Figures {
9 readonly opened: string;
10 readonly closed: string;
11 readonly days: number;
12 readonly doneCount: number;
13 readonly costUsd: number;
14 }
15
16 const usd = (n: number): string => `$${n.toFixed(2)}`;
17
18 /** Whole days from `a` to `b`, at least one. */
19 export function daysBetween(a: string, b: string): number {
20 return Math.max(1, Math.round((Date.parse(b) - Date.parse(a)) / 86_400_000));
21 }
22
23 /** One sentence for a landed item. */
24 export function sentence(i: Item): string {
25 const by = i.builder === null ? "" : ` Built by ${i.builder}.`;
26 const cost = i.costUsd > 0 ? ` Cost ${usd(i.costUsd)}.` : "";
27 const title = i.title.trim().replace(/[.\s]+$/, "");
28 return `- ${title} (#${i.number}).${by}${cost}`;
29 }
30
31 /** The notes: fixes and additions grouped, then what moved to the next chapter. */
32 export function chapterNotes(
33 name: string,
34 means: string,
35 landed: readonly { item: Item; fix: boolean }[],
36 moved: readonly Item[],
37 next: string | undefined,
38 figures: Figures,
39 ): string {
40 const lines = [`# ${name}`, ""];
41 if (means !== "") lines.push(means, "");
42 lines.push(
43 `Shipped ${figures.closed.slice(0, 10)}: ${figures.doneCount} ${figures.doneCount === 1 ? "item" : "items"} in ${figures.days} ${figures.days === 1 ? "day" : "days"}, costing ${usd(figures.costUsd)}.`,
44 "",
45 );
46 const added = landed.filter((l) => !l.fix);
47 const fixed = landed.filter((l) => l.fix);
48 if (added.length > 0) lines.push("## Added", ...added.map((l) => sentence(l.item)), "");
49 if (fixed.length > 0) lines.push("## Fixed", ...fixed.map((l) => sentence(l.item)), "");
50 if (landed.length === 0) lines.push("Nothing had landed when this chapter closed.", "");
51 if (moved.length > 0 && next !== undefined)
52 lines.push(`## Moved to ${next}`, ...moved.map((m) => `- ${m.title.trim()} (#${m.number})`), "");
53 return `${lines.join("\n").trimEnd()}\n`;
54 }
src/core/chapters.ts added +380
@@ -0,0 +1,380 @@
1 /**
2 * Chapters (#11603): a named list of a repository's issues that is finished, tagged and closed.
3 * A repository has at most one open chapter and any number drafted after it. While one is open the
4 * crew builds only what is in it; once the last is closed the crew rests until the owner opens the
5 * next. A repository with no chapter is left alone: continuous flow, as before.
6 *
7 * Plain data in and out: the db, ids, a clock. The acts (who may, the tag, the events) are in
8 * `actions/chapters.ts`; the closing words are in `core/chapter_notes.ts`.
9 */
10
11 import type { Database } from "bun:sqlite";
12 import { releaseOfPull } from "../release/record.ts";
13 import { id } from "../kernel/id.ts";
14 import { err, ok, type Result } from "../kernel/result.ts";
15 import { closesFromBody, getPull } from "./pulls.ts";
16 import type { Issue } from "./issues.ts";
17
18 export type ChapterState = "draft" | "open" | "closed";
19
20 export interface Chapter {
21 readonly id: string;
22 readonly repoId: string;
23 readonly name: string;
24 readonly means: string;
25 readonly state: ChapterState;
26 readonly position: number;
27 readonly patchOf: string | null;
28 readonly createdAt: string;
29 readonly openedAt: string | null;
30 readonly closedAt: string | null;
31 readonly tagOid: string | null;
32 readonly notes: string;
33 readonly costUsd: number | null;
34 }
35
36 interface Row {
37 id: string;
38 repo_id: string;
39 name: string;
40 means: string;
41 state: ChapterState;
42 position: number;
43 patch_of: string | null;
44 created_at: string;
45 opened_at: string | null;
46 closed_at: string | null;
47 tag_oid: string | null;
48 notes: string;
49 cost_usd: number | null;
50 }
51
52 const toChapter = (r: Row): Chapter => ({
53 id: r.id,
54 repoId: r.repo_id,
55 name: r.name,
56 means: r.means,
57 state: r.state,
58 position: r.position,
59 patchOf: r.patch_of,
60 createdAt: r.created_at,
61 openedAt: r.opened_at,
62 closedAt: r.closed_at,
63 tagOid: r.tag_oid,
64 notes: r.notes,
65 costUsd: r.cost_usd,
66 });
67
68 /** A chapter's name is also its git tag: a short, plain ref name. */
69 export const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/;
70 export const MAX_MEANS = 300;
71
72 export type ChapterError = "invalid_name" | "means_too_long" | "exists" | "already_open" | "not_draft" | "closed" | "not_found";
73
74 export function listChapters(db: Database, repoId: string): Chapter[] {
75 return db
76 .query<Row, [string]>(
77 `SELECT * FROM chapter WHERE repo_id = ?1
78 ORDER BY CASE state WHEN 'open' THEN 0 WHEN 'draft' THEN 1 ELSE 2 END, position, created_at`,
79 )
80 .all(repoId)
81 .map(toChapter);
82 }
83
84 export function getChapter(db: Database, repoId: string, name: string): Chapter | undefined {
85 const row = db.query<Row, [string, string]>("SELECT * FROM chapter WHERE repo_id = ?1 AND name = ?2").get(repoId, name);
86 return row === null ? undefined : toChapter(row);
87 }
88
89 export function getChapterById(db: Database, chapterId: string): Chapter | undefined {
90 const row = db.query<Row, [string]>("SELECT * FROM chapter WHERE id = ?1").get(chapterId);
91 return row === null ? undefined : toChapter(row);
92 }
93
94 /** The repository's open chapter, if it has one. */
95 export function openChapterOf(db: Database, repoId: string): Chapter | undefined {
96 const row = db.query<Row, [string]>("SELECT * FROM chapter WHERE repo_id = ?1 AND state = 'open'").get(repoId);
97 return row === null ? undefined : toChapter(row);
98 }
99
100 /** The drafted chapter that comes next: the one first in line. */
101 export function nextChapterOf(db: Database, repoId: string): Chapter | undefined {
102 const row = db
103 .query<Row, [string]>("SELECT * FROM chapter WHERE repo_id = ?1 AND state = 'draft' ORDER BY position, created_at LIMIT 1")
104 .get(repoId);
105 return row === null ? undefined : toChapter(row);
106 }
107
108 /** Draft a chapter, last in line. A patch chapter names the closed one it fixes. */
109 export function draftChapter(
110 db: Database,
111 repoId: string,
112 input: { name: string; means?: string; patchOf?: string },
113 byId: string,
114 ): Result<Chapter, ChapterError> {
115 const name = input.name.trim();
116 const means = (input.means ?? "").trim();
117 if (!NAME.test(name) || name.endsWith(".") || name.includes("..")) return err("invalid_name");
118 if (means.length > MAX_MEANS) return err("means_too_long");
119 if (getChapter(db, repoId, name) !== undefined) return err("exists");
120 const last = db
121 .query<{ p: number | null }, [string]>("SELECT MAX(position) AS p FROM chapter WHERE repo_id = ?1")
122 .get(repoId)?.p;
123 const chapterId = id();
124 db.query(
125 `INSERT INTO chapter (id, repo_id, name, means, position, patch_of, created_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
126 ).run(chapterId, repoId, name, means, (last ?? 0) + 1, input.patchOf ?? null, byId);
127 return ok(getChapterById(db, chapterId)!);
128 }
129
130 /** Open a drafted chapter: the repository's one open chapter, from now. */
131 export function startChapter(db: Database, chapter: Chapter, now: Date): Result<Chapter, ChapterError> {
132 if (chapter.state === "closed") return err("closed");
133 if (chapter.state === "open") return err("already_open");
134 if (openChapterOf(db, chapter.repoId) !== undefined) return err("already_open");
135 db.query("UPDATE chapter SET state = 'open', opened_at = ?2 WHERE id = ?1").run(chapter.id, now.toISOString());
136 return ok(getChapterById(db, chapter.id)!);
137 }
138
139 /** The next name after `name`: v1.0.0 → v2.0.0. */
140 export function nextName(name: string): string {
141 const m = /^(\D*)(\d+)(.*)$/.exec(name);
142 if (m === null) return `${name}-next`;
143 return `${m[1]}${Number(m[2]) + 1}${(m[3] ?? "").replace(/\d+/g, "0")}`;
144 }
145
146 /** A patch's name after `name`: v1.0.0 → v1.0.1; a name without three numbers gets `.1`. */
147 export function patchName(name: string): string {
148 const m = /^(.*\d+\.\d+\.)(\d+)$/.exec(name);
149 return m === null ? `${name}.1` : `${m[1]}${Number(m[2]) + 1}`;
150 }
151
152 // ---- items ----
153
154 /** The chapter an issue is in, if any. */
155 export function chapterOfIssue(db: Database, issueId: string): Chapter | undefined {
156 const row = db
157 .query<Row, [string]>("SELECT c.* FROM chapter c JOIN chapter_item i ON i.chapter_id = c.id WHERE i.issue_id = ?1")
158 .get(issueId);
159 return row === null ? undefined : toChapter(row);
160 }
161
162 /** Put an issue in a chapter (moving it from another that is not closed). Closed chapters hold their list. */
163 export function putIssue(db: Database, chapter: Chapter, issueId: string, byId: string | undefined): Result<void, ChapterError> {
164 if (chapter.state === "closed") return err("closed");
165 const from = chapterOfIssue(db, issueId);
166 if (from?.state === "closed") return err("closed");
167 db.query(
168 `INSERT INTO chapter_item (issue_id, chapter_id, by_id) VALUES (?1, ?2, ?3)
169 ON CONFLICT(issue_id) DO UPDATE SET chapter_id = ?2, by_id = ?3, at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
170 ).run(issueId, chapter.id, byId ?? null);
171 return ok(undefined);
172 }
173
174 export function takeIssueOut(db: Database, chapter: Chapter, issueId: string): boolean {
175 if (chapter.state === "closed") return false;
176 return db.query("DELETE FROM chapter_item WHERE issue_id = ?1 AND chapter_id = ?2").run(issueId, chapter.id).changes > 0;
177 }
178
179 export type ItemStatus = "done" | "building" | "left";
180
181 export interface Item {
182 readonly issueId: string;
183 readonly number: number;
184 readonly title: string;
185 readonly status: ItemStatus;
186 /** When it went live and proven; null until then. */
187 readonly doneAt: string | null;
188 /** The pull that shipped it, when one did. */
189 readonly pull: number | null;
190 readonly builder: string | null;
191 readonly costUsd: number;
192 }
193
194 interface ItemRow {
195 id: string;
196 number: number;
197 title: string;
198 state: "open" | "closed";
199 }
200
201 interface PullFact {
202 readonly id: string;
203 readonly number: number;
204 readonly mergedAt: string | null;
205 readonly closes: readonly number[];
206 }
207
208 const round4 = (n: number): number => Math.round(n * 1e4) / 1e4;
209
210 /**
211 * Every pull of the repository with the issue numbers it closes, read once for the whole list: the
212 * list's cost is one pass over the pulls however many items it holds, never a scan per item.
213 */
214 function pullFacts(db: Database, repoId: string): PullFact[] {
215 return db
216 .query<{ id: string; number: number; body: string; at: string | null }, [string]>(
217 `SELECT issue.id AS id, issue.number AS number, issue.body AS body, pull.merged_at AS at
218 FROM pull JOIN issue ON issue.id = pull.issue_id WHERE issue.repo_id = ?1 AND issue.body LIKE '%#%'`,
219 )
220 .all(repoId)
221 .map((r) => ({ id: r.id, number: r.number, mergedAt: r.at, closes: closesFromBody(r.body) }));
222 }
223
224 /** What each issue's own runs spent, by issue id: one grouped read for the repository. */
225 function runSpend(db: Database, repoId: string): Map<string, number> {
226 const rows = db
227 .query<{ id: string; usd: number }, [string]>(
228 `SELECT run.issue_id AS id, COALESCE(SUM(run.cost_usd), 0) AS usd FROM run JOIN issue ON issue.id = run.issue_id
229 WHERE issue.repo_id = ?1 GROUP BY run.issue_id`,
230 )
231 .all(repoId);
232 return new Map(rows.map((r) => [r.id, r.usd]));
233 }
234
235 /**
236 * Where an item stands. Done is live and proven: closed, shipped by a merged pull that production's
237 * release proof passed for (`release.ok`), never merely merged.
238 */
239 function statusOf(
240 db: Database,
241 repoId: string,
242 row: ItemRow,
243 closing: readonly PullFact[],
244 ): Pick<Item, "status" | "doneAt" | "pull" | "builder"> {
245 // Merged pulls that close it, newest first: any one of them may be the one production proved.
246 const merged =
247 row.state === "closed"
248 ? closing
249 .filter((f) => f.mergedAt !== null)
250 .sort((a, b) => (a.mergedAt === b.mergedAt ? b.number - a.number : a.mergedAt! < b.mergedAt! ? 1 : -1))
251 : [];
252 const first = merged[0];
253 for (const m of merged) {
254 const proof = releaseOfPull(db, repoId, m.number);
255 if (proof !== undefined) return { status: "done", doneAt: proof.createdAt, pull: m.number, builder: getPull(db, repoId, m.number)?.authorName ?? null };
256 }
257 const active =
258 db
259 .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM run WHERE issue_id = ?1 AND state IN ('queued','running')")
260 .get(row.id)?.n ?? 0;
261 return { status: active > 0 || first !== undefined || row.state === "closed" ? "building" : "left", doneAt: null, pull: first?.number ?? null, builder: null };
262 }
263
264 /**
265 * A closed chapter's list never changes (its items are held, its pulls have landed), so it is read
266 * once and kept: a public page or API read of a closed chapter costs a lookup after the first.
267 */
268 const FROZEN_MAX = 200;
269 const frozen = new Map<string, Item[]>();
270
271 /** Forget the kept lists: a test that rebuilds a database. */
272 export function forgetFrozenItems(): void {
273 frozen.clear();
274 }
275
276 /** A chapter's list, in issue order, each item with where it stands and what it cost. */
277 export function itemsOf(db: Database, chapter: Chapter): Item[] {
278 const kept = chapter.state === "closed" ? frozen.get(chapter.id) : undefined;
279 if (kept !== undefined) return kept;
280 const rows = db
281 .query<ItemRow, [string]>(
282 `SELECT i.id, i.number, i.title, i.state FROM chapter_item c JOIN issue i ON i.id = c.issue_id
283 WHERE c.chapter_id = ?1`,
284 )
285 .all(chapter.id)
286 .sort((a, b) => a.number - b.number);
287 const out: Item[] = [];
288 if (rows.length > 0) {
289 const facts = pullFacts(db, chapter.repoId);
290 const spend = runSpend(db, chapter.repoId);
291 for (const r of rows) {
292 const closing = facts.filter((f) => f.id !== r.id && f.closes.includes(r.number));
293 const cost = round4((spend.get(r.id) ?? 0) + closing.reduce((sum, f) => sum + round4(spend.get(f.id) ?? 0), 0));
294 out.push({ issueId: r.id, number: r.number, title: r.title, ...statusOf(db, chapter.repoId, r, closing), costUsd: cost });
295 }
296 }
297 if (chapter.state === "closed") {
298 if (frozen.size >= FROZEN_MAX) frozen.delete(frozen.keys().next().value!);
299 frozen.set(chapter.id, out);
300 }
301 return out;
302 }
303
304 export interface Progress {
305 readonly done: number;
306 readonly total: number;
307 /** When the list will be finished at the pace so far, or null before there is a pace. */
308 readonly finishesOn: string | null;
309 }
310
311 /** Items done against the list, and the day it finishes at the pace since the chapter opened. */
312 export function progressOf(chapter: Chapter, items: readonly Item[], now: Date): Progress {
313 const done = items.filter((i) => i.status === "done").length;
314 const total = items.length;
315 if (chapter.openedAt === null || done === 0 || done >= total) return { done, total, finishesOn: null };
316 const days = Math.max((now.getTime() - Date.parse(chapter.openedAt)) / 86_400_000, 1 / 24);
317 const left = (total - done) / (done / days);
318 return { done, total, finishesOn: new Date(now.getTime() + left * 86_400_000).toISOString().slice(0, 10) };
319 }
320
321 // ---- the crew's gate ----
322
323 /** Whether the repository has closed a chapter and opened none since: the crew rests. */
324 export function resting(db: Database, repoId: string): boolean {
325 return (
326 openChapterOf(db, repoId) === undefined &&
327 db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM chapter WHERE repo_id = ?1 AND state = 'closed'").get(repoId)!.n > 0
328 );
329 }
330
331 /** The issue number a body says it is a defect of: "Defect of #12". */
332 export function defectOf(body: string): number | undefined {
333 const m = /\bdefect (?:of|in|on) #(\d+)\b/i.exec(body);
334 return m === null ? undefined : Number(m[1]);
335 }
336
337 /**
338 * Why the crew may not start work on this issue now, or null when it may: a chapter is open and the
339 * issue (or, for a pull, what it closes) is not in it, or the repository rests between chapters.
340 * A repository with no chapter is never held.
341 */
342 export function chapterHold(db: Database, repoId: string, issue: Pick<Issue, "id" | "kind" | "body">): string | null {
343 const open = openChapterOf(db, repoId);
344 if (open === undefined) return resting(db, repoId) ? "the repository rests between chapters; the owner opens the next" : null;
345 const inOpen = (issueId: string): boolean => chapterOfIssue(db, issueId)?.id === open.id;
346 if (inOpen(issue.id)) return null;
347 if (issue.kind === "pull") {
348 const closes = closesFromBody(issue.body);
349 if (closes.length === 0) return null;
350 for (const n of closes) {
351 const target = db.query<{ id: string }, [string, number]>("SELECT id FROM issue WHERE repo_id = ?1 AND number = ?2").get(repoId, n);
352 if (target !== null && inOpen(target.id)) return null;
353 }
354 }
355 return `outside ${open.name}: the crew builds only what the open chapter holds`;
356 }
357
358 /**
359 * Where an issue the planner opens lands while a chapter is open: a defect of a chapter item joins the
360 * chapter; anything else waits in the next chapter. Undefined when no chapter is open (nothing to do)
361 * or no later chapter is drafted (the caller sends it to Later).
362 */
363 export function placeNewIssue(db: Database, repoId: string, issueId: string, body: string): { chapter: Chapter; scope: "defect" | "next" } | "later" | undefined {
364 const open = openChapterOf(db, repoId);
365 if (open === undefined) return undefined;
366 const n = defectOf(body);
367 if (n !== undefined) {
368 const parent = db.query<{ id: string }, [string, number]>("SELECT id FROM issue WHERE repo_id = ?1 AND number = ?2").get(repoId, n);
369 if (parent !== null && chapterOfIssue(db, parent.id)?.id === open.id) return { chapter: open, scope: "defect" };
370 }
371 const next = nextChapterOf(db, repoId);
372 return next === undefined ? "later" : { chapter: next, scope: "next" };
373 }
374
375 /** Parts of a split issue stay in its chapter. */
376 export function inheritChapter(db: Database, parent: Issue, children: readonly Issue[], byId: string | undefined): void {
377 const chapter = chapterOfIssue(db, parent.id);
378 if (chapter === undefined || chapter.state === "closed") return;
379 for (const child of children) putIssue(db, chapter, child.id, byId);
380 }
src/core/event_audience.ts +6
@@ -186,6 +186,12 @@ const READER_KINDS: ReadonlySet<string> = new Set([
186186 "start.made",
187187 "start.claimed",
188188 "start.swept",
189 // A chapter's drafting, opening, filling and closing are on its public page (#11603).
190 "chapter.drafted",
191 "chapter.opened",
192 "chapter.item_added",
193 "chapter.item_removed",
194 "chapter.closed",
189195 ]);
190196
191197 /**
src/core/migration-pins/20261112120000-chapters.pin added +1
@@ -0,0 +1,1 @@
1 53dd8bcfb61b52ada401560dfe3c15dcc8539adda061c7a78283373e384679db
src/core/migrations/20261112120000-chapters.ts added +32
@@ -0,0 +1,32 @@
1 // `chapter` (#11603): a named list of a repository's issues that is finished, tagged and closed.
2 // At most one is open per repository (the partial unique index); any number are drafts that wait.
3 // `chapter_item` puts an issue in at most one chapter. A closed chapter keeps its tag, notes and
4 // figures forever. New tables and indexes only. Additive.
5 export default `
6 CREATE TABLE chapter (
7 id TEXT PRIMARY KEY,
8 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
9 name TEXT NOT NULL,
10 means TEXT NOT NULL DEFAULT '',
11 state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','open','closed')),
12 position INTEGER NOT NULL DEFAULT 0,
13 patch_of TEXT REFERENCES chapter(id) ON DELETE SET NULL,
14 created_by TEXT REFERENCES user(id) ON DELETE SET NULL,
15 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
16 opened_at TEXT,
17 closed_at TEXT,
18 tag_oid TEXT,
19 notes TEXT NOT NULL DEFAULT '',
20 cost_usd REAL,
21 UNIQUE (repo_id, name)
22 );
23 CREATE UNIQUE INDEX chapter_one_open ON chapter(repo_id) WHERE state = 'open';
24 CREATE INDEX chapter_repo ON chapter(repo_id, state, position);
25 CREATE TABLE chapter_item (
26 issue_id TEXT PRIMARY KEY REFERENCES issue(id) ON DELETE CASCADE,
27 chapter_id TEXT NOT NULL REFERENCES chapter(id) ON DELETE CASCADE,
28 by_id TEXT REFERENCES user(id) ON DELETE SET NULL,
29 at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
30 );
31 CREATE INDEX chapter_item_chapter ON chapter_item(chapter_id);
32 `;
src/crew/dispatch.ts +9
@@ -9,6 +9,7 @@
99 */
1010
1111 import { pausedUntil } from "../core/planner_pause.ts";
12 import { chapterHold } from "../core/chapters.ts";
1213 import { crewFilters, isStrangerPull } from "./stranger.ts";
1314 import { TEMP_FULL } from "../checks/disk_full.ts";
1415 import { freshPullFor } from "./fresh_pull.ts";
@@ -468,6 +469,14 @@ export function queueAgentRun(
468469 // The owner's switch (#11604): a paused planner starts nothing; builders and reviewers carry on.
469470 if (crewRole(app.db, repo.id, agent.user.id) === "planner" && pausedUntil(app.db, repo.id, new Date()) !== null)
470471 return undefined;
472 // A chapter is open (#11603): builders and reviewers start only what it holds, and the crew rests
473 // between chapters. A planner plans (its scope is kept where it opens issues); a run carrying on finishes.
474 if (
475 again === undefined &&
476 crewRole(app.db, repo.id, agent.user.id) !== "planner" &&
477 chapterHold(app.db, repo.id, issue) !== null
478 )
479 return undefined;
471480 // An archived repository starts nothing new; a run carrying on (`again`)
472481 // is one already going, and finishes. A first run
473482 // (`attempt` 0, as a conflict or converge run is queued) is new.
src/http/api.ts +2
@@ -383,6 +383,7 @@ import { TEMPLATE_ERRORS } from "../apps/template_tip.ts";
383383 import { flagsRoutes } from "./api/flags.ts";
384384 import { appTemplateRoutes } from "./api/app_templates.ts";
385385 import { issueRoutes, labelsHandler } from "./api/issues.ts";
386 import { chapterRoutes } from "./api/chapters.ts";
386387 import { FORGE_NOT_CARRIED } from "../origins/words.ts";
387388 import { forgeOriginRoutes } from "./api/forge_origin.ts";
388389 import { userRoutes } from "./api/user.ts";
@@ -2171,6 +2172,7 @@ export function apiRoutes(router: Router<Handler>): void {
21712172 // ── issues — the label-as-queue surface automation drives ────────────
21722173
21732174 issueRoutes(router);
2175 chapterRoutes(router);
21742176
21752177 reportRoutes(router);
21762178 startRoutes(router);
src/http/api/chapters.ts added +76
@@ -0,0 +1,76 @@
1 /**
2 * Chapters over the API (#11603), in the same words as the page: the list with each chapter's items,
3 * and the owner's acts (draft, open, add, remove, ship, patch). The rules are in `core/chapters.ts`
4 * and the acts in `actions/chapters.ts`; this only reads the request and answers the result.
5 */
6
7 import { CHAPTER_WORDS, close, draft, fill, open, patch } from "../../actions/chapters.ts";
8 import { itemsOf, listChapters, progressOf, type Chapter } from "../../core/chapters.ts";
9 import type { Handler } from "../context.ts";
10 import { apiError, body, json, notFound, requireScope } from "../respond.ts";
11 import { loadRepo } from "../api.ts";
12 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
13 import type { Router } from "../router.ts";
14
15 const shape = (db: Parameters<typeof itemsOf>[0], c: Chapter, now: Date) => {
16 const items = itemsOf(db, c);
17 return {
18 name: c.name,
19 means: c.means,
20 state: c.state,
21 patchOf: c.patchOf,
22 openedAt: c.openedAt,
23 closedAt: c.closedAt,
24 tag: c.state === "closed" ? { name: c.name, oid: c.tagOid } : null,
25 notes: c.notes,
26 costUsd: c.costUsd,
27 progress: progressOf(c, items, now),
28 items: items.map((i) => ({ number: i.number, title: i.title, status: i.status, doneAt: i.doneAt, pull: i.pull, builder: i.builder, costUsd: i.costUsd })),
29 };
30 };
31
32 export function chapterRoutes(router: Router<Handler>): void {
33 router.add("GET", "/api/v1/repos/:owner/:name/chapters", (ctx) => {
34 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
35 const repo = loadRepo(ctx);
36 if (!repo) return notFound();
37 const now = new Date();
38 return json({ chapters: listChapters(ctx.app.db, repo.id).map((c) => shape(ctx.app.db, c, now)) }, 200, { "cache-control": "no-store" });
39 });
40
41 router.add("POST", "/api/v1/repos/:owner/:name/chapters", async (ctx) => {
42 const denied = requireScope(ctx, "rw");
43 if (denied) return denied;
44 const repo = loadRepo(ctx);
45 if (!repo) return notFound();
46 const input = await body(ctx.request);
47 const made = draft(ctx.app, ctx.auth.user, repo, {
48 name: typeof input["name"] === "string" ? input["name"] : "",
49 means: typeof input["means"] === "string" ? input["means"] : "",
50 });
51 if (!made.ok) return apiError(made.error, made.error === "forbidden" ? 403 : made.error === "exists" ? 409 : 422, CHAPTER_WORDS[made.error]);
52 return json(shape(ctx.app.db, made.value, new Date()), 201);
53 });
54
55 router.add("POST", "/api/v1/repos/:owner/:name/chapters/:chapter/:verb", async (ctx) => {
56 const denied = requireScope(ctx, "rw");
57 if (denied) return denied;
58 const repo = loadRepo(ctx);
59 if (!repo) return notFound();
60 const input = await body(ctx.request);
61 const name = ctx.params.chapter ?? "";
62 const verb = ctx.params.verb ?? "";
63 const number = typeof input["number"] === "number" ? input["number"] : Number.NaN;
64 const done =
65 verb === "open" ? open(ctx.app, ctx.auth.user, repo, name)
66 : verb === "add" || verb === "remove" ? fill(ctx.app, ctx.auth.user, repo, name, number, verb === "add")
67 : verb === "ship" ? await close(ctx.app, ctx.auth.user, repo, name, true)
68 : verb === "patch" ? patch(ctx.app, ctx.auth.user, repo, name)
69 : null;
70 if (done === null) return notFound();
71 if (!done.ok)
72 return apiError(done.error, done.error === "forbidden" ? 403 : done.error === "not_found" || done.error === "no_issue" ? 404 : 409, CHAPTER_WORDS[done.error]);
73 const value = done.value;
74 return json(value === undefined ? { ok: true } : shape(ctx.app.db, value, new Date()), 200);
75 });
76 }
src/http/server.ts +5
@@ -172,6 +172,7 @@ import { apiPage, appApiPage } from "../web/pages/api.ts";
172172 import { flowsCheck, flowsPage, flowsProbe, flowsUpdate } from "../web/pages/flows.ts";
173173 import { findingAccept, findingsPage } from "../web/pages/findings.ts";
174174 import { queuePage, queueSwitch } from "../web/pages/queue.ts";
175 import { chapterAct, chapterPage, chaptersPage } from "../web/pages/chapters.ts";
175176 import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts";
176177 import { changelogJson, changelogPage } from "../web/pages/changelog.ts";
177178 import { docsFile, docsFolder } from "../web/pages/docs_link.ts";
@@ -1298,6 +1299,10 @@ export function buildRouter(): Router<Handler> {
12981299 router.add("POST", "/:owner/:name/findings/:id/accept", findingAccept);
12991300 router.add("GET", "/:owner/:name/queue", queuePage);
13001301 router.add("POST", "/:owner/:name/queue/planner", queueSwitch);
1302 router.add("GET", "/:owner/:name/chapters", chaptersPage);
1303 router.add("POST", "/:owner/:name/chapters", chapterAct);
1304 router.add("GET", "/:owner/:name/chapters/:chapter", chapterPage);
1305 router.add("POST", "/:owner/:name/chapters/:chapter/:verb", chapterAct);
13011306 router.add("GET", "/:owner/:name/bets", betsPage);
13021307 router.add("POST", "/:owner/:name/bets/propose", betPropose);
13031308 router.add("GET", "/:owner/:name/bets/:id", betPage);
src/tui/screens/chapters.ts added +221
@@ -0,0 +1,221 @@
1 /**
2 * An app's chapters, in the terminal (#11603): the page's list (`web/pages/chapters.ts`) from the same
3 * acts (`actions/chapters.ts`), so the same owner-only gate applies: anyone who can read the repository
4 * sees the chapters, only a person who administers it drafts, opens, fills, ships or patches. `n`
5 * drafts, `o` opens the one under the cursor, `s` ships it, `p` opens a patch chapter for a closed
6 * one, `enter` shows its list, where `a` adds an issue by number and `x` takes the one under the
7 * cursor out. Each act is an event on the bus, so an open web page sees it, and this screen follows
8 * a change made there.
9 */
10
11 import { CHAPTER_WORDS, close, draft, fill, open, patch } from "../../actions/chapters.ts";
12 import { getChapter, itemsOf, listChapters, progressOf, type Chapter, type Item } from "../../core/chapters.ts";
13 import { canAdmin, type Repo } from "../../core/repos.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import type { Command } from "../app/palette.ts";
16 import { binding } from "../app/keys.ts";
17 import { style } from "../term/style.ts";
18 import { truncate } from "../term/width.ts";
19 import type { Session } from "../session.ts";
20 import { fill as fillSpace, len, split } from "../ui/layout.ts";
21 import { ListState, confirmScreen, drawList, live, promptScreen, type ListRow } from "./common.ts";
22
23 const isChapterEvent = (kind: string): boolean => kind.startsWith("chapter.") || kind === "release.verified_live";
24
25 /** The words under a chapter's name: what done means, then how far it is. */
26 function chapterRow(f: Session, c: Chapter): ListRow {
27 const p = progressOf(c, itemsOf(f.app.db, c), new Date());
28 const progress = c.state === "closed" ? "finished" : `${p.done} of ${p.total} done`;
29 return { title: `${c.name} — ${c.state}`, note: c.means === "" ? progress : `${c.means} (${progress})` };
30 }
31
32 function itemRow(i: Item): ListRow {
33 const where = i.status === "done" ? `live ${(i.doneAt ?? "").slice(0, 10)}` : i.status === "building" ? "building now" : "left";
34 return { title: `#${i.number} ${i.title}`, meta: where };
35 }
36
37 /** The mayor's check, as the acts make it: a person who administers the repository. */
38 const mayAct = (f: Session, repo: Repo): boolean => f.user !== undefined && f.user.kind !== "agent" && canAdmin(f.app.db, repo, f.user);
39
40 export function chapterScreen(f: Session, ctx: Ctx, repo: Repo, name: string): Screen {
41 const state = new ListState();
42 const items = (): Item[] => {
43 const c = getChapter(f.app.db, repo.id, name);
44 return c === undefined ? [] : itemsOf(f.app.db, c);
45 };
46 const screen: Screen = {
47 title: `${repo.ownerName}/${repo.name} — ${name}`,
48 keys: [binding(["j", "k"], "move cursor"), binding(["a"], "add an issue"), binding(["x"], "take it out"), binding(["o"], "open"), binding(["s"], "ship")],
49 async load(signal) {
50 live(f, signal, { repoId: repo.id, where: (e) => isChapterEvent(e.kind) }, () => ctx.invalidate());
51 },
52 render(area, s) {
53 const c = ctx.theme.c;
54 const chapter = getChapter(f.app.db, repo.id, name);
55 const all = items();
56 const [head, list] = split(area, "v", [len(1), fillSpace(1)]);
57 const p = chapter === undefined ? undefined : progressOf(chapter, all, new Date());
58 s.text(
59 head!.x,
60 head!.y,
61 truncate(
62 chapter === undefined ? "No such chapter." : `${chapter.state}: ${p!.done} of ${p!.total} done${p!.finishesOn === null ? "" : `, finishes ${p!.finishesOn}`}`,
63 head!.w,
64 ),
65 style(c.mutedFg, c.bg),
66 head!.x + head!.w,
67 );
68 drawList(s, list!, c, state, all.map(itemRow));
69 },
70 key(e) {
71 const all = items();
72 if (state.key(e, all.length)) return true;
73 if (e.ctrl || e.alt) return false;
74 if (e.key === "a") {
75 ctx.push(
76 promptScreen(ctx, screen, {
77 title: `Add to ${name}`,
78 label: "Issue number",
79 onSubmit: (value) => {
80 const n = Number(value.replace(/^#/, ""));
81 if (!Number.isInteger(n) || n < 1) return "An issue number, like 12.";
82 const done = fill(f.app, f.user, repo, name, n, true);
83 if (!done.ok) return CHAPTER_WORDS[done.error];
84 ctx.invalidate();
85 return undefined;
86 },
87 }),
88 );
89 return true;
90 }
91 if (e.key === "x") {
92 const at = all[state.index];
93 if (at === undefined) return true;
94 const done = fill(f.app, f.user, repo, name, at.number, false);
95 ctx.toast(done.ok ? `#${at.number} is out of ${name}.` : CHAPTER_WORDS[done.error], done.ok ? undefined : "danger");
96 ctx.invalidate();
97 return true;
98 }
99 if (e.key === "o" || e.key === "s") {
100 act(f, ctx, repo, screen, name, e.key);
101 return true;
102 }
103 return false;
104 },
105 };
106 return screen;
107 }
108
109 /** Open or ship a chapter by name; shipping asks first, since it tags and closes. */
110 function act(f: Session, ctx: Ctx, repo: Repo, parent: Screen, name: string, key: "o" | "s"): void {
111 if (key === "o") {
112 const done = open(f.app, f.user, repo, name);
113 ctx.toast(done.ok ? `${name} is open.` : CHAPTER_WORDS[done.error], done.ok ? undefined : "danger");
114 ctx.invalidate();
115 return;
116 }
117 ctx.push(
118 confirmScreen(ctx, parent, {
119 title: `Ship ${name}?`,
120 body: [`Tags the default branch ${name}, writes its notes and closes it;`, "what is not done moves to the next chapter."],
121 actions: [{ label: "Ship it" }, { label: "Cancel", variant: "ghost" }],
122 yesNo: true,
123 onChoose: (i) => {
124 if (i !== 0) return;
125 void ctx.track(
126 close(f.app, f.user, repo, name, true).then((done) => {
127 ctx.toast(done.ok ? `${name} is finished.` : CHAPTER_WORDS[done.error], done.ok ? undefined : "danger");
128 ctx.invalidate();
129 }),
130 );
131 },
132 }),
133 );
134 }
135
136 export function chaptersScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
137 const state = new ListState();
138 const all = (): Chapter[] => listChapters(f.app.db, repo.id);
139 const screen: Screen = {
140 title: `${repo.ownerName}/${repo.name} — Chapters`,
141 keys: [
142 binding(["j", "k"], "move cursor"),
143 binding(["enter"], "its list"),
144 binding(["n"], "draft"),
145 binding(["o"], "open"),
146 binding(["s"], "ship"),
147 binding(["p"], "patch"),
148 ],
149 async load(signal) {
150 live(f, signal, { repoId: repo.id, where: (e) => isChapterEvent(e.kind) }, () => ctx.invalidate());
151 },
152 render(area, s) {
153 const c = ctx.theme.c;
154 const chapters = all();
155 const [head, list] = split(area, "v", [len(1), fillSpace(1)]);
156 s.text(
157 head!.x,
158 head!.y,
159 truncate(
160 chapters.length === 0 ? "No chapter yet: the crew works continuously." : mayAct(f, repo) ? "n drafts, o opens, s ships, p patches, enter shows the list." : "A person who administers this app drafts and ships chapters.",
161 head!.w,
162 ),
163 style(c.mutedFg, c.bg),
164 head!.x + head!.w,
165 );
166 drawList(s, list!, c, state, chapters.map((ch) => chapterRow(f, ch)));
167 },
168 key(e) {
169 const chapters = all();
170 if (state.key(e, chapters.length)) return true;
171 if (e.ctrl || e.alt) return false;
172 const at = chapters[state.index];
173 if (e.key === "n") {
174 ctx.push(
175 promptScreen(ctx, screen, {
176 title: "Draft a chapter",
177 label: "Name (it becomes the git tag)",
178 placeholder: "v1.0.0",
179 onSubmit: (value) => {
180 const made = draft(f.app, f.user, repo, { name: value });
181 if (!made.ok) return CHAPTER_WORDS[made.error];
182 ctx.invalidate();
183 return undefined;
184 },
185 }),
186 );
187 return true;
188 }
189 if (at === undefined) return e.key === "o" || e.key === "s" || e.key === "p" || e.key === "enter";
190 if (e.key === "enter") {
191 ctx.push(chapterScreen(f, ctx, repo, at.name));
192 return true;
193 }
194 if (e.key === "o" || e.key === "s") {
195 act(f, ctx, repo, screen, at.name, e.key);
196 return true;
197 }
198 if (e.key === "p") {
199 const done = patch(f.app, f.user, repo, at.name);
200 ctx.toast(done.ok ? `${done.value.name} is open.` : CHAPTER_WORDS[done.error], done.ok ? undefined : "danger");
201 ctx.invalidate();
202 return true;
203 }
204 return false;
205 },
206 };
207 return screen;
208 }
209
210 /** The palette's command while a repository is open. */
211 export function chaptersCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
212 if (repo === undefined) return [];
213 return [
214 {
215 id: "chapters-app",
216 title: `${repo.ownerName}/${repo.name} — Chapters`,
217 hint: "a version with an end: draft, fill, open, ship, patch",
218 run: () => ctx.push(chaptersScreen(f, ctx, repo)),
219 },
220 ];
221 }
src/tui/screens/workspace.ts +2
@@ -169,6 +169,7 @@ import { appEnvCommands } from "./appenv.ts";
169169 import { variantsCommands } from "./variants.ts";
170170 import { autonomyCommands } from "./autonomy.ts";
171171 import { trafficCommands } from "./trafficview.ts";
172 import { chaptersCommands } from "./chapters.ts";
172173 import { schedulesCommands } from "./schedules.ts";
173174 import { queueCommands } from "./queuegrowth.ts";
174175 import { blockedCommands } from "./blocked.ts";
@@ -1995,6 +1996,7 @@ export function workspaceScreen(
19951996 ...autonomyCommands(f, ctx, repo),
19961997 ...trafficCommands(f, ctx, repo),
19971998 ...schedulesCommands(f, ctx, repo),
1999 ...chaptersCommands(f, ctx, repo),
19982000 ...queueCommands(f, ctx, repo),
19992001 ...blockedCommands(f, ctx, repo),
20002002 ...statementCommands(f, ctx, repo),
src/web/pages/chapters.ts added +169
@@ -0,0 +1,169 @@
1 /**
2 * Chapters (#11603): `/:owner/:name/chapters` lists the open chapter, the drafts after it and the
3 * closed ones; `/:owner/:name/chapters/:chapter` is the list being crossed off — done items struck
4 * through with the day they went live, what is building, what is left, the date at the current pace —
5 * or, once closed, the one page that says it is finished. Server-rendered, no script; every change
6 * is a same-origin POST by a person who administers the repository.
7 */
8
9 import { CHAPTER_WORDS, close, draft, fill, open, patch } from "../../actions/chapters.ts";
10 import { getChapter, itemsOf, listChapters, progressOf, type Chapter, type Item } from "../../core/chapters.ts";
11 import { canAdmin, type Repo } from "../../core/repos.ts";
12 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
13 import type { Ctx } from "../../http/context.ts";
14 import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette } from "../chrome.ts";
15 import { field, readForm } from "../form.ts";
16 import { html, type Html } from "../html.ts";
17 import { flashTo, page } from "../layout.ts";
18 import { badge, btn, row } from "../ui.ts";
19
20 const usd = (n: number): string => `$${n.toFixed(2)}`;
21 const day = (iso: string | null): string => (iso === null ? "" : iso.slice(0, 10));
22
23 const isOwner = (ctx: Ctx, repo: Repo): boolean =>
24 ctx.auth.user !== undefined && ctx.auth.user.kind !== "agent" && canAdmin(ctx.app.db, repo, ctx.auth.user);
25
26 const hrefOf = (repo: Repo, c: Chapter): string => `${repoBase(repo)}/chapters/${encodeURIComponent(c.name)}`;
27
28 const STATE_WORDS = { open: "open", draft: "waiting its turn", closed: "closed" } as const;
29
30 function itemRow(repo: Repo, i: Item, may: boolean, chapter: Chapter): Html {
31 const title = html`<a href="${repoBase(repo)}/issues/${i.number}">#${i.number}</a> ${i.status === "done" ? html`<s>${i.title}</s>` : i.title}`;
32 const trail =
33 i.status === "done"
34 ? badge(`live ${day(i.doneAt)}`, { tone: "success" })
35 : i.status === "building"
36 ? badge("building now")
37 : badge("left", { variant: "outline" });
38 const out =
39 may && chapter.state !== "closed"
40 ? html`<form method="post" action="${hrefOf(repo, chapter)}/remove"><input type="hidden" name="number" value="${String(i.number)}">${btn("Take out", { variant: "ghost" })}</form>`
41 : null;
42 return html`<li data-item="${String(i.number)}" data-status="${i.status}">${row({ title, trail })}${out}</li>`;
43 }
44
45 // GET /:owner/:name/chapters
46 export function chaptersPage(ctx: Ctx): Response {
47 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
48 const repo = loadRepo(ctx);
49 if (!repo) return notFoundPage(ctx);
50 const may = isOwner(ctx, repo);
51 const all = listChapters(ctx.app.db, repo.id);
52 const rendering = performance.now();
53 const rows = all.map((c) =>
54 row({
55 href: hrefOf(repo, c),
56 nav: true,
57 title: html`${c.name}${c.means === "" ? null : html` — ${c.means}`}`,
58 trail: badge(STATE_WORDS[c.state], { tone: c.state === "open" ? "success" : "default" }),
59 }),
60 );
61 const make = may
62 ? html`<h2>Draft a chapter</h2>
63 <form method="post" action="${repoBase(repo)}/chapters" class="bar">
64 <input class="input" name="name" placeholder="v1.0.0" aria-label="Name" required maxlength="40">
65 <input class="input" name="means" placeholder="What done means, in one sentence" aria-label="What done means" maxlength="300">
66 ${btn("Draft", { variant: "primary" })}
67 </form>`
68 : null;
69 const body = html`${repoHeader(ctx, repo, "issues")}
70 ${pageHead("Chapters", { sub: "A chapter is a version with an end: finished, tagged and closed." })}
71 ${all.length === 0 ? html`<p>No chapter yet: the crew works continuously. Draft one to say what v1 is.</p>` : html`<ul class="list setting-list">${rows}</ul>`}
72 ${make}`;
73 const response = page({ ctx, title: `Chapters · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body });
74 notePhase(ctx.request, "render", performance.now() - rendering);
75 return response;
76 }
77
78 const fact = (name: string, value: string): Html => html`<div class="status-row"><dt>${name}</dt><dd>${value}</dd></div>`;
79
80 function finished(repo: Repo, c: Chapter, items: readonly Item[], may: boolean): Html {
81 const done = items.filter((i) => i.status === "done");
82 const days = c.openedAt === null || c.closedAt === null ? 0 : Math.max(1, Math.round((Date.parse(c.closedAt) - Date.parse(c.openedAt)) / 86_400_000));
83 return html`<p data-finished><strong>${c.name} is finished.</strong> ${c.means}</p>
84 <dl class="status-rows">
outdated

Principle 7: these are facts (shipped, took, cost, tag), so they are dl rows, not list rows with badges. Badges are for state, not values.

85 ${fact("Shipped", `${done.length} ${done.length === 1 ? "item" : "items"}`)}
86 ${fact("Took", `${days} ${days === 1 ? "day" : "days"}`)}
87 ${fact("Cost", usd(c.costUsd ?? 0))}
88 ${fact("Tag", `${c.name} ${(c.tagOid ?? "").slice(0, 8)}`)}
89 </dl>
90 <h2>Notes</h2>
91 <pre data-notes>${c.notes}</pre>
92 ${may
93 ? html`<form method="post" action="${hrefOf(repo, c)}/patch">${btn(`Open a patch chapter for ${c.name}`, { variant: "primary" })}</form>`
94 : null}`;
95 }
96
97 // GET /:owner/:name/chapters/:chapter
98 export function chapterPage(ctx: Ctx): Response {
99 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
100 const repo = loadRepo(ctx);
101 if (!repo) return notFoundPage(ctx);
102 const c = getChapter(ctx.app.db, repo.id, ctx.params.chapter ?? "");
103 if (c === undefined) return notFoundPage(ctx);
104 const may = isOwner(ctx, repo);
105 const items = itemsOf(ctx.app.db, c);
106 const p = progressOf(c, items, new Date());
107 const rendering = performance.now();
108 const here = hrefOf(repo, c);
109 let main: Html;
110 if (c.state === "closed") main = finished(repo, c, items, may);
111 else {
112 const building = items.filter((i) => i.status === "building");
113 const left = items.filter((i) => i.status === "left");
114 const done = items.filter((i) => i.status === "done");
115 main = html`<p>${c.means}</p>
116 <p data-progress>${p.done} of ${p.total} done${p.finishesOn === null ? "" : `; at this pace it finishes ${p.finishesOn}`}.</p>
outdated

Principle 1: the subtitle already says 'waiting its turn'; this paragraph says it a second time. Drop it.

117 ${items.length === 0 ? html`<p>No issues in this chapter yet.</p>` : null}
118 <ul class="list setting-list">${[...done, ...building, ...left].map((i) => itemRow(repo, i, may, c))}</ul>
119 ${may
120 ? html`<form method="post" action="${here}/add" class="bar">
121 <input class="input" name="number" inputmode="numeric" pattern="[0-9]+" placeholder="Issue number" aria-label="Issue number" required>
122 ${btn("Add to chapter")}
123 </form>
124 ${c.state === "draft" ? html`<form method="post" action="${here}/open">${btn("Open this chapter", { variant: "primary" })}</form>` : null}
125 ${c.state === "open"
126 ? html`<form method="post" action="${here}/ship">${btn(p.done === p.total && p.total > 0 ? "Close the book" : `Ship it, move ${p.total - p.done} to the next`, { variant: "primary" })}</form>`
outdated

Principle 1: this paragraph explains the Ship it button. Put the consequence in the button's label or leave it out; nothing should explain itself twice.

127 : null}`
128 : null}`;
129 }
130 const body = html`${repoHeader(ctx, repo, "issues")}
131 ${pageHead(c.name, { sub: STATE_WORDS[c.state] })}
132 ${main}
133 <p><a href="${repoBase(repo)}/chapters">All chapters</a></p>`;
134 const response = page({ ctx, title: `${c.name} · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body });
135 notePhase(ctx.request, "render", performance.now() - rendering);
136 return response;
137 }
138
139 /** What a chapter act answers: the page it stays on, with a flash. */
140 export async function chapterAct(ctx: Ctx): Promise<Response> {
141 const repo = loadRepo(ctx);
142 if (!repo) return notFoundPage(ctx);
143 const form = await readForm(ctx, "rw");
144 if (form instanceof Response) return form;
145 const list = `${repoBase(repo)}/chapters`;
146 const name = ctx.params.chapter;
147 const user = ctx.auth.user;
148 if (user === undefined) return flashTo(ctx, list, { tone: "danger", message: "Sign in first." });
149 const app = ctx.app;
150 if (name === undefined) {
151 const made = draft(app, user, repo, { name: field(form, "name"), means: field(form, "means") });
152 return made.ok
153 ? flashTo(ctx, hrefOf(repo, made.value), { message: `Drafted ${made.value.name}.` })
154 : flashTo(ctx, list, { tone: "danger", message: CHAPTER_WORDS[made.error] });
155 }
156 const at = `${list}/${encodeURIComponent(name)}`;
157 const verb = ctx.params.verb ?? "";
158 const number = Number(field(form, "number"));
159 const result =
160 verb === "open" ? open(app, user, repo, name)
161 : verb === "add" || verb === "remove" ? fill(app, user, repo, name, number, verb === "add")
162 : verb === "ship" ? await close(app, user, repo, name, true)
163 : verb === "patch" ? patch(app, user, repo, name)
164 : null;
165 if (result === null) return notFoundPage(ctx);
166 if (!result.ok) return flashTo(ctx, at, { tone: "danger", message: CHAPTER_WORDS[result.error] });
167 const landed = verb === "patch" && result.value !== undefined ? `${list}/${encodeURIComponent(result.value.name)}` : at;
168 return flashTo(ctx, landed, { message: verb === "ship" ? `${name} is shipped.` : "Done." });
169 }
src/web/pages/queue.ts +1
@@ -86,6 +86,7 @@ export function queuePage(ctx: Ctx): Response {
8686 const body = html`${repoHeader(ctx, repo, "issues")}
8787 ${pageHead("Queue", { sub: "Is the work growing or shrinking?" })}
8888 ${switchRow(repo, until, owner)}
89 <p><a href="${base}/chapters">Chapters</a></p>
outdated

Principle 1: the sentence after the link explains what the Chapters page says again in its own subtitle. Keep the link only.

8990 ${aged.length === 0 ? null : html`<ul class="list setting-list">${aged}</ul>`}
9091 ${growthRows(g)}`;
9192 const response = page({ ctx, title: `Queue · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body });
test/chapters.test.ts added +374
@@ -0,0 +1,374 @@
1 /**
2 * Chapters (#11603): with one open the crew starts nothing outside it and the planner's new issues land
3 * in the next; an item is struck only when its production proof passed; Ship it with items left moves
4 * them, tags, writes notes and closes; afterwards the crew rests until a chapter is opened; a patch
5 * chapter opens from a closed one; the owner's page walks open → cross off → ship → finished.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { newApp } from "../src/actions/apps.ts";
12 import { close, draft, fill, open, patch } from "../src/actions/chapters.ts";
13 import { openIssue } from "../src/actions/issues.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { addCrew, createAgentRow, getAgent } from "../src/core/agents.ts";
16 import { chapterOfIssue, forgetFrozenItems, getChapter, inheritChapter, itemsOf, nextName, patchName, progressOf } from "../src/core/chapters.ts";
17 import { createIssue, getIssue, type Issue } from "../src/core/issues.ts";
18 import { getRepo, type Repo } from "../src/core/repos.ts";
19 import { createToken } from "../src/core/tokens.ts";
20 import { createUser, type User } from "../src/core/users.ts";
21 import { queueAgentRun } from "../src/crew/dispatch.ts";
22 import { runGit } from "../src/git/env.ts";
23 import { repoDir } from "../src/git/repos.ts";
24 import { commitFiles } from "../src/git/write.ts";
25 import { serve, type Servers } from "../src/http/server.ts";
26 import { chapterScreen, chaptersCommands, chaptersScreen } from "../src/tui/screens/chapters.ts";
27 import type { Ctx, Screen } from "../src/tui/app/app.ts";
28 import type { Session } from "../src/tui/session.ts";
29 import { quietLogs } from "./helpers/globals.ts";
30
31 quietLogs();
32
33 describe("chapters", () => {
34 let dataDir = "";
35 let app: App;
36 let servers: Servers;
37 let owner: User;
38 let planner: User;
39 let builder: User;
40 let repo: Repo;
41 let token = "";
42 const issues: Issue[] = [];
43
44 const mkIssue = (title: string, body = ""): Issue => {
45 const made = createIssue(app.db, { repoId: repo.id, authorId: owner.id, title, body });
46 if (!made.ok) throw new Error(String(made.error));
47 return made.value;
48 };
49 const builderAgent = () => getAgent(app.db, builder.id)!;
50 /** Whether the dispatcher queues a builder run on the issue; the run is cancelled so the next probe starts clean. */
51 const starts = (issue: Issue): boolean => {
52 const run = queueAgentRun(app, repo, issue, builderAgent(), "assigned");
53 app.db.query("UPDATE run SET state = 'cancelled' WHERE repo_id = ?1").run(repo.id);
54 return run !== undefined;
55 };
56 /** Merge a pull that closes `issue` and, when `proven`, record production's passing proof for it. */
57 let pullNo = 5000;
58 const land = (issue: Issue, proven: boolean) => {
59 app.db.query("UPDATE issue SET state = 'closed', closed_at = ?2 WHERE id = ?1").run(issue.id, "2026-11-10T00:00:00.000Z");
60 const n = ++pullNo;
61 const pid = `pull${n}`;
62 app.db
63 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state) VALUES (?1, ?2, ?3, 'pull', ?4, ?5, ?6, 'closed')`)
64 .run(pid, repo.id, n, `Build #${issue.number}`, `Closes #${issue.number}`, builder.id);
65 app.db
66 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', ?2, 'a', 'b', '2026-11-10T00:00:00.000Z')`)
67 .run(pid, `b${n}`);
68 if (proven)
69 app.db
70 .query(`INSERT INTO release (sha, repo_id, pull_number, ok, line, created_at) VALUES ('abc', ?1, ?2, 1, 'ok', '2026-11-11T08:00:00.000Z')`)
71 .run(repo.id, n);
72 };
73
74 beforeAll(async () => {
75 dataDir = await mkdtemp(join(tmpdir(), "forge-chapters-"));
76 app = createApp({ dataDir, port: 0, host: "forge.test" });
77 servers = await serve(app);
78 const mk = async (username: string, agent: boolean, roles: string[] = []) => {
79 const made = await createUser(app.db, {
80 username,
81 email: `${username}@forge.test`,
82 ...(agent
83 ? { agent: true, origin: "agent" as const }
84 : { password: "a-good-passphrase", origin: "seed" as const, trusted: true, adminIfFirst: true }),
85 });
86 if (!made.ok) throw new Error(String(made.error));
87 if (agent) createAgentRow(app.db, { userId: made.value.id, roles: roles as never });
88 return made.value;
89 };
90 owner = await mk("alice", false);
91 planner = await mk("plan", true, ["planner"]);
92 builder = await mk("build", true, ["builder"]);
93 const made = await newApp(app, owner, { name: "shop", description: "Words", visibility: "public" });
94 if (!made.ok) throw new Error(String(made.error));
95 repo = getRepo(app.db, "alice", "shop")!;
96 addCrew(app.db, repo.id, planner.id, owner.id, "planner");
97 addCrew(app.db, repo.id, builder.id, owner.id, "builder");
98 const c = await commitFiles(repoDir(app.reposRoot, "alice", "shop"), {
99 branch: repo.defaultBranch,
100 message: "first",
101 files: [{ path: "README.md", content: "hello" }],
102 author: { name: "T", email: "[email protected]" },
103 });
104 if (!c.ok) throw new Error(JSON.stringify(c.error));
105 token = createToken(app.db, owner.id, "t", "rw").token;
106 for (const t of ["a", "b", "c"]) issues.push(mkIssue(`Item ${t}`));
107 }, 30_000);
108
109 afterAll(async () => {
110 servers.stop();
111 app.close();
112 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
113 });
114
115 test("the chapter reads walk an index and sort nothing in SQL", () => {
116 const sqls = [
117 "SELECT i.id, i.number, i.title, i.state FROM chapter_item c JOIN issue i ON i.id = c.issue_id WHERE c.chapter_id = ?1",
118 "SELECT c.* FROM chapter c JOIN chapter_item i ON i.chapter_id = c.id WHERE i.issue_id = ?1",
119 "SELECT * FROM chapter WHERE repo_id = ?1 AND state = 'open'",
120 "SELECT COUNT(*) AS n FROM run WHERE issue_id = ?1 AND state IN ('queued','running')",
121 ];
122 for (const sql of sqls) {
123 const plan = app.db.query(`EXPLAIN QUERY PLAN ${sql}`).all("x").map((r) => (r as { detail: string }).detail);
124 expect(plan.filter((d) => d.startsWith("SCAN") || d.includes("TEMP B-TREE"))).toEqual([]);
125 }
126 });
127
128 test("names: the next chapter and the patch after one", () => {
129 expect(nextName("v1.0.0")).toBe("v2.0.0");
130 expect(patchName("v1.0.0")).toBe("v1.0.1");
131 expect(patchName("v2")).toBe("v2.1");
132 });
133
134 test("with no chapter everything flows as before", () => {
135 expect(starts(issues[0]!)).toBe(true);
136 });
137
138 test("only the owner drafts, opens and fills: a planner or a stranger is refused", () => {
139 expect(draft(app, planner, repo, { name: "v1.0.0" })).toEqual({ ok: false, error: "forbidden" });
140 expect(draft(app, undefined, repo, { name: "v1.0.0" })).toEqual({ ok: false, error: "forbidden" });
141 expect(draft(app, owner, repo, { name: "bad name!" })).toEqual({ ok: false, error: "invalid_name" });
142 expect(draft(app, owner, repo, { name: "v1.0.0", means: "x".repeat(301) })).toEqual({ ok: false, error: "means_too_long" });
143 expect(draft(app, owner, repo, { name: "v1.0.0", means: "The shop takes an order." }).ok).toBe(true);
144 expect(draft(app, owner, repo, { name: "v1.0.0" })).toEqual({ ok: false, error: "exists" });
145 expect(draft(app, owner, repo, { name: "v2.0.0", means: "Payments." }).ok).toBe(true);
146 expect(fill(app, planner, repo, "v1.0.0", issues[0]!.number, true)).toEqual({ ok: false, error: "forbidden" });
147 expect(fill(app, owner, repo, "v1.0.0", issues[0]!.number, true).ok).toBe(true);
148 expect(fill(app, owner, repo, "v1.0.0", issues[1]!.number, true).ok).toBe(true);
149 expect(open(app, planner, repo, "v1.0.0")).toEqual({ ok: false, error: "forbidden" });
150 });
151
152 test("a drafted chapter holds nothing back until it is opened; open, the crew builds only what is in it", () => {
153 expect(starts(issues[2]!)).toBe(true);
154 expect(open(app, owner, repo, "v1.0.0").ok).toBe(true);
155 expect(open(app, owner, repo, "v2.0.0")).toEqual({ ok: false, error: "already_open" });
156 expect(starts(issues[0]!)).toBe(true);
157 expect(starts(issues[1]!)).toBe(true);
158 expect(starts(issues[2]!)).toBe(false);
159 // The owner's act is recorded.
160 const kinds = app.db.query<{ kind: string }, [string]>("SELECT kind FROM event WHERE repo_id = ?1 AND kind LIKE 'chapter.%'").all(repo.id).map((e) => e.kind);
161 expect(kinds).toContain("chapter.item_added");
162 expect(kinds).toContain("chapter.opened");
163 });
164
165 test("the planner's new issues land in the next chapter, a defect of a chapter item joins this one; it never adds scope", () => {
166 const plain = openIssue(app, planner, repo, { title: "Someday idea" });
167 const defect = openIssue(app, planner, repo, { title: "Order total is wrong", body: `Defect of #${issues[0]!.number}` });
168 const elsewhere = openIssue(app, planner, repo, { title: "Odd", body: `Defect of #${issues[2]!.number}` });
169 if (!plain.ok || !defect.ok || !elsewhere.ok) throw new Error("planner issues");
170 expect(chapterOfIssue(app.db, plain.value.id)?.name).toBe("v2.0.0");
171 expect(chapterOfIssue(app.db, defect.value.id)?.name).toBe("v1.0.0");
172 expect(chapterOfIssue(app.db, elsewhere.value.id)?.name).toBe("v2.0.0");
173 expect(starts(plain.value)).toBe(false);
174 expect(starts(defect.value)).toBe(true);
175 // A person's issue is theirs to place: it is not moved.
176 const mine = openIssue(app, owner, repo, { title: "Mine" });
177 if (!mine.ok) throw new Error("owner issue");
178 expect(chapterOfIssue(app.db, mine.value.id)).toBeUndefined();
179 });
180
181 test("with no later chapter drafted a planner's issue waits behind Later, and parts of a split stay in their chapter", () => {
182 const partOf = mkIssue("Part of a");
183 inheritChapter(app.db, issues[0]!, [partOf], owner.id);
184 expect(chapterOfIssue(app.db, partOf.id)?.name).toBe("v1.0.0");
185 expect(chapterOfIssue(app.db, issues[0]!.id)?.name).toBe("v1.0.0");
186 });
187
188 test("an item is struck only when live and proven, not when its pull merged", () => {
189 const chapter = getChapter(app.db, repo.id, "v1.0.0")!;
190 const find = (n: number) => itemsOf(app.db, chapter).find((i) => i.number === n)!;
191 expect(find(issues[0]!.number).status).not.toBe("done");
192 land(issues[0]!, false);
193 expect(find(issues[0]!.number).status).toBe("building");
194 expect(find(issues[0]!.number).doneAt).toBeNull();
195 land(issues[0]!, true);
196 const done = find(issues[0]!.number);
197 expect(done.status).toBe("done");
198 expect(done.doneAt).toBe("2026-11-11T08:00:00.000Z");
199 expect(done.builder).toBe("build");
200 const progress = progressOf(chapter, itemsOf(app.db, chapter), new Date("2026-11-12T00:00:00.000Z"));
201 expect(progress.done).toBe(1);
202 expect(progress.finishesOn).not.toBeNull();
203 });
204
205 test("closing without Ship it refuses while items are left; Ship it tags, writes notes, moves what is left and closes", async () => {
206 expect(await close(app, owner, repo, "v1.0.0", false)).toEqual({ ok: false, error: "unfinished" });
207 expect(await close(app, planner, repo, "v1.0.0", true)).toEqual({ ok: false, error: "forbidden" });
208 const shipped = await close(app, owner, repo, "v1.0.0", true, new Date("2026-11-12T12:00:00.000Z"));
209 if (!shipped.ok) throw new Error(shipped.error);
210 expect(shipped.value.state).toBe("closed");
211 expect(shipped.value.notes).toContain("# v1.0.0");
212 expect(shipped.value.notes).toContain("Item a");
213 expect(shipped.value.notes).toContain("Built by build");
214 expect(shipped.value.notes).toContain("## Moved to v2.0.0");
215 expect(shipped.value.notes).toContain("Item b");
216 const dir = repoDir(app.reposRoot, "alice", "shop");
217 const tag = await runGit(["rev-parse", "refs/tags/v1.0.0^{commit}"], { cwd: dir });
218 expect(tag.ok && tag.value.trim()).toBe(shipped.value.tagOid!);
219 expect(chapterOfIssue(app.db, issues[1]!.id)?.name).toBe("v2.0.0");
220 // The closed chapter keeps its list and cannot be reopened or changed.
221 expect(chapterOfIssue(app.db, issues[0]!.id)?.name).toBe("v1.0.0");
222 expect(open(app, owner, repo, "v1.0.0")).toEqual({ ok: false, error: "closed" });
223 expect(fill(app, owner, repo, "v1.0.0", issues[2]!.number, true)).toEqual({ ok: false, error: "closed" });
224 expect(await close(app, owner, repo, "v1.0.0", true)).toEqual({ ok: false, error: "closed" });
225 });
226
227 test("after closing the crew starts nothing until a chapter is opened; a patch chapter opens from the closed one", () => {
228 expect(starts(issues[1]!)).toBe(false);
229 expect(starts(issues[2]!)).toBe(false);
230 const made = patch(app, owner, repo, "v1.0.0");
231 if (!made.ok) throw new Error(made.error);
232 expect(made.value.name).toBe("v1.0.1");
233 expect(made.value.state).toBe("open");
234 expect(made.value.patchOf).toBe(getChapter(app.db, repo.id, "v1.0.0")!.id);
235 expect(patch(app, owner, repo, "v1.0.0")).toEqual({ ok: false, error: "already_open" });
236 expect(starts(issues[1]!)).toBe(false); // in v2.0.0, which waits
237 expect(fill(app, owner, repo, "v1.0.1", issues[1]!.number, true).ok).toBe(true);
238 expect(starts(issues[1]!)).toBe(true);
239 expect(getIssue(app.db, repo.id, issues[1]!.number)?.state).toBe("open");
240 });
241
242 test("the page and the API: open → cross off → ship → the finished page", async () => {
243 const post = (path: string, form: string) =>
244 fetch(`${servers.url}${path}`, {
245 method: "POST",
246 redirect: "manual",
247 headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
248 body: form,
249 });
250 const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${token}` } })).text();
251 // Patch chapter v1.0.1 is open: finish its one item, then close the book on it.
252 land(issues[1]!, true);
253 const page = await get("/alice/shop/chapters/v1.0.1");
254 expect(page).toContain("1 of 1 done");
255 expect(page).toContain("<s>Item b</s>");
256 expect(page).toContain("live 2026-11-11");
257 expect((await fetch(`${servers.url}/alice/shop/chapters/v1.0.1/ship`, { method: "POST", redirect: "manual", headers: { authorization: `Bearer ${token}`, origin: "https://evil.example" } })).status).toBe(403);
258 expect(getChapter(app.db, repo.id, "v1.0.1")?.state).toBe("open");
259 expect((await post("/alice/shop/chapters/v1.0.1/ship", "")).status).toBeLessThan(400);
260 const finished = await get("/alice/shop/chapters/v1.0.1");
261 expect(finished).toContain("is finished");
262 expect(finished).toContain("Open a patch chapter");
263 expect(finished).toContain("Cost");
264 const api = (await (await fetch(`${servers.url}/api/v1/repos/alice/shop/chapters`)).json()) as { chapters: { name: string; state: string; tag: { oid: string } | null }[] };
265 expect(api.chapters.map((c) => `${c.name}:${c.state}`).sort()).toEqual(["v1.0.0:closed", "v1.0.1:closed", "v2.0.0:draft"]);
266 expect(api.chapters.find((c) => c.name === "v1.0.1")?.tag?.oid).toHaveLength(40);
267 // The API drafts and opens too, with a token and not without one.
268 const json = (path: string, body: unknown, auth = true) =>
269 fetch(`${servers.url}${path}`, { method: "POST", headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) });
270 expect((await json("/api/v1/repos/alice/shop/chapters", { name: "v3" }, false)).status).toBe(401);
271 expect((await json("/api/v1/repos/alice/shop/chapters", { name: "v3", means: "Later" })).status).toBe(201);
272 expect((await json("/api/v1/repos/alice/shop/chapters/v2.0.0/open", {})).status).toBe(200);
273 expect(starts(issues[2]!)).toBe(false);
274 expect(getChapter(app.db, repo.id, "v2.0.0")?.state).toBe("open");
275 });
276 test("a public read costs one pass over the pulls, and a closed chapter's list is read once", () => {
277 forgetFrozenItems();
278 const closed = getChapter(app.db, repo.id, "v1.0.0")!;
279 expect(closed.state).toBe("closed");
280 let statements = 0;
281 let scans = 0;
282 const counting = new Proxy(app.db, {
283 get(target, key) {
284 if (key === "query")
285 return (sql: string) => {
286 statements++;
287 if (sql.includes("LIKE")) scans++;
288 return target.query(sql);
289 };
290 const v = Reflect.get(target, key) as unknown;
291 return typeof v === "function" ? v.bind(target) : v;
292 },
293 });
294 const first = itemsOf(counting, closed);
295 expect(first.length).toBeGreaterThan(0);
296 expect(scans).toBeLessThanOrEqual(1);
297 const after = statements;
298 expect(itemsOf(counting, closed)).toBe(first);
299 expect(statements).toBe(after);
300 });
301
302 test("a ship whose database step fails takes its tag back, so a retry can close the book", async () => {
303 const open2 = getChapter(app.db, repo.id, "v2.0.0")!;
304 expect(open2.state).toBe("open");
305 const dir = repoDir(app.reposRoot, "alice", "shop");
306 const tagged = async () => (await runGit(["rev-parse", "--verify", "--quiet", "refs/tags/v2.0.0"], { cwd: dir })).ok;
307 app.db.exec("CREATE TRIGGER chapter_refuses BEFORE UPDATE ON chapter BEGIN SELECT RAISE(ABORT, 'refused'); END");
308 const refused = await close(app, owner, repo, "v2.0.0", true);
309 expect(refused.ok).toBe(false);
310 expect(await tagged()).toBe(false);
311 expect(getChapter(app.db, repo.id, "v2.0.0")?.state).toBe("open");
312 app.db.exec("DROP TRIGGER chapter_refuses");
313 const retried = await close(app, owner, repo, "v2.0.0", true);
314 expect(retried.ok).toBe(true);
315 expect(await tagged()).toBe(true);
316 });
317
318 describe("the terminal door", () => {
319 const session = (user: User | undefined) => ({ user, db: app.db, app }) as unknown as Session;
320 const rig = () => {
321 const toasts: string[] = [];
322 const pushed: Screen[] = [];
323 const ctx = { toast: (t: string) => toasts.push(t), invalidate() {}, push: (sc: Screen) => pushed.push(sc), pop() {}, track: (p: Promise<unknown>) => p } as unknown as Ctx;
324 return { toasts, pushed, ctx };
325 };
326 const typed = (sc: Screen, text: string) => {
327 for (const ch of text) sc.key({ key: ch } as never);
328 return sc.key({ key: "enter" } as never);
329 };
330
331 test("a person who does not administer the app is refused every act", () => {
332 expect(chaptersCommands(session(undefined), rig().ctx, repo)).toHaveLength(1);
333 for (const who of [planner, undefined]) {
334 const { ctx, pushed } = rig();
335 const screen = chaptersScreen(session(who), ctx, repo);
336 screen.key({ key: "n" } as never);
337 typed(pushed[0]!, "v77");
338 expect(getChapter(app.db, repo.id, "v77")).toBeUndefined();
339 }
340 });
341
342 test("the owner drafts, fills, opens and ships from the terminal, and each act is an event", async () => {
343 const heard: string[] = [];
344 const off = app.bus.subscribe((e) => heard.push(e.kind));
345 const { ctx, pushed, toasts } = rig();
346 const screen = chaptersScreen(session(owner), ctx, repo);
347 screen.key({ key: "n" } as never);
348 typed(pushed[0]!, "v8");
349 expect(getChapter(app.db, repo.id, "v8")?.state).toBe("draft");
350 const idx = (await import("../src/core/chapters.ts")).listChapters(app.db, repo.id).findIndex((c) => c.name === "v8");
351 for (let i = 0; i < idx; i++) screen.key({ key: "j" } as never);
352 screen.key({ key: "enter" } as never);
353 const detail = pushed.at(-1)!;
354 detail.key({ key: "a" } as never);
355 typed(pushed.at(-1)!, String(issues[0]!.number));
356 expect(chapterOfIssue(app.db, issues[0]!.id)?.name).toBe("v1.0.0"); // closed chapters hold their list
357 detail.key({ key: "a" } as never);
358 const extra = mkIssue("Item d");
359 typed(pushed.at(-1)!, String(extra.number));
360 expect(chapterOfIssue(app.db, extra.id)?.name).toBe("v8");
361 detail.key({ key: "o" } as never);
362 expect(toasts.at(-1)).toBe("v8 is open.");
363 expect(getChapter(app.db, repo.id, "v8")?.state).toBe("open");
364 detail.key({ key: "s" } as never);
365 const ask = pushed.at(-1)!;
366 ask.key({ key: "enter" } as never);
367 await new Promise((r) => setTimeout(r, 300));
368 expect(getChapter(app.db, repo.id, "v8")?.state).toBe("closed");
369 off();
370 expect(heard).toEqual(expect.arrayContaining(["chapter.drafted", "chapter.item_added", "chapter.opened", "chapter.closed"]));
371 expect(chapterScreen(session(owner), ctx, repo, "v8").title).toContain("v8");
372 });
373 });
374 });
test/no-polling.test.ts +1
@@ -289,6 +289,7 @@ const NOT_WALKED: Record<string, string> = {
289289 "/:owner/:name/builds/:label/cart": "needs a pinned build with a cart",
290290 "/:owner/:name/builds/:label/cart.png": "needs a pinned build with a cart",
291291 "/:owner/:name/builds/:label/shots": "needs a pinned build with pictures",
292 "/:owner/:name/chapters/:chapter": "needs a drafted chapter, which the seed does not make",
292293 "/:owner/:name/builds/:label/shots/:file": "needs a pinned build with pictures",
293294 "/:owner/:name/commit/:oid": "the seed does not keep the commit id; the commit page is a plain diff with the shell script only",
294295 "/:owner/:name/looks/:env/:commit/:kind.png": "an image of a captured look, not a page",
test/pages-budget.test.ts +1 −1
@@ -43,7 +43,7 @@ const HEAVIER: Record<string, { statements?: number; git?: number }> = {
4343 };
4444
4545 /** Patterns with no fixture today: lower this as fixtures are added, never raise it silently. */
46 const MAX_UNFIXTURED = 145;
46 const MAX_UNFIXTURED = 146;
4747
4848 /** Streams and live fragments: no page. */
4949 const NOT_PAGES = new Set([
test/security-floor-routes.test.ts +1
@@ -68,6 +68,7 @@ const UNSEEDED: Record<string, string> = {
6868 "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing",
6969 "/fleet/ops/:id": FLEET,
7070 "/:owner/:name/bets/:id": BET,
71 "/:owner/:name/chapters/:chapter": "needs a chapter, which the seed does not draft (test/chapters.test.ts covers it)",
7172 "/:owner/:name/builds/:label/cart": CART,
7273 "/:owner/:name/builds/:label/cart.png": CART,
7374 "/:owner/:name/builds/:label/shots": SHOT,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs