Public: anyone can read this; the crew can change it.
The MCP opens a chapter, fills it and closes the book, with the page's confirm and the page's words #11881
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11750-the-mcp-opens-a-chapter-fills-it-and-clo → main 6 commits fork 4 threads · 4 unresolved
9 files
changed +351
−32
docs/MCP.md +1 −1
| @@ -178,7 +178,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 178 | 178 | | `release_hold` | — | Hold or release the release keeper's cuts (admin) | 202 | |
| 179 | 179 | | `release_keeper` | — | The release keeper's releases, holds and what blocks the next cut | 192 | |
| 180 | 180 | | `releases` | — | Kept releases, newest first | 253 | |
| 181 | | `repo` | — | One repository you can read, as GET /api/v1/repos/:owner/:name | 296 | | |
| 181 | | `repo` | `open`, `add`, `remove`, `close` | One repository you can read, as GET /api/v1/repos/:owner/:name | 383 | | |
| 182 | 182 | | `repo_labels` | — | Repo labels: list (default), create, update, remove (admin token) | 351 | |
| 183 | 183 | | `repo_settings` | — | Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams | 444 | |
| 184 | 184 | | `reports` | `accept`, `dismiss` | Outsiders' reports; `id`+`action`: accept or dismiss | 310 | |
docs/features/chapters.md +2
| @@ -13,4 +13,6 @@ Doors: the page (`/chapters`, linked from `/queue`), the terminal (`src/tui/scre | ||
| 13 | 13 | |
| 14 | 14 | The MCP answers too: the `repo` tool with `view: "chapters"` lists a repository's chapters (name, what done means, state); adding `chapter: "v1"` names that chapter's items (`done`, `building` or `left`), its `progress` (the same sentence the page and the terminal print, from `progressSentence`) and `finishesOn`. `chapter` without `view` is `invalid`; a repository the caller cannot read is refused as the page refuses it. |
| 15 | 15 | |
| 16 | The same tool acts, for the owner: `action` is `open`, `add`, `remove` or `close`, with `chapter` (and an issue `number` for `add` and `remove`). Without `confirm: true` it changes nothing and answers `{ done: false, says }`: what it would do (for `close`, also the `tag` it would write and the `into` chapter and `moves` of what is left), made by the same checks the act makes, so a preview never promises what the act refuses. With `confirm: true` it does it through the page's own functions (`open`, `fill`, `close` with `ship`, in `src/actions/chapters.ts`) and answers `{ done: true }`; each is recorded as the page records it. A refusal is the page's: `CHAPTER_WORDS` under its code (`forbidden` for a person who does not administer the repository or an agent, `already_open`, `closed`, `no_issue`, `tag_exists`, …). A token that may only read gets the previews and is refused the confirmed act. The MCP's close always ships: what is left moves on, so `unfinished` is the page's refusal only for a close that does not ship. | |
| 17 | ||
| 16 | 18 | A closed chapter's list is frozen, so `itemsOf` keeps it in process memory (at most 200, `forgetFrozenItems` drops them); an open chapter's list is one pass over the repository's pulls and one grouped read of its runs, however many items it holds, so the anonymous page and API read stay cheap. |
src/actions/chapters.ts +104 −25
| @@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | itemsOf, |
| 14 | 14 | nextChapterOf, |
| 15 | 15 | nextName, |
| 16 | chapterOfIssue, | |
| 16 | 17 | openChapterOf, |
| 17 | 18 | patchName, |
| 18 | 19 | placeNewIssue, |
| @@ -20,6 +21,7 @@ import { | ||
| 20 | 21 | startChapter, |
| 21 | 22 | takeIssueOut, |
| 22 | 23 | type Chapter, |
| 24 | type Item, | |
| 23 | 25 | type ChapterError, |
| 24 | 26 | } from "../core/chapters.ts"; |
| 25 | 27 | import { addDependency } from "../core/dependencies.ts"; |
| @@ -45,31 +47,120 @@ export function draft(app: App, actor: User | undefined, repo: Repo, input: { na | ||
| 45 | 47 | return made; |
| 46 | 48 | } |
| 47 | 49 | |
| 48 | export function open(app: App, actor: User | undefined, repo: Repo, name: string, now = new Date()): Result<Chapter, ChapterActError> { | |
| 50 | /** The checks `open` makes before it writes, shared with `preview` so the two cannot disagree. */ | |
| 51 | function openPlan(app: App, actor: User | undefined, repo: Repo, name: string): Result<{ actor: User; chapter: Chapter }, ChapterActError> { | |
| 49 | 52 | if (!owner(app, actor, repo)) return err("forbidden"); |
| 50 | 53 | const chapter = getChapter(app.db, repo.id, name); |
| 51 | 54 | 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 } }); | |
| 55 | if (chapter.state === "closed") return err("closed"); | |
| 56 | if (chapter.state === "open" || openChapterOf(app.db, repo.id) !== undefined) return err("already_open"); | |
| 57 | return ok({ actor, chapter }); | |
| 58 | } | |
| 59 | ||
| 60 | export function open(app: App, actor: User | undefined, repo: Repo, name: string, now = new Date()): Result<Chapter, ChapterActError> { | |
| 61 | const planned = openPlan(app, actor, repo, name); | |
| 62 | if (!planned.ok) return planned; | |
| 63 | const started = startChapter(app.db, planned.value.chapter, now); | |
| 64 | if (started.ok) app.bus.append({ kind: "chapter.opened", repoId: repo.id, actorId: planned.value.actor.id, payload: { name } }); | |
| 54 | 65 | return started; |
| 55 | 66 | } |
| 56 | 67 | |
| 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> { | |
| 68 | /** The checks `fill` makes before it writes, shared with `preview`. */ | |
| 69 | function fillPlan( | |
| 70 | app: App, | |
| 71 | actor: User | undefined, | |
| 72 | repo: Repo, | |
| 73 | name: string, | |
| 74 | number: number, | |
| 75 | add: boolean, | |
| 76 | ): Result<{ actor: User; chapter: Chapter; issue: Issue }, ChapterActError> { | |
| 59 | 77 | if (!owner(app, actor, repo)) return err("forbidden"); |
| 60 | 78 | const chapter = getChapter(app.db, repo.id, name); |
| 61 | 79 | if (chapter === undefined) return err("not_found"); |
| 62 | 80 | const issue = getIssue(app.db, repo.id, number); |
| 63 | 81 | if (issue === undefined || issue.kind !== "issue") return err("no_issue"); |
| 82 | if (chapter.state === "closed") return err(add ? "closed" : "not_found"); | |
| 83 | if (add) { | |
| 84 | if (chapterOfIssue(app.db, issue.id)?.state === "closed") return err("closed"); | |
| 85 | } else if (!itemsOf(app.db, chapter).some((i) => i.number === issue.number)) return err("not_found"); | |
| 86 | return ok({ actor, chapter, issue }); | |
| 87 | } | |
| 88 | ||
| 89 | /** Add an issue to a chapter, or take it out (`add` false): the owner's act, recorded. */ | |
| 90 | export function fill(app: App, actor: User | undefined, repo: Repo, name: string, number: number, add: boolean): Result<void, ChapterActError> { | |
| 91 | const planned = fillPlan(app, actor, repo, name, number, add); | |
| 92 | if (!planned.ok) return planned; | |
| 93 | const { chapter, issue } = planned.value; | |
| 64 | 94 | const done: Result<void, ChapterActError> = add |
| 65 | ? putIssue(app.db, chapter, issue.id, actor.id) | |
| 95 | ? putIssue(app.db, chapter, issue.id, planned.value.actor.id) | |
| 66 | 96 | : takeIssueOut(app.db, chapter, issue.id) |
| 67 | 97 | ? ok(undefined) |
| 68 | 98 | : 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 } }); | |
| 99 | if (done.ok) app.bus.append({ kind: add ? "chapter.item_added" : "chapter.item_removed", repoId: repo.id, actorId: planned.value.actor.id, payload: { name, number } }); | |
| 70 | 100 | return done; |
| 71 | 101 | } |
| 72 | 102 | |
| 103 | /** What closing would do, found by the checks `close` makes before it writes anything. */ | |
| 104 | interface ClosePlan { | |
| 105 | readonly actor: User; | |
| 106 | readonly chapter: Chapter; | |
| 107 | readonly items: readonly Item[]; | |
| 108 | readonly left: readonly Item[]; | |
| 109 | readonly dir: string; | |
| 110 | readonly tip: string; | |
| 111 | readonly ref: string; | |
| 112 | } | |
| 113 | ||
| 114 | async function closePlan(app: App, actor: User | undefined, repo: Repo, name: string, ship: boolean): Promise<Result<ClosePlan, ChapterActError>> { | |
| 115 | if (!owner(app, actor, repo)) return err("forbidden"); | |
| 116 | const chapter = getChapter(app.db, repo.id, name); | |
| 117 | if (chapter === undefined) return err("not_found"); | |
| 118 | if (chapter.state === "closed") return err("closed"); | |
| 119 | if (chapter.state !== "open") return err("not_draft"); | |
| 120 | const items = itemsOf(app.db, chapter); | |
| 121 | const left = items.filter((i) => i.status !== "done"); | |
| 122 | if (left.length > 0 && !ship) return err("unfinished"); | |
| 123 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 124 | const tipped = await runGit(["rev-parse", "--verify", "--quiet", `refs/heads/${repo.defaultBranch}^{commit}`], { cwd: dir }); | |
| 125 | if (!tipped.ok) return err("no_branch"); | |
| 126 | const ref = `refs/tags/${chapter.name}`; | |
| 127 | if ((await runGit(["rev-parse", "--verify", "--quiet", ref], { cwd: dir })).ok) return err("tag_exists"); | |
| 128 | return ok({ actor, chapter, items, left, dir, tip: tipped.value.trim(), ref }); | |
| 129 | } | |
| 130 | ||
| 131 | /** What an act would do, said before it is done and changing nothing: the same refusals as the act itself. */ | |
| 132 | export async function preview( | |
| 133 | app: App, | |
| 134 | actor: User | undefined, | |
| 135 | repo: Repo, | |
| 136 | verb: "open" | "add" | "remove" | "close", | |
| 137 | name: string, | |
| 138 | number?: number, | |
| 139 | ): Promise<Result<{ says: string; tag?: string; moves?: { number: number; title: string }[]; into?: string }, ChapterActError>> { | |
| 140 | if (verb === "close") { | |
| 141 | const planned = await closePlan(app, actor, repo, name, true); | |
| 142 | if (!planned.ok) return planned; | |
| 143 | const { chapter, items, left, tip } = planned.value; | |
| 144 | const into = left.length === 0 ? undefined : (nextChapterOf(app.db, repo.id)?.name ?? nextName(chapter.name)); | |
| 145 | const moves = left.map((i) => ({ number: i.number, title: i.title })); | |
| 146 | return ok({ | |
| 147 | says: | |
| 148 | `Would close ${chapter.name}: tag ${chapter.name} at ${tip.slice(0, 12)} on ${repo.defaultBranch}, write the notes for ${items.length - left.length} done` + | |
| 149 | (into === undefined ? "." : `, and move ${left.length} unfinished to ${into}.`), | |
| 150 | tag: chapter.name, | |
| 151 | ...(into === undefined ? {} : { into, moves }), | |
| 152 | }); | |
| 153 | } | |
| 154 | if (verb === "open") { | |
| 155 | const planned = openPlan(app, actor, repo, name); | |
| 156 | return planned.ok ? ok({ says: `Would open ${planned.value.chapter.name}.` }) : planned; | |
| 157 | } | |
| 158 | const planned = fillPlan(app, actor, repo, name, number ?? 0, verb === "add"); | |
| 159 | if (!planned.ok) return planned; | |
| 160 | const { chapter, issue } = planned.value; | |
| 161 | return ok({ says: verb === "add" ? `Would add #${issue.number} to ${chapter.name}.` : `Would take #${issue.number} out of ${chapter.name}.` }); | |
| 162 | } | |
| 163 | ||
| 73 | 164 | /** |
| 74 | 165 | * Close the book: tag the default branch's tip with the chapter's name, write the notes from what |
| 75 | 166 | * landed, move what is left (only with `ship`) to the next chapter and mark the chapter closed. |
| @@ -83,21 +174,9 @@ export async function close( | ||
| 83 | 174 | ship: boolean, |
| 84 | 175 | now = new Date(), |
| 85 | 176 | ): 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"); | |
| 177 | const planned = await closePlan(app, actor, repo, name, ship); | |
| 178 | if (!planned.ok) return planned; | |
| 179 | const { actor: by, chapter, items, left, dir, tip, ref } = planned.value; | |
| 101 | 180 | if (!(await runGit(["update-ref", ref, tip, ""], { cwd: dir })).ok) return err("git"); |
| 102 | 181 | |
| 103 | 182 | const closedAt = now.toISOString(); |
| @@ -113,7 +192,7 @@ export async function close( | ||
| 113 | 192 | // What is left moves on to the next chapter, drafted when there is none. |
| 114 | 193 | next = left.length === 0 ? undefined : nextChapterOf(app.db, repo.id); |
| 115 | 194 | if (left.length > 0 && next === undefined) { |
| 116 | const made = draftChapter(app.db, repo.id, { name: nextName(chapter.name) }, actor.id); | |
| 195 | const made = draftChapter(app.db, repo.id, { name: nextName(chapter.name) }, by.id); | |
| 117 | 196 | next = made.ok ? made.value : undefined; |
| 118 | 197 | } |
| 119 | 198 | const notes = chapterNotes(chapter.name, chapter.means, landed, left, next?.name, { |
| @@ -123,7 +202,7 @@ export async function close( | ||
| 123 | 202 | doneCount: landed.length, |
| 124 | 203 | costUsd, |
| 125 | 204 | }); |
| 126 | if (next !== undefined) for (const i of left) putIssue(app.db, next, i.issueId, actor.id); | |
| 205 | if (next !== undefined) for (const i of left) putIssue(app.db, next, i.issueId, by.id); | |
| 127 | 206 | const closed = app.db |
| 128 | 207 | .query("UPDATE chapter SET state = 'closed', closed_at = ?2, tag_oid = ?3, notes = ?4, cost_usd = ?5 WHERE id = ?1 AND state = 'open'") |
| 129 | 208 | .run(chapter.id, closedAt, tip, notes, costUsd); |
| @@ -133,7 +212,7 @@ export async function close( | ||
| 133 | 212 | await runGit(["update-ref", "-d", ref, tip], { cwd: dir }); |
| 134 | 213 | return err(getChapter(app.db, repo.id, name)?.state === "closed" ? "closed" : "git"); |
| 135 | 214 | } |
| 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 }) } }); | |
| 215 | app.bus.append({ kind: "chapter.closed", repoId: repo.id, actorId: by.id, payload: { name, tag: chapter.name, shipped: landed.length, moved: left.length, ...(next === undefined ? {} : { into: next.name }) } }); | |
| 137 | 216 | return ok(getChapter(app.db, repo.id, name)!); |
| 138 | 217 | } |
| 139 | 218 |
src/http/mcp/tools/repo.ts +27 −3
| @@ -3,23 +3,47 @@ import { forkParentFor } from "../../../core/repos.ts"; | ||
| 3 | 3 | import { audienceFor } from "../../../core/audience.ts"; |
| 4 | 4 | import { upstreamFacts } from "../../../actions/upstream_follow.ts"; |
| 5 | 5 | import { creditFacts } from "../../../actions/credit_facts.ts"; |
| 6 | import { CHAPTER_WORDS, type ChapterActError, close, fill, open, preview } from "../../../actions/chapters.ts"; | |
| 6 | 7 | import { getChapter, itemsOf, listChapters, progressOf, progressSentence } from "../../../core/chapters.ts"; |
| 8 | import { err } from "../../../kernel/result.ts"; | |
| 7 | 9 | import { good, str, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 8 | 10 | |
| 9 | 11 | export const tool: Tool = { |
| 10 | 12 | name: "repo", |
| 11 | doc: "`{ repo }`: one repository's description, visibility, autonomy, `audience { level, who, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), `credit` (a mirror's upstream, licence, authors; `null` otherwise), the same as `GET /api/v1/repos/:owner/:name`; `view: \"chapters\"` lists its chapters with state, and with `chapter` names one's items (`done|building|left`) and `progress` (the page's sentence)", | |
| 13 | doc: "`{ repo }`: one repository's description, visibility, autonomy, `audience { level, who, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), `credit` (a mirror's upstream, licence, authors; `null` otherwise), the same as `GET /api/v1/repos/:owner/:name`; `action` (`open|add|remove|close` with `chapter`, `number` for add/remove; a person who administers it, `rw` token) says what it would do, and with `confirm: true` does it, as the page; `view: \"chapters\"` lists its chapters with state, and with `chapter` names one's items (`done|building|left`) and `progress` (the page's sentence)", | |
| 12 | 14 | description: |
| 13 | 15 | "One repository you can read, as GET /api/v1/repos/:owner/:name.", |
| 14 | 16 | inputSchema: { |
| 15 | 17 | type: "object", |
| 16 | properties: { repo: REPO_ARG, view: { type: "string", enum: ["chapters"] }, chapter: { type: "string" } }, | |
| 18 | properties: { repo: REPO_ARG, view: { type: "string", enum: ["chapters"] }, chapter: { type: "string" }, action: { type: "string", enum: ["open", "add", "remove", "close"] }, number: { type: "integer" }, confirm: { type: "boolean" } }, | |
| 17 | 19 | required: ["repo"], |
| 18 | 20 | }, |
| 19 | writes: false, | |
| 21 | writes: true, | |
| 22 | /** Without a confirmed `action` it only reads or previews. */ | |
| 23 | readsWhen: (args) => args["action"] === undefined || args["confirm"] !== true, | |
| 20 | 24 | async run(ctx, user, args) { |
| 21 | 25 | const repo = repoArg(ctx, user, args); |
| 22 | 26 | if (isAnswer(repo)) return repo; |
| 27 | if (args["action"] !== undefined) { | |
| 28 | const verb = args["action"]; | |
| 29 | if (verb !== "open" && verb !== "add" && verb !== "remove" && verb !== "close") return bad("invalid", "`action` is open, add, remove or close"); | |
| 30 | const name = str(args["chapter"]); | |
| 31 | if (name === "") return bad("invalid", "`action` needs `chapter`"); | |
| 32 | const number = args["number"]; | |
| 33 | if ((verb === "add" || verb === "remove") && (typeof number !== "number" || !Number.isInteger(number) || number < 1)) | |
| 34 | return bad("invalid", "`add` and `remove` need an issue `number`"); | |
| 35 | const n = typeof number === "number" ? number : undefined; | |
| 36 | const refused = (e: keyof typeof CHAPTER_WORDS) => bad(e, CHAPTER_WORDS[e]); | |
| 37 | if (args["confirm"] !== true) { | |
| 38 | const seen = await preview(ctx.app, user, repo, verb, name, n); | |
| 39 | return seen.ok ? good({ ...seen.value, done: false, says: `${seen.value.says} Send confirm: true to do it.` }) : refused(seen.error); | |
| 40 | } | |
| 41 | const done = verb === "close" ? await close(ctx.app, user, repo, name, true) | |
| 42 | : verb === "open" ? open(ctx.app, user, repo, name) | |
| 43 | : n === undefined ? err<ChapterActError>("not_found") | |
outdated
|
||
| 44 | : fill(ctx.app, user, repo, name, n, verb === "add"); | |
| 45 | return done.ok ? good({ done: true, chapter: name, action: verb, ...(n === undefined ? {} : { number: n }) }) : refused(done.error); | |
| 46 | } | |
| 23 | 47 | if (args["view"] === undefined && args["chapter"] !== undefined) return bad("invalid", "`chapter` needs `view: chapters`"); |
| 24 | 48 | if (args["view"] !== undefined) { |
| 25 | 49 | if (args["view"] !== "chapters") return bad("invalid", "`view` is \"chapters\""); |
test/conversation-confirm.test.ts +1 −1
| @@ -125,7 +125,7 @@ describe("the one list of what asks first", () => { | ||
| 125 | 125 | "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "feed_chips", "feed_dismiss", "feed_like", "flag_set", "flag_winner", |
| 126 | 126 | "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "knobs", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app", |
| 127 | 127 | "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports", |
| 128 | "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates", | |
| 128 | "repo", "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates", | |
| 129 | 129 | "teams", "tell", "threads", "trail", "upstream", "variant", "wait_for", "watch", "whoami_can", "write_file", |
| 130 | 130 | ]); |
| 131 | 131 |
test/mcp-chapters-act.test.ts added +212
| @@ -0,0 +1,212 @@ | ||
| 1 | /** `repo` with `action`: open, fill and close a chapter through the MCP, previewed, receipted and refused in the page's words. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { CHAPTER_WORDS, close, draft, fill, open } from "../src/actions/chapters.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { createIssue } from "../src/core/issues.ts"; | |
| 10 | import { getChapter, itemsOf, listChapters } from "../src/core/chapters.ts"; | |
| 11 | import { getRepo } from "../src/core/repos.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | import { runGit } from "../src/git/env.ts"; | |
| 15 | import { repoDir } from "../src/git/repos.ts"; | |
| 16 | import { commitFiles } from "../src/git/write.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | ||
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let alice: string; | |
| 23 | let aliceRo: string; | |
| 24 | let bob: string; | |
| 25 | let bot: string; | |
| 26 | let nums: number[] = []; | |
| 27 | let n = 1; | |
| 28 | ||
| 29 | const call = async (token: string, args: unknown) => { | |
| 30 | const res = await fetch(`${servers.url}/mcp`, { | |
| 31 | method: "POST", | |
| 32 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 33 | body: JSON.stringify({ jsonrpc: "2.0", id: n++, method: "tools/call", params: { name: "repo", arguments: args } }), | |
| 34 | }); | |
| 35 | const body: any = await res.json(); | |
| 36 | const text: string = body.result.content[0].text; | |
| 37 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 38 | }; | |
| 39 | const repo = () => getRepo(app.db, "alice", "pub")!; | |
| 40 | const state = () => JSON.stringify(listChapters(app.db, repo().id).map((c) => [c.name, c.state, itemsOf(app.db, c).map((i) => i.number)])); | |
| 41 | ||
| 42 | beforeAll(async () => { | |
| 43 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-chapters-act-")); | |
| 44 | app = createApp({ dataDir, port: 0 }); | |
| 45 | servers = await serve(app); | |
| 46 | const mk = async (username: string, first = false) => { | |
| 47 | const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 48 | if (!u.ok) throw new Error(u.error); | |
| 49 | return u.value; | |
| 50 | }; | |
| 51 | const a = await mk("alice", true); | |
| 52 | const b = await mk("bob"); | |
| 53 | alice = createToken(app.db, a.id, "a", "admin").token; | |
| 54 | aliceRo = createToken(app.db, a.id, "r", "ro").token; | |
| 55 | bob = createToken(app.db, b.id, "b", "admin").token; | |
| 56 | const agent = await createUser(app.db, { origin: "agent", agent: true, username: "robo", email: "[email protected]" }); | |
| 57 | if (agent.ok) app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(agent.value.id); | |
| 58 | if (!agent.ok) throw new Error(agent.error); | |
| 59 | bot = createToken(app.db, agent.value.id, "x", "admin").token; | |
| 60 | const made = await createRepo(app, a, { name: "pub", visibility: "public" }); | |
| 61 | if (!made.ok) throw new Error(String(made.error)); | |
| 62 | const r = repo(); | |
| 63 | const c = await commitFiles(repoDir(app.reposRoot, "alice", "pub"), { | |
| 64 | branch: r.defaultBranch, message: "first", files: [{ path: "README.md", content: "hi" }], author: { name: "T", email: "[email protected]" }, | |
| 65 | }); | |
| 66 | if (!c.ok) throw new Error("commit"); | |
| 67 | for (const t of ["one", "two"]) { | |
| 68 | const i = createIssue(app.db, { repoId: r.id, authorId: a.id, title: t, body: "" }); | |
| 69 | if (!i.ok) throw new Error("issue"); | |
| 70 | nums.push(i.value.number); | |
| 71 | } | |
| 72 | expect(draft(app, a, r, { name: "v1" }).ok).toBe(true); | |
| 73 | }, 30_000); | |
| 74 | ||
| 75 | afterAll(async () => { | |
| 76 | servers.stop(); | |
| 77 | app.close(); | |
| 78 | await rm(dataDir, { recursive: true, force: true }); | |
| 79 | }); | |
| 80 | ||
| 81 | test("an unconfirmed act says what it would do and changes nothing", async () => { | |
| 82 | const before = state(); | |
| 83 | const r = await call(alice, { repo: "alice/pub", action: "open", chapter: "v1" }); | |
| 84 | expect(r.value.done).toBe(false); | |
outdated
Assert the refusal words for the ro token, not just |
||
| 85 | expect(r.value.says).toContain("Would open v1"); | |
| 86 | const f = await call(aliceRo, { repo: "alice/pub", action: "add", chapter: "v1", number: nums[0] }); | |
| 87 | expect(f.value.says).toContain(`Would add #${nums[0]} to v1`); | |
| 88 | expect(state()).toBe(before); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a non-owner is refused in the page's words, confirmed or not", async () => { | |
| 92 | for (const confirm of [false, true]) { | |
| 93 | const r = await call(bob, { repo: "alice/pub", action: "open", chapter: "v1", confirm }); | |
| 94 | expect(r.error).toContain(CHAPTER_WORDS.forbidden); | |
| 95 | } | |
| 96 | expect((await call(bob, { repo: "alice/pub", action: "close", chapter: "v1" })).error).toContain(CHAPTER_WORDS.forbidden); | |
| 97 | expect(listChapters(app.db, repo().id)[0]!.state).toBe("draft"); | |
| 98 | const ro = await call(aliceRo, { repo: "alice/pub", action: "open", chapter: "v1", confirm: true }); | |
| 99 | expect(ro.error).toBe("forbidden: this token may read, not act"); | |
| 100 | expect(listChapters(app.db, repo().id)[0]!.state).toBe("draft"); | |
| 101 | }); | |
| 102 | ||
| 103 | test("confirmed open and fill land; a refusal uses CHAPTER_WORDS", async () => { | |
| 104 | expect((await call(alice, { repo: "alice/pub", action: "open", chapter: "v1", confirm: true })).value.done).toBe(true); | |
| 105 | expect((await call(alice, { repo: "alice/pub", action: "open", chapter: "v1", confirm: true })).error).toContain(CHAPTER_WORDS.already_open); | |
| 106 | for (const num of nums) expect((await call(alice, { repo: "alice/pub", action: "add", chapter: "v1", number: num, confirm: true })).value.done).toBe(true); | |
| 107 | expect(itemsOf(app.db, getChapter(app.db, repo().id, "v1")!).length).toBe(2); | |
| 108 | expect((await call(alice, { repo: "alice/pub", action: "remove", chapter: "v1", number: nums[1], confirm: true })).value.done).toBe(true); | |
| 109 | expect((await call(alice, { repo: "alice/pub", action: "add", chapter: "v1", number: 999, confirm: true })).error).toContain(CHAPTER_WORDS.no_issue); | |
| 110 | expect((await call(alice, { repo: "alice/pub", action: "add", chapter: "v1", confirm: true })).error).toContain("number"); | |
| 111 | }); | |
| 112 | ||
| 113 | test("an unconfirmed close names the tag and what moves, and changes nothing; confirmed close lands it", async () => { | |
| 114 | expect((await call(alice, { repo: "alice/pub", action: "add", chapter: "v1", number: nums[1], confirm: true })).value.done).toBe(true); | |
| 115 | const before = state(); | |
| 116 | const seen = await call(alice, { repo: "alice/pub", action: "close", chapter: "v1" }); | |
| 117 | expect(seen.value.done).toBe(false); | |
| 118 | expect(seen.value.tag).toBe("v1"); | |
| 119 | expect(seen.value.into).toBe("v2"); | |
| 120 | expect(seen.value.moves.map((m: any) => m.number)).toEqual([...nums]); | |
| 121 | expect(state()).toBe(before); | |
| 122 | expect(getChapter(app.db, repo().id, "v1")!.state).toBe("open"); | |
| 123 | expect((await runGit(["rev-parse", "--verify", "--quiet", "refs/tags/v1"], { cwd: repoDir(app.reposRoot, "alice", "pub") })).ok).toBe(false); | |
| 124 | ||
| 125 | const done = await call(alice, { repo: "alice/pub", action: "close", chapter: "v1", confirm: true }); | |
| 126 | expect(done.value.done).toBe(true); | |
| 127 | const c = getChapter(app.db, repo().id, "v1")!; | |
| 128 | expect(c.state).toBe("closed"); | |
| 129 | const next = getChapter(app.db, repo().id, "v2")!; | |
| 130 | expect(itemsOf(app.db, next).map((i) => i.number).sort()).toEqual([...nums].sort()); | |
| 131 | expect((await runGit(["rev-parse", "--verify", "--quiet", "refs/tags/v1"], { cwd: repoDir(app.reposRoot, "alice", "pub") })).ok).toBe(true); | |
| 132 | expect((await call(alice, { repo: "alice/pub", action: "close", chapter: "v1" })).error).toContain(CHAPTER_WORDS.closed); | |
| 133 | }); | |
| 134 | ||
| 135 | test("an agent that administers the repository is refused in the page's words, previewed or confirmed", async () => { | |
| 136 | for (const confirm of [false, true]) { | |
| 137 | const r = await call(bot, { repo: "alice/pub", action: "add", chapter: "v2", number: nums[0], confirm }); | |
| 138 | expect(r.error).toContain(CHAPTER_WORDS.forbidden); | |
| 139 | } | |
| 140 | expect((await call(bot, { repo: "alice/pub", action: "close", chapter: "v2", confirm: true })).error).toContain(CHAPTER_WORDS.forbidden); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a preview refuses what the act refuses, for open, add and remove", async () => { | |
| 144 | const cases: [string, number | undefined][] = [["open", undefined], ["add", nums[0]], ["remove", nums[0]], ["add", 999], ["remove", nums[1]]]; | |
| 145 | for (const chapter of ["v1", "v2", "v9"]) { | |
| 146 | for (const [action, number] of cases) { | |
| 147 | const args = { repo: "alice/pub", action, chapter, ...(number === undefined ? {} : { number }) }; | |
| 148 | const seen = await call(alice, args); | |
| 149 | const did = await call(alice, { ...args, confirm: true }); | |
| 150 | const at = `${action} ${chapter} ${number ?? ""}: ${seen.error ?? "previewed"} vs ${did.error ?? "done"}`; | |
| 151 | expect([at, seen.error === undefined]).toEqual([at, did.error === undefined]); | |
| 152 | if (seen.error !== undefined) expect(seen.error).toEqual(did.error as string); | |
| 153 | } | |
| 154 | } | |
| 155 | }); | |
| 156 | ||
| 157 | test("an unfinished chapter is refused by the page's close, and a taken tag by the MCP's, each in its words", async () => { | |
| 158 | const r = repo(); | |
| 159 | const a = getUserByName(app.db, "alice")!; | |
| 160 | expect(getChapter(app.db, r.id, "v2")!.state).toBe("open"); | |
| 161 | expect(fill(app, a, r, "v2", nums[0]!, true).ok).toBe(true); | |
| 162 | const unfinished = await close(app, a, r, "v2", false); | |
| 163 | expect(!unfinished.ok && unfinished.error).toBe("unfinished"); | |
| 164 | expect(CHAPTER_WORDS.unfinished).toContain("Items are still open"); | |
| 165 | const dir = repoDir(app.reposRoot, "alice", "pub"); | |
| 166 | const tip = await runGit(["rev-parse", `refs/heads/${r.defaultBranch}`], { cwd: dir }); | |
| 167 | if (!tip.ok) throw new Error("tip"); | |
| 168 | await runGit(["update-ref", "refs/tags/v2", tip.value.trim(), ""], { cwd: dir }); | |
| 169 | const before = state(); | |
| 170 | for (const confirm of [false, true]) { | |
| 171 | const taken = await call(alice, { repo: "alice/pub", action: "close", chapter: "v2", confirm }); | |
| 172 | expect(taken.error).toContain(CHAPTER_WORDS.tag_exists); | |
| 173 | } | |
| 174 | expect(state()).toBe(before); | |
| 175 | expect(getChapter(app.db, r.id, "v2")!.state).toBe("open"); | |
| 176 | }); | |
| 177 | ||
| 178 | test("closing through the MCP leaves what the page's close leaves", async () => { | |
| 179 | const a = getUserByName(app.db, "alice")!; | |
| 180 | const made: string[] = []; | |
| 181 | for (const name of ["twin", "mcp"]) { | |
| 182 | const rp = await createRepo(app, a, { name, visibility: "public" }); | |
| 183 | if (!rp.ok) throw new Error(String(rp.error)); | |
| 184 | const r = getRepo(app.db, "alice", name)!; | |
| 185 | const c = await commitFiles(repoDir(app.reposRoot, "alice", name), { | |
| 186 | branch: r.defaultBranch, message: "first", files: [{ path: "README.md", content: "hi" }], author: { name: "T", email: "[email protected]" }, | |
| 187 | }); | |
| 188 | if (!c.ok) throw new Error("commit"); | |
| 189 | expect(draft(app, a, r, { name: "v1" }).ok).toBe(true); | |
| 190 | expect(open(app, a, r, "v1").ok).toBe(true); | |
| 191 | for (const t of ["x", "y"]) { | |
| 192 | const i = createIssue(app.db, { repoId: r.id, authorId: a.id, title: t, body: "" }); | |
| 193 | if (!i.ok) throw new Error("issue"); | |
| 194 | expect(fill(app, a, r, "v1", i.value.number, true).ok).toBe(true); | |
| 195 | } | |
| 196 | made.push(name); | |
| 197 | } | |
| 198 | const shape = async (name: string) => { | |
| 199 | const r = getRepo(app.db, "alice", name)!; | |
| 200 | const tag = await runGit(["rev-parse", "--verify", "--quiet", "refs/tags/v1^{commit}"], { cwd: repoDir(app.reposRoot, "alice", name) }); | |
| 201 | const head = await runGit(["rev-parse", `refs/heads/${r.defaultBranch}`], { cwd: repoDir(app.reposRoot, "alice", name) }); | |
| 202 | return { | |
| 203 | chapters: listChapters(app.db, r.id).map((c) => [c.name, c.state, c.tagOid === null ? null : "tip", itemsOf(app.db, c).map((i) => i.title)]), | |
| 204 | tagAtTip: tag.ok && head.ok && tag.value.trim() === head.value.trim(), | |
| 205 | }; | |
| 206 | }; | |
| 207 | expect((await close(app, a, getRepo(app.db, "alice", "twin")!, "v1", true)).ok).toBe(true); | |
| 208 | expect((await call(alice, { repo: "alice/mcp", action: "close", chapter: "v1", confirm: true })).value.done).toBe(true); | |
| 209 | const page = await shape("twin"); | |
| 210 | expect(await shape("mcp")).toEqual(page); | |
| 211 | expect(page.tagAtTip).toBe(true); | |
| 212 | }); |
test/mcp-ro-refuses-writes.test.ts +1 −1
| @@ -43,7 +43,7 @@ const TRAFFIC = new Set(["route_timing", "usage_count"]); | ||
| 43 | 43 | /** Arguments that take a writing tool past every `plansOnly` door it has. */ |
| 44 | 44 | function writeArgs(name: string): Record<string, unknown> { |
| 45 | 45 | const tool = TOOLS.find((t) => t.name === name); |
| 46 | const args: Record<string, unknown> = { repo: REPO, dryRun: false, apply: true, action: "write" }; | |
| 46 | const args: Record<string, unknown> = { repo: REPO, dryRun: false, apply: true, action: "write", confirm: true }; | |
| 47 | 47 | for (const k of tool?.readsWithout === undefined ? [] : [tool.readsWithout].flat()) args[k] ??= "x"; |
| 48 | 48 | return args; |
| 49 | 49 | } |
test/mcp-write-scope.test.ts +1
| @@ -45,6 +45,7 @@ const ADMIN_ARGS: Record<string, Record<string, unknown>> = { | ||
| 45 | 45 | /** Valid arguments for a tool whose write is one call among reads and plans (`readsWhen`): the generic arguments would only read. */ |
| 46 | 46 | const WRITE_ARGS: Record<string, Record<string, unknown>> = { |
| 47 | 47 | flow: { kind: "drift", action: "update", repo: "alice/game", pull: 1 }, |
| 48 | repo: { repo: "alice/game", action: "open", chapter: "v1", confirm: true }, | |
| 48 | 49 | }; |
| 49 | 50 | |
| 50 | 51 | /** What `visible()` answers for a tool the token's scope does not open. */ |
test/talk-work.test.ts +2 −1
| @@ -191,7 +191,8 @@ test("a tool that changes an issue or pull says changed, not opened", () => { | ||
| 191 | 191 | }); |
| 192 | 192 | |
| 193 | 193 | test("the tools said to change an issue or pull are the ones that write by repo and number", () => { |
| 194 | const numbered = TOOLS.filter((t) => ["repo", "number"].every((k) => Object.keys(t.inputSchema.properties ?? {}).includes(k))); | |
| 194 | // `repo` writes by repo and number too, but a chapter's items, not an issue or pull. | |
| 195 | const numbered = TOOLS.filter((t) => t.name !== "repo" && ["repo", "number"].every((k) => Object.keys(t.inputSchema.properties ?? {}).includes(k))); | |
| 195 | 196 | expect(new Set(numbered.filter((t) => t.writes).map((t) => t.name))).toEqual(CHANGES); |
| 196 | 197 | }); |
| 197 | 198 |
new above
offline
Unreachable:
closePlanalready returns forbidden for an undefined actor. Remove.