Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

pr-slash-commands.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.

pr-slash-commands.test.tsBlame522 lines · 1 contributor
15db0e0Claude1/**
2 * Tests for `src/lib/pr-slash-commands.ts`.
3 *
4 * Two layers (matching the project convention):
5 * 1. Pure helpers — `parseSlashCommand`, marker detection,
6 * `parseMergeStrategy`, `/help` rendering. Run unconditionally.
7 * 2. End-to-end with a real DB row + bare repo. Gated on
8 * `DATABASE_URL` via `HAS_DB` skipIf, matching ai-ci-healer.test.ts.
9 *
10 * All AI calls are stubbed via the `deps.anthropic` injection point.
11 * The merge call is stubbed via `deps.merge` to avoid touching real
12 * git refs (the executor's job is to format the result, not to re-test
13 * pr-merge.ts).
14 */
15
16import { describe, it, expect, beforeAll, afterAll } from "bun:test";
17import { join } from "path";
18import { mkdir, rm } from "fs/promises";
19import { eq } from "drizzle-orm";
20
21import {
22 parseSlashCommand,
23 executeSlashCommand,
24 detectSlashCmdComment,
25 stripSlashCmdMarker,
26 slashCmdMarker,
27 SLASH_COMMANDS,
28 __test,
29} from "../lib/pr-slash-commands";
30import { db } from "../db";
31import {
32 pullRequests,
33 prComments,
34 repositories,
35 users,
36 workflows,
37} from "../db/schema";
38import { initBareRepo, createOrUpdateFileOnBranch } from "../git/repository";
39
40const HAS_DB = Boolean(process.env.DATABASE_URL);
41
42const TEST_REPOS = join(
43 import.meta.dir,
44 "../../.test-repos-pr-slash-" + Date.now()
45);
46
47beforeAll(async () => {
48 process.env.GIT_REPOS_PATH = TEST_REPOS;
49 await rm(TEST_REPOS, { recursive: true, force: true });
50 await mkdir(TEST_REPOS, { recursive: true });
51});
52
53afterAll(async () => {
54 await rm(TEST_REPOS, { recursive: true, force: true });
55});
56
57// ---------------------------------------------------------------------------
58// 1. Pure parser surface — runs without a DB
59// ---------------------------------------------------------------------------
60
61describe("parseSlashCommand", () => {
62 it("returns null for empty input", () => {
63 expect(parseSlashCommand("")).toBeNull();
64 expect(parseSlashCommand(" ")).toBeNull();
65 });
66
67 it("returns null for free-form text", () => {
68 expect(parseSlashCommand("hey team, take a look")).toBeNull();
69 expect(parseSlashCommand("hey /merge")).toBeNull(); // must start at col 0
70 });
71
72 it("returns null for Unix-path-like comments that start with /", () => {
73 expect(parseSlashCommand("/usr/local/bin/foo")).toBeNull();
74 expect(parseSlashCommand("/etc/passwd is interesting")).toBeNull();
75 });
76
77 it("recognises every command without args", () => {
78 for (const cmd of SLASH_COMMANDS) {
79 const parsed = parseSlashCommand(`/${cmd}`);
80 expect(parsed).not.toBeNull();
81 expect(parsed!.command).toBe(cmd);
82 expect(parsed!.args).toEqual([]);
83 }
84 });
85
86 it("parses /merge with a strategy arg", () => {
87 expect(parseSlashCommand("/merge squash")).toEqual({
88 command: "merge",
89 args: ["squash"],
90 raw: "merge squash",
91 });
92 expect(parseSlashCommand("/merge rebase")).toEqual({
93 command: "merge",
94 args: ["rebase"],
95 raw: "merge rebase",
96 });
97 expect(parseSlashCommand("/merge merge")).toEqual({
98 command: "merge",
99 args: ["merge"],
100 raw: "merge merge",
101 });
102 });
103
104 it("parses /cc with multiple @users", () => {
105 const p = parseSlashCommand("/cc @alice @bob @carol");
106 expect(p).not.toBeNull();
107 expect(p!.command).toBe("cc");
108 expect(p!.args).toEqual(["@alice", "@bob", "@carol"]);
109 });
110
111 it("only inspects the first non-blank line", () => {
112 const p = parseSlashCommand("/needs-work\nplease tighten the loop body");
113 expect(p).not.toBeNull();
114 expect(p!.command).toBe("needs-work");
115 expect(p!.args).toEqual([]);
116 });
117
118 it("normalises trailing punctuation", () => {
119 expect(parseSlashCommand("/help.")!.command).toBe("help");
120 expect(parseSlashCommand("/HELP")!.command).toBe("help");
121 });
122
123 it("ignores a leading space after the slash", () => {
124 expect(parseSlashCommand("/ merge")).toBeNull();
125 });
126});
127
128describe("detectSlashCmdComment / stripSlashCmdMarker", () => {
129 it("detects each command marker", () => {
130 for (const cmd of SLASH_COMMANDS) {
131 const body = `${slashCmdMarker(cmd)}\n\nbody text`;
132 expect(detectSlashCmdComment(body)).toBe(cmd);
133 expect(stripSlashCmdMarker(body)).toBe("body text");
134 }
135 });
136
137 it("returns null for normal comments", () => {
138 expect(detectSlashCmdComment("just a plain comment")).toBeNull();
139 expect(detectSlashCmdComment("<!-- something else -->")).toBeNull();
140 });
141});
142
143describe("__test.parseMergeStrategy", () => {
144 it("defaults to merge when the arg is missing or junk", () => {
145 expect(__test.parseMergeStrategy(undefined)).toBe("merge");
146 expect(__test.parseMergeStrategy("")).toBe("merge");
147 expect(__test.parseMergeStrategy("rocket")).toBe("merge");
148 });
149 it("accepts the three documented strategies", () => {
150 expect(__test.parseMergeStrategy("squash")).toBe("squash");
151 expect(__test.parseMergeStrategy("rebase")).toBe("rebase");
152 expect(__test.parseMergeStrategy("merge")).toBe("merge");
153 expect(__test.parseMergeStrategy("SQUASH")).toBe("squash");
154 });
155});
156
157// ---------------------------------------------------------------------------
158// 2. Executor — /help runs without any deps
159// ---------------------------------------------------------------------------
160
161describe("executeSlashCommand — /help", () => {
162 it("renders the full command list with the marker", async () => {
163 const result = await executeSlashCommand({
164 command: "help",
165 args: [],
166 prId: "00000000-0000-0000-0000-000000000000",
167 userId: "00000000-0000-0000-0000-000000000000",
168 repositoryId: "00000000-0000-0000-0000-000000000000",
169 });
170 expect(result.ok).toBe(true);
171 expect(result.marker).toBe(slashCmdMarker("help"));
172 expect(result.body).toContain(slashCmdMarker("help"));
173 // Every recognised command is documented in the help output.
174 for (const cmd of SLASH_COMMANDS) {
175 expect(result.body).toContain(`/${cmd}`);
176 }
177 });
178});
179
180describe("executeSlashCommand — unrecognised input never throws", () => {
181 it("does not throw when no DB is available", async () => {
182 // parseSlashCommand would normally reject this; we call execute
183 // directly to make sure the route-side defence-in-depth is fine.
184 const result = await executeSlashCommand({
185 // @ts-expect-error — exercising the default-case branch
186 command: "does-not-exist",
187 args: [],
188 prId: "00000000-0000-0000-0000-000000000000",
189 userId: "00000000-0000-0000-0000-000000000000",
190 repositoryId: "00000000-0000-0000-0000-000000000000",
191 });
192 expect(result.ok).toBe(false);
193 expect(result.body).toContain("Unrecognised slash command");
194 });
195});
196
197// ---------------------------------------------------------------------------
198// 3. DB-backed flows — /explain (mock Claude), /merge (mock performMerge)
199// ---------------------------------------------------------------------------
200
201function fakeAnthropic(responseText: string) {
202 return {
203 messages: {
204 create: async () => ({
205 id: "msg_test",
206 type: "message" as const,
207 role: "assistant" as const,
208 model: "claude-sonnet-4-test",
209 stop_reason: "end_turn" as const,
210 stop_sequence: null,
211 usage: { input_tokens: 0, output_tokens: 0 },
212 content: [{ type: "text" as const, text: responseText }],
213 }),
214 },
215 } as any;
216}
217
218interface SlashFixture {
219 userId: string;
220 repoId: string;
221 prId: string;
222 ownerUsername: string;
223 repoName: string;
224}
225
226async function seedFixture(label: string): Promise<SlashFixture> {
227 const username = `slash_${label}_${Date.now()}_${Math.random()
228 .toString(36)
229 .slice(2, 6)}`;
230 const [u] = await db
231 .insert(users)
232 .values({
233 username,
234 email: `${username}@example.com`,
235 passwordHash: "x",
236 })
237 .returning({ id: users.id });
238
239 const repoName = `subject_${label}_${Date.now()}`;
240 const [r] = await db
241 .insert(repositories)
242 .values({
243 ownerId: u.id,
244 name: repoName,
245 diskPath: `/tmp/${username}/${repoName}`,
246 defaultBranch: "main",
247 })
248 .returning({ id: repositories.id });
249
250 await initBareRepo(username, repoName);
251 // Seed a base commit on main + a head commit on feat-x so the diff
252 // /explain consumes is non-empty.
253 await createOrUpdateFileOnBranch({
254 owner: username,
255 name: repoName,
256 branch: "main",
257 filePath: "src/index.ts",
258 bytes: new TextEncoder().encode("export const v = 1;\n"),
259 message: "base",
260 authorName: "Seeder",
261 authorEmail: "s@e.com",
262 });
263 await createOrUpdateFileOnBranch({
264 owner: username,
265 name: repoName,
266 branch: "feat-x",
267 filePath: "src/index.ts",
268 bytes: new TextEncoder().encode("export const v = 2;\n"),
269 message: "feat: bump v",
270 authorName: "Seeder",
271 authorEmail: "s@e.com",
272 });
273
274 const [pr] = await db
275 .insert(pullRequests)
276 .values({
277 repositoryId: r.id,
278 number: 1,
279 title: "Bump v",
280 body: "Bumps the version constant.",
281 authorId: u.id,
282 baseBranch: "main",
283 headBranch: "feat-x",
284 state: "open",
285 })
286 .returning({ id: pullRequests.id });
287
288 return {
289 userId: u.id,
290 repoId: r.id,
291 prId: pr.id,
292 ownerUsername: username,
293 repoName,
294 };
295}
296
297describe.skipIf(!HAS_DB)("executeSlashCommand — /explain (DB-backed)", () => {
298 it("calls Claude and returns its explanation in the result body", async () => {
299 const fx = await seedFixture("explain");
300 const result = await executeSlashCommand({
301 command: "explain",
302 args: [],
303 prId: fx.prId,
304 userId: fx.userId,
305 repositoryId: fx.repoId,
306 deps: { anthropic: fakeAnthropic("This PR bumps `v` from 1 to 2.") },
307 });
308 expect(result.ok).toBe(true);
309 expect(result.body).toContain(slashCmdMarker("explain"));
310 expect(result.body).toContain("This PR bumps `v` from 1 to 2.");
311 }, 15_000);
312});
313
314describe.skipIf(!HAS_DB)("executeSlashCommand — /merge (DB-backed)", () => {
315 it("forwards the requested strategy and reports the merge result", async () => {
316 const fx = await seedFixture("merge");
317
318 let observedActor: string | null = null;
319 const fakeMerge = async (mergeArgs: any) => {
320 observedActor = mergeArgs.actorUserId;
321 expect(mergeArgs.pr.id).toBe(fx.prId);
322 expect(mergeArgs.pr.headBranch).toBe("feat-x");
323 expect(mergeArgs.pr.baseBranch).toBe("main");
324 return {
325 ok: true as const,
326 closedIssueNumbers: [42],
327 resolvedFiles: [],
328 };
329 };
330
331 const result = await executeSlashCommand({
332 command: "merge",
333 args: ["squash"],
334 prId: fx.prId,
335 userId: fx.userId,
336 repositoryId: fx.repoId,
337 deps: {
338 merge: fakeMerge as any,
339 // Repo owner = userId, so resolveAccess would return "owner"
340 // anyway; pin it for determinism + DB isolation.
341 resolveAccess: async () => "owner",
342 },
343 });
344 expect(result.ok).toBe(true);
345 expect(observedActor).toBe(fx.userId);
346 expect(result.body).toContain(slashCmdMarker("merge"));
347 expect(result.body).toContain("Merged");
348 expect(result.body).toContain("/merge squash");
349 expect(result.body).toContain("#42");
350 });
351
352 it("refuses to merge when the actor lacks write access", async () => {
353 const fx = await seedFixture("merge-noauth");
354 const result = await executeSlashCommand({
355 command: "merge",
356 args: [],
357 prId: fx.prId,
358 userId: fx.userId,
359 repositoryId: fx.repoId,
360 deps: {
361 merge: async () => {
362 throw new Error("should not be called");
363 },
364 resolveAccess: async () => "read",
365 },
366 });
367 expect(result.ok).toBe(false);
368 expect(result.body).toContain("denied");
369 });
370
371 it("surfaces the underlying performMerge error verbatim", async () => {
372 const fx = await seedFixture("merge-fail");
373 const result = await executeSlashCommand({
374 command: "merge",
375 args: [],
376 prId: fx.prId,
377 userId: fx.userId,
378 repositoryId: fx.repoId,
379 deps: {
380 merge: async () => ({
381 ok: false as const,
382 error: "git update-ref failed: not-fast-forward",
383 closedIssueNumbers: [],
384 resolvedFiles: [],
385 }),
386 resolveAccess: async () => "owner",
387 },
388 });
389 expect(result.ok).toBe(false);
390 expect(result.body).toContain("not-fast-forward");
391 });
392});
393
394describe.skipIf(!HAS_DB)("executeSlashCommand — /lgtm + /needs-work (DB-backed)", () => {
395 it("posts an approval-style body for /lgtm", async () => {
396 const fx = await seedFixture("lgtm");
397 const result = await executeSlashCommand({
398 command: "lgtm",
399 args: [],
400 prId: fx.prId,
401 userId: fx.userId,
402 repositoryId: fx.repoId,
403 });
404 expect(result.ok).toBe(true);
405 expect(result.body.toLowerCase()).toContain("approved");
406 });
407
408 it("captures the reason given to /needs-work", async () => {
409 const fx = await seedFixture("nw");
410 const result = await executeSlashCommand({
411 command: "needs-work",
412 args: ["please", "tighten", "the", "loop"],
413 prId: fx.prId,
414 userId: fx.userId,
415 repositoryId: fx.repoId,
416 });
417 expect(result.ok).toBe(true);
418 expect(result.body).toContain("please tighten the loop");
419 });
420});
421
422describe.skipIf(!HAS_DB)("executeSlashCommand — /cc (DB-backed)", () => {
423 it("resolves known users and flags unknowns", async () => {
424 const fx = await seedFixture("cc");
425 const knownName = `slash_cc_known_${Date.now()}`;
426 await db
427 .insert(users)
428 .values({
429 username: knownName,
430 email: `${knownName}@example.com`,
431 passwordHash: "x",
432 });
433
434 const result = await executeSlashCommand({
435 command: "cc",
436 args: [`@${knownName}`, "@nobody-knows-this"],
437 prId: fx.prId,
438 userId: fx.userId,
439 repositoryId: fx.repoId,
440 });
441 expect(result.ok).toBe(true);
442 expect(result.body).toContain(knownName);
443 expect(result.body).toContain("Skipped");
444 });
445
446 it("rejects when no @users are supplied", async () => {
447 const fx = await seedFixture("cc-empty");
448 const result = await executeSlashCommand({
449 command: "cc",
450 args: [],
451 prId: fx.prId,
452 userId: fx.userId,
453 repositoryId: fx.repoId,
454 });
455 expect(result.ok).toBe(false);
456 expect(result.body).toContain("requires one or more");
457 });
458});
459
460describe.skipIf(!HAS_DB)("executeSlashCommand — /test (DB-backed)", () => {
461 it("enqueues a workflow_dispatch when a test.yml workflow exists", async () => {
462 const fx = await seedFixture("test");
463 const [w] = await db
464 .insert(workflows)
465 .values({
466 repositoryId: fx.repoId,
467 name: "Tests",
468 path: ".gluecron/workflows/test.yml",
469 yaml: "name: Tests\non: [push, workflow_dispatch]\njobs:\n t:\n steps:\n - run: bun test\n",
470 parsed: JSON.stringify({
471 name: "Tests",
472 on: ["push", "workflow_dispatch"],
473 jobs: { t: { steps: [{ run: "bun test" }] } },
474 }),
475 onEvents: JSON.stringify(["push", "workflow_dispatch"]),
476 })
477 .returning({ id: workflows.id });
478
479 const result = await executeSlashCommand({
480 command: "test",
481 args: [],
482 prId: fx.prId,
483 userId: fx.userId,
484 repositoryId: fx.repoId,
485 });
486 expect(result.ok).toBe(true);
487 expect(result.body).toContain("dispatched");
488
489 // Sanity: the lookup helper actually finds the row we just inserted.
490 const found = await __test.findTestWorkflow(fx.repoId);
491 expect(found?.id).toBe(w.id);
492 }, 15_000);
493
494 it("falls back gracefully when no test workflow is configured", async () => {
495 const fx = await seedFixture("test-missing");
496 const result = await executeSlashCommand({
497 command: "test",
498 args: [],
499 prId: fx.prId,
500 userId: fx.userId,
501 repositoryId: fx.repoId,
502 });
503 expect(result.ok).toBe(false);
504 expect(result.body).toContain("could not find a test workflow");
505 });
506});
507
508// ---------------------------------------------------------------------------
509// 4. Integration smoke — parse → execute round-trip preserves identity
510// ---------------------------------------------------------------------------
511
512describe("parse → execute round-trip (no DB needed)", () => {
513 it("a slash that doesn't pass parseSlashCommand stays a normal comment", () => {
514 // The route handler stores the comment verbatim and only THEN consults
515 // parseSlashCommand. This test asserts the contract:
516 // - "/usr/bin/x" returns null → route does NOT execute anything
517 // - "/help" returns a parse → route executes
518 expect(parseSlashCommand("/usr/bin/x")).toBeNull();
519 expect(parseSlashCommand("hello world")).toBeNull();
520 expect(parseSlashCommand("/help")).not.toBeNull();
521 });
522});