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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12288-the-owner-sees-each-chapter-on-the-page → main 6 commits fork 4 threads · 4 unresolved
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 | ||
| 9 | 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 | 10 | - **No chapter, no change.** A repository that never drafted one flows continuously, as before. |
| 11 | 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. | |
| 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. | |
| 13 | 13 | |
| 14 | 14 | 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. |
| 15 | 15 | |
| 16 | 16 | 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. |
| 17 | 17 | |
| 18 | 18 | 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 { | ||
| 14 | 14 | nextChapterOf, |
| 15 | 15 | nextName, |
| 16 | 16 | chapterOfIssue, |
| 17 | forgetFrozenItems, | |
| 17 | 18 | openChapterOf, |
| 18 | 19 | patchName, |
| 19 | 20 | placeNewIssue, |
| @@ -33,7 +34,65 @@ import { runGit } from "../git/env.ts"; | ||
| 33 | 34 | import { repoDir } from "../git/repos.ts"; |
| 34 | 35 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 35 | 36 | |
| 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"); | |
| 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 | } | |
| 37 | 96 | |
| 38 | 97 | /** A person who administers the repository, never an agent. */ |
| 39 | 98 | function owner(app: App, actor: User | undefined, repo: Repo): actor is User { |
| @@ -265,4 +324,7 @@ export const CHAPTER_WORDS: Readonly<Record<ChapterActError, string>> = { | ||
| 265 | 324 | no_branch: "The default branch has no commit to tag yet.", |
| 266 | 325 | tag_exists: "A git tag with that name already exists.", |
| 267 | 326 | 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.", | |
| 268 | 330 | }; |
src/core/event_audience.ts +1
| @@ -199,6 +199,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 199 | 199 | "chapter.item_added", |
| 200 | 200 | "chapter.item_removed", |
| 201 | 201 | "chapter.closed", |
| 202 | "chapter.reopened", | |
| 202 | 203 | ]); |
| 203 | 204 | |
| 204 | 205 | /** |
src/tui/screens/chapters.ts +30 −7
| @@ -2,13 +2,14 @@ | ||
| 2 | 2 | * An app's chapters, in the terminal (#11603): the page's list (`web/pages/chapters.ts`) from the same |
| 3 | 3 | * acts (`actions/chapters.ts`), so the same owner-only gate applies: anyone who can read the repository |
| 4 | 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 | |
| 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 | |
| 6 | 7 | * one, `enter` shows its list, where `a` adds an issue by number and `x` takes the one under the |
| 7 | 8 | * cursor out. Each act is an event on the bus, so an open web page sees it, and this screen follows |
| 8 | 9 | * a change made there. |
| 9 | 10 | */ |
| 10 | 11 | |
| 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"; | |
| 12 | 13 | import { getChapter, itemsOf, listChapters, progressOf, progressSentence, type Chapter, type Item } from "../../core/chapters.ts"; |
| 13 | 14 | import { canAdmin, type Repo } from "../../core/repos.ts"; |
| 14 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -23,9 +24,10 @@ import { ListState, confirmScreen, drawList, live, promptScreen, type ListRow } | ||
| 23 | 24 | const isChapterEvent = (kind: string): boolean => kind.startsWith("chapter.") || kind === "release.verified_live"; |
| 24 | 25 | |
| 25 | 26 | /** 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 { | |
| 27 | 28 | 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; | |
| 29 | 31 | return { title: `${c.name} — ${c.state}`, note: c.means === "" ? progress : `${c.means} ${progress}` }; |
| 30 | 32 | } |
| 31 | 33 | |
| @@ -37,6 +39,21 @@ function itemRow(i: Item): ListRow { | ||
| 37 | 39 | /** The mayor's check, as the acts make it: a person who administers the repository. */ |
| 38 | 40 | const mayAct = (f: Session, repo: Repo): boolean => f.user !== undefined && f.user.kind !== "agent" && canAdmin(f.app.db, repo, f.user); |
| 39 | 41 | |
| 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 | ||
| 40 | 57 | export function chapterScreen(f: Session, ctx: Ctx, repo: Repo, name: string): Screen { |
| 41 | 58 | const state = new ListState(); |
| 42 | 59 | const items = (): Item[] => { |
| @@ -144,6 +161,8 @@ export function chaptersScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 144 | 161 | binding(["n"], "draft"), |
| 145 | 162 | binding(["o"], "open"), |
| 146 | 163 | binding(["s"], "ship"), |
| 164 | binding(["c"], "close chapter"), | |
| 165 | binding(["u"], "undo a close"), | |
| 147 | 166 | binding(["p"], "patch"), |
| 148 | 167 | ], |
| 149 | 168 | async load(signal) { |
| @@ -157,13 +176,13 @@ export function chaptersScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 157 | 176 | head!.x, |
| 158 | 177 | head!.y, |
| 159 | 178 | 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.", | |
| 161 | 180 | head!.w, |
| 162 | 181 | ), |
| 163 | 182 | style(c.mutedFg, c.bg), |
| 164 | 183 | head!.x + head!.w, |
| 165 | 184 | ); |
| 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))); | |
| 167 | 186 | }, |
| 168 | 187 | key(e) { |
| 169 | 188 | const chapters = all(); |
| @@ -186,7 +205,7 @@ export function chaptersScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 186 | 205 | ); |
| 187 | 206 | return true; |
| 188 | 207 | } |
| 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"; | |
| 190 | 209 | if (e.key === "enter") { |
| 191 | 210 | ctx.push(chapterScreen(f, ctx, repo, at.name)); |
| 192 | 211 | return true; |
| @@ -195,6 +214,10 @@ export function chaptersScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 195 | 214 | act(f, ctx, repo, screen, at.name, e.key); |
| 196 | 215 | return true; |
| 197 | 216 | } |
| 217 | if (e.key === "c" || e.key === "u") { | |
| 218 | void ctx.track(closeOrUndo(f, ctx, repo, at.name, e.key)); | |
| 219 | return true; | |
| 220 | } | |
| 198 | 221 | if (e.key === "p") { |
| 199 | 222 | const done = patch(f.app, f.user, repo, at.name); |
| 200 | 223 | 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 @@ | ||
| 6 | 6 | * is a same-origin POST by a person who administers the repository. |
| 7 | 7 | */ |
| 8 | 8 | |
| 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"; | |
| 10 | 10 | import { getChapter, itemsOf, listChapters, progressOf, progressSentence, type Chapter, type Item } from "../../core/chapters.ts"; |
| 11 | 11 | import { canAdmin, type Repo } from "../../core/repos.ts"; |
| 12 | 12 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | 14 | 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"; | |
| 16 | 16 | import { html, type Html } from "../html.ts"; |
| 17 | 17 | import { flashTo, page } from "../layout.ts"; |
| 18 | 18 | import { badge, btn, row } from "../ui.ts"; |
| @@ -153,17 +153,24 @@ export async function chapterAct(ctx: Ctx): Promise<Response> { | ||
| 153 | 153 | ? flashTo(ctx, hrefOf(repo, made.value), { message: `Drafted ${made.value.name}.` }) |
| 154 | 154 | : flashTo(ctx, list, { tone: "danger", message: CHAPTER_WORDS[made.error] }); |
| 155 | 155 | } |
| 156 | const at = `${list}/${encodeURIComponent(name)}`; | |
| 156 | const at = backTo(form, `${list}/${encodeURIComponent(name)}`); | |
| 157 | 157 | const verb = ctx.params.verb ?? ""; |
| 158 | 158 | const number = Number(field(form, "number")); |
| 159 | 159 | const result = |
| 160 | 160 | verb === "open" ? open(app, user, repo, name) |
| 161 | 161 | : verb === "add" || verb === "remove" ? fill(app, user, repo, name, number, verb === "add") |
| 162 | 162 | : 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) | |
| 163 | 165 | : verb === "patch" ? patch(app, user, repo, name) |
| 164 | 166 | : null; |
| 165 | 167 | 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 | } | |
| 167 | 173 | 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 }); | |
| 169 | 176 | } |
src/web/pages/next.ts +2
| @@ -24,6 +24,7 @@ import { backTo, field, readForm } from "../form.ts"; | ||
| 24 | 24 | import { html, raw, type Html } from "../html.ts"; |
| 25 | 25 | import { flashOrJson, page } from "../layout.ts"; |
| 26 | 26 | import { btn, kbd, row } from "../ui.ts"; |
| 27 | import { chaptersSection } from "./next_chapters.ts"; | |
| 27 | 28 | import { countWords, filterPath, markBefore, filtered, matches, readFilter } from "./next_filter.ts"; |
| 28 | 29 | |
| 29 | 30 | /** |
| @@ -253,6 +254,7 @@ export function nextPage(ctx: Ctx): Response { | ||
| 253 | 254 | ? html`<p class="muted">Nothing is ready: everything open waits for something. <a href="${base}/map">See the map</a>.</p>` |
| 254 | 255 | : 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} |
| 255 | 256 | ${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)} | |
| 256 | 258 | <p class="muted small mt" id="next-week">${sentence} <a href="${base}/week">The week</a>${writer |
| 257 | 259 | ? " · Drag a row, or press the key on its button; j and k move between rows." |
| 258 | 260 | : 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 | }); |
new above
offline
A newly opened chapter also makes undoableClose false, but this answers 'moved', which says items moved. Give that case its own code and sentence.