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

A test pins MCP `variant`: like or dislike only, a later vote replaces the earlier, and a dislike closes the pull with its reason #7172

Merged opened this

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

Merged

scout/forge:scout/7166-a-test-pins-mcp-variant-like-or-dislike → main 1 commit fork

  • Merged. scout/7166-a-test-pins-mcp-variant-like-or-dislike was merged into main as 154c5bc.

Review

Sign in to review this change.
1 file changed +199 −0
test/mcp-variant.test.ts added +199
@@ -0,0 +1,199 @@
1 /**
2 * The `variant` tool through `/mcp` with real tokens (#7166): like or dislike
3 * only, a later vote replaces the earlier, a dislike closes the pull with its
4 * reason as its one comment; a pull that is not a variant, or one the caller
5 * cannot read, is refused with nothing changed.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { openPull } from "../src/actions/pulls.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { listDecisions } from "../src/core/decisions.ts";
15 import { listComments } from "../src/core/issues.ts";
16 import { getPull } from "../src/core/pulls.ts";
17 import type { Repo } from "../src/core/repos.ts";
18 import { createToken } from "../src/core/tokens.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22 import { promote } from "./helpers/members.ts";
23
24 quietLogs();
25
26 describe("MCP variant", () => {
27 let dataDir: string;
28 let app: App;
29 let servers: Servers;
30 let cy: User;
31 let pub: Repo;
32 let hid: Repo;
33 let cyToken = "";
34 let eveToken = "";
35 let id = 1;
36 let n = 0;
37
38 const call = async (token: string, args: Record<string, unknown>) => {
39 const res = await fetch(`${servers.url}/mcp`, {
40 method: "POST",
41 headers: {
42 "content-type": "application/json",
43 accept: "application/json, text/event-stream",
44 authorization: `Bearer ${token}`,
45 },
46 body: JSON.stringify({
47 jsonrpc: "2.0",
48 id: id++,
49 method: "tools/call",
50 params: { name: "variant", arguments: args },
51 }),
52 });
53 const body = (await res.json()) as any;
54 if (body.error) return { text: body.error.message as string, error: true };
55 return { text: body.result.content[0].text as string, error: body.result.isError === true };
56 };
57 const pull = async (repo: Repo, group: string | null) => {
58 const branch = `b${++n}`;
59 const c = await commitToBranch(app, cy, repo, {
60 branch,
61 message: branch,
62 files: [{ path: `${branch}.md`, content: "x\n" }],
63 });
64 if (!c.ok) throw new Error(JSON.stringify(c.error));
65 const o = await openPull(app, cy, repo, {
66 title: `Try ${branch}`,
67 baseBranch: "main",
68 headBranch: branch,
69 ...(group === null ? {} : { variantGroup: group }),
70 });
71 if (!o.ok) throw new Error(String(o.error));
72 return o.value;
73 };
74 const votes = (repo: Repo, issueId: string) =>
75 listDecisions(app.db, repo.id, { kind: "lean" }).filter((r) => r.issueId === issueId);
76 const comments = (issueId: string) => listComments(app.db, issueId);
77
78 beforeAll(async () => {
79 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-variant-"));
80 app = createApp({ dataDir, port: 0, host: "forge.test" });
81 servers = await serve(app);
82 const make = async (username: string, adminIfFirst = false): Promise<User> => {
83 const made = await createUser(app.db, {
84 origin: "seed",
85 trusted: true,
86 username,
87 email: `${username}@forge.local`,
88 password: "a-good-passphrase",
89 adminIfFirst,
90 });
91 if (!made.ok) throw new Error(made.error);
92 return made.value;
93 };
94 await make("ada", true);
95 cy = await make("cy");
96 promote(app.db, cy);
97 const eve = await make("eve");
98 const mk = async (name: string, visibility: "public" | "private") => {
99 const made = await createRepo(app, cy, { name, visibility, description: "" });
100 if (!made.ok) throw new Error(String(made.error));
101 const seed = await commitToBranch(app, cy, made.value, {
102 message: "seed",
103 files: [{ path: "README.md", content: `# ${name}\n` }],
104 });
105 if (!seed.ok) throw new Error(JSON.stringify(seed.error));
106 return made.value;
107 };
108 pub = await mk("lib", "public");
109 hid = await mk("vault", "private");
110 cyToken = createToken(app.db, cy.id, "admin", "admin").token;
111 eveToken = createToken(app.db, eve.id, "admin", "admin").token;
112 }, 60_000);
113
114 afterAll(async () => {
115 servers.stop();
116 app.close();
117 await rm(dataDir, { recursive: true, force: true });
118 });
119
120 test("like then dislike leaves one vote, and the pull closed with the reason as its one comment", async () => {
121 const sibling = await pull(pub, "g1");
122 const target = await pull(pub, "g1");
123 const before = comments(target.id).length;
124
125 const liked = await call(cyToken, { repo: "cy/lib", number: target.number, answer: "like" });
126 expect(liked.error).toBe(false);
127 expect(JSON.parse(liked.text)).toEqual({ answer: "like", closed: false });
128 expect(getPull(app.db, pub.id, target.number)!.state).toBe("open");
129 expect(votes(pub, target.id).map((v) => v.answer)).toEqual(["like"]);
130
131 const disliked = await call(cyToken, {
132 repo: "cy/lib",
133 number: target.number,
134 answer: "dislike",
135 reason: "too noisy",
136 });
137 expect(disliked.error).toBe(false);
138 expect(JSON.parse(disliked.text)).toEqual({ answer: "dislike", closed: true });
139 expect(getPull(app.db, pub.id, target.number)!.state).toBe("closed");
140 const rows = votes(pub, target.id);
141 expect(rows).toHaveLength(1);
142 expect(rows[0]!.answer).toBe("dislike");
143 const added = comments(target.id).slice(before);
144 expect(added).toHaveLength(1);
145 expect(added[0]!.body).toContain("too noisy");
146 // The sibling is untouched.
147 expect(getPull(app.db, pub.id, sibling.number)!.state).toBe("open");
148 expect(votes(pub, sibling.id)).toHaveLength(0);
149 });
150
151 test("an answer other than like or dislike is invalid_answer and stores no vote", async () => {
152 const target = await pull(pub, "g2");
153 const before = comments(target.id).length;
154 for (const answer of ["meh", "", "LIKE"]) {
155 const r = await call(cyToken, { repo: "cy/lib", number: target.number, answer });
156 expect(r.error).toBe(true);
157 expect(r.text).toContain("invalid_answer");
158 }
159 expect(votes(pub, target.id)).toHaveLength(0);
160 expect(getPull(app.db, pub.id, target.number)!.state).toBe("open");
161 expect(comments(target.id).length).toBe(before);
162 });
163
164 test("a pull that is not a variant is refused and nothing changes", async () => {
165 const plain = await pull(pub, null);
166 const before = comments(plain.id).length;
167 const r = await call(cyToken, {
168 repo: "cy/lib",
169 number: plain.number,
170 answer: "dislike",
171 reason: "no",
172 });
173 expect(r.error).toBe(true);
174 expect(r.text).toContain("not_variant");
175 expect(getPull(app.db, pub.id, plain.number)!.state).toBe("open");
176 expect(votes(pub, plain.id)).toHaveLength(0);
177 expect(comments(plain.id).length).toBe(before);
178 });
179
180 test("a pull the caller cannot read is refused and nothing changes", async () => {
181 const secret = await pull(hid, "g3");
182 const before = comments(secret.id).length;
183 const r = await call(eveToken, {
184 repo: "cy/vault",
185 number: secret.number,
186 answer: "dislike",
187 reason: "no",
188 });
189 expect(r.error).toBe(true);
190 expect(getPull(app.db, hid.id, secret.number)!.state).toBe("open");
191 expect(votes(hid, secret.id)).toHaveLength(0);
192 expect(comments(secret.id).length).toBe(before);
193
194 // A pull number that does not exist is refused the same way.
195 const missing = await call(cyToken, { repo: "cy/lib", number: 9999, answer: "like" });
196 expect(missing.error).toBe(true);
197 expect(missing.text).toContain("no_such_pull");
198 });
199 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs