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

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