Public: anyone can read this; the crew can change it.
MCP `review`: approve a pull, or request changes with a reason, as the page does #3657
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3646-mcp-review-approve-a-pull-or-request-cha → main 2 commits fork 2 threads · 2 unresolved
4 files
changed +240
−0
docs/MCP.md +1
| @@ -58,6 +58,7 @@ for a tunnel and a forge that sleeps. | ||
| 58 | 58 | | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | | |
| 59 | 59 | | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes | |
| 60 | 60 | | `watch` | `{ app, issue, watch? }`: watch a milestone, or stop with `watch: false`: one item in your feed that follows it, and one push to your phone when it lands | yes | |
| 61 | | `review` | `{ repo, number, verdict, reason? }`: approve a pull (`approve`) or request changes (`changes`, with a `reason`), as the page does; a writer's or the crew's, and never your own pull | yes | | |
| 61 | 62 | | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | | |
| 62 | 63 | | `labels` | `{ repo, number, labels }`: set an issue's or a pull's labels to exactly these (`[]` clears); only labels the repository has, by a writer | yes | |
| 63 | 64 | | `link` | `{ app, issue, blockedBy }`: the issue waits for another; loops and cross-repository are refused | yes | |
src/http/mcp.ts +57
| @@ -53,6 +53,7 @@ import { | ||
| 53 | 53 | linkIssue, |
| 54 | 54 | unlinkIssue, |
| 55 | 55 | } from "../actions/dependencies.ts"; |
| 56 | import { submitReview } from "../actions/pulls.ts"; | |
| 56 | 57 | import { originError } from "../web/pages/thread.ts"; |
| 57 | 58 | import { holdPull, releasePull } from "../actions/holds.ts"; |
| 58 | 59 | import { openPlan, parsePlan, PLAN_MAX, proposePlan } from "../actions/plan.ts"; |
| @@ -2348,6 +2349,62 @@ const TOOLS: readonly Tool[] = [ | ||
| 2348 | 2349 | : bad("not_found", "no such app of yours (owner/name)"); |
| 2349 | 2350 | }, |
| 2350 | 2351 | }, |
| 2352 | { | |
| 2353 | name: "review", | |
| 2354 | description: | |
| 2355 | "Approve a pull, or request changes with a reason (`reason` is required for changes); approving your own pull, or a verdict without write access, is refused as on the page.", | |
| 2356 | inputSchema: { | |
| 2357 | type: "object", | |
| 2358 | properties: { | |
| 2359 | repo: REPO_ARG, | |
| 2360 | number: { type: "integer", minimum: 1 }, | |
| 2361 | verdict: { type: "string", enum: ["approve", "changes"] }, | |
| 2362 | reason: { type: "string", maxLength: 65536 }, | |
| 2363 | }, | |
| 2364 | required: ["repo", "number", "verdict"], | |
| 2365 | }, | |
| 2366 | writes: true, | |
| 2367 | async run(ctx, user, args) { | |
| 2368 | const repo = repoArg(ctx, user, args); | |
| 2369 | if (isAnswer(repo)) return repo; | |
| 2370 | const verdict = args["verdict"]; | |
| 2371 | if (verdict !== "approve" && verdict !== "changes") | |
| 2372 | return bad("invalid", "verdict is approve or changes"); | |
| 2373 | const reason = str(args["reason"]); | |
| 2374 | if (verdict === "changes" && reason === "") | |
| 2375 | return bad("invalid", "say what to change (reason)"); | |
| 2376 | const n = args["number"]; | |
| 2377 | const pull = | |
| 2378 | typeof n === "number" && Number.isInteger(n) && n > 0 | |
| 2379 | ? getPull(ctx.app.db, repo.id, n) | |
| 2380 | : undefined; | |
| 2381 | if (pull === undefined || !mayView(ctx.app.db, repo, pull, user)) | |
| 2382 | return bad("no_such_pull", `no pull #${String(n)}`); | |
| 2383 | const state = verdict === "approve" ? "approved" : "changes_requested"; | |
| 2384 | const sent = await submitReview(ctx.app, user, repo, pull, { | |
| 2385 | state, | |
| 2386 | ...(reason === "" ? {} : { body: reason }), | |
| 2387 | }); | |
| 2388 | if (!sent.ok) { | |
| 2389 | const error = sent.error; | |
| 2390 | return bad( | |
| 2391 | "refused", | |
| 2392 | originError(error) ?? | |
| 2393 | (error === "self_review" | |
| 2394 | ? "You cannot approve your own pull request." | |
| 2395 | : error === "forbidden" | |
| 2396 | ? "A verdict that gates a merge needs write access." | |
| 2397 | : error === "body_too_long" | |
| 2398 | ? "That review is too long (64 KB at most)." | |
| 2399 | : "That review could not be recorded."), | |
| 2400 | ); | |
| 2401 | } | |
| 2402 | return good({ | |
| 2403 | says: verdict === "approve" ? "Approved." : "Changes requested.", | |
| 2404 | url: `/${repo.ownerName}/${repo.name}/pulls/${pull.number}`, | |
| 2405 | }); | |
| 2406 | }, | |
| 2407 | }, | |
| 2351 | 2408 | { |
| 2352 | 2409 | name: "spend", |
| 2353 | 2410 | description: |
test/mcp-review.test.ts added +181
| @@ -0,0 +1,181 @@ | ||
| 1 | /** | |
| 2 | * The `review` tool over MCP: the page's verdict, with the page's gate — | |
| 3 | * a writer's approval lands, a reader's is refused in the page's words, an | |
| 4 | * author cannot approve their own pull, and bad input stops at the boundary. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { openPull } from "../src/actions/pulls.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { listReviews } from "../src/core/reviews.ts"; | |
| 14 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { promote } from "./helpers/members.ts"; | |
| 18 | ||
| 19 | describe("the review tool over MCP", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let alice: User; | |
| 24 | let bob: User; | |
| 25 | let aliceRw: string; | |
| 26 | let bobRw: string; | |
| 27 | let nextId = 1; | |
| 28 | ||
| 29 | const tool = async (token: string, args: unknown) => { | |
| 30 | const res = await fetch(`${servers.url}/mcp`, { | |
| 31 | method: "POST", | |
| 32 | headers: { | |
| 33 | "content-type": "application/json", | |
| 34 | accept: "application/json, text/event-stream", | |
| 35 | authorization: `Bearer ${token}`, | |
| 36 | }, | |
| 37 | body: JSON.stringify({ | |
| 38 | jsonrpc: "2.0", | |
| 39 | id: nextId++, | |
| 40 | method: "tools/call", | |
| 41 | params: { name: "review", arguments: args }, | |
| 42 | }), | |
| 43 | }); | |
| 44 | const body = (await res.json()) as any; | |
| 45 | const text: string = body.result.content[0].text; | |
| 46 | return body.result.isError === true | |
| 47 | ? { error: text } | |
| 48 | : { value: JSON.parse(text) }; | |
| 49 | }; | |
| 50 | ||
| 51 | /** A pull on alice/game, its author set to `author`. */ | |
| 52 | const pullBy = async (branch: string, author: User) => { | |
| 53 | const repo = (await import("../src/core/repos.ts")).getRepo( | |
| 54 | app.db, | |
| 55 | "alice", | |
| 56 | "game", | |
| 57 | )!; | |
| 58 | const made = await commitToBranch(app, alice, repo, { | |
| 59 | branch, | |
| 60 | message: branch, | |
| 61 | files: [{ path: `${branch}.txt`, content: `${branch}\n` }], | |
| 62 | }); | |
| 63 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 64 | const opened = await openPull(app, alice, repo, { | |
| 65 | title: branch, | |
| 66 | baseBranch: "main", | |
| 67 | headBranch: branch, | |
| 68 | }); | |
| 69 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 70 | app.db | |
| 71 | .query("UPDATE issue SET author_id = ?1 WHERE id = ?2") | |
| 72 | .run(author.id, opened.value.id); | |
| 73 | return opened.value; | |
| 74 | }; | |
| 75 | ||
| 76 | beforeAll(async () => { | |
| 77 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-review-")); | |
| 78 | app = createApp({ dataDir, port: 0 }); | |
| 79 | servers = await serve(app); | |
| 80 | const mk = async (username: string, first = false) => { | |
| 81 | const made = await createUser(app.db, { | |
| 82 | origin: "seed", | |
| 83 | trusted: true, | |
| 84 | username, | |
| 85 | email: `${username}@forge.local`, | |
| 86 | password: "a-good-passphrase", | |
| 87 | adminIfFirst: first, | |
| 88 | }); | |
| 89 | if (!made.ok) throw new Error(made.error); | |
| 90 | return made.value; | |
| 91 | }; | |
| 92 | alice = await mk("alice", true); | |
| 93 | bob = await mk("bob"); | |
| 94 | promote(app.db, alice, bob); | |
| 95 | aliceRw = createToken(app.db, alice.id, "rw", "rw").token; | |
| 96 | bobRw = createToken(app.db, bob.id, "rw", "rw").token; | |
| 97 | const made = await createRepo(app, alice, { | |
| 98 | name: "game", | |
| 99 | visibility: "public", | |
| 100 | }); | |
| 101 | if (!made.ok) throw new Error(String(made.error)); | |
| 102 | await commitToBranch(app, alice, made.value, { | |
| 103 | message: "seed", | |
| 104 | files: [{ path: "README.md", content: "# game\n" }], | |
| 105 | }); | |
| 106 | }, 30_000); | |
| 107 | ||
| 108 | afterAll(async () => { | |
| 109 | servers.stop(); | |
| 110 | app.close(); | |
| 111 | await rm(dataDir, { recursive: true, force: true }); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a writer approves, and requests changes with a reason", async () => { | |
| 115 | const pull = await pullBy("one", bob); | |
| 116 | const approved = await tool(aliceRw, { | |
| 117 | repo: "alice/game", | |
| 118 | number: pull.number, | |
| 119 | verdict: "approve", | |
| 120 | }); | |
| 121 | expect(approved.value).toEqual({ | |
| 122 | says: "Approved.", | |
| 123 | url: `/alice/game/pulls/${pull.number}`, | |
| 124 | }); | |
| 125 | const asked = await tool(aliceRw, { | |
| 126 | repo: "alice/game", | |
| 127 | number: pull.number, | |
| 128 | verdict: "changes", | |
| 129 | reason: "Add a test.", | |
| 130 | }); | |
| 131 | expect(asked.value.says).toBe("Changes requested."); | |
| 132 | expect(listReviews(app.db, pull.id).map((r) => [r.state, r.body])).toEqual([ | |
| 133 | ["approved", ""], | |
| 134 | ["changes_requested", "Add a test."], | |
| 135 | ]); | |
| 136 | }); | |
| 137 | ||
| 138 | test("a reader's verdict is refused with the page's words", async () => { | |
| 139 | const pull = await pullBy("two", alice); | |
| 140 | const refused = await tool(bobRw, { | |
| 141 | repo: "alice/game", | |
| 142 | number: pull.number, | |
| 143 | verdict: "approve", | |
| 144 | }); | |
| 145 | expect(refused.error).toContain( | |
| 146 | "A verdict that gates a merge needs write access.", | |
| 147 | ); | |
| 148 | expect(listReviews(app.db, pull.id)).toEqual([]); | |
| 149 | }); | |
| 150 | ||
| 151 | test("an author cannot approve their own pull", async () => { | |
| 152 | const pull = await pullBy("three", alice); | |
| 153 | const refused = await tool(aliceRw, { | |
| 154 | repo: "alice/game", | |
| 155 | number: pull.number, | |
| 156 | verdict: "approve", | |
| 157 | }); | |
| 158 | expect(refused.error).toContain("You cannot approve your own pull request."); | |
| 159 | }); | |
| 160 | ||
| 161 | test("bad input is refused at the boundary", async () => { | |
| 162 | const pull = await pullBy("four", bob); | |
| 163 | const base = { repo: "alice/game", number: pull.number }; | |
| 164 | expect((await tool(aliceRw, { ...base, verdict: "lgtm" })).error).toContain( | |
| 165 | "verdict is approve or changes", | |
| 166 | ); | |
| 167 | expect( | |
| 168 | (await tool(aliceRw, { ...base, verdict: "changes", reason: " " })).error, | |
| 169 | ).toContain("say what to change"); | |
| 170 | expect( | |
| 171 | (await tool(aliceRw, { ...base, number: 9999, verdict: "approve" })).error, | |
| 172 | ).toContain("no pull #9999"); | |
| 173 | expect( | |
| 174 | (await tool(aliceRw, { ...base, number: "1", verdict: "approve" })).error, | |
| 175 | ).toContain("no pull"); | |
| 176 | expect( | |
| 177 | (await tool(aliceRw, { repo: "nope", number: 1, verdict: "approve" })).error, | |
| 178 | ).toContain("not_found"); | |
| 179 | expect(listReviews(app.db, pull.id)).toEqual([]); | |
| 180 | }); | |
| 181 | }); |
test/mcp.test.ts +1
| @@ -221,6 +221,7 @@ describe("the forge over MCP", () => { | ||
| 221 | 221 | "watch", |
| 222 | 222 | "limb", |
| 223 | 223 | "returns", |
| 224 | "review", | |
| 224 | 225 | "spend", |
| 225 | 226 | "usage", |
| 226 | 227 | "labels", |
new above
offline