Public: anyone can read this; the crew can change it.

MCP `assign`: set or clear an issue's or a pull's assignee, with the same rules as the API #5356

Merged opened this

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

Merged

scout/forge:scout/5317-mcp-assign-set-or-clear-an-issue-s-or-a → main 7 commits fork 5 threads · 5 unresolved

  • Merged. scout/5317-mcp-assign-set-or-clear-an-issue-s-or-a was merged into main as c3787d0.

Review

Sign in to review this change.
29 files changed +245 −24
docs/MCP.md +1
@@ -41,6 +41,7 @@ for a tunnel and a forge that sleeps.
4141 | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | |
4242 | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes |
4343 | `asked` | `{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write | |
44 | `assign` | `{ repo, number, kind, assignee }`: assign an issue or pull (`kind` is `issue` or `pull`) to a user, or clear it with `null`, as `PUT …/assignee` and with its refusals | yes |
4445 | `assigned` | `{ limit? }`: the open issues and pulls assigned to you in every repository you can still read (repository, number, title, kind, state), as `GET /api/v1/user/assigned` | |
4546 | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`) | |
4647 | `board_delete` | `{ slug }` (person admins): delete a board by slug; its history keeps it | yes |
src/http/mcp/tools/asks_act.ts +1 −1
@@ -4,7 +4,7 @@ import { good, bad, type Tool } from "../shared.ts";
44 export const tool: Tool = {
55 name: "asks_act",
66 description:
7 "Decide asks (`items`: ask ids, `issue:<id>`): dismiss, decline, ship (final), close (`reason`), mute, replan",
7 "Decide asks (`items`): dismiss, decline, ship, close (`reason`), mute, replan",
88 inputSchema: {
99 type: "object",
1010 properties: {
src/http/mcp/tools/asks_decide.ts +1 −1
@@ -4,7 +4,7 @@ import { good, bad, type Tool } from "../shared.ts";
44 export const tool: Tool = {
55 name: "asks_decide",
66 description:
7 "Let the decider answer a hand-over (`item`: `parked:<id>`); only a sure set_aside (70%+) clears it.",
7 "Let the decider answer a hand-over (`item`: `parked:<id>`); only a sure set_aside clears it.",
88 inputSchema: {
99 type: "object",
1010 properties: { item: { type: "string", maxLength: 80 } },
src/http/mcp/tools/assign.ts added +59
@@ -0,0 +1,59 @@
1 import { getUserByName } from "../../../core/users.ts";
2 import { ARCHIVED_WHY } from "../../../core/repos.ts";
3 import { NOT_A_MEMBER } from "../../../core/roles.ts";
4 import { assignIssue } from "../../../actions/issues.ts";
5 import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "assign",
9 doc: "`{ repo, number, kind, assignee }`: assign an issue or pull (`kind` is `issue` or `pull`) to a user, or clear it with `null`, as `PUT …/assignee` and with its refusals",
10 description: "Assign an issue or pull to a user, or clear (null); PUT refusals apply.",
11 inputSchema: {
12 type: "object",
13 properties: {
14 repo: REPO_ARG,
15 number: { type: "integer", minimum: 1 },
16 kind: { type: "string", enum: ["issue", "pull"] },
17 assignee: { type: ["string", "null"] },

Low: assignee has no maxLength. Other string arguments here are bounded, and the name is echoed back in no account named ${name}. The request body cap limits the damage, but maxLength: 64 (or the username limit) would make the schema say what it accepts. Optional.

18 },
19 required: ["repo", "number", "kind", "assignee"],
20 },
21 writes: true,
22 run(ctx, user, args) {
23 const repo = repoArg(ctx, user, args);
24 if (isAnswer(repo)) return repo;
25 const kind = args["kind"];
26 if (kind !== "issue" && kind !== "pull") return bad("invalid", "kind is issue or pull");
27 const name = args["assignee"];
28 if (name !== null && typeof name !== "string")
29 return bad("invalid_assignee", "assignee must be a username or null");
30 const issue = issueArg(ctx, repo, args, "number");
31 if ("ok" in issue) return issue;
32 if (issue.kind !== kind) return bad("no_such_issue", `no ${kind} #${issue.number}`);
33 const assignee = name === null || name === "" ? null : getUserByName(ctx.app.db, name);
34 if (assignee === undefined) return bad("no_such_user", `no account named ${name}`);
35 const done = assignIssue(ctx.app, user, repo, issue, assignee);
36 if (!done.ok) {
37 const e = done.error;
38 if (e === "not_found") return bad("not_found", "no such app or repository (owner/name)");
39 if (e === "unverified") return bad(e, "verify your email first");
40 if (e === "archived") return bad(e, ARCHIVED_WHY);
41 if (e === "not_member") return bad(e, NOT_A_MEMBER);
42 if (e === "assignee_cannot_read") return bad(e, "the assignee cannot read this repository");
43 if (e === "agent_not_on_crew") return bad(e, "that agent is not on this repository's crew");
44 if (user.kind === "agent" && e === "forbidden")
45 return bad(e, "an agent may not assign; that is a person's call");
46 if (e === "forbidden" || e === "unauthorized")
47 return bad(e, "your credentials lack the scope for this action");
48 return bad(e, "that assignment could not be made");
49 }
50 return good({
51 says: assignee
52 ? `#${issue.number} is assigned to ${assignee.username}.`
53 : `#${issue.number} is unassigned.`,
54 number: issue.number,
55 kind,
56 assignee: assignee?.username ?? null,
57 });
58 },
59 };
src/http/mcp/tools/decisions_by.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "decisions_by",
88 doc: "`{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent",
99 description:
10 "Who decides a repository's parked hand-over cards: `me`, `decider`, `decider_tell`. No `by` reads it.",
10 "Who decides parked hand-over cards: `me`, `decider`, `decider_tell`. No `by` reads.",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/egress_grants.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "egress_grants",
88 doc: "`{ repo, op?, destination? }`: the private `host:port` destinations an app's forge.json asks for and which are granted; `op` grant or revoke by a site admin (never an agent), and every op needs an admin-scope token; the same as the settings page and `/api/v1/repos/:owner/:name/egress-grants`",
99 description:
10 "An app's private destinations and their grants: list, grant or revoke (site admin). See docs/MCP.md.",
10 "Private destinations and grants: list, grant or revoke (site admin).",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/flow.ts +1 −1
@@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts";
55 export const tool: Tool = {
66 name: "flow",
77 description:
8 "Pipeline (forge admin): stage counts, `oldestWaiting`, `capacity`, bottleneck, `queue`, `reviewing`.",
8 "Pipeline (forge admin): stage counts, `oldestWaiting`, `capacity`, `queue`.",
99 inputSchema: { type: "object", properties: {} },
1010 writes: false,
1111 run(ctx, user) {
src/http/mcp/tools/follow_mode.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "follow_mode",
77 doc: "`{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only",
88 description:
9 "How a spawned forge follows its parent: `tell-me` or `take-updates`. Its `forge` repository's admin.",
9 "How a spawned forge follows its parent: `tell-me` or `take-updates`.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/front_page.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "front_page",
77 doc: "`{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10)",
88 description:
9 "The front page as a stranger sees it; a site admin also gets and sets `pinned`, `hidden`, `weights`.",
9 "The front page as a stranger sees it; a site admin gets and sets `pinned`, `hidden`, `weights`.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/hooks.ts +1 −1
@@ -20,7 +20,7 @@ const notAdmin = (): Answer => bad("not_found", "no such app or repository, or y
2020 export const tool: Tool = {
2121 name: "hooks",
2222 doc: "`{ repo, action, url, secret, kinds, id }`: `list`, `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; an admin-scope token on a repository you administer, and a secret is never shown",
23 description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) a repository's webhooks; admins; a secret is never shown.",
23 description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) webhooks; admins.",
2424 inputSchema: {
2525 type: "object",
2626 properties: {
src/http/mcp/tools/index.ts +1
@@ -17,6 +17,7 @@ export { tool as asks_act } from "./asks_act.ts";
1717 export { tool as asks_decide } from "./asks_decide.ts";
1818 export { tool as asks_proposal } from "./asks_proposal.ts";
1919 export { tool as asks_undecide } from "./asks_undecide.ts";
20 export { tool as assign } from "./assign.ts";
2021 export { tool as assigned } from "./assigned.ts";
2122 export { tool as attach_look } from "./attach_look.ts";
2223 export { tool as audit } from "./audit.ts";
src/http/mcp/tools/issue.ts +1 −1
@@ -10,7 +10,7 @@ export const tool: Tool = {
1010 name: "issue",
1111 doc: "`{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found",
1212 description:
13 "One issue with comments, `blockedBy`, `blocks`; crew agents get `untrusted: true` on non-writers' text.",
13 "One issue with comments, `blockedBy`, `blocks`; non-writers' text is `untrusted`.",
1414 inputSchema: {
1515 type: "object",
1616 properties: {
src/http/mcp/tools/map.ts +1 −1
@@ -10,7 +10,7 @@ export const tool: Tool = {
1010 name: "map",
1111 doc: "an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`); draws the `ui://forge/map` card",
1212 description:
13 "An app's plan: open issues ready or waiting, critical path, edges, where the crew is, `parkedTwice`.",
13 "An app's plan: open issues ready or waiting, critical path, edges, crew, `parkedTwice`.",
1414 inputSchema: {
1515 type: "object",
1616 properties: { app: APP_ARG },
src/http/mcp/tools/model_ceiling.ts +1 −1
@@ -6,7 +6,7 @@ import { good, bad, type Tool, mayBoards } from "../shared.ts";
66 export const tool: Tool = {
77 name: "model_ceiling",
88 description:
9 "Set an account's daily and per-run model spend ceilings in dollars; a run past one does not start.",
9 "Set an account's daily and per-run model spend ceilings in dollars.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/new_app.ts +1 −1
@@ -11,7 +11,7 @@ export const tool: Tool = {
1111 name: "new_app",
1212 doc: "`{ name, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken)",
1313 description:
14 "Make an app from a sentence: a repository, production started, and with `description` a first issue.",
14 "Make an app from a sentence: a repository, production, and with `description` a first issue.",
1515 inputSchema: {
1616 type: "object",
1717 properties: {
src/http/mcp/tools/now.ts +1 −1
@@ -15,7 +15,7 @@ export const tool: Tool = {
1515 name: "now",
1616 doc: "what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`)",
1717 description:
18 "What the crew is doing across your apps: who, on what, since when, cost; what waits on your review.",
18 "What the crew is doing across your apps: who, on what, since when, cost; what awaits review.",
1919 inputSchema: { type: "object", properties: {} },
2020 writes: false,
2121 run(ctx, user) {
src/http/mcp/tools/probe.ts +1 −1
@@ -8,7 +8,7 @@ export const tool: Tool = {
88 name: "probe",
99 doc: "knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract",
1010 description:
11 "Try every door of a running app as nobody, a user and an admin against its contract.json; read-only.",
11 "Try every door of a running app as nobody, a user and an admin; read-only.",
1212 inputSchema: {
1313 type: "object",
1414 properties: { repo: REPO_ARG },
src/http/mcp/tools/prompts.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "prompts",
66 doc: "`{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it",
77 description:
8 "An app's prompt sources (Instagram): list, add (`kind`, `token`, `at`), run or remove (`id`). Admin token.",
8 "Prompt sources: list, add (`kind`, `token`, `at`), run or remove (`id`). Admin.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/review.ts +1 −1
@@ -9,7 +9,7 @@ export const tool: Tool = {
99 name: "review",
1010 doc: "`{ 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",
1111 description:
12 "Approve a pull, or request changes with a `reason` (required for changes); your own pull is refused.",
12 "Approve a pull, or request changes (`reason` required); your own pull is refused.",
1313 inputSchema: {
1414 type: "object",
1515 properties: {
src/http/mcp/tools/scorecard.ts +1 −1
@@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts";
55 export const tool: Tool = {
66 name: "scorecard",
77 description:
8 "Crew scorecard (forge admin): per agent, all time and 30 days: landed, rounds, red rate, reverts.",
8 "Crew scorecard (forge admin): per agent, all time and 30 days: landed, rounds, red rate.",
99 inputSchema: { type: "object", properties: {} },
1010 writes: false,
1111 adminOnly: true,
src/http/mcp/tools/share.ts +1 −1
@@ -6,7 +6,7 @@ import { good, bad, type Tool, str } from "../shared.ts";
66 export const tool: Tool = {
77 name: "share",
88 description:
9 "Share a feed item: no `to` tells the app's crew and owners, else its followers; daily budget each.",
9 "Share a feed item: no `to` tells the app's crew and owners, else followers; daily budget.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/spend.ts +1 −1
@@ -4,7 +4,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"
44 export const tool: Tool = {
55 name: "spend",
66 description:
7 "Report what your session spent on work the forge did not run: who, model, tokens or dollars, issue.",
7 "Report what your session spent outside the forge's runs: who, model, tokens or dollars, issue.",
88 inputSchema: {
99 type: "object",
1010 properties: {
src/http/mcp/tools/stop.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "stop",
66 doc: "stop one run by its id from `now`",
77 description:
8 "Stop one of the crew's runs, by its id from `now`. What it had pushed stays; nothing is re-queued.",
8 "Stop one of the crew's runs, by its id from `now`. Its pushes stay; nothing is re-queued.",
99 inputSchema: {
1010 type: "object",
1111 properties: { repo: REPO_ARG, run: { type: "string" } },
src/http/mcp/tools/system.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "system",
77 doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read`",
88 description:
9 "The forge's machine now: CPU, load, memory, disk, network, processes. Forge admin or telemetry:read.",
9 "The forge's machine now: CPU, memory, disk, network, processes. Admin or telemetry:read.",
1010 inputSchema: { type: "object", properties: {} },
1111 writes: false,
1212 run(ctx, user) {
src/http/mcp/tools/tell.ts +1 −1
@@ -8,7 +8,7 @@ export const tool: Tool = {
88 name: "tell",
99 doc: "a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick",
1010 description:
11 "Tell an app's crew what should change (first line is the title). No `repo`: kept; answers a thought id.",
11 "Tell an app's crew what to change (first line is the title). No `repo`: kept.",
1212 inputSchema: {
1313 type: "object",
1414 properties: {
src/http/mcp/tools/watch.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "watch",
77 doc: "`{ 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",
88 description:
9 "Watch a milestone, or stop (`watch: false`): one feed item; your phone is told once when it lands.",
9 "Watch a milestone, or stop (`watch: false`); your phone is told once when it lands.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
test/mcp-assign.test.ts added +159
@@ -0,0 +1,159 @@
1 /** The `assign` tool does what PUT …/assignee does: a writer assigns and clears; a reader and an unknown user are refused. */

docs/MCP.md promises "a writer, never an agent; the assignee must read the repository", but neither refusal is exercised here. Add a private-repository case where the assignee can't read, and an agent-token case. Both should check that the assignee is unchanged.

2 import { afterAll, beforeAll, describe, 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 { createApp, type App } from "../src/app.ts";
7 import { addToCrew, createAgent } from "../src/actions/crew.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { createIssue, getIssue } from "../src/core/issues.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { promote } from "./helpers/members.ts";
14
15 describe("MCP assign", () => {
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let aliceTok: string;
20 let carolTok: string;
21 let repoId: string;
22 let hiddenId: string;
23 let bobId: string;
24 let botTok: string;
25 let id = 1;
26
27 const call = async (token: string, args: unknown) => {
28 const res = await fetch(`${servers.url}/mcp`, {
29 method: "POST",
30 headers: {
31 "content-type": "application/json",
32 accept: "application/json, text/event-stream",
33 authorization: `Bearer ${token}`,
34 },
35 body: JSON.stringify({
36 jsonrpc: "2.0",
37 id: id++,
38 method: "tools/call",
39 params: { name: "assign", arguments: args },
40 }),
41 });
42 const body: any = await res.json();
43 const text: string = body.result.content[0].text;
44 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
45 };
46 const assigneeOf = () => getIssue(app.db, repoId, 1)!.assigneeId;
47
48 beforeAll(async () => {
49 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-assign-"));
50 app = createApp({ dataDir, port: 0 });
51 servers = await serve(app);
52 const mk = async (username: string, first = false) => {
53 const u = await createUser(app.db, {
54 origin: "seed",
55 trusted: true,
56 username,
57 email: `${username}@forge.local`,
58 password: "a-good-passphrase",
59 adminIfFirst: first,
60 });
61 if (!u.ok) throw new Error(u.error);
62 promote(app.db, u.value);
63 return u.value;
64 };
65 const alice = await mk("alice", true);
66 const bob = await mk("bob");
67 const carol = await mk("carol");
68 bobId = bob.id;
69 aliceTok = createToken(app.db, alice.id, "rw", "rw").token;
70 carolTok = createToken(app.db, carol.id, "rw", "rw").token;
71 const made = await createRepo(app, alice, { name: "one", visibility: "public" });
72 if (!made.ok) throw new Error(String(made.error));
73 repoId = made.value.id;
74 const issue = createIssue(app.db, { repoId, authorId: alice.id, title: "task" });
75 if (!issue.ok) throw new Error(issue.error);
76 const pull = createIssue(app.db, { repoId, authorId: alice.id, title: "change", kind: "pull" });
77 if (!pull.ok) throw new Error(pull.error);
78 const hidden = await createRepo(app, alice, { name: "hidden", visibility: "private" });
79 if (!hidden.ok) throw new Error(String(hidden.error));
80 hiddenId = hidden.value.id;
81 const secret = createIssue(app.db, { repoId: hiddenId, authorId: alice.id, title: "secret" });
82 if (!secret.ok) throw new Error(secret.error);
83 const bot = await createAgent(app, alice, { username: "bot" });
84 if (!bot.ok) throw new Error(String(bot.error));
85 await createAgent(app, alice, { username: "idle" });
86 if (!addToCrew(app, alice, made.value, "bot").ok) throw new Error("crew");
87 botTok = createToken(app.db, bot.value.user.id, "rw", "rw").token;
88 }, 30_000);
89
90 afterAll(async () => {
91 servers.stop();
92 app.close();
93 await rm(dataDir, { recursive: true, force: true });
94 });
95
96 const args = (assignee: string | null, extra = {}) => ({
97 repo: "alice/one",
98 number: 1,
99 kind: "issue",
100 assignee,
101 ...extra,
102 });
103
104 test("a writer assigns and clears", async () => {
105 const set = await call(aliceTok, args("bob"));
106 expect(set.value.assignee).toBe("bob");
107 expect(assigneeOf()).toBe(bobId);
108 const cleared = await call(aliceTok, args(null));
109 expect(cleared.value.assignee).toBeNull();
110 expect(assigneeOf()).toBeNull();
111 });
112
113 test("a writer assigns and clears a pull", async () => {
114 const set = await call(aliceTok, args("bob", { kind: "pull", number: 2 }));
115 expect(set.value.assignee).toBe("bob");
116 expect(getIssue(app.db, repoId, 2)!.assigneeId).toBe(bobId);
117 await call(aliceTok, args(null, { kind: "pull", number: 2 }));
118 expect(getIssue(app.db, repoId, 2)!.assigneeId).toBeNull();
119 });
120
121 test("a reader is refused with the API's words", async () => {
122 const got = await call(carolTok, args("bob"));
123 const api = await fetch(`${servers.url}/api/v1/repos/alice/one/issues/1/assignee`, {
124 method: "PUT",
125 headers: { authorization: `Bearer ${carolTok}`, "content-type": "application/json" },
126 body: JSON.stringify({ assignee: "bob" }),
127 });
128 expect(api.status).toBe(403);
129 const apiBody: any = await api.json();
130 expect(got.error).toBe(`forbidden: ${apiBody.message}`);
131 expect(assigneeOf()).toBeNull();
132 });
133
134 test("an unknown user, a wrong kind and a bad shape are refused", async () => {
135 expect((await call(aliceTok, args("nobody"))).error).toStartWith("no_such_user");
136 expect((await call(aliceTok, args("bob", { kind: "pull" }))).error).toStartWith("no_such_issue");
137 expect((await call(aliceTok, args("bob", { kind: "thing" }))).error).toStartWith("invalid");
138 expect((await call(aliceTok, args(5 as any))).error).toStartWith("invalid_assignee");
139 expect(assigneeOf()).toBeNull();
140 });
141
142 test("an assignee who cannot read the repository is refused, and the assignee stays", async () => {
143 const got = await call(aliceTok, args("bob", { repo: "alice/hidden" }));
144 expect(got.error).toBe("assignee_cannot_read: the assignee cannot read this repository");
145 expect(getIssue(app.db, hiddenId, 1)!.assigneeId).toBeNull();
146 });
147
148 test("an agent off the crew is refused as the API refuses it", async () => {
149 const got = await call(aliceTok, args("idle"));
150 expect(got.error).toBe("agent_not_on_crew: that agent is not on this repository's crew");
151 expect(assigneeOf()).toBeNull();
152 });
153
154 test("an agent's rw token may not assign, and is told why", async () => {
155 const got = await call(botTok, args("bob"));
156 expect(got.error).toStartWith("forbidden: an agent may not assign");
157 expect(assigneeOf()).toBeNull();
158 });
159 });
test/mcp.test.ts +1
@@ -243,6 +243,7 @@ describe("the forge over MCP", () => {
243243 "thought",
244244 "asked",
245245 "assigned",
246 "assign",
246247 "app_members",
247248 "egress_grants",
248249 "explore",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs