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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11603-a-chapter-is-a-version-with-an-end-a-nam → main 5 commits fork 4 threads · 4 unresolved
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 | ||
| 15 | 15 | |
| 16 | 16 | A new `/settings…` page route goes in `src/http/settings_routes.ts` (`settingsRoutes`), not `src/http/server.ts`. |
| 17 | 17 | |
| 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. | |
| 19 | 19 | |
| 20 | 20 | `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. |
| 21 | 21 |
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"; | ||
| 25 | 25 | import { adoptResolution } from "../core/trusted_sources.ts"; |
| 26 | 26 | import { crewRole, isCrew } from "../core/agents.ts"; |
| 27 | 27 | import { pausedUntil } from "../core/planner_pause.ts"; |
| 28 | import { keepWithinChapter } from "./chapters.ts"; | |
| 29 | import { inheritChapter } from "../core/chapters.ts"; | |
| 28 | 30 | import { moveBlockersToChildren } from "../core/dependencies.ts"; |
| 29 | 31 | import { |
| 30 | 32 | addComment as insertComment, |
| @@ -118,6 +120,9 @@ export function openIssue( | ||
| 118 | 120 | sealed: input.sealed === true, |
| 119 | 121 | }); |
| 120 | 122 | 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); | |
| 121 | 126 | app.bus.append({ |
| 122 | 127 | kind: "issue.opened", |
| 123 | 128 | repoId: repo.id, |
| @@ -522,8 +527,11 @@ export function setIssueState( | ||
| 522 | 527 | return written; |
| 523 | 528 | })(); |
| 524 | 529 | if (!changed) return ok(getIssueById(app.db, issue.id) ?? issue); |
| 525 | if (children.length > 0) | |
| 530 | if (children.length > 0) { | |
| 526 | 531 | 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 | } | |
| 527 | 535 | app.bus.append({ |
| 528 | 536 | kind: `${issue.kind}.${state === "closed" ? "closed" : "reopened"}`, |
| 529 | 537 | 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 | ||
| 134 | 134 | export { route as getApiV1ReposOwnerNameBuildsLabelCart } from "./getApiV1ReposOwnerNameBuildsLabelCart.ts"; |
| 135 | 135 | export { route as getApiV1ReposOwnerNameBuildsLabelShots } from "./getApiV1ReposOwnerNameBuildsLabelShots.ts"; |
| 136 | 136 | export { route as getApiV1ReposOwnerNameBuildsLabelShotsLooks } from "./getApiV1ReposOwnerNameBuildsLabelShotsLooks.ts"; |
| 137 | export { route as getApiV1ReposOwnerNameChapters } from "./getApiV1ReposOwnerNameChapters.ts"; | |
| 137 | 138 | export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts"; |
| 138 | 139 | export { route as getApiV1ReposOwnerNameCommitsOid } from "./getApiV1ReposOwnerNameCommitsOid.ts"; |
| 139 | 140 | export { route as getApiV1ReposOwnerNameCommitsOidCheck } from "./getApiV1ReposOwnerNameCommitsOidCheck.ts"; |
| @@ -317,6 +318,8 @@ export { route as postApiV1ReposOwnerNameBuildsLabelShots } from "./postApiV1Rep | ||
| 317 | 318 | export { route as postApiV1ReposOwnerNameBuildsLabelShotsFile } from "./postApiV1ReposOwnerNameBuildsLabelShotsFile.ts"; |
| 318 | 319 | export { route as postApiV1ReposOwnerNameBuildsLabelShotsLooks } from "./postApiV1ReposOwnerNameBuildsLabelShotsLooks.ts"; |
| 319 | 320 | export { route as postApiV1ReposOwnerNameBuildsPreview } from "./postApiV1ReposOwnerNameBuildsPreview.ts"; |
| 321 | export { route as postApiV1ReposOwnerNameChapters } from "./postApiV1ReposOwnerNameChapters.ts"; | |
| 322 | export { route as postApiV1ReposOwnerNameChaptersChapterVerb } from "./postApiV1ReposOwnerNameChaptersChapterVerb.ts"; | |
| 320 | 323 | export { route as postApiV1ReposOwnerNameChecksRecheck } from "./postApiV1ReposOwnerNameChecksRecheck.ts"; |
| 321 | 324 | export { route as postApiV1ReposOwnerNameContents } from "./postApiV1ReposOwnerNameContents.ts"; |
| 322 | 325 | 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>> = { | ||
| 17 | 17 | contribute: "forks", |
| 18 | 18 | queue: "gates", |
| 19 | 19 | growth: "issues", |
| 20 | chapters: "issues", | |
| 20 | 21 | checks: "pulls", |
| 21 | 22 | conversation: "conversations", |
| 22 | 23 | "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([ | ||
| 186 | 186 | "start.made", |
| 187 | 187 | "start.claimed", |
| 188 | 188 | "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", | |
| 189 | 195 | ]); |
| 190 | 196 | |
| 191 | 197 | /** |
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 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { pausedUntil } from "../core/planner_pause.ts"; |
| 12 | import { chapterHold } from "../core/chapters.ts"; | |
| 12 | 13 | import { crewFilters, isStrangerPull } from "./stranger.ts"; |
| 13 | 14 | import { TEMP_FULL } from "../checks/disk_full.ts"; |
| 14 | 15 | import { freshPullFor } from "./fresh_pull.ts"; |
| @@ -468,6 +469,14 @@ export function queueAgentRun( | ||
| 468 | 469 | // The owner's switch (#11604): a paused planner starts nothing; builders and reviewers carry on. |
| 469 | 470 | if (crewRole(app.db, repo.id, agent.user.id) === "planner" && pausedUntil(app.db, repo.id, new Date()) !== null) |
| 470 | 471 | 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; | |
| 471 | 480 | // An archived repository starts nothing new; a run carrying on (`again`) |
| 472 | 481 | // is one already going, and finishes. A first run |
| 473 | 482 | // (`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"; | ||
| 383 | 383 | import { flagsRoutes } from "./api/flags.ts"; |
| 384 | 384 | import { appTemplateRoutes } from "./api/app_templates.ts"; |
| 385 | 385 | import { issueRoutes, labelsHandler } from "./api/issues.ts"; |
| 386 | import { chapterRoutes } from "./api/chapters.ts"; | |
| 386 | 387 | import { FORGE_NOT_CARRIED } from "../origins/words.ts"; |
| 387 | 388 | import { forgeOriginRoutes } from "./api/forge_origin.ts"; |
| 388 | 389 | import { userRoutes } from "./api/user.ts"; |
| @@ -2171,6 +2172,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2171 | 2172 | // ── issues — the label-as-queue surface automation drives ──────────── |
| 2172 | 2173 | |
| 2173 | 2174 | issueRoutes(router); |
| 2175 | chapterRoutes(router); | |
| 2174 | 2176 | |
| 2175 | 2177 | reportRoutes(router); |
| 2176 | 2178 | 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"; | ||
| 172 | 172 | import { flowsCheck, flowsPage, flowsProbe, flowsUpdate } from "../web/pages/flows.ts"; |
| 173 | 173 | import { findingAccept, findingsPage } from "../web/pages/findings.ts"; |
| 174 | 174 | import { queuePage, queueSwitch } from "../web/pages/queue.ts"; |
| 175 | import { chapterAct, chapterPage, chaptersPage } from "../web/pages/chapters.ts"; | |
| 175 | 176 | import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts"; |
| 176 | 177 | import { changelogJson, changelogPage } from "../web/pages/changelog.ts"; |
| 177 | 178 | import { docsFile, docsFolder } from "../web/pages/docs_link.ts"; |
| @@ -1298,6 +1299,10 @@ export function buildRouter(): Router<Handler> { | ||
| 1298 | 1299 | router.add("POST", "/:owner/:name/findings/:id/accept", findingAccept); |
| 1299 | 1300 | router.add("GET", "/:owner/:name/queue", queuePage); |
| 1300 | 1301 | 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); | |
| 1301 | 1306 | router.add("GET", "/:owner/:name/bets", betsPage); |
| 1302 | 1307 | router.add("POST", "/:owner/:name/bets/propose", betPropose); |
| 1303 | 1308 | 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"; | ||
| 169 | 169 | import { variantsCommands } from "./variants.ts"; |
| 170 | 170 | import { autonomyCommands } from "./autonomy.ts"; |
| 171 | 171 | import { trafficCommands } from "./trafficview.ts"; |
| 172 | import { chaptersCommands } from "./chapters.ts"; | |
| 172 | 173 | import { schedulesCommands } from "./schedules.ts"; |
| 173 | 174 | import { queueCommands } from "./queuegrowth.ts"; |
| 174 | 175 | import { blockedCommands } from "./blocked.ts"; |
| @@ -1995,6 +1996,7 @@ export function workspaceScreen( | ||
| 1995 | 1996 | ...autonomyCommands(f, ctx, repo), |
| 1996 | 1997 | ...trafficCommands(f, ctx, repo), |
| 1997 | 1998 | ...schedulesCommands(f, ctx, repo), |
| 1999 | ...chaptersCommands(f, ctx, repo), | |
| 1998 | 2000 | ...queueCommands(f, ctx, repo), |
| 1999 | 2001 | ...blockedCommands(f, ctx, repo), |
| 2000 | 2002 | ...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"> | |
| 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 { | ||
| 86 | 86 | const body = html`${repoHeader(ctx, repo, "issues")} |
| 87 | 87 | ${pageHead("Queue", { sub: "Is the work growing or shrinking?" })} |
| 88 | 88 | ${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. |
||
| 89 | 90 | ${aged.length === 0 ? null : html`<ul class="list setting-list">${aged}</ul>`} |
| 90 | 91 | ${growthRows(g)}`; |
| 91 | 92 | 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> = { | ||
| 289 | 289 | "/:owner/:name/builds/:label/cart": "needs a pinned build with a cart", |
| 290 | 290 | "/:owner/:name/builds/:label/cart.png": "needs a pinned build with a cart", |
| 291 | 291 | "/: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", | |
| 292 | 293 | "/:owner/:name/builds/:label/shots/:file": "needs a pinned build with pictures", |
| 293 | 294 | "/:owner/:name/commit/:oid": "the seed does not keep the commit id; the commit page is a plain diff with the shell script only", |
| 294 | 295 | "/: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 }> = { | ||
| 43 | 43 | }; |
| 44 | 44 | |
| 45 | 45 | /** 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; | |
| 47 | 47 | |
| 48 | 48 | /** Streams and live fragments: no page. */ |
| 49 | 49 | const NOT_PAGES = new Set([ |
test/security-floor-routes.test.ts +1
| @@ -68,6 +68,7 @@ const UNSEEDED: Record<string, string> = { | ||
| 68 | 68 | "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing", |
| 69 | 69 | "/fleet/ops/:id": FLEET, |
| 70 | 70 | "/: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)", | |
| 71 | 72 | "/:owner/:name/builds/:label/cart": CART, |
| 72 | 73 | "/:owner/:name/builds/:label/cart.png": CART, |
| 73 | 74 | "/:owner/:name/builds/:label/shots": SHOT, |
new above
offline
Principle 7: these are facts (shipped, took, cost, tag), so they are
dlrows, not list rows with badges. Badges are for state, not values.