Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

mcp-write.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

mcp-write.test.tsBlame850 lines · 1 contributor
6551045Claude1/**
2 * Block K1 — MCP write surface tests.
3 *
4 * Covers the 10 new tools added in src/lib/mcp-tools.ts:
5 * create_issue / comment_issue / close_issue / reopen_issue
6 * create_pr / get_pr / list_prs / comment_pr
7 * merge_pr / close_pr
8 *
9 * Each tool gets at minimum:
10 * - Happy-ish path: authenticated owner returns the spec-described shape
11 * - Auth gate : ctx.userId === null → McpError(-32602 INVALID_PARAMS)
12 * - Write-access : authed but no write access → McpError(-32601 NOT_FOUND)
13 *
14 * We stub the `../db` module (same pattern as repo-access.test.ts and
15 * import-verify.test.ts) so these tests never touch Neon. The fake `db`
16 * dispatches on the table passed to `.from(...)` / `.insert(...)` to
17 * decide which canned shape to return. Mutations are recorded in the
18 * `_inserted` / `_updated` arrays so the assertions can verify the audit
19 * + DB-write path was actually exercised.
20 *
21 * We also stub `./notify` to avoid the email-fanout path (which would
22 * also try to hit the DB), and `./gate` + `./branch-protection` +
23 * `./merge-resolver` for the merge_pr happy path so we don't shell out
24 * to git.
25 */
26
27import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
28import {
29 ERR_INVALID_PARAMS,
30 ERR_METHOD_NOT_FOUND,
31 McpError,
32} from "../lib/mcp";
33
34// ---------------------------------------------------------------------------
35// Module stubs
36// ---------------------------------------------------------------------------
37
38/** Per-test override hooks. Reset in beforeEach. */
39let _nextRepoRow: any = null;
40let _nextCollabRow: any = null;
41let _nextIssueRow: any = null;
42let _nextPrRow: any = null;
43let _nextAiCommentRows: any[] = [];
44let _nextProtectionRule: any = null;
45
46const _inserted: { table: string; values: any; returned: any }[] = [];
47const _updated: { table: string; set: any }[] = [];
48
49let _lastSelectFrom: any = null;
50let _lastInsertTable: any = null;
51let _lastUpdateTable: any = null;
52let _lastInsertValues: any = null;
53
54const tableName = (t: any): string => {
55 if (!t || typeof t !== "object") return "?";
56 // Drizzle pgTable objects expose a Symbol-keyed name. We probe `_` /
57 // common keys to identify the table without importing the schema.
58 if ("isPrivate" in t) return "repositories";
59 if ("acceptedAt" in t || "invitedAt" in t) return "repo_collaborators";
60 if ("issueId" in t && "body" in t) return "issue_comments";
61 if ("pullRequestId" in t) return "pr_comments";
62 if (
63 "baseBranch" in t ||
64 "headBranch" in t ||
65 "mergedAt" in t ||
66 "isDraft" in t
67 )
68 return "pull_requests";
69 if ("state" in t && "closedAt" in t && "title" in t && !("baseBranch" in t))
70 return "issues";
71 if ("username" in t && "passwordHash" in t) return "users";
72 if ("kind" in t) return "notifications";
73 if ("action" in t && "userId" in t) return "audit_log";
74 return "?";
75};
76
77const _selectChain: any = {
78 from: (t: any) => {
79 _lastSelectFrom = t;
80 return _selectChain;
81 },
82 innerJoin: () => _selectChain,
83 leftJoin: () => _selectChain,
84 rightJoin: () => _selectChain,
85 where: () => _selectChain,
86 orderBy: () => _selectChain,
87 groupBy: () => _selectChain,
88 limit: async () => {
89 const name = tableName(_lastSelectFrom);
90 if (name === "repositories") {
91 return _nextRepoRow ? [_nextRepoRow] : [];
92 }
93 if (name === "repo_collaborators") {
94 return _nextCollabRow ? [_nextCollabRow] : [];
95 }
96 if (name === "issues") {
97 return _nextIssueRow ? [_nextIssueRow] : [];
98 }
99 if (name === "pull_requests") {
100 return _nextPrRow ? [_nextPrRow] : [];
101 }
102 if (name === "pr_comments") {
103 return _nextAiCommentRows;
104 }
105 if (name === "users") {
106 return _nextRepoRow?.username ? [{ username: _nextRepoRow.username }] : [{ username: "owner-x" }];
107 }
108 return [];
109 },
110 // For pr-comments AI-approval lookup, the route doesn't `.limit(1)` — it
111 // awaits the chain directly. We expose `.then` so `await chain` yields
112 // the rows.
113 then: (resolve: (v: any) => void) => {
114 const name = tableName(_lastSelectFrom);
115 if (name === "pr_comments") return resolve(_nextAiCommentRows);
116 if (name === "pull_requests") return resolve(_nextPrRow ? [_nextPrRow] : []);
117 return resolve([]);
118 },
119};
120
121const _insertChain = (table: any) => {
122 _lastInsertTable = table;
123 return {
124 values: (vals: any) => {
125 _lastInsertValues = vals;
126 return {
127 returning: async () => {
128 const name = tableName(table);
129 let returned: any;
130 if (name === "issues") {
131 returned = {
132 id: "iss-id-1",
133 number: 42,
134 repositoryId: vals.repositoryId,
135 authorId: vals.authorId,
136 title: vals.title,
137 body: vals.body,
138 state: "open",
139 createdAt: new Date(),
140 updatedAt: new Date(),
141 closedAt: null,
142 };
143 } else if (name === "issue_comments") {
144 returned = {
145 id: "ic-id-1",
146 issueId: vals.issueId,
147 authorId: vals.authorId,
148 body: vals.body,
149 createdAt: new Date(),
150 updatedAt: new Date(),
151 };
152 } else if (name === "pull_requests") {
153 returned = {
154 id: "pr-id-1",
155 number: 7,
156 repositoryId: vals.repositoryId,
157 authorId: vals.authorId,
158 title: vals.title,
159 body: vals.body,
160 state: "open",
161 baseBranch: vals.baseBranch,
162 headBranch: vals.headBranch,
163 isDraft: vals.isDraft ?? false,
164 mergeStrategy: "merge",
165 milestoneId: null,
166 mergedAt: null,
167 mergedBy: null,
168 createdAt: new Date(),
169 updatedAt: new Date(),
170 closedAt: null,
171 };
172 } else if (name === "pr_comments") {
173 returned = {
174 id: "pc-id-1",
175 pullRequestId: vals.pullRequestId,
176 authorId: vals.authorId,
177 body: vals.body,
178 isAiReview: vals.isAiReview ?? false,
179 filePath: null,
180 lineNumber: null,
181 createdAt: new Date(),
182 updatedAt: new Date(),
183 };
184 } else {
185 returned = vals;
186 }
187 _inserted.push({ table: name, values: vals, returned });
188 return [returned];
189 },
190 // Allow `await db.insert(...).values(...)` (no `.returning()`),
191 // used by notify/audit + auto-close-issues path.
192 then: (resolve: (v: any) => void) => {
193 _inserted.push({ table: tableName(table), values: vals, returned: null });
194 resolve(undefined);
195 },
196 };
197 },
198 };
199};
200
201const _updateChain = (table: any) => {
202 _lastUpdateTable = table;
203 return {
204 set: (vals: any) => {
205 _updated.push({ table: tableName(table), set: vals });
206 return {
207 where: () => ({
208 then: (resolve: (v: any) => void) => resolve(undefined),
209 }),
210 };
211 },
212 };
213};
214
215const _fakeDb = {
216 db: {
217 select: () => _selectChain,
218 insert: (t: any) => _insertChain(t),
219 update: (t: any) => _updateChain(t),
220 delete: () => ({ where: () => Promise.resolve() }),
221 },
222 getDb: () => _fakeDb.db,
223 isNeonUrl: () => false,
224};
225mock.module("../db", () => _fakeDb);
226
227// Stub notify so we don't shell out to email + DB on the fan-out path.
228const _notifyCalls: any[] = [];
229const _auditCalls: any[] = [];
230mock.module("../lib/notify", () => ({
231 notify: async (userId: string, opts: any) => {
232 _notifyCalls.push({ userId, ...opts });
233 },
234 notifyMany: async () => {},
235 audit: async (opts: any) => {
236 _auditCalls.push(opts);
237 },
238}));
239
240// merge_pr depends on these — stub the green path.
241mock.module("../lib/gate", () => ({
242 runAllGateChecks: async () => ({ checks: [] }),
243}));
244mock.module("../lib/branch-protection", () => ({
245 matchProtection: async () => null,
246 countHumanApprovals: async () => 0,
247 listRequiredChecks: async () => [],
248 passingCheckNames: async () => [],
249 evaluateProtection: () => ({ allowed: true, reasons: [] }),
250}));
251mock.module("../lib/merge-resolver", () => ({
252 mergeWithAutoResolve: async () => ({ success: true, resolvedFiles: [] }),
253}));
254mock.module("../lib/ai-review", () => ({
255 isAiReviewEnabled: () => false,
256 triggerAiReview: async () => {},
257}));
258mock.module("../lib/close-keywords", () => ({
259 extractClosingRefsMulti: () => [],
260}));
261
262// Block all SSE fanout — the import is dynamic so we need to mock it too.
263mock.module("../lib/sse", () => ({
264 publish: () => {},
265 subscribe: () => () => {},
266 topicSubscriberCount: () => 0,
267}));
268
269// We stub Bun.spawn globally so any git subprocess (e.g. resolveRef,
270// `git update-ref` in merge_pr) returns a deterministic happy exit. We
271// intentionally do NOT mock `../git/repository` so the rest of the
272// library's static imports keep resolving correctly.
273const _realBunSpawn = (globalThis as any).Bun?.spawn;
274function stubBunSpawnSuccess() {
275 (globalThis as any).Bun.spawn = () => ({
276 exited: Promise.resolve(0),
277 stdout: new ReadableStream({
278 start(c) {
279 c.enqueue(new TextEncoder().encode("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n"));
280 c.close();
281 },
282 }),
283 stderr: new ReadableStream({
284 start(c) {
285 c.close();
286 },
287 }),
288 });
289}
290function restoreBunSpawn() {
291 if (_realBunSpawn) (globalThis as any).Bun.spawn = _realBunSpawn;
292}
293// Install the stub globally for all tests — most calls don't care, the
294// ones that do are happy with exitCode 0 + the deadbeef SHA.
295stubBunSpawnSuccess();
296
297afterAll(() => {
298 restoreBunSpawn();
299});
300
301// ---------------------------------------------------------------------------
302// Test fixtures
303// ---------------------------------------------------------------------------
304
305const OWNER_ID = "11111111-1111-1111-1111-111111111111";
306const REPO_ID = "22222222-2222-2222-2222-222222222222";
307const OTHER_USER_ID = "33333333-3333-3333-3333-333333333333";
308
309const PUBLIC_REPO_ROW = {
310 id: REPO_ID,
311 ownerId: OWNER_ID,
312 isPrivate: false,
313 defaultBranch: "main",
314 username: "alice",
315};
316
317const ownerCtx = { userId: OWNER_ID };
318const anonCtx = { userId: null };
319const otherCtx = { userId: OTHER_USER_ID };
320
321function resetState() {
322 _nextRepoRow = PUBLIC_REPO_ROW;
323 _nextCollabRow = null; // No collaborator rows → public repo non-owner = read
324 _nextIssueRow = null;
325 _nextPrRow = null;
326 _nextAiCommentRows = [];
327 _nextProtectionRule = null;
328 _inserted.length = 0;
329 _updated.length = 0;
330 _notifyCalls.length = 0;
331 _auditCalls.length = 0;
332}
333
334beforeEach(() => {
335 resetState();
336});
337
338async function getTools() {
339 const m = await import("../lib/mcp-tools");
340 return m.__test;
341}
342
343// ---------------------------------------------------------------------------
344// gluecron_create_issue
345// ---------------------------------------------------------------------------
346
347describe("gluecron_create_issue", () => {
348 it("owner can create an issue (happy path)", async () => {
349 const T = await getTools();
350 const result = (await T.createIssue.run(
351 { owner: "alice", repo: "demo", title: "Hello", body: "World" },
352 ownerCtx
353 )) as { number: number; url: string };
354 expect(result.number).toBe(42);
355 expect(result.url).toBe("/alice/demo/issues/42");
356 expect(_inserted.find((r) => r.table === "issues")).toBeTruthy();
357 expect(_auditCalls.some((a) => a.action === "issue.created")).toBe(true);
358 });
359
360 it("anonymous → -32602 INVALID_PARAMS", async () => {
361 const T = await getTools();
362 let caught: McpError | null = null;
363 try {
364 await T.createIssue.run(
365 { owner: "alice", repo: "demo", title: "x" },
366 anonCtx
367 );
368 } catch (err) {
369 if (err instanceof McpError) caught = err;
370 }
371 expect(caught?.code).toBe(ERR_INVALID_PARAMS);
372 expect(caught?.message).toMatch(/authentication required/);
373 });
374
375 it("authed-but-no-write → -32601 METHOD_NOT_FOUND", async () => {
376 const T = await getTools();
377 // Public repo, other user, no collab row → only "read"
378 let caught: McpError | null = null;
379 try {
380 await T.createIssue.run(
381 { owner: "alice", repo: "demo", title: "x" },
382 otherCtx
383 );
384 } catch (err) {
385 if (err instanceof McpError) caught = err;
386 }
387 expect(caught?.code).toBe(ERR_METHOD_NOT_FOUND);
388 expect(caught?.message).toMatch(/no write access/);
389 });
390});
391
392// ---------------------------------------------------------------------------
393// gluecron_comment_issue
394// ---------------------------------------------------------------------------
395
396describe("gluecron_comment_issue", () => {
397 it("owner can comment on an issue", async () => {
398 const T = await getTools();
399 _nextIssueRow = {
400 id: "iss-id-1",
401 number: 42,
402 repositoryId: REPO_ID,
403 authorId: OWNER_ID,
404 state: "open",
405 };
406 const result = (await T.commentIssue.run(
407 { owner: "alice", repo: "demo", number: 42, body: "thanks!" },
408 ownerCtx
409 )) as { commentId: string };
410 expect(result.commentId).toBe("ic-id-1");
411 expect(_inserted.find((r) => r.table === "issue_comments")).toBeTruthy();
412 });
413
414 it("anonymous → -32602", async () => {
415 const T = await getTools();
416 expect(
417 T.commentIssue.run(
418 { owner: "alice", repo: "demo", number: 1, body: "x" },
419 anonCtx
420 )
421 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
422 });
423
424 it("other user (read-only) → -32601", async () => {
425 const T = await getTools();
426 expect(
427 T.commentIssue.run(
428 { owner: "alice", repo: "demo", number: 1, body: "x" },
429 otherCtx
430 )
431 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
432 });
433});
434
435// ---------------------------------------------------------------------------
436// gluecron_close_issue
437// ---------------------------------------------------------------------------
438
439describe("gluecron_close_issue", () => {
440 it("owner can close an open issue", async () => {
441 const T = await getTools();
442 _nextIssueRow = {
443 id: "iss-1",
444 number: 5,
445 repositoryId: REPO_ID,
446 authorId: OWNER_ID,
447 state: "open",
448 };
449 const result = (await T.closeIssue.run(
450 { owner: "alice", repo: "demo", number: 5 },
451 ownerCtx
452 )) as { state: string };
453 expect(result.state).toBe("closed");
454 expect(_updated.find((u) => u.table === "issues")).toBeTruthy();
455 });
456
457 it("anonymous → -32602", async () => {
458 const T = await getTools();
459 expect(
460 T.closeIssue.run({ owner: "alice", repo: "demo", number: 5 }, anonCtx)
461 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
462 });
463
464 it("other user → -32601", async () => {
465 const T = await getTools();
466 expect(
467 T.closeIssue.run({ owner: "alice", repo: "demo", number: 5 }, otherCtx)
468 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
469 });
470});
471
472// ---------------------------------------------------------------------------
473// gluecron_reopen_issue
474// ---------------------------------------------------------------------------
475
476describe("gluecron_reopen_issue", () => {
477 it("owner can reopen a closed issue", async () => {
478 const T = await getTools();
479 _nextIssueRow = {
480 id: "iss-1",
481 number: 5,
482 repositoryId: REPO_ID,
483 authorId: OWNER_ID,
484 state: "closed",
485 closedAt: new Date(),
486 };
487 const result = (await T.reopenIssue.run(
488 { owner: "alice", repo: "demo", number: 5 },
489 ownerCtx
490 )) as { state: string };
491 expect(result.state).toBe("open");
492 expect(_updated.find((u) => u.table === "issues")).toBeTruthy();
493 });
494
495 it("anonymous → -32602", async () => {
496 const T = await getTools();
497 expect(
498 T.reopenIssue.run({ owner: "alice", repo: "demo", number: 5 }, anonCtx)
499 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
500 });
501
502 it("other user → -32601", async () => {
503 const T = await getTools();
504 expect(
505 T.reopenIssue.run({ owner: "alice", repo: "demo", number: 5 }, otherCtx)
506 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
507 });
508});
509
510// ---------------------------------------------------------------------------
511// gluecron_create_pr
512// ---------------------------------------------------------------------------
513
514describe("gluecron_create_pr", () => {
515 it("owner can open a PR (base defaults to repo default branch)", async () => {
516 const T = await getTools();
517 const result = (await T.createPr.run(
518 {
519 owner: "alice",
520 repo: "demo",
521 title: "Feature X",
522 head_branch: "feat/x",
523 },
524 ownerCtx
525 )) as { number: number; url: string };
526 expect(result.number).toBe(7);
527 expect(result.url).toBe("/alice/demo/pulls/7");
528 const ins = _inserted.find((r) => r.table === "pull_requests");
529 expect(ins).toBeTruthy();
530 expect(ins?.values.baseBranch).toBe("main");
531 expect(ins?.values.headBranch).toBe("feat/x");
532 });
533
534 it("rejects when base === head", async () => {
535 const T = await getTools();
536 let caught: McpError | null = null;
537 try {
538 await T.createPr.run(
539 {
540 owner: "alice",
541 repo: "demo",
542 title: "x",
543 head_branch: "main",
544 base_branch: "main",
545 },
546 ownerCtx
547 );
548 } catch (err) {
549 if (err instanceof McpError) caught = err;
550 }
551 expect(caught?.code).toBe(ERR_INVALID_PARAMS);
552 });
553
554 it("anonymous → -32602", async () => {
555 const T = await getTools();
556 expect(
557 T.createPr.run(
558 { owner: "alice", repo: "demo", title: "x", head_branch: "feat" },
559 anonCtx
560 )
561 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
562 });
563
564 it("other user → -32601", async () => {
565 const T = await getTools();
566 expect(
567 T.createPr.run(
568 { owner: "alice", repo: "demo", title: "x", head_branch: "feat" },
569 otherCtx
570 )
571 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
572 });
573});
574
575// ---------------------------------------------------------------------------
576// gluecron_get_pr
577// ---------------------------------------------------------------------------
578
579describe("gluecron_get_pr", () => {
580 it("returns full detail for an authed reader", async () => {
581 const T = await getTools();
582 _nextPrRow = {
583 id: "pr-1",
584 number: 7,
585 repositoryId: REPO_ID,
586 authorId: OWNER_ID,
587 title: "Feature",
588 body: "Body",
589 state: "open",
590 baseBranch: "main",
591 headBranch: "feat/x",
592 isDraft: false,
593 createdAt: new Date(),
594 updatedAt: new Date(),
595 mergedAt: null,
596 closedAt: null,
597 };
598 const r = (await T.getPr.run(
599 { owner: "alice", repo: "demo", number: 7 },
600 ownerCtx
601 )) as any;
602 expect(r.number).toBe(7);
603 expect(r.state).toBe("open");
604 expect(r.baseBranch).toBe("main");
605 expect(r.headBranch).toBe("feat/x");
606 expect(r.url).toBe("/alice/demo/pulls/7");
607 });
608
609 it("anonymous → -32602 (write surface requires auth)", async () => {
610 const T = await getTools();
611 expect(
612 T.getPr.run({ owner: "alice", repo: "demo", number: 1 }, anonCtx)
613 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
614 });
615
616 it("private repo + non-collaborator → -32601 (privacy)", async () => {
617 const T = await getTools();
618 _nextRepoRow = { ...PUBLIC_REPO_ROW, isPrivate: true };
619 expect(
620 T.getPr.run({ owner: "alice", repo: "demo", number: 1 }, otherCtx)
621 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
622 });
623});
624
625// ---------------------------------------------------------------------------
626// gluecron_list_prs
627// ---------------------------------------------------------------------------
628
629describe("gluecron_list_prs", () => {
630 it("rejects an unknown state value", async () => {
631 const T = await getTools();
632 expect(
633 T.listPrs.run(
634 { owner: "alice", repo: "demo", state: "garbage" },
635 ownerCtx
636 )
637 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
638 });
639
640 it("anonymous → -32602", async () => {
641 const T = await getTools();
642 expect(
643 T.listPrs.run({ owner: "alice", repo: "demo" }, anonCtx)
644 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
645 });
646
647 it("private repo + non-collaborator → -32601", async () => {
648 const T = await getTools();
649 _nextRepoRow = { ...PUBLIC_REPO_ROW, isPrivate: true };
650 expect(
651 T.listPrs.run({ owner: "alice", repo: "demo" }, otherCtx)
652 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
653 });
654
655 it("authed reader gets a 0-row list when none exist", async () => {
656 const T = await getTools();
657 const r = (await T.listPrs.run(
658 { owner: "alice", repo: "demo" },
659 ownerCtx
660 )) as { total: number; prs: any[] };
661 // The fake DB returns [] for the listPrs path because the chain only
662 // returns canned rows on `.limit(1)` of repositories. The unbounded
663 // PR list shape comes back empty — which is the explicit "no PRs"
664 // happy path we want to assert.
665 expect(typeof r.total).toBe("number");
666 expect(Array.isArray(r.prs)).toBe(true);
667 });
668});
669
670// ---------------------------------------------------------------------------
671// gluecron_comment_pr
672// ---------------------------------------------------------------------------
673
674describe("gluecron_comment_pr", () => {
675 it("owner can comment on a PR", async () => {
676 const T = await getTools();
677 _nextPrRow = {
678 id: "pr-1",
679 number: 7,
680 repositoryId: REPO_ID,
681 authorId: OWNER_ID,
682 state: "open",
683 baseBranch: "main",
684 headBranch: "feat/x",
685 isDraft: false,
686 };
687 const result = (await T.commentPr.run(
688 { owner: "alice", repo: "demo", number: 7, body: "LGTM" },
689 ownerCtx
690 )) as { commentId: string };
691 expect(result.commentId).toBe("pc-id-1");
692 expect(_inserted.find((r) => r.table === "pr_comments")).toBeTruthy();
693 });
694
695 it("anonymous → -32602", async () => {
696 const T = await getTools();
697 expect(
698 T.commentPr.run(
699 { owner: "alice", repo: "demo", number: 1, body: "x" },
700 anonCtx
701 )
702 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
703 });
704
705 it("other user → -32601", async () => {
706 const T = await getTools();
707 expect(
708 T.commentPr.run(
709 { owner: "alice", repo: "demo", number: 1, body: "x" },
710 otherCtx
711 )
712 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
713 });
714});
715
716// ---------------------------------------------------------------------------
717// gluecron_merge_pr
718// ---------------------------------------------------------------------------
719
720describe("gluecron_merge_pr", () => {
721 it("owner can merge a clean open PR (no protection, no conflicts)", async () => {
722 const T = await getTools();
723 _nextPrRow = {
724 id: "pr-1",
725 number: 7,
726 repositoryId: REPO_ID,
727 authorId: OWNER_ID,
728 title: "Feature",
729 body: "",
730 state: "open",
731 baseBranch: "main",
732 headBranch: "feat/x",
733 isDraft: false,
734 };
735 stubBunSpawnSuccess();
736 try {
737 const r = (await T.mergePr.run(
738 { owner: "alice", repo: "demo", number: 7 },
739 ownerCtx
740 )) as { merged: boolean; sha?: string; reason?: string };
741 expect(r.merged).toBe(true);
742 expect(typeof r.sha).toBe("string");
743 expect(_updated.some((u) => u.table === "pull_requests")).toBe(true);
744 } finally {
745 restoreBunSpawn();
746 }
747 });
748
749 it("draft PR → merged=false with human-readable reason", async () => {
750 const T = await getTools();
751 _nextPrRow = {
752 id: "pr-1",
753 number: 7,
754 repositoryId: REPO_ID,
755 authorId: OWNER_ID,
756 state: "open",
757 baseBranch: "main",
758 headBranch: "feat/x",
759 isDraft: true,
760 };
761 const r = (await T.mergePr.run(
762 { owner: "alice", repo: "demo", number: 7 },
763 ownerCtx
764 )) as { merged: boolean; reason?: string };
765 expect(r.merged).toBe(false);
766 expect(r.reason).toMatch(/draft/i);
767 });
768
769 it("anonymous → -32602", async () => {
770 const T = await getTools();
771 expect(
772 T.mergePr.run({ owner: "alice", repo: "demo", number: 1 }, anonCtx)
773 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
774 });
775
776 it("other user → -32601", async () => {
777 const T = await getTools();
778 expect(
779 T.mergePr.run({ owner: "alice", repo: "demo", number: 1 }, otherCtx)
780 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
781 });
782});
783
784// ---------------------------------------------------------------------------
785// gluecron_close_pr
786// ---------------------------------------------------------------------------
787
788describe("gluecron_close_pr", () => {
789 it("owner can close an open PR", async () => {
790 const T = await getTools();
791 _nextPrRow = {
792 id: "pr-1",
793 number: 7,
794 repositoryId: REPO_ID,
795 authorId: OWNER_ID,
796 state: "open",
797 baseBranch: "main",
798 headBranch: "feat/x",
799 isDraft: false,
800 };
801 const r = (await T.closePr.run(
802 { owner: "alice", repo: "demo", number: 7 },
803 ownerCtx
804 )) as { state: string };
805 expect(r.state).toBe("closed");
806 expect(_updated.some((u) => u.table === "pull_requests")).toBe(true);
807 });
808
809 it("anonymous → -32602", async () => {
810 const T = await getTools();
811 expect(
812 T.closePr.run({ owner: "alice", repo: "demo", number: 1 }, anonCtx)
813 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
814 });
815
816 it("other user → -32601", async () => {
817 const T = await getTools();
818 expect(
819 T.closePr.run({ owner: "alice", repo: "demo", number: 1 }, otherCtx)
820 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
821 });
822});
823
824// ---------------------------------------------------------------------------
825// Default registry contains the new tools
826// ---------------------------------------------------------------------------
827
828describe("defaultTools — registry shape", () => {
829 it("includes all 10 K1 write-surface tools", async () => {
830 const { defaultTools } = await import("../lib/mcp-tools");
831 const tools = defaultTools();
832 const expected = [
833 "gluecron_create_issue",
834 "gluecron_comment_issue",
835 "gluecron_close_issue",
836 "gluecron_reopen_issue",
837 "gluecron_create_pr",
838 "gluecron_get_pr",
839 "gluecron_list_prs",
840 "gluecron_comment_pr",
841 "gluecron_merge_pr",
842 "gluecron_close_pr",
843 ];
844 for (const name of expected) {
845 expect(tools[name]).toBeTruthy();
846 expect(tools[name].tool.name).toBe(name);
847 expect(tools[name].tool.inputSchema.type).toBe("object");
848 }
849 });
850});