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

The owner sees each chapter on the page: its issues, how many are closed, and a Close chapter button that says what stops it #12307

Merged opened this

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

Merged

scout/forge:scout/12288-the-owner-sees-each-chapter-on-the-page → main 6 commits fork 4 threads · 4 unresolved

  • Merged. scout/12288-the-owner-sees-each-chapter-on-the-page was merged into main as 4345bb2.

Review

Sign in to review this change.
8 files changed +430 −14
docs/features/chapters.md +3 −1
@@ -9,10 +9,12 @@ A chapter is a named list of a repository's issues that is finished, tagged and
99 - **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.
1010 - **No chapter, no change.** A repository that never drafted one flows continuously, as before.
1111
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.
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, `c` closes (nothing moves; refused with the page's sentence naming what is in the way), `u` undoes a close within 60 seconds, `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.
1313
1414 The MCP answers too: the `repo` tool with `view: "chapters"` lists a repository's chapters (name, what done means, state); adding `chapter: "v1"` names that chapter's items (`done`, `building` or `left`), its `progress` (the same sentence the page and the terminal print, from `progressSentence`) and `finishesOn`. `chapter` without `view` is `invalid`; a repository the caller cannot read is refused as the page refuses it.
1515
1616 The same tool acts, for the owner: `action` is `open`, `add`, `remove` or `close`, with `chapter` (and an issue `number` for `add` and `remove`). Without `confirm: true` it changes nothing and answers `{ done: false, says }`: what it would do (for `close`, also the `tag` it would write and the `into` chapter and `moves` of what is left), made by the same checks the act makes, so a preview never promises what the act refuses. With `confirm: true` it does it through the page's own functions (`open`, `fill`, `close` with `ship`, in `src/actions/chapters.ts`) and answers `{ done: true }`; each is recorded as the page records it. A refusal is the page's: `CHAPTER_WORDS` under its code (`forbidden` for a person who does not administer the repository or an agent, `already_open`, `closed`, `no_issue`, `tag_exists`, …). A token that may only read gets the previews and is refused the confirmed act. The MCP's close always ships: what is left moves on, so `unfinished` is the page's refusal only for a close that does not ship.
1717
1818 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.
19
20 - **On /next (#12288).** `/:owner/:name/next` has a Chapters section: each chapter with issues closed against its list, and, for the repository's owner, **Close chapter** on the open one (`POST …/chapters/:chapter/close`, same-origin, nothing moves). A chapter with issues still open says which ones are in its way and refuses the close with the same sentence. A close shows its receipt (name, tag) and an **Undo** for 60 seconds (`…/undo`: the tag goes, the chapter is open again); a close that moved items to the next chapter (Ship it) has no undo. The terminal does the same on `src/tui/screens/chapters.ts` (`c`, `u`); its acts append the same events, so an open web page follows.
src/actions/chapters.ts +63 −1
@@ -14,6 +14,7 @@ import {
1414 nextChapterOf,
1515 nextName,
1616 chapterOfIssue,
17 forgetFrozenItems,
1718 openChapterOf,
1819 patchName,
1920 placeNewIssue,
@@ -33,7 +34,65 @@ import { runGit } from "../git/env.ts";
3334 import { repoDir } from "../git/repos.ts";
3435 import { err, ok, type Result } from "../kernel/result.ts";
3536
36 export type ChapterActError = ChapterError | "forbidden" | "no_issue" | "unfinished" | "no_branch" | "tag_exists" | "git";
37 export type ChapterActError = ChapterError | "forbidden" | "no_issue" | "unfinished" | "no_branch" | "tag_exists" | "git" | "too_late" | "moved" | "another_open";
38
39 /** How long after closing a chapter its owner may take the close back. */
40 export const CLOSE_UNDO_MS = 60_000;
41
42 /** The items in a chapter's way: everything not yet live and proven. */
43 export function inTheWay(app: App, chapter: Chapter): Item[] {
44 return itemsOf(app.db, chapter).filter((i) => i.status !== "done");
45 }
46
47 /** What stops a close, in one sentence naming the issues, or null when nothing does. */
48 export function stopsClose(app: App, chapter: Chapter): string | null {
49 const left = inTheWay(app, chapter);
50 if (left.length === 0) return null;
51 const shown = left.slice(0, 5).map((i) => `#${i.number} ${i.title}`).join(", ");
52 return `${chapter.name} cannot close: ${left.length} ${left.length === 1 ? "issue is" : "issues are"} still in its way: ${shown}${left.length > 5 ? `, and ${left.length - 5} more` : ""}.`;
53 }
54
55 /**
56 * Why a close just made cannot be taken back, or null when it can: nothing in the minute's way.
57 * `moved` is a close that moved items on; `another_open` is a chapter opened since.
58 */
59 export function undoBlock(app: App, repo: Repo, chapter: Chapter): "moved" | "another_open" | null {
60 if (openChapterOf(app.db, repo.id) !== undefined) return "another_open";
61 const events = app.db
62 .query<{ payload: string }, [string]>("SELECT payload FROM event WHERE repo_id = ?1 AND kind = 'chapter.closed' ORDER BY id DESC LIMIT 5")
63 .all(repo.id);
64 const mine = events.map((e) => JSON.parse(e.payload) as { name?: string; moved?: number }).find((p) => p.name === chapter.name);
65 return mine !== undefined && mine.moved === 0 ? null : "moved";
66 }
67
68 /** Whether a chapter closed just now can still be taken back: within the minute, nothing in
69 * the way. */
70 export function undoableClose(app: App, repo: Repo, chapter: Chapter, now = Date.now()): boolean {
71 if (chapter.state !== "closed" || chapter.closedAt === null || chapter.tagOid === null) return false;
72 if (now - Date.parse(chapter.closedAt) > CLOSE_UNDO_MS) return false;
73 return undoBlock(app, repo, chapter) === null;
74 }
75
76 /** Take a close back within a minute: the tag goes, the chapter is open again with its list. */
77 export async function reopen(app: App, actor: User | undefined, repo: Repo, name: string, now = Date.now()): Promise<Result<Chapter, ChapterActError>> {
78 if (!owner(app, actor, repo)) return err("forbidden");
outdated

A newly opened chapter also makes undoableClose false, but this answers 'moved', which says items moved. Give that case its own code and sentence.

79 const chapter = getChapter(app.db, repo.id, name);
80 if (chapter === undefined) return err("not_found");
81 if (chapter.state !== "closed" || chapter.closedAt === null || chapter.tagOid === null) return err("not_found");
82 if (now - Date.parse(chapter.closedAt) > CLOSE_UNDO_MS) return err("too_late");
83 const blocked = undoBlock(app, repo, chapter);
84 if (blocked !== null) return err(blocked);
85 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
86 const gone = await runGit(["update-ref", "-d", `refs/tags/${chapter.name}`, chapter.tagOid], { cwd: dir });
87 if (!gone.ok) return err("git");
88 const back = app.db
89 .query("UPDATE chapter SET state = 'open', closed_at = NULL, tag_oid = NULL, notes = '', cost_usd = NULL WHERE id = ?1 AND state = 'closed'")
90 .run(chapter.id);
91 if (back.changes !== 1) return err("closed");
92 forgetFrozenItems();
93 app.bus.append({ kind: "chapter.reopened", repoId: repo.id, actorId: actor.id, payload: { name } });
94 return ok(getChapter(app.db, repo.id, name)!);
95 }
3796
3897 /** A person who administers the repository, never an agent. */
3998 function owner(app: App, actor: User | undefined, repo: Repo): actor is User {
@@ -265,4 +324,7 @@ export const CHAPTER_WORDS: Readonly<Record<ChapterActError, string>> = {
265324 no_branch: "The default branch has no commit to tag yet.",
266325 tag_exists: "A git tag with that name already exists.",
267326 git: "The tag could not be written.",
327 too_late: "The minute to take the close back has passed.",
328 moved: "That close moved items to the next chapter, so it cannot be taken back.",
329 another_open: "Another chapter has been opened since, so this close cannot be taken back.",
268330 };
src/core/event_audience.ts +1
@@ -199,6 +199,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([
199199 "chapter.item_added",
200200 "chapter.item_removed",
201201 "chapter.closed",
202 "chapter.reopened",
202203 ]);
203204
204205 /**
src/tui/screens/chapters.ts +30 −7
@@ -2,13 +2,14 @@
22 * An app's chapters, in the terminal (#11603): the page's list (`web/pages/chapters.ts`) from the same
33 * acts (`actions/chapters.ts`), so the same owner-only gate applies: anyone who can read the repository
44 * 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
5 * drafts, `c` closes the one under the cursor when nothing is in its way and `u` takes a close back
6 * within a minute (#12288), `o` opens the one under the cursor, `s` ships it, `p` opens a patch chapter for a closed
67 * one, `enter` shows its list, where `a` adds an issue by number and `x` takes the one under the
78 * cursor out. Each act is an event on the bus, so an open web page sees it, and this screen follows
89 * a change made there.
910 */
1011
11 import { CHAPTER_WORDS, close, draft, fill, open, patch } from "../../actions/chapters.ts";
12 import { CHAPTER_WORDS, close, draft, fill, open, patch, reopen, stopsClose, undoableClose } from "../../actions/chapters.ts";
1213 import { getChapter, itemsOf, listChapters, progressOf, progressSentence, type Chapter, type Item } from "../../core/chapters.ts";
1314 import { canAdmin, type Repo } from "../../core/repos.ts";
1415 import type { Ctx, Screen } from "../app/app.ts";
@@ -23,9 +24,10 @@ import { ListState, confirmScreen, drawList, live, promptScreen, type ListRow }
2324 const isChapterEvent = (kind: string): boolean => kind.startsWith("chapter.") || kind === "release.verified_live";
2425
2526 /** The words under a chapter's name: what done means, then how far it is. */
26 function chapterRow(f: Session, c: Chapter): ListRow {
27 function chapterRow(f: Session, repo: Repo, c: Chapter): ListRow {
2728 const p = progressOf(c, itemsOf(f.app.db, c), new Date());
28 const progress = c.state === "closed" ? "finished" : progressSentence(p);
29 const undo = c.state === "closed" && undoableClose(f.app, repo, c) ? " Closed just now: u undoes it." : "";
30 const progress = (c.state === "closed" ? "finished" : progressSentence(p)) + undo;
2931 return { title: `${c.name} — ${c.state}`, note: c.means === "" ? progress : `${c.means} ${progress}` };
3032 }
3133
@@ -37,6 +39,21 @@ function itemRow(i: Item): ListRow {
3739 /** The mayor's check, as the acts make it: a person who administers the repository. */
3840 const mayAct = (f: Session, repo: Repo): boolean => f.user !== undefined && f.user.kind !== "agent" && canAdmin(f.app.db, repo, f.user);
3941
42 /** Close a chapter with nothing moving, or say what stops it (the page's sentence); `u` takes
43 * it back. */
44 async function closeOrUndo(f: Session, ctx: Ctx, repo: Repo, name: string, key: "c" | "u"): Promise<void> {
45 const chapter = getChapter(f.app.db, repo.id, name);
46 if (key === "c") {
47 const done = await close(f.app, f.user, repo, name, false);
48 const why = !done.ok && done.error === "unfinished" && chapter !== undefined ? (stopsClose(f.app, chapter) ?? CHAPTER_WORDS.unfinished) : done.ok ? "" : CHAPTER_WORDS[done.error];
49 ctx.toast(done.ok ? `Closed ${name} and tagged it. You can undo for a minute.` : why, done.ok ? undefined : "danger");
50 } else {
51 const done = await reopen(f.app, f.user, repo, name);
52 ctx.toast(done.ok ? `${name} is open again.` : CHAPTER_WORDS[done.error], done.ok ? undefined : "danger");
53 }
54 ctx.invalidate();
55 }
56
4057 export function chapterScreen(f: Session, ctx: Ctx, repo: Repo, name: string): Screen {
4158 const state = new ListState();
4259 const items = (): Item[] => {
@@ -144,6 +161,8 @@ export function chaptersScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
144161 binding(["n"], "draft"),
145162 binding(["o"], "open"),
146163 binding(["s"], "ship"),
164 binding(["c"], "close chapter"),
165 binding(["u"], "undo a close"),
147166 binding(["p"], "patch"),
148167 ],
149168 async load(signal) {
@@ -157,13 +176,13 @@ export function chaptersScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
157176 head!.x,
158177 head!.y,
159178 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.",
179 chapters.length === 0 ? "No chapter yet: the crew works continuously." : mayAct(f, repo) ? "n drafts, o opens, c closes, u undoes a close, s ships, p patches, enter shows the list." : "A person who administers this app drafts and ships chapters.",
161180 head!.w,
162181 ),
163182 style(c.mutedFg, c.bg),
164183 head!.x + head!.w,
165184 );
166 drawList(s, list!, c, state, chapters.map((ch) => chapterRow(f, ch)));
185 drawList(s, list!, c, state, chapters.map((ch) => chapterRow(f, repo, ch)));
167186 },
168187 key(e) {
169188 const chapters = all();
@@ -186,7 +205,7 @@ export function chaptersScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
186205 );
187206 return true;
188207 }
189 if (at === undefined) return e.key === "o" || e.key === "s" || e.key === "p" || e.key === "enter";
208 if (at === undefined) return "oscupn".includes(e.key) || e.key === "enter";
190209 if (e.key === "enter") {
191210 ctx.push(chapterScreen(f, ctx, repo, at.name));
192211 return true;
@@ -195,6 +214,10 @@ export function chaptersScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
195214 act(f, ctx, repo, screen, at.name, e.key);
196215 return true;
197216 }
217 if (e.key === "c" || e.key === "u") {
218 void ctx.track(closeOrUndo(f, ctx, repo, at.name, e.key));
219 return true;
220 }
198221 if (e.key === "p") {
199222 const done = patch(f.app, f.user, repo, at.name);
200223 ctx.toast(done.ok ? `${done.value.name} is open.` : CHAPTER_WORDS[done.error], done.ok ? undefined : "danger");
src/web/pages/chapters.ts +12 −5
@@ -6,13 +6,13 @@
66 * is a same-origin POST by a person who administers the repository.
77 */
88
9 import { CHAPTER_WORDS, close, draft, fill, open, patch } from "../../actions/chapters.ts";
9 import { CHAPTER_WORDS, close, draft, fill, open, patch, reopen, stopsClose } from "../../actions/chapters.ts";
1010 import { getChapter, itemsOf, listChapters, progressOf, progressSentence, type Chapter, type Item } from "../../core/chapters.ts";
1111 import { canAdmin, type Repo } from "../../core/repos.ts";
1212 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1313 import type { Ctx } from "../../http/context.ts";
1414 import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette } from "../chrome.ts";
15 import { field, readForm } from "../form.ts";
15 import { backTo, field, readForm } from "../form.ts";
1616 import { html, type Html } from "../html.ts";
1717 import { flashTo, page } from "../layout.ts";
1818 import { badge, btn, row } from "../ui.ts";
@@ -153,17 +153,24 @@ export async function chapterAct(ctx: Ctx): Promise<Response> {
153153 ? flashTo(ctx, hrefOf(repo, made.value), { message: `Drafted ${made.value.name}.` })
154154 : flashTo(ctx, list, { tone: "danger", message: CHAPTER_WORDS[made.error] });
155155 }
156 const at = `${list}/${encodeURIComponent(name)}`;
156 const at = backTo(form, `${list}/${encodeURIComponent(name)}`);
157157 const verb = ctx.params.verb ?? "";
158158 const number = Number(field(form, "number"));
159159 const result =
160160 verb === "open" ? open(app, user, repo, name)
161161 : verb === "add" || verb === "remove" ? fill(app, user, repo, name, number, verb === "add")
162162 : verb === "ship" ? await close(app, user, repo, name, true)
163 : verb === "close" ? await close(app, user, repo, name, false)
164 : verb === "undo" ? await reopen(app, user, repo, name)
163165 : verb === "patch" ? patch(app, user, repo, name)
164166 : null;
165167 if (result === null) return notFoundPage(ctx);
166 if (!result.ok) return flashTo(ctx, at, { tone: "danger", message: CHAPTER_WORDS[result.error] });
168 if (!result.ok) {
169 const chapter = getChapter(app.db, repo.id, name);
170 const why = result.error === "unfinished" && chapter !== undefined ? (stopsClose(app, chapter) ?? CHAPTER_WORDS.unfinished) : CHAPTER_WORDS[result.error];
171 return flashTo(ctx, at, { tone: "danger", message: why });
172 }
167173 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." });
174 const said = verb === "ship" ? `${name} is shipped.` : verb === "close" ? `${name} is closed and tagged.` : verb === "undo" ? `${name} is open again.` : "Done.";
175 return flashTo(ctx, landed, { message: said });
169176 }
src/web/pages/next.ts +2
@@ -24,6 +24,7 @@ import { backTo, field, readForm } from "../form.ts";
2424 import { html, raw, type Html } from "../html.ts";
2525 import { flashOrJson, page } from "../layout.ts";
2626 import { btn, kbd, row } from "../ui.ts";
27 import { chaptersSection } from "./next_chapters.ts";
2728 import { countWords, filterPath, markBefore, filtered, matches, readFilter } from "./next_filter.ts";
2829
2930 /**
@@ -253,6 +254,7 @@ export function nextPage(ctx: Ctx): Response {
253254 ? html`<p class="muted">Nothing is ready: everything open waits for something. <a href="${base}/map">See the map</a>.</p>`
254255 : html`${bar}${isFiltered ? html`<p class="muted" id="next-count">${countWords(ats.length, rows.length)} shown${ats.length + parked.length === 0 ? html`. ${nothingWords}` : null}. Numbers and the cut line are the whole list's; the week's capacity bar is left out while filtered.</p>` : null}
255256 ${first.length > 0 ? section(isFiltered ? "Matching" : "Up next", first, true, "next-list") : null}${rest.length > 0 ? section("After", rest, false) : null}`}
257 ${chaptersSection(ctx, repo, back)}
256258 <p class="muted small mt" id="next-week">${sentence} <a href="${base}/week">The week</a>${writer
257259 ? " · Drag a row, or press the key on its button; j and k move between rows."
258260 : null}</p>
src/web/pages/next_chapters.ts added +62
@@ -0,0 +1,62 @@
1 /**
2 * The chapters section of /next (#12288): each chapter with its issues done against its list,
3 * and for the owner a Close chapter button. A chapter that cannot close says which open issues
4 * are in its way; one closed in the last minute shows its receipt and an Undo. The acts are
5 * `actions/chapters.ts`.
6 */
7 import { CLOSE_UNDO_MS, stopsClose, undoableClose } from "../../actions/chapters.ts";
8 import { itemsOf, listChapters, progressOf, progressSentence, type Chapter } from "../../core/chapters.ts";
9 import { canAdmin, type Repo } from "../../core/repos.ts";
10 import type { Ctx } from "../../http/context.ts";
11 import { repoBase } from "../chrome.ts";
12 import { html, type Html } from "../html.ts";
13 import { badge, btn, row } from "../ui.ts";
14
15 const SHOWN = 10;
16
17 function act(base: string, c: Chapter, verb: "close" | "undo", back: string, label: string, primary: boolean): Html {
18 return html`<form method="post" action="${base}/chapters/${encodeURIComponent(c.name)}/${verb}">
19 <input type="hidden" name="back" value="${back}" />
20 ${btn(label, { variant: primary ? "primary" : "outline", size: "sm" })}
21 </form>`;
22 }
23
24 export function chaptersSection(ctx: Ctx, repo: Repo, back: string): Html | null {

Both doors: no src/tui/ screen mirrors this section. Add one and its entry per AGENTS.md.

25 const app = ctx.app;
26 const all = listChapters(app.db, repo.id);
27 if (all.length === 0) return null;
28 const base = repoBase(repo);
29 const may = ctx.auth.user !== undefined && ctx.auth.user.kind !== "agent" && canAdmin(app.db, repo, ctx.auth.user);
30 const now = Date.now();
31 const rows = all.slice(0, SHOWN).map((c) => {
32 const items = itemsOf(app.db, c);
33 const p = progressOf(c, items, new Date(now));
34 const link = html`<a href="${base}/chapters/${encodeURIComponent(c.name)}">${c.name}</a>`;
35 const stop = c.state === "open" ? stopsClose(app, c) : null;
36 const undo = c.state === "closed" && may && undoableClose(app, repo, c, now);
37 const recent = c.state === "closed" && c.closedAt !== null && now - Date.parse(c.closedAt) <= CLOSE_UNDO_MS;
38 const meta =
39 c.state === "closed"
40 ? html`closed · ${String(p.done)} shipped`
outdated

Principle 1 and 8: the tag and the 8-character id appear here and again in the receipt below. Say it once, in words.

41 : html`${String(p.done)} of ${String(p.total)} closed · ${progressSentence(p)}`;
42 const button =
43 may && c.state === "open"
44 ? act(base, c, "close", back, "Close chapter", true)
45 : undo
46 ? act(base, c, "undo", back, "Undo", false)
47 : null;
48 const after = html`${button}${stop === null ? null : html`<span class="muted small" data-in-the-way>${stop}</span>`}${recent && undo
49 ? html`<span class="muted small" data-receipt>Closed ${c.name} and tagged it. You can undo for a minute.</span>`
outdated

Principle 1: duplicates the meta line's tag. Drop the id; 'Closed and tagged. You can undo for a minute.' is enough.

50 : null}`;
51 return row({
52 title: link,
53 meta,
54 trail: badge(c.state === "draft" ? "waiting its turn" : c.state, { tone: c.state === "open" ? "success" : "default" }),
55 data: { chapter: c.name, state: c.state },
56 ...(button === null && stop === null ? {} : { after }),
57 });
58 });
59 return html`<section class="mt" id="chapters"><h2>Chapters · ${String(all.length)}</h2>
60 <ul class="list setting-list">${rows}</ul>
61 ${all.length > SHOWN ? html`<p class="muted small"><a href="${base}/chapters">All ${String(all.length)} chapters</a></p>` : null}</section>`;
62 }
test/chapters-page.test.ts added +257
@@ -0,0 +1,257 @@
1 /**
2 * The chapters section of /next (#12288): each chapter with its progress, a Close chapter button
3 * that names the open issues in its way, a same-origin close with a receipt, and a minute to
4 * undo it.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { newApp } from "../src/actions/apps.ts";
11 import { CHAPTER_WORDS, close, draft, fill, open, reopen, stopsClose } from "../src/actions/chapters.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { getChapter, listChapters } from "../src/core/chapters.ts";
14 import { createIssue } from "../src/core/issues.ts";
15 import { getRepo, type Repo } from "../src/core/repos.ts";
16 import { createAgentRow } from "../src/core/agents.ts";
17 import { chaptersScreen } from "../src/tui/screens/chapters.ts";
18 import type { Ctx, Screen } from "../src/tui/app/app.ts";
19 import type { Session } from "../src/tui/session.ts";
20 import { createToken } from "../src/core/tokens.ts";
21 import { createUser, type User } from "../src/core/users.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 { quietLogs } from "./helpers/globals.ts";
27
28 quietLogs();
29
30 describe("chapters on /next", () => {
31 let dataDir = "";
32 let app: App;
33 let servers: Servers;
34 let owner: User;
35 let repo: Repo;
36 let token = "";
37 const numbers: number[] = [];
38
39 const get = (path: string) => fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${token}` } });
40 const post = (path: string, origin = servers.url) =>
41 fetch(`${servers.url}${path}`, {
42 method: "POST",
43 redirect: "manual",
44 headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded", origin },
45 body: "back=%2Falice%2Fshop%2Fnext",
46 });
47 const landed = (n: number) => {
48 const row = app.db.query<{ id: string }, [string, number]>("SELECT id FROM issue WHERE repo_id = ?1 AND number = ?2").get(repo.id, n)!;
49 app.db.query("UPDATE issue SET state = 'closed', closed_at = ?2 WHERE id = ?1").run(row.id, "2026-11-10T00:00:00.000Z");
50 const pn = 9000 + n;
51 app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state) VALUES (?1, ?2, ?3, 'pull', 'p', ?4, ?5, 'closed')`).run(`pl${pn}`, repo.id, pn, `Closes #${n}`, owner.id);
52 app.db.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')`).run(`pl${pn}`, `b${pn}`);
53 app.db.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')`).run(repo.id, pn);
54 };
55
56 beforeAll(async () => {
57 dataDir = await mkdtemp(join(tmpdir(), "forge-chapters-page-"));
58 app = createApp({ dataDir, port: 0, host: "forge.test" });
59 servers = await serve(app);
60 const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true });
61 if (!made.ok) throw new Error(String(made.error));
62 owner = made.value;
63 const created = await newApp(app, owner, { name: "shop", description: "Words", visibility: "public" });
64 if (!created.ok) throw new Error(String(created.error));
65 repo = getRepo(app.db, "alice", "shop")!;
66 const c = await commitFiles(repoDir(app.reposRoot, "alice", "shop"), {
67 branch: repo.defaultBranch,
68 message: "first",
69 files: [{ path: "README.md", content: "hello" }],
70 author: { name: "T", email: "[email protected]" },
71 });
72 if (!c.ok) throw new Error(JSON.stringify(c.error));
73 token = createToken(app.db, owner.id, "t", "rw").token;
74 for (const t of ["Alpha", "Beta"]) {
75 const i = createIssue(app.db, { repoId: repo.id, authorId: owner.id, title: `Item ${t}`, body: "" });
76 if (!i.ok) throw new Error(String(i.error));
77 numbers.push(i.value.number);
78 }
79 draft(app, owner, repo, { name: "v1.0.0", means: "The shop takes an order." });
80 for (const n of numbers) fill(app, owner, repo, "v1.0.0", n, true);
81 open(app, owner, repo, "v1.0.0");
82 }, 30_000);
83
84 afterAll(async () => {
85 servers.stop();
86 app.close();
87 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
88 });
89
90 test("the section lists the chapter with its progress and a Close chapter button", async () => {
91 const res = await get("/alice/shop/next");
92 const page = await res.text();
93 expect(page).toContain('id="chapters"');
94 expect(page).toContain("v1.0.0");
95 expect(page).toContain("0 of 2 closed");
96 expect(page).toContain("Close chapter");
97 const timing = res.headers.get("server-timing") ?? "";
98 expect(timing).toContain("session");
99 expect(timing).toContain("render");
100 // Anyone may read it; no one but the owner gets the button.
101 const anon = await (await fetch(`${servers.url}/alice/shop/next`)).text();
102 expect(anon).toContain("v1.0.0");
103 expect(anon).not.toContain("Close chapter");
104 });
105
106 test("a chapter that cannot close names the open issues in its way, and the close changes nothing", async () => {
107 const page = await (await get("/alice/shop/next")).text();
108 expect(page).toContain("v1.0.0 cannot close: 2 issues are still in its way");
109 expect(page).toContain(`#${numbers[0]} Item Alpha`);
110 expect(stopsClose(app, getChapter(app.db, repo.id, "v1.0.0")!)).toContain(`#${numbers[1]} Item Beta`);
111 const refused = await post("/alice/shop/chapters/v1.0.0/close");
112 expect(refused.status).toBeLessThan(400);
113 expect(decodeURIComponent(refused.headers.get("set-cookie") ?? "")).toContain("cannot close");
114 expect(getChapter(app.db, repo.id, "v1.0.0")?.state).toBe("open");
115 });
116
117 test("the close is a same-origin POST, with a receipt and an undo for a minute", async () => {
118 for (const n of numbers) landed(n);
119 const page = await (await get("/alice/shop/next")).text();
120 expect(page).toContain("2 of 2 closed");
121 expect(page).not.toContain("cannot close");
122 expect((await post("/alice/shop/chapters/v1.0.0/close", "https://evil.example")).status).toBe(403);
123 expect(getChapter(app.db, repo.id, "v1.0.0")?.state).toBe("open");
124
125 const done = await post("/alice/shop/chapters/v1.0.0/close");
126 expect(done.status).toBeLessThan(400);
127 expect(getChapter(app.db, repo.id, "v1.0.0")?.state).toBe("closed");
128 const after = await (await get("/alice/shop/next")).text();
129 expect(after).toContain("data-receipt");
130 expect(after).toContain("Undo");
131
132 const undone = await post("/alice/shop/chapters/v1.0.0/undo");
133 expect(undone.status).toBeLessThan(400);
134 expect(getChapter(app.db, repo.id, "v1.0.0")?.state).toBe("open");
135 const dir = repoDir(app.reposRoot, "alice", "shop");
136 expect((await runGit(["rev-parse", "--verify", "--quiet", "refs/tags/v1.0.0"], { cwd: dir })).ok).toBe(false);
137 });
138
139 test("after the minute the undo is gone", async () => {
140 expect((await post("/alice/shop/chapters/v1.0.0/close")).status).toBeLessThan(400);
141 const c = getChapter(app.db, repo.id, "v1.0.0")!;
142 expect(await reopen(app, owner, repo, "v1.0.0", Date.parse(c.closedAt!) + 61_000)).toEqual({ ok: false, error: "too_late" });
143 expect(getChapter(app.db, repo.id, "v1.0.0")?.state).toBe("closed");
144 });
145
146 const kindsOf = () => app.db.query<{ kind: string }, [string]>("SELECT kind FROM event WHERE repo_id = ?1 AND kind LIKE 'chapter.%' ORDER BY id").all(repo.id).map((e) => e.kind);
147 const mkUser = async (username: string, agent: boolean) => {
148 const made = await createUser(app.db, {
149 username,
150 email: `${username}@forge.test`,
151 ...(agent ? { agent: true, origin: "agent" as const } : { password: "a-good-passphrase", origin: "seed" as const, trusted: true }),
152 });
153 if (!made.ok) throw new Error(String(made.error));
154 if (agent) createAgentRow(app.db, { userId: made.value.id, roles: ["builder"] });
155 return made.value;
156 };
157
158 test("the undo is refused to anyone but the owner, over the page and the act", async () => {
159 // v1.0.0 is closed just now from the test above.
160 expect(getChapter(app.db, repo.id, "v1.0.0")?.state).toBe("closed");
161 const anon = await fetch(`${servers.url}/alice/shop/chapters/v1.0.0/undo`, {
162 method: "POST",
163 redirect: "manual",
164 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
165 body: "",
166 });
167 expect(anon.status).not.toBe(200);
168 expect(getChapter(app.db, repo.id, "v1.0.0")?.state).toBe("closed");
169 const anonClose = await fetch(`${servers.url}/alice/shop/chapters/v1.0.0/close`, {
170 method: "POST",
171 redirect: "manual",
172 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
173 body: "",
174 });
175 expect(anonClose.status).not.toBe(200);
176 for (const who of [await mkUser("bob", false), await mkUser("bot", true)]) {
177 expect(await reopen(app, who, repo, "v1.0.0")).toEqual({ ok: false, error: "forbidden" });
178 const t = createToken(app.db, who.id, "t", "rw").token;
179 const res = await fetch(`${servers.url}/alice/shop/chapters/v1.0.0/undo`, {
180 method: "POST",
181 redirect: "manual",
182 headers: { authorization: `Bearer ${t}`, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
183 body: "",
184 });
185 expect(res.status).not.toBe(200);
186 expect(getChapter(app.db, repo.id, "v1.0.0")?.state).toBe("closed");
187 }
188 });
189
190 test("an undo is an event, and the page then shows the chapter open again", async () => {
191 const before = kindsOf().filter((k) => k === "chapter.reopened").length;
192 expect((await post("/alice/shop/chapters/v1.0.0/undo")).status).toBeLessThan(400);
193 expect(kindsOf().filter((k) => k === "chapter.reopened").length).toBe(before + 1);
194 expect(getChapter(app.db, repo.id, "v1.0.0")?.state).toBe("open");
195 });
196
197 test("a chapter opened since has its own refusal, not the one about moved items", async () => {
198 expect((await post("/alice/shop/chapters/v1.0.0/close")).status).toBeLessThan(400);
199 draft(app, owner, repo, { name: "v1.1.0" });
200 expect(open(app, owner, repo, "v1.1.0").ok).toBe(true);
201 const r = await reopen(app, owner, repo, "v1.0.0");
202 expect(r).toEqual({ ok: false, error: "another_open" });
203 expect(CHAPTER_WORDS.another_open).not.toBe(CHAPTER_WORDS.moved);
204 expect(CHAPTER_WORDS.another_open).not.toContain("moved");
205 expect(getChapter(app.db, repo.id, "v1.0.0")?.state).toBe("closed");
206 });
207
208 test("a close that moved items on says so, and is not undone", async () => {
209 const extra = createIssue(app.db, { repoId: repo.id, authorId: owner.id, title: "Item Gamma", body: "" });
210 if (!extra.ok) throw new Error(String(extra.error));
211 fill(app, owner, repo, "v1.1.0", extra.value.number, true);
212 draft(app, owner, repo, { name: "v1.2.0" });
213 const shipped = await close(app, owner, repo, "v1.1.0", true);
214 expect(shipped.ok).toBe(true);
215 expect(await reopen(app, owner, repo, "v1.1.0")).toEqual({ ok: false, error: "moved" });
216 expect(getChapter(app.db, repo.id, "v1.1.0")?.state).toBe("closed");
217 });
218
219 test("the terminal closes with the page's sentence and undoes within the minute", async () => {
220 const toasts: string[] = [];
221 const work: Promise<unknown>[] = [];
222 const stub = {
223 toast: (t: string) => toasts.push(t),
224 invalidate() {},
225 push: (_: Screen) => {},
226 pop() {},
227 track: (p: Promise<unknown>) => (work.push(p), p),
228 };
229 const ctx = stub as unknown as Ctx;
230 const press = (sc: Screen, key: string) => sc.key({ type: "key", key, ctrl: false, alt: false, shift: false });
231 const settled = () => Promise.all(work.splice(0));
232 const session = (user: User) => ({ user, db: app.db, app }) as unknown as Session;
233 expect(open(app, owner, repo, "v1.2.0").ok).toBe(true);
234 const on = (name: string): Screen => {
235 const sc = chaptersScreen(session(owner), ctx, repo);
236 const at = listChapters(app.db, repo.id).findIndex((c) => c.name === name);
237 for (let i = 0; i < at; i++) press(sc, "j");
238 return sc;
239 };
240 // v1.2.0 holds the moved item: its close is refused naming the issue.
241 press(on("v1.2.0"), "c");
242 await settled();
243 expect(toasts.at(-1)).toContain("v1.2.0 cannot close");
244 expect(toasts.at(-1)).toContain("Item Gamma");
245 // v1.2.0 is empty after the item is landed; close then undo.
246 const row = app.db.query<{ id: string }, [string]>("SELECT id FROM issue WHERE repo_id = ?1 AND title = 'Item Gamma'").get(repo.id)!;
247 app.db.query("DELETE FROM chapter_item WHERE issue_id = ?1").run(row.id);
248 press(on("v1.2.0"), "c");
249 await settled();
250 expect(getChapter(app.db, repo.id, "v1.2.0")?.state).toBe("closed");
251 expect(toasts.at(-1)).toContain("You can undo for a minute");
252 press(on("v1.2.0"), "u");
253 await settled();
254 expect(getChapter(app.db, repo.id, "v1.2.0")?.state).toBe("open");
255 expect(toasts.at(-1)).toBe("v1.2.0 is open again.");
256 });
257 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs