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

Merged opened this

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

Merged

mason/forge:mason/11750-the-mcp-opens-a-chapter-fills-it-and-clo → main 6 commits fork 4 threads · 4 unresolved

  • Merged. mason/11750-the-mcp-opens-a-chapter-fills-it-and-clo was merged into main as 7bdee0a.

Review

Sign in to review this change.
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`;
178178 | `release_hold` | — | Hold or release the release keeper's cuts (admin) | 202 |
179179 | `release_keeper` | — | The release keeper's releases, holds and what blocks the next cut | 192 |
180180 | `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 |
182182 | `repo_labels` | — | Repo labels: list (default), create, update, remove (admin token) | 351 |
183183 | `repo_settings` | — | Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams | 444 |
184184 | `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
1313
1414 The MCP answers too: the `repo` tool with `view: "chapters"` lists a repository's chapters (name, what done means, state); adding `chapter: "v1"` names that chapter's items (`done`, `building` or `left`), its `progress` (the same sentence the page and the terminal print, from `progressSentence`) and `finishesOn`. `chapter` without `view` is `invalid`; a repository the caller cannot read is refused as the page refuses it.
1515
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
1618 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 {
1313 itemsOf,
1414 nextChapterOf,
1515 nextName,
16 chapterOfIssue,
1617 openChapterOf,
1718 patchName,
1819 placeNewIssue,
@@ -20,6 +21,7 @@ import {
2021 startChapter,
2122 takeIssueOut,
2223 type Chapter,
24 type Item,
2325 type ChapterError,
2426 } from "../core/chapters.ts";
2527 import { addDependency } from "../core/dependencies.ts";
@@ -45,31 +47,120 @@ export function draft(app: App, actor: User | undefined, repo: Repo, input: { na
4547 return made;
4648 }
4749
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> {
4952 if (!owner(app, actor, repo)) return err("forbidden");
5053 const chapter = getChapter(app.db, repo.id, name);
5154 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 } });
5465 return started;
5566 }
5667
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> {
5977 if (!owner(app, actor, repo)) return err("forbidden");
6078 const chapter = getChapter(app.db, repo.id, name);
6179 if (chapter === undefined) return err("not_found");
6280 const issue = getIssue(app.db, repo.id, number);
6381 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;
6494 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)
6696 : takeIssueOut(app.db, chapter, issue.id)
6797 ? ok(undefined)
6898 : 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 } });
70100 return done;
71101 }
72102
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}.` });
outdated

Unreachable: closePlan already returns forbidden for an undefined actor. Remove.

162 }
163
73164 /**
74165 * Close the book: tag the default branch's tip with the chapter's name, write the notes from what
75166 * 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(
83174 ship: boolean,
84175 now = new Date(),
85176 ): 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;
101180 if (!(await runGit(["update-ref", ref, tip, ""], { cwd: dir })).ok) return err("git");
102181
103182 const closedAt = now.toISOString();
@@ -113,7 +192,7 @@ export async function close(
113192 // What is left moves on to the next chapter, drafted when there is none.
114193 next = left.length === 0 ? undefined : nextChapterOf(app.db, repo.id);
115194 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);
117196 next = made.ok ? made.value : undefined;
118197 }
119198 const notes = chapterNotes(chapter.name, chapter.means, landed, left, next?.name, {
@@ -123,7 +202,7 @@ export async function close(
123202 doneCount: landed.length,
124203 costUsd,
125204 });
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);
127206 const closed = app.db
128207 .query("UPDATE chapter SET state = 'closed', closed_at = ?2, tag_oid = ?3, notes = ?4, cost_usd = ?5 WHERE id = ?1 AND state = 'open'")
129208 .run(chapter.id, closedAt, tip, notes, costUsd);
@@ -133,7 +212,7 @@ export async function close(
133212 await runGit(["update-ref", "-d", ref, tip], { cwd: dir });
134213 return err(getChapter(app.db, repo.id, name)?.state === "closed" ? "closed" : "git");
135214 }
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 }) } });
137216 return ok(getChapter(app.db, repo.id, name)!);
138217 }
139218
src/http/mcp/tools/repo.ts +27 −3
@@ -3,23 +3,47 @@ import { forkParentFor } from "../../../core/repos.ts";
33 import { audienceFor } from "../../../core/audience.ts";
44 import { upstreamFacts } from "../../../actions/upstream_follow.ts";
55 import { creditFacts } from "../../../actions/credit_facts.ts";
6 import { CHAPTER_WORDS, type ChapterActError, close, fill, open, preview } from "../../../actions/chapters.ts";
67 import { getChapter, itemsOf, listChapters, progressOf, progressSentence } from "../../../core/chapters.ts";
8 import { err } from "../../../kernel/result.ts";
79 import { good, str, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
810
911 export const tool: Tool = {
1012 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)",
1214 description:
1315 "One repository you can read, as GET /api/v1/repos/:owner/:name.",
1416 inputSchema: {
1517 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" } },
1719 required: ["repo"],
1820 },
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,
2024 async run(ctx, user, args) {
2125 const repo = repoArg(ctx, user, args);
2226 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

n! hides a type hole; narrow number once for add/remove and pass a typed value.

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 }
2347 if (args["view"] === undefined && args["chapter"] !== undefined) return bad("invalid", "`chapter` needs `view: chapters`");
2448 if (args["view"] !== undefined) {
2549 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", () => {
125125 "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "feed_chips", "feed_dismiss", "feed_like", "flag_set", "flag_winner",
126126 "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "knobs", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app",
127127 "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",
129129 "teams", "tell", "threads", "trail", "upstream", "variant", "wait_for", "watch", "whoami_can", "write_file",
130130 ]);
131131
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 toBeDefined().

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"]);
4343 /** Arguments that take a writing tool past every `plansOnly` door it has. */
4444 function writeArgs(name: string): Record<string, unknown> {
4545 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 };
4747 for (const k of tool?.readsWithout === undefined ? [] : [tool.readsWithout].flat()) args[k] ??= "x";
4848 return args;
4949 }
test/mcp-write-scope.test.ts +1
@@ -45,6 +45,7 @@ const ADMIN_ARGS: Record<string, Record<string, unknown>> = {
4545 /** Valid arguments for a tool whose write is one call among reads and plans (`readsWhen`): the generic arguments would only read. */
4646 const WRITE_ARGS: Record<string, Record<string, unknown>> = {
4747 flow: { kind: "drift", action: "update", repo: "alice/game", pull: 1 },
48 repo: { repo: "alice/game", action: "open", chapter: "v1", confirm: true },
4849 };
4950
5051 /** 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", () => {
191191 });
192192
193193 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)));
195196 expect(new Set(numbered.filter((t) => t.writes).map((t) => t.name))).toEqual(CHANGES);
196197 });
197198

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs