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.tsBlame900 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
2136cc5Claude34// Capture the REAL modules BEFORE any mock.module() call below so we can
35// restore them in afterAll. `mock.module()` is process-global in Bun and
36// otherwise bleeds into every test file that runs after this one in the
37// same `bun test` invocation, silently breaking every test that imports
38// (directly or transitively) any of the modules we stub.
39const _real_db = await import("../db");
40const _real_notify = await import("../lib/notify");
41const _real_gate = await import("../lib/gate");
42const _real_branchProtection = await import("../lib/branch-protection");
43const _real_mergeResolver = await import("../lib/merge-resolver");
44const _real_aiReview = await import("../lib/ai-review");
45const _real_closeKeywords = await import("../lib/close-keywords");
46const _real_sse = await import("../lib/sse");
47
6551045Claude48// ---------------------------------------------------------------------------
49// Module stubs
50// ---------------------------------------------------------------------------
51
52/** Per-test override hooks. Reset in beforeEach. */
53let _nextRepoRow: any = null;
54let _nextCollabRow: any = null;
55let _nextIssueRow: any = null;
56let _nextPrRow: any = null;
57let _nextAiCommentRows: any[] = [];
58let _nextProtectionRule: any = null;
59
60const _inserted: { table: string; values: any; returned: any }[] = [];
61const _updated: { table: string; set: any }[] = [];
62
63let _lastSelectFrom: any = null;
64let _lastInsertTable: any = null;
65let _lastUpdateTable: any = null;
66let _lastInsertValues: any = null;
67
68const tableName = (t: any): string => {
69 if (!t || typeof t !== "object") return "?";
70 // Drizzle pgTable objects expose a Symbol-keyed name. We probe `_` /
71 // common keys to identify the table without importing the schema.
72 if ("isPrivate" in t) return "repositories";
73 if ("acceptedAt" in t || "invitedAt" in t) return "repo_collaborators";
74 if ("issueId" in t && "body" in t) return "issue_comments";
75 if ("pullRequestId" in t) return "pr_comments";
76 if (
77 "baseBranch" in t ||
78 "headBranch" in t ||
79 "mergedAt" in t ||
80 "isDraft" in t
81 )
82 return "pull_requests";
83 if ("state" in t && "closedAt" in t && "title" in t && !("baseBranch" in t))
84 return "issues";
85 if ("username" in t && "passwordHash" in t) return "users";
86 if ("kind" in t) return "notifications";
87 if ("action" in t && "userId" in t) return "audit_log";
88 return "?";
89};
90
91const _selectChain: any = {
92 from: (t: any) => {
93 _lastSelectFrom = t;
94 return _selectChain;
95 },
96 innerJoin: () => _selectChain,
97 leftJoin: () => _selectChain,
98 rightJoin: () => _selectChain,
99 where: () => _selectChain,
100 orderBy: () => _selectChain,
101 groupBy: () => _selectChain,
102 limit: async () => {
103 const name = tableName(_lastSelectFrom);
104 if (name === "repositories") {
105 return _nextRepoRow ? [_nextRepoRow] : [];
106 }
107 if (name === "repo_collaborators") {
108 return _nextCollabRow ? [_nextCollabRow] : [];
109 }
110 if (name === "issues") {
111 return _nextIssueRow ? [_nextIssueRow] : [];
112 }
113 if (name === "pull_requests") {
114 return _nextPrRow ? [_nextPrRow] : [];
115 }
116 if (name === "pr_comments") {
117 return _nextAiCommentRows;
118 }
119 if (name === "users") {
2136cc5Claude120 // Only echo a username when the test explicitly set one via
121 // `_nextRepoRow.username`. Returning a default row breaks every
122 // downstream test that expects an empty result for nonexistent
123 // users (graphql, api-v2, etc.).
124 return _nextRepoRow?.username ? [{ username: _nextRepoRow.username }] : [];
6551045Claude125 }
126 return [];
127 },
128 // For pr-comments AI-approval lookup, the route doesn't `.limit(1)` — it
129 // awaits the chain directly. We expose `.then` so `await chain` yields
130 // the rows.
131 then: (resolve: (v: any) => void) => {
132 const name = tableName(_lastSelectFrom);
133 if (name === "pr_comments") return resolve(_nextAiCommentRows);
134 if (name === "pull_requests") return resolve(_nextPrRow ? [_nextPrRow] : []);
135 return resolve([]);
136 },
137};
138
139const _insertChain = (table: any) => {
140 _lastInsertTable = table;
141 return {
142 values: (vals: any) => {
143 _lastInsertValues = vals;
144 return {
145 returning: async () => {
146 const name = tableName(table);
147 let returned: any;
148 if (name === "issues") {
149 returned = {
150 id: "iss-id-1",
151 number: 42,
152 repositoryId: vals.repositoryId,
153 authorId: vals.authorId,
154 title: vals.title,
155 body: vals.body,
156 state: "open",
157 createdAt: new Date(),
158 updatedAt: new Date(),
159 closedAt: null,
160 };
161 } else if (name === "issue_comments") {
162 returned = {
163 id: "ic-id-1",
164 issueId: vals.issueId,
165 authorId: vals.authorId,
166 body: vals.body,
167 createdAt: new Date(),
168 updatedAt: new Date(),
169 };
170 } else if (name === "pull_requests") {
171 returned = {
172 id: "pr-id-1",
173 number: 7,
174 repositoryId: vals.repositoryId,
175 authorId: vals.authorId,
176 title: vals.title,
177 body: vals.body,
178 state: "open",
179 baseBranch: vals.baseBranch,
180 headBranch: vals.headBranch,
181 isDraft: vals.isDraft ?? false,
182 mergeStrategy: "merge",
183 milestoneId: null,
184 mergedAt: null,
185 mergedBy: null,
186 createdAt: new Date(),
187 updatedAt: new Date(),
188 closedAt: null,
189 };
190 } else if (name === "pr_comments") {
191 returned = {
192 id: "pc-id-1",
193 pullRequestId: vals.pullRequestId,
194 authorId: vals.authorId,
195 body: vals.body,
196 isAiReview: vals.isAiReview ?? false,
197 filePath: null,
198 lineNumber: null,
199 createdAt: new Date(),
200 updatedAt: new Date(),
201 };
202 } else {
203 returned = vals;
204 }
205 _inserted.push({ table: name, values: vals, returned });
206 return [returned];
207 },
208 // Allow `await db.insert(...).values(...)` (no `.returning()`),
209 // used by notify/audit + auto-close-issues path.
210 then: (resolve: (v: any) => void) => {
211 _inserted.push({ table: tableName(table), values: vals, returned: null });
212 resolve(undefined);
213 },
214 };
215 },
216 };
217};
218
219const _updateChain = (table: any) => {
220 _lastUpdateTable = table;
221 return {
222 set: (vals: any) => {
223 _updated.push({ table: tableName(table), set: vals });
224 return {
225 where: () => ({
226 then: (resolve: (v: any) => void) => resolve(undefined),
227 }),
228 };
229 },
230 };
231};
232
233const _fakeDb = {
234 db: {
235 select: () => _selectChain,
236 insert: (t: any) => _insertChain(t),
237 update: (t: any) => _updateChain(t),
238 delete: () => ({ where: () => Promise.resolve() }),
239 },
240 getDb: () => _fakeDb.db,
2136cc5Claude241 // NOTE: `isNeonUrl` is intentionally NOT overridden. The real export
242 // is a pure substring helper that downstream tests (db-driver.test.ts)
243 // exercise directly — overriding it here used to bleed `() => false`
244 // into every test in the run.
6551045Claude245};
2136cc5Claude246// Mock ONLY `../db`. Bun's `mock.module()` is process-global AND it
247// poisons every downstream test file that imports the same module —
248// overrides applied here persist past `afterAll` because ESM caches
249// the resolved module in each test file at load time. So we keep the
250// stub surface minimal: only the modules that would touch external
251// resources (real DB, real git subprocess, real Anthropic API) get
252// inert overrides. Everything else runs the real implementation,
253// which behaves correctly when the underlying DB returns nothing.
254//
255// For modules that DO need their behaviour neutered (gate runner,
256// merge-resolver, ai-review's API call), we install spy-style stubs
257// inside `beforeEach` and remove them in `afterAll` so they don't
258// leak across files.
6551045Claude259const _notifyCalls: any[] = [];
260const _auditCalls: any[] = [];
2136cc5Claude261mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
6551045Claude262mock.module("../lib/notify", () => ({
2136cc5Claude263 ..._real_notify,
6551045Claude264 notify: async (userId: string, opts: any) => {
265 _notifyCalls.push({ userId, ...opts });
266 },
267 notifyMany: async () => {},
268 audit: async (opts: any) => {
269 _auditCalls.push(opts);
270 },
271}));
2136cc5Claude272// `runAllGateChecks` shells out to git + the gate runner; replace with
273// a permissive no-op. Other gate exports stay real.
6551045Claude274mock.module("../lib/gate", () => ({
2136cc5Claude275 ..._real_gate,
6551045Claude276 runAllGateChecks: async () => ({ checks: [] }),
277}));
2136cc5Claude278// `mergeWithAutoResolve` shells out to git; replace with a success no-op.
6551045Claude279mock.module("../lib/merge-resolver", () => ({
2136cc5Claude280 ..._real_mergeResolver,
6551045Claude281 mergeWithAutoResolve: async () => ({ success: true, resolvedFiles: [] }),
282}));
2136cc5Claude283// `triggerAiReview` calls Anthropic; replace with a no-op. Crucially,
284// `AI_REVIEW_MARKER` (used by auto-merge.ts elsewhere) stays the real
285// const.
6551045Claude286mock.module("../lib/ai-review", () => ({
2136cc5Claude287 ..._real_aiReview,
6551045Claude288 isAiReviewEnabled: () => false,
289 triggerAiReview: async () => {},
290}));
2136cc5Claude291// NOTE: `../lib/branch-protection`, `../lib/close-keywords`, and
292// `../lib/sse` are intentionally NOT mocked here. The real modules
293// are pure (close-keywords) or fail-safe when the DB is the inert
294// stub (branch-protection's `matchProtection` returns null when no
295// rows come back, which is the K1 happy-path expectation anyway).
296// Mocking these previously broke K2's auto-merge.test.ts and the
297// sse + close-keywords + branch-protection test files.
6551045Claude298
299// We stub Bun.spawn globally so any git subprocess (e.g. resolveRef,
300// `git update-ref` in merge_pr) returns a deterministic happy exit. We
301// intentionally do NOT mock `../git/repository` so the rest of the
302// library's static imports keep resolving correctly.
303const _realBunSpawn = (globalThis as any).Bun?.spawn;
304function stubBunSpawnSuccess() {
305 (globalThis as any).Bun.spawn = () => ({
306 exited: Promise.resolve(0),
307 stdout: new ReadableStream({
308 start(c) {
309 c.enqueue(new TextEncoder().encode("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n"));
310 c.close();
311 },
312 }),
313 stderr: new ReadableStream({
314 start(c) {
315 c.close();
316 },
317 }),
318 });
319}
320function restoreBunSpawn() {
321 if (_realBunSpawn) (globalThis as any).Bun.spawn = _realBunSpawn;
322}
2136cc5Claude323// Bun.spawn stub is NOT installed globally — the previous global install
324// bled into every downstream test file in the run and broke any test
325// whose code path eventually shelled out to `git`. The merge-PR happy
326// path test below installs + restores it inside the test body instead.
6551045Claude327
328afterAll(() => {
329 restoreBunSpawn();
2136cc5Claude330 // Reset every per-test row hook so any downstream test file whose
331 // code path runs `db.select()...limit(1)` against our stub sees an
332 // empty result (the no-rows branch) rather than inheriting the
333 // PUBLIC_REPO_ROW that K1's beforeEach last installed.
334 _nextRepoRow = null;
335 _nextCollabRow = null;
336 _nextIssueRow = null;
337 _nextPrRow = null;
338 _nextAiCommentRows = [];
339 _nextProtectionRule = null;
340 // Best-effort restoration: re-register the real modules. Note that
341 // downstream test files' static imports are already cached against
342 // whatever was active at their load time, so this is mostly a hygiene
343 // measure for files using dynamic imports.
344 mock.module("../db", () => _real_db);
345 mock.module("../lib/notify", () => _real_notify);
346 mock.module("../lib/gate", () => _real_gate);
347 mock.module("../lib/merge-resolver", () => _real_mergeResolver);
348 mock.module("../lib/ai-review", () => _real_aiReview);
6551045Claude349});
350
351// ---------------------------------------------------------------------------
352// Test fixtures
353// ---------------------------------------------------------------------------
354
355const OWNER_ID = "11111111-1111-1111-1111-111111111111";
356const REPO_ID = "22222222-2222-2222-2222-222222222222";
357const OTHER_USER_ID = "33333333-3333-3333-3333-333333333333";
358
359const PUBLIC_REPO_ROW = {
360 id: REPO_ID,
361 ownerId: OWNER_ID,
362 isPrivate: false,
363 defaultBranch: "main",
364 username: "alice",
365};
366
367const ownerCtx = { userId: OWNER_ID };
368const anonCtx = { userId: null };
369const otherCtx = { userId: OTHER_USER_ID };
370
371function resetState() {
372 _nextRepoRow = PUBLIC_REPO_ROW;
373 _nextCollabRow = null; // No collaborator rows → public repo non-owner = read
374 _nextIssueRow = null;
375 _nextPrRow = null;
376 _nextAiCommentRows = [];
377 _nextProtectionRule = null;
378 _inserted.length = 0;
379 _updated.length = 0;
380 _notifyCalls.length = 0;
381 _auditCalls.length = 0;
382}
383
384beforeEach(() => {
385 resetState();
386});
387
388async function getTools() {
389 const m = await import("../lib/mcp-tools");
390 return m.__test;
391}
392
393// ---------------------------------------------------------------------------
394// gluecron_create_issue
395// ---------------------------------------------------------------------------
396
397describe("gluecron_create_issue", () => {
398 it("owner can create an issue (happy path)", async () => {
399 const T = await getTools();
400 const result = (await T.createIssue.run(
401 { owner: "alice", repo: "demo", title: "Hello", body: "World" },
402 ownerCtx
403 )) as { number: number; url: string };
404 expect(result.number).toBe(42);
405 expect(result.url).toBe("/alice/demo/issues/42");
406 expect(_inserted.find((r) => r.table === "issues")).toBeTruthy();
407 expect(_auditCalls.some((a) => a.action === "issue.created")).toBe(true);
408 });
409
410 it("anonymous → -32602 INVALID_PARAMS", async () => {
411 const T = await getTools();
412 let caught: McpError | null = null;
413 try {
414 await T.createIssue.run(
415 { owner: "alice", repo: "demo", title: "x" },
416 anonCtx
417 );
418 } catch (err) {
419 if (err instanceof McpError) caught = err;
420 }
421 expect(caught?.code).toBe(ERR_INVALID_PARAMS);
422 expect(caught?.message).toMatch(/authentication required/);
423 });
424
425 it("authed-but-no-write → -32601 METHOD_NOT_FOUND", async () => {
426 const T = await getTools();
427 // Public repo, other user, no collab row → only "read"
428 let caught: McpError | null = null;
429 try {
430 await T.createIssue.run(
431 { owner: "alice", repo: "demo", title: "x" },
432 otherCtx
433 );
434 } catch (err) {
435 if (err instanceof McpError) caught = err;
436 }
437 expect(caught?.code).toBe(ERR_METHOD_NOT_FOUND);
438 expect(caught?.message).toMatch(/no write access/);
439 });
440});
441
442// ---------------------------------------------------------------------------
443// gluecron_comment_issue
444// ---------------------------------------------------------------------------
445
446describe("gluecron_comment_issue", () => {
447 it("owner can comment on an issue", async () => {
448 const T = await getTools();
449 _nextIssueRow = {
450 id: "iss-id-1",
451 number: 42,
452 repositoryId: REPO_ID,
453 authorId: OWNER_ID,
454 state: "open",
455 };
456 const result = (await T.commentIssue.run(
457 { owner: "alice", repo: "demo", number: 42, body: "thanks!" },
458 ownerCtx
459 )) as { commentId: string };
460 expect(result.commentId).toBe("ic-id-1");
461 expect(_inserted.find((r) => r.table === "issue_comments")).toBeTruthy();
462 });
463
464 it("anonymous → -32602", async () => {
465 const T = await getTools();
466 expect(
467 T.commentIssue.run(
468 { owner: "alice", repo: "demo", number: 1, body: "x" },
469 anonCtx
470 )
471 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
472 });
473
474 it("other user (read-only) → -32601", async () => {
475 const T = await getTools();
476 expect(
477 T.commentIssue.run(
478 { owner: "alice", repo: "demo", number: 1, body: "x" },
479 otherCtx
480 )
481 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
482 });
483});
484
485// ---------------------------------------------------------------------------
486// gluecron_close_issue
487// ---------------------------------------------------------------------------
488
489describe("gluecron_close_issue", () => {
490 it("owner can close an open issue", async () => {
491 const T = await getTools();
492 _nextIssueRow = {
493 id: "iss-1",
494 number: 5,
495 repositoryId: REPO_ID,
496 authorId: OWNER_ID,
497 state: "open",
498 };
499 const result = (await T.closeIssue.run(
500 { owner: "alice", repo: "demo", number: 5 },
501 ownerCtx
502 )) as { state: string };
503 expect(result.state).toBe("closed");
504 expect(_updated.find((u) => u.table === "issues")).toBeTruthy();
505 });
506
507 it("anonymous → -32602", async () => {
508 const T = await getTools();
509 expect(
510 T.closeIssue.run({ owner: "alice", repo: "demo", number: 5 }, anonCtx)
511 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
512 });
513
514 it("other user → -32601", async () => {
515 const T = await getTools();
516 expect(
517 T.closeIssue.run({ owner: "alice", repo: "demo", number: 5 }, otherCtx)
518 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
519 });
520});
521
522// ---------------------------------------------------------------------------
523// gluecron_reopen_issue
524// ---------------------------------------------------------------------------
525
526describe("gluecron_reopen_issue", () => {
527 it("owner can reopen a closed issue", async () => {
528 const T = await getTools();
529 _nextIssueRow = {
530 id: "iss-1",
531 number: 5,
532 repositoryId: REPO_ID,
533 authorId: OWNER_ID,
534 state: "closed",
535 closedAt: new Date(),
536 };
537 const result = (await T.reopenIssue.run(
538 { owner: "alice", repo: "demo", number: 5 },
539 ownerCtx
540 )) as { state: string };
541 expect(result.state).toBe("open");
542 expect(_updated.find((u) => u.table === "issues")).toBeTruthy();
543 });
544
545 it("anonymous → -32602", async () => {
546 const T = await getTools();
547 expect(
548 T.reopenIssue.run({ owner: "alice", repo: "demo", number: 5 }, anonCtx)
549 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
550 });
551
552 it("other user → -32601", async () => {
553 const T = await getTools();
554 expect(
555 T.reopenIssue.run({ owner: "alice", repo: "demo", number: 5 }, otherCtx)
556 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
557 });
558});
559
560// ---------------------------------------------------------------------------
561// gluecron_create_pr
562// ---------------------------------------------------------------------------
563
564describe("gluecron_create_pr", () => {
565 it("owner can open a PR (base defaults to repo default branch)", async () => {
566 const T = await getTools();
567 const result = (await T.createPr.run(
568 {
569 owner: "alice",
570 repo: "demo",
571 title: "Feature X",
572 head_branch: "feat/x",
573 },
574 ownerCtx
575 )) as { number: number; url: string };
576 expect(result.number).toBe(7);
577 expect(result.url).toBe("/alice/demo/pulls/7");
578 const ins = _inserted.find((r) => r.table === "pull_requests");
579 expect(ins).toBeTruthy();
580 expect(ins?.values.baseBranch).toBe("main");
581 expect(ins?.values.headBranch).toBe("feat/x");
582 });
583
584 it("rejects when base === head", async () => {
585 const T = await getTools();
586 let caught: McpError | null = null;
587 try {
588 await T.createPr.run(
589 {
590 owner: "alice",
591 repo: "demo",
592 title: "x",
593 head_branch: "main",
594 base_branch: "main",
595 },
596 ownerCtx
597 );
598 } catch (err) {
599 if (err instanceof McpError) caught = err;
600 }
601 expect(caught?.code).toBe(ERR_INVALID_PARAMS);
602 });
603
604 it("anonymous → -32602", async () => {
605 const T = await getTools();
606 expect(
607 T.createPr.run(
608 { owner: "alice", repo: "demo", title: "x", head_branch: "feat" },
609 anonCtx
610 )
611 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
612 });
613
614 it("other user → -32601", async () => {
615 const T = await getTools();
616 expect(
617 T.createPr.run(
618 { owner: "alice", repo: "demo", title: "x", head_branch: "feat" },
619 otherCtx
620 )
621 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
622 });
623});
624
625// ---------------------------------------------------------------------------
626// gluecron_get_pr
627// ---------------------------------------------------------------------------
628
629describe("gluecron_get_pr", () => {
630 it("returns full detail for an authed reader", async () => {
631 const T = await getTools();
632 _nextPrRow = {
633 id: "pr-1",
634 number: 7,
635 repositoryId: REPO_ID,
636 authorId: OWNER_ID,
637 title: "Feature",
638 body: "Body",
639 state: "open",
640 baseBranch: "main",
641 headBranch: "feat/x",
642 isDraft: false,
643 createdAt: new Date(),
644 updatedAt: new Date(),
645 mergedAt: null,
646 closedAt: null,
647 };
648 const r = (await T.getPr.run(
649 { owner: "alice", repo: "demo", number: 7 },
650 ownerCtx
651 )) as any;
652 expect(r.number).toBe(7);
653 expect(r.state).toBe("open");
654 expect(r.baseBranch).toBe("main");
655 expect(r.headBranch).toBe("feat/x");
656 expect(r.url).toBe("/alice/demo/pulls/7");
657 });
658
659 it("anonymous → -32602 (write surface requires auth)", async () => {
660 const T = await getTools();
661 expect(
662 T.getPr.run({ owner: "alice", repo: "demo", number: 1 }, anonCtx)
663 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
664 });
665
666 it("private repo + non-collaborator → -32601 (privacy)", async () => {
667 const T = await getTools();
668 _nextRepoRow = { ...PUBLIC_REPO_ROW, isPrivate: true };
669 expect(
670 T.getPr.run({ owner: "alice", repo: "demo", number: 1 }, otherCtx)
671 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
672 });
673});
674
675// ---------------------------------------------------------------------------
676// gluecron_list_prs
677// ---------------------------------------------------------------------------
678
679describe("gluecron_list_prs", () => {
680 it("rejects an unknown state value", async () => {
681 const T = await getTools();
682 expect(
683 T.listPrs.run(
684 { owner: "alice", repo: "demo", state: "garbage" },
685 ownerCtx
686 )
687 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
688 });
689
690 it("anonymous → -32602", async () => {
691 const T = await getTools();
692 expect(
693 T.listPrs.run({ owner: "alice", repo: "demo" }, anonCtx)
694 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
695 });
696
697 it("private repo + non-collaborator → -32601", async () => {
698 const T = await getTools();
699 _nextRepoRow = { ...PUBLIC_REPO_ROW, isPrivate: true };
700 expect(
701 T.listPrs.run({ owner: "alice", repo: "demo" }, otherCtx)
702 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
703 });
704
705 it("authed reader gets a 0-row list when none exist", async () => {
706 const T = await getTools();
707 const r = (await T.listPrs.run(
708 { owner: "alice", repo: "demo" },
709 ownerCtx
710 )) as { total: number; prs: any[] };
711 // The fake DB returns [] for the listPrs path because the chain only
712 // returns canned rows on `.limit(1)` of repositories. The unbounded
713 // PR list shape comes back empty — which is the explicit "no PRs"
714 // happy path we want to assert.
715 expect(typeof r.total).toBe("number");
716 expect(Array.isArray(r.prs)).toBe(true);
717 });
718});
719
720// ---------------------------------------------------------------------------
721// gluecron_comment_pr
722// ---------------------------------------------------------------------------
723
724describe("gluecron_comment_pr", () => {
725 it("owner can comment on a PR", async () => {
726 const T = await getTools();
727 _nextPrRow = {
728 id: "pr-1",
729 number: 7,
730 repositoryId: REPO_ID,
731 authorId: OWNER_ID,
732 state: "open",
733 baseBranch: "main",
734 headBranch: "feat/x",
735 isDraft: false,
736 };
737 const result = (await T.commentPr.run(
738 { owner: "alice", repo: "demo", number: 7, body: "LGTM" },
739 ownerCtx
740 )) as { commentId: string };
741 expect(result.commentId).toBe("pc-id-1");
742 expect(_inserted.find((r) => r.table === "pr_comments")).toBeTruthy();
743 });
744
745 it("anonymous → -32602", async () => {
746 const T = await getTools();
747 expect(
748 T.commentPr.run(
749 { owner: "alice", repo: "demo", number: 1, body: "x" },
750 anonCtx
751 )
752 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
753 });
754
755 it("other user → -32601", async () => {
756 const T = await getTools();
757 expect(
758 T.commentPr.run(
759 { owner: "alice", repo: "demo", number: 1, body: "x" },
760 otherCtx
761 )
762 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
763 });
764});
765
766// ---------------------------------------------------------------------------
767// gluecron_merge_pr
768// ---------------------------------------------------------------------------
769
770describe("gluecron_merge_pr", () => {
771 it("owner can merge a clean open PR (no protection, no conflicts)", async () => {
772 const T = await getTools();
773 _nextPrRow = {
774 id: "pr-1",
775 number: 7,
776 repositoryId: REPO_ID,
777 authorId: OWNER_ID,
778 title: "Feature",
779 body: "",
780 state: "open",
781 baseBranch: "main",
782 headBranch: "feat/x",
783 isDraft: false,
784 };
785 stubBunSpawnSuccess();
786 try {
787 const r = (await T.mergePr.run(
788 { owner: "alice", repo: "demo", number: 7 },
789 ownerCtx
790 )) as { merged: boolean; sha?: string; reason?: string };
791 expect(r.merged).toBe(true);
792 expect(typeof r.sha).toBe("string");
793 expect(_updated.some((u) => u.table === "pull_requests")).toBe(true);
794 } finally {
795 restoreBunSpawn();
796 }
797 });
798
799 it("draft PR → merged=false with human-readable reason", async () => {
800 const T = await getTools();
801 _nextPrRow = {
802 id: "pr-1",
803 number: 7,
804 repositoryId: REPO_ID,
805 authorId: OWNER_ID,
806 state: "open",
807 baseBranch: "main",
808 headBranch: "feat/x",
809 isDraft: true,
810 };
811 const r = (await T.mergePr.run(
812 { owner: "alice", repo: "demo", number: 7 },
813 ownerCtx
814 )) as { merged: boolean; reason?: string };
815 expect(r.merged).toBe(false);
816 expect(r.reason).toMatch(/draft/i);
817 });
818
819 it("anonymous → -32602", async () => {
820 const T = await getTools();
821 expect(
822 T.mergePr.run({ owner: "alice", repo: "demo", number: 1 }, anonCtx)
823 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
824 });
825
826 it("other user → -32601", async () => {
827 const T = await getTools();
828 expect(
829 T.mergePr.run({ owner: "alice", repo: "demo", number: 1 }, otherCtx)
830 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
831 });
832});
833
834// ---------------------------------------------------------------------------
835// gluecron_close_pr
836// ---------------------------------------------------------------------------
837
838describe("gluecron_close_pr", () => {
839 it("owner can close an open PR", async () => {
840 const T = await getTools();
841 _nextPrRow = {
842 id: "pr-1",
843 number: 7,
844 repositoryId: REPO_ID,
845 authorId: OWNER_ID,
846 state: "open",
847 baseBranch: "main",
848 headBranch: "feat/x",
849 isDraft: false,
850 };
851 const r = (await T.closePr.run(
852 { owner: "alice", repo: "demo", number: 7 },
853 ownerCtx
854 )) as { state: string };
855 expect(r.state).toBe("closed");
856 expect(_updated.some((u) => u.table === "pull_requests")).toBe(true);
857 });
858
859 it("anonymous → -32602", async () => {
860 const T = await getTools();
861 expect(
862 T.closePr.run({ owner: "alice", repo: "demo", number: 1 }, anonCtx)
863 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
864 });
865
866 it("other user → -32601", async () => {
867 const T = await getTools();
868 expect(
869 T.closePr.run({ owner: "alice", repo: "demo", number: 1 }, otherCtx)
870 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
871 });
872});
873
874// ---------------------------------------------------------------------------
875// Default registry contains the new tools
876// ---------------------------------------------------------------------------
877
878describe("defaultTools — registry shape", () => {
879 it("includes all 10 K1 write-surface tools", async () => {
880 const { defaultTools } = await import("../lib/mcp-tools");
881 const tools = defaultTools();
882 const expected = [
883 "gluecron_create_issue",
884 "gluecron_comment_issue",
885 "gluecron_close_issue",
886 "gluecron_reopen_issue",
887 "gluecron_create_pr",
888 "gluecron_get_pr",
889 "gluecron_list_prs",
890 "gluecron_comment_pr",
891 "gluecron_merge_pr",
892 "gluecron_close_pr",
893 ];
894 for (const name of expected) {
895 expect(tools[name]).toBeTruthy();
896 expect(tools[name].tool.name).toBe(name);
897 expect(tools[name].tool.inputSchema.type).toBe("object");
898 }
899 });
900});