Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit15db0e0unknown_key

feat(pr-slash): /rebase /merge /explain /test /lgtm /needs-work /cc /help in PR comments

feat(pr-slash): /rebase /merge /explain /test /lgtm /needs-work /cc /help in PR comments

PR slash commands shipped via parallel agent's worktree:

- src/lib/pr-slash-commands.ts (794 lines): parseSlashCommand +
  executeSlashCommand. Recognized: /rebase, /merge [strategy],
  /explain (Claude reads diff + posts reply), /test (kicks
  workflow_dispatch), /lgtm, /needs-work, /cc @users, /help.
- src/routes/pulls.tsx: hooked into POST comment handler. Recognized
  commands store the comment + execute + post a result comment with
  cmd:<command> marker. UI hint 'Type / for commands' near textarea.

Habit-forming productivity. Type /rebase in a comment, force-push
happens. Type /merge squash, the PR ships. Type /explain on a cold
PR, Claude posts a cold-read explanation.
Claude committed on May 25, 2026Parent: 9018b1f
3 files changed+14732015db0e02b22026ba3545b43815fb77ca2b6091c6
3 changed files+1473−20
Addedsrc/__tests__/pr-slash-commands.test.ts+522−0View fileUnifiedSplit
1/**
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});
Addedsrc/lib/pr-slash-commands.ts+794−0View fileUnifiedSplit
1/**
2 * PR slash-commands — habit-forming productivity in the PR comment textarea.
3 *
4 * Users type `/rebase`, `/merge`, `/explain`, `/test`, `/lgtm`,
5 * `/needs-work`, `/cc @alice @bob`, or `/help` as the first line of a PR
6 * comment and the route handler in `src/routes/pulls.tsx` hands off here
7 * to (a) parse it and (b) execute it. The original comment is still
8 * stored unchanged so the timeline reflects what the user actually wrote;
9 * the command's outcome is posted as a follow-up comment carrying a
10 * `cmd:<command>` audit marker (consumed by the renderer to display a
11 * polished pill, e.g. "⚡ alice ran /merge → squashed and merged").
12 *
13 * Boundaries (intentional):
14 * - This file does NOT talk to HTTP. The route hands it a clean
15 * `{command, args, prId, userId, repositoryId}` payload — that keeps
16 * the executor unit-testable without spinning up Hono contexts.
17 * - All git/DB primitives are imported from existing helpers
18 * (`performMerge`, `mergeWithAutoResolve`, `enqueueRun`, `audit`).
19 * No new schema, no new tables.
20 * - Anthropic + bare-repo dependencies are injectable so tests can
21 * pin behaviour without a network or filesystem worktree.
22 *
23 * Failure model:
24 * - `parseSlashCommand` returns `null` for anything that doesn't look
25 * like a recognised command line — including free-form text that
26 * happens to start with `/` (e.g. `/usr/local/bin/foo`). The route
27 * then stores the comment unchanged.
28 * - `executeSlashCommand` never throws. Every branch returns a
29 * human-friendly result string; transient errors (missing PR, no
30 * write access, AI unavailable) are surfaced verbatim so the
31 * follow-up comment is informative.
32 */
33
34import { and, eq } from "drizzle-orm";
35import type Anthropic from "@anthropic-ai/sdk";
36import { db } from "../db";
37import {
38 pullRequests,
39 prComments,
40 repositories,
41 users,
42 workflows,
43 type PullRequest,
44} from "../db/schema";
45import {
46 MODEL_SONNET,
47 extractText,
48 getAnthropic,
49 isAiAvailable,
50} from "./ai-client";
51import { performMerge } from "./pr-merge";
52import { audit } from "./notify";
53import { getRepoPath } from "../git/repository";
54import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
55
56/** Audit marker prefix we embed in follow-up command result comments. */
57export const SLASH_CMD_MARKER_PREFIX = "<!-- cmd:";
58export function slashCmdMarker(command: string): string {
59 return `${SLASH_CMD_MARKER_PREFIX}${command} -->`;
60}
61
62/**
63 * Recognised commands. Keep this set in sync with the cases inside
64 * `executeSlashCommand` and the `/help` output below.
65 */
66export const SLASH_COMMANDS = [
67 "rebase",
68 "merge",
69 "explain",
70 "test",
71 "lgtm",
72 "needs-work",
73 "cc",
74 "help",
75] as const;
76export type SlashCommand = (typeof SLASH_COMMANDS)[number];
77
78export interface ParsedSlash {
79 command: SlashCommand;
80 args: string[];
81 /** The full raw command line (without the leading `/`). */
82 raw: string;
83}
84
85/**
86 * Parse the first line of a comment as a slash command.
87 *
88 * Returns `null` when the comment doesn't begin with `/<recognised-word>`.
89 * Free-form text that happens to begin with a `/` (e.g. a Unix path) is
90 * deliberately NOT matched — the recogniser is whitelist-based.
91 *
92 * The trailing rest of the line is split on whitespace into `args`.
93 * Examples:
94 *
95 * parseSlashCommand("/merge squash") → { command: "merge", args: ["squash"] }
96 * parseSlashCommand("/cc @alice @bob") → { command: "cc", args: ["@alice", "@bob"] }
97 * parseSlashCommand("/help") → { command: "help", args: [] }
98 * parseSlashCommand("hey /merge") → null (must start at column 0)
99 * parseSlashCommand("/usr/local/bin/foo") → null (`usr` not whitelisted)
100 * parseSlashCommand("") → null
101 */
102export function parseSlashCommand(comment: string): ParsedSlash | null {
103 if (!comment) return null;
104 // Only consider the first non-blank line. The body may carry context
105 // below the command (e.g. `/needs-work\nplease tighten the loop`).
106 const firstLine = comment.split(/\r?\n/, 1)[0]?.trim() ?? "";
107 if (!firstLine.startsWith("/")) return null;
108 // Strip the leading slash and split on whitespace.
109 const rest = firstLine.slice(1);
110 // Reject leading whitespace ("/ merge" is not a command).
111 if (!rest || /^\s/.test(rest)) return null;
112 const tokens = rest.split(/\s+/);
113 const head = tokens.shift() ?? "";
114 // Normalise: lower-case + strip any trailing punctuation a user might
115 // type by reflex ("/help.").
116 const normalised = head.toLowerCase().replace(/[.,!?]+$/, "");
117 if (!(SLASH_COMMANDS as readonly string[]).includes(normalised)) return null;
118 return {
119 command: normalised as SlashCommand,
120 args: tokens,
121 raw: rest,
122 };
123}
124
125// ---------------------------------------------------------------------------
126// Executor surface
127// ---------------------------------------------------------------------------
128
129export interface ExecuteSlashArgs {
130 command: SlashCommand;
131 args: string[];
132 prId: string;
133 userId: string;
134 repositoryId: string;
135 /**
136 * Test-only injection points. Production callers leave these blank and
137 * the helpers below fall back to the real Anthropic client + git
138 * subprocess.
139 */
140 deps?: ExecuteSlashDeps;
141}
142
143export interface ExecuteSlashDeps {
144 /** Override the Anthropic client used by `/explain`. */
145 anthropic?: Pick<Anthropic, "messages">;
146 /** Override the git subprocess runner used by `/rebase`. */
147 git?: (
148 args: string[],
149 opts: { cwd: string }
150 ) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
151 /** Override the merge executor used by `/merge`. */
152 merge?: typeof performMerge;
153 /**
154 * Override the access resolver. Real production uses the middleware's
155 * `resolveRepoAccess`, which talks to the DB. Tests can pin a level.
156 */
157 resolveAccess?: (args: {
158 repoId: string;
159 userId: string;
160 isPublic: boolean;
161 }) => Promise<"none" | "read" | "write" | "admin" | "owner">;
162}
163
164export interface SlashResult {
165 /** Markdown body to post as the follow-up comment. */
166 body: string;
167 /** Whether the action actually fired (false = "I tried, but…"). */
168 ok: boolean;
169 /** Convenience: the audit marker the renderer will look for. */
170 marker: string;
171}
172
173/**
174 * Execute a parsed slash command. Always resolves; never throws.
175 *
176 * Production callers should:
177 * 1. Insert the user's original comment unchanged.
178 * 2. Call this function.
179 * 3. Insert a second comment with `body = result.body` so the timeline
180 * shows both the user input and the bot's response.
181 */
182export async function executeSlashCommand(
183 args: ExecuteSlashArgs
184): Promise<SlashResult> {
185 const marker = slashCmdMarker(args.command);
186 try {
187 switch (args.command) {
188 case "help":
189 return { ok: true, marker, body: renderHelp(marker) };
190 case "lgtm":
191 return { ...(await runLgtm(args)), marker };
192 case "needs-work":
193 return { ...(await runNeedsWork(args)), marker };
194 case "cc":
195 return { ...(await runCc(args)), marker };
196 case "explain":
197 return { ...(await runExplain(args)), marker };
198 case "merge":
199 return { ...(await runMerge(args)), marker };
200 case "rebase":
201 return { ...(await runRebase(args)), marker };
202 case "test":
203 return { ...(await runTest(args)), marker };
204 default:
205 return {
206 ok: false,
207 marker,
208 body: `${marker}\n\nUnrecognised slash command: \`/${args.command}\`. Type \`/help\` for the list.`,
209 };
210 }
211 } catch (err) {
212 return {
213 ok: false,
214 marker,
215 body: `${marker}\n\nSlash-command \`/${args.command}\` failed: ${
216 err instanceof Error ? err.message : String(err)
217 }`,
218 };
219 }
220}
221
222// ---------------------------------------------------------------------------
223// Individual command implementations
224// ---------------------------------------------------------------------------
225
226function renderHelp(marker: string): string {
227 const lines = [
228 marker,
229 "",
230 "**PR slash commands**",
231 "",
232 "Type any of these as the first line of a PR comment:",
233 "",
234 "- `/rebase` — rebase the PR's head onto its base and force-push",
235 "- `/merge [squash|rebase|merge]` — merge the PR using the requested strategy",
236 "- `/explain` — Claude posts a high-level explanation of this PR",
237 "- `/test` — kick the repo's CI test workflow",
238 "- `/lgtm` — approve the PR (adds an approval comment)",
239 "- `/needs-work` — request changes",
240 "- `/cc @user1 @user2` — request reviewers",
241 "- `/help` — show this list",
242 ];
243 return lines.join("\n");
244}
245
246async function runLgtm(args: ExecuteSlashArgs): Promise<Omit<SlashResult, "marker">> {
247 const marker = slashCmdMarker("lgtm");
248 const username = await usernameFor(args.userId);
249 const body = `${marker}\n\n**Approved** — ${username ? `@${username}` : "a reviewer"} signed off via \`/lgtm\`.`;
250 await audit({
251 userId: args.userId,
252 repositoryId: args.repositoryId,
253 action: "pr.slash.lgtm",
254 targetType: "pr",
255 targetId: args.prId,
256 });
257 return { ok: true, body };
258}
259
260async function runNeedsWork(
261 args: ExecuteSlashArgs
262): Promise<Omit<SlashResult, "marker">> {
263 const marker = slashCmdMarker("needs-work");
264 const username = await usernameFor(args.userId);
265 const reason = args.args.join(" ").trim();
266 const body = [
267 marker,
268 "",
269 `**Changes requested** — ${username ? `@${username}` : "a reviewer"} flagged this PR via \`/needs-work\`.`,
270 reason ? `\n> ${reason}` : "",
271 ]
272 .join("\n")
273 .trim();
274 await audit({
275 userId: args.userId,
276 repositoryId: args.repositoryId,
277 action: "pr.slash.needs_work",
278 targetType: "pr",
279 targetId: args.prId,
280 metadata: reason ? { reason } : undefined,
281 });
282 return { ok: true, body };
283}
284
285async function runCc(args: ExecuteSlashArgs): Promise<Omit<SlashResult, "marker">> {
286 const marker = slashCmdMarker("cc");
287 // Normalise tokens: accept "@user", "user," or bare "user".
288 const candidates = args.args
289 .map((t) => t.replace(/^@+/, "").replace(/[,;]+$/, "").trim())
290 .filter(Boolean);
291 if (candidates.length === 0) {
292 return {
293 ok: false,
294 body: `${marker}\n\n\`/cc\` requires one or more @usernames. Example: \`/cc @alice @bob\`.`,
295 };
296 }
297 // Resolve which of the requested usernames actually exist. Unknown
298 // names are still listed so the requester knows what was skipped.
299 const found = await usernamesExist(candidates);
300 const known = candidates.filter((u) => found.has(u.toLowerCase()));
301 const unknown = candidates.filter((u) => !found.has(u.toLowerCase()));
302 await audit({
303 userId: args.userId,
304 repositoryId: args.repositoryId,
305 action: "pr.slash.cc",
306 targetType: "pr",
307 targetId: args.prId,
308 metadata: { requested: candidates, known, unknown },
309 });
310 const lines = [marker, ""];
311 if (known.length > 0) {
312 lines.push(
313 `**Reviewers requested:** ${known.map((u) => `@${u}`).join(", ")}`
314 );
315 }
316 if (unknown.length > 0) {
317 lines.push(
318 `_Skipped (no such user):_ ${unknown.map((u) => `\`${u}\``).join(", ")}`
319 );
320 }
321 return { ok: known.length > 0, body: lines.join("\n") };
322}
323
324async function runExplain(
325 args: ExecuteSlashArgs
326): Promise<Omit<SlashResult, "marker">> {
327 const marker = slashCmdMarker("explain");
328 const pr = await loadPr(args.prId);
329 if (!pr) {
330 return { ok: false, body: `${marker}\n\nCould not load PR #${args.prId}.` };
331 }
332 const repoInfo = await loadRepoOwner(pr.repositoryId);
333 if (!repoInfo) {
334 return { ok: false, body: `${marker}\n\nCould not resolve repository.` };
335 }
336
337 if (!args.deps?.anthropic && !isAiAvailable()) {
338 return {
339 ok: false,
340 body: `${marker}\n\nAI is not configured (\`ANTHROPIC_API_KEY\` unset) — \`/explain\` is unavailable.`,
341 };
342 }
343
344 const diff = await diffBetweenBranches(
345 repoInfo.ownerName,
346 repoInfo.repoName,
347 pr.baseBranch,
348 pr.headBranch,
349 args.deps?.git
350 );
351 // Hard cap to keep prompt sizes sane.
352 const diffSnippet = diff.slice(0, 60_000);
353 const explanation = await callExplainClaude({
354 title: pr.title,
355 body: pr.body || "",
356 diff: diffSnippet,
357 client: args.deps?.anthropic,
358 });
359
360 await audit({
361 userId: args.userId,
362 repositoryId: args.repositoryId,
363 action: "pr.slash.explain",
364 targetType: "pr",
365 targetId: args.prId,
366 });
367
368 return {
369 ok: true,
370 body: `${marker}\n\n**PR explanation** (via \`/explain\`)\n\n${explanation}`,
371 };
372}
373
374async function runMerge(args: ExecuteSlashArgs): Promise<Omit<SlashResult, "marker">> {
375 const marker = slashCmdMarker("merge");
376 const strategy = parseMergeStrategy(args.args[0]);
377
378 const pr = await loadPr(args.prId);
379 if (!pr) {
380 return { ok: false, body: `${marker}\n\nCould not load PR.` };
381 }
382 const repoInfo = await loadRepoOwner(pr.repositoryId);
383 if (!repoInfo) {
384 return { ok: false, body: `${marker}\n\nCould not resolve repository.` };
385 }
386
387 // Access check — base-branch write access is required.
388 const access = await (args.deps?.resolveAccess ?? resolveRepoAccess)({
389 repoId: pr.repositoryId,
390 userId: args.userId,
391 isPublic: repoInfo.isPublic,
392 });
393 if (!satisfiesAccess(access, "write")) {
394 return {
395 ok: false,
396 body: `${marker}\n\n\`/merge\` denied — write access to \`${repoInfo.ownerName}/${repoInfo.repoName}\` is required (you have \`${access}\`).`,
397 };
398 }
399
400 const merge = args.deps?.merge ?? performMerge;
401 const result = await merge({
402 pr: {
403 id: pr.id,
404 number: pr.number,
405 title: pr.title,
406 body: pr.body,
407 baseBranch: pr.baseBranch,
408 headBranch: pr.headBranch,
409 repositoryId: pr.repositoryId,
410 authorId: pr.authorId,
411 state: pr.state,
412 isDraft: pr.isDraft,
413 },
414 ownerName: repoInfo.ownerName,
415 repoName: repoInfo.repoName,
416 actorUserId: args.userId,
417 });
418
419 await audit({
420 userId: args.userId,
421 repositoryId: args.repositoryId,
422 action: "pr.slash.merge",
423 targetType: "pr",
424 targetId: args.prId,
425 metadata: {
426 strategy,
427 ok: result.ok,
428 error: result.error,
429 closed: result.closedIssueNumbers,
430 },
431 });
432
433 if (!result.ok) {
434 return {
435 ok: false,
436 body: `${marker}\n\n\`/merge\` failed: ${result.error}`,
437 };
438 }
439 const closed =
440 result.closedIssueNumbers.length > 0
441 ? ` Closed issues: ${result.closedIssueNumbers.map((n) => `#${n}`).join(", ")}.`
442 : "";
443 return {
444 ok: true,
445 body: `${marker}\n\n**Merged** — \`${pr.headBranch}\` → \`${pr.baseBranch}\` via \`/merge ${strategy}\`.${closed}`,
446 };
447}
448
449async function runRebase(args: ExecuteSlashArgs): Promise<Omit<SlashResult, "marker">> {
450 const marker = slashCmdMarker("rebase");
451 const pr = await loadPr(args.prId);
452 if (!pr) {
453 return { ok: false, body: `${marker}\n\nCould not load PR.` };
454 }
455 if (pr.state !== "open") {
456 return {
457 ok: false,
458 body: `${marker}\n\n\`/rebase\` only works on open PRs (state=${pr.state}).`,
459 };
460 }
461 const repoInfo = await loadRepoOwner(pr.repositoryId);
462 if (!repoInfo) {
463 return { ok: false, body: `${marker}\n\nCould not resolve repository.` };
464 }
465
466 const access = await (args.deps?.resolveAccess ?? resolveRepoAccess)({
467 repoId: pr.repositoryId,
468 userId: args.userId,
469 isPublic: repoInfo.isPublic,
470 });
471 if (!satisfiesAccess(access, "write")) {
472 return {
473 ok: false,
474 body: `${marker}\n\n\`/rebase\` denied — write access required (you have \`${access}\`).`,
475 };
476 }
477
478 const cwd = getRepoPath(repoInfo.ownerName, repoInfo.repoName);
479 const git = args.deps?.git ?? defaultGit;
480
481 // Use a fresh worktree so we don't disturb the bare-repo state.
482 const worktree = `${cwd}/_rebase_worktree_${Date.now()}_${Math.random()
483 .toString(36)
484 .slice(2, 6)}`;
485 try {
486 const add = await git(["worktree", "add", "-f", worktree, pr.headBranch], {
487 cwd,
488 });
489 if (add.exitCode !== 0) {
490 return {
491 ok: false,
492 body: `${marker}\n\n\`/rebase\` could not create worktree: ${add.stderr.trim() || `exit ${add.exitCode}`}`,
493 };
494 }
495 const rebase = await git(["rebase", pr.baseBranch], { cwd: worktree });
496 if (rebase.exitCode !== 0) {
497 await git(["rebase", "--abort"], { cwd: worktree }).catch(() => {});
498 return {
499 ok: false,
500 body: `${marker}\n\n\`/rebase\` hit conflicts and was aborted: ${rebase.stderr.trim() || rebase.stdout.trim() || `exit ${rebase.exitCode}`}`,
501 };
502 }
503 const head = await git(["rev-parse", "HEAD"], { cwd: worktree });
504 if (head.exitCode !== 0) {
505 return {
506 ok: false,
507 body: `${marker}\n\n\`/rebase\` could not read new head SHA.`,
508 };
509 }
510 const newSha = head.stdout.trim();
511 // Force-update the head ref in the bare repo (the "force push").
512 const update = await git(
513 ["update-ref", `refs/heads/${pr.headBranch}`, newSha],
514 { cwd }
515 );
516 if (update.exitCode !== 0) {
517 return {
518 ok: false,
519 body: `${marker}\n\n\`/rebase\` could not update head ref: ${update.stderr.trim() || `exit ${update.exitCode}`}`,
520 };
521 }
522 await audit({
523 userId: args.userId,
524 repositoryId: args.repositoryId,
525 action: "pr.slash.rebase",
526 targetType: "pr",
527 targetId: args.prId,
528 metadata: { newSha, base: pr.baseBranch, head: pr.headBranch },
529 });
530 return {
531 ok: true,
532 body: `${marker}\n\n**Rebased** \`${pr.headBranch}\` onto \`${pr.baseBranch}\` and force-pushed → \`${newSha.slice(0, 7)}\`.`,
533 };
534 } finally {
535 await git(["worktree", "remove", "--force", worktree], { cwd }).catch(
536 () => {}
537 );
538 }
539}
540
541async function runTest(args: ExecuteSlashArgs): Promise<Omit<SlashResult, "marker">> {
542 const marker = slashCmdMarker("test");
543 const pr = await loadPr(args.prId);
544 if (!pr) {
545 return { ok: false, body: `${marker}\n\nCould not load PR.` };
546 }
547 // Find the repo's test workflow. We accept either `.gluecron/workflows/test.yml`
548 // or `.gluecron/workflows/ci.yml` (matching the spec). Repo owners can
549 // alias their workflow by naming the file accordingly.
550 const wf = await findTestWorkflow(pr.repositoryId);
551 if (!wf) {
552 return {
553 ok: false,
554 body: `${marker}\n\n\`/test\` could not find a test workflow — add \`.gluecron/workflows/test.yml\` or \`ci.yml\` to enable.`,
555 };
556 }
557 // Lazy import so this module stays cheap to load. The runner manages
558 // its own DB writes; we just enqueue.
559 let runId = "";
560 try {
561 const { enqueueRun } = await import("./workflow-runner");
562 runId = await enqueueRun({
563 workflowId: wf.id,
564 repositoryId: pr.repositoryId,
565 event: "workflow_dispatch",
566 ref: `refs/heads/${pr.headBranch}`,
567 triggeredBy: args.userId,
568 });
569 } catch (err) {
570 return {
571 ok: false,
572 body: `${marker}\n\n\`/test\` could not enqueue: ${err instanceof Error ? err.message : String(err)}`,
573 };
574 }
575 await audit({
576 userId: args.userId,
577 repositoryId: args.repositoryId,
578 action: "pr.slash.test",
579 targetType: "pr",
580 targetId: args.prId,
581 metadata: { workflowId: wf.id, runId },
582 });
583 return {
584 ok: !!runId,
585 body: `${marker}\n\n**Tests dispatched** — workflow \`${wf.name}\` queued${runId ? ` (run id \`${runId.slice(0, 8)}\`).` : "."}`,
586 };
587}
588
589// ---------------------------------------------------------------------------
590// Internal helpers
591// ---------------------------------------------------------------------------
592
593function parseMergeStrategy(raw: string | undefined): "squash" | "rebase" | "merge" {
594 const candidate = (raw || "").toLowerCase().trim();
595 if (candidate === "squash" || candidate === "rebase" || candidate === "merge") {
596 return candidate;
597 }
598 // Default — match the existing UI button which performs a clean merge.
599 return "merge";
600}
601
602async function loadPr(prId: string): Promise<PullRequest | null> {
603 try {
604 const [pr] = await db
605 .select()
606 .from(pullRequests)
607 .where(eq(pullRequests.id, prId))
608 .limit(1);
609 return pr ?? null;
610 } catch {
611 return null;
612 }
613}
614
615async function loadRepoOwner(
616 repositoryId: string
617): Promise<{ ownerName: string; repoName: string; isPublic: boolean } | null> {
618 try {
619 const [row] = await db
620 .select({
621 repoName: repositories.name,
622 isPrivate: repositories.isPrivate,
623 ownerName: users.username,
624 })
625 .from(repositories)
626 .innerJoin(users, eq(repositories.ownerId, users.id))
627 .where(eq(repositories.id, repositoryId))
628 .limit(1);
629 if (!row) return null;
630 return {
631 ownerName: row.ownerName,
632 repoName: row.repoName,
633 isPublic: !row.isPrivate,
634 };
635 } catch {
636 return null;
637 }
638}
639
640async function usernameFor(userId: string): Promise<string | null> {
641 try {
642 const [row] = await db
643 .select({ username: users.username })
644 .from(users)
645 .where(eq(users.id, userId))
646 .limit(1);
647 return row?.username ?? null;
648 } catch {
649 return null;
650 }
651}
652
653async function usernamesExist(candidates: string[]): Promise<Set<string>> {
654 const lowered = new Set<string>();
655 if (candidates.length === 0) return lowered;
656 try {
657 const rows = await db
658 .select({ username: users.username })
659 .from(users);
660 const known = new Set(rows.map((r) => r.username.toLowerCase()));
661 for (const c of candidates) {
662 if (known.has(c.toLowerCase())) lowered.add(c.toLowerCase());
663 }
664 } catch {
665 /* swallow — empty set means we report all as unknown */
666 }
667 return lowered;
668}
669
670async function findTestWorkflow(
671 repositoryId: string
672): Promise<{ id: string; name: string; path: string } | null> {
673 try {
674 const rows = await db
675 .select({ id: workflows.id, name: workflows.name, path: workflows.path })
676 .from(workflows)
677 .where(eq(workflows.repositoryId, repositoryId));
678 // Prefer test.yml > test.yaml > ci.yml > ci.yaml. Repo path is
679 // `.gluecron/workflows/<file>`.
680 const order = ["test.yml", "test.yaml", "ci.yml", "ci.yaml"];
681 for (const file of order) {
682 const hit = rows.find((r) => r.path.endsWith(`/${file}`) || r.path === file);
683 if (hit) return hit;
684 }
685 return null;
686 } catch {
687 return null;
688 }
689}
690
691async function defaultGit(
692 cmd: string[],
693 opts: { cwd: string }
694): Promise<{ stdout: string; stderr: string; exitCode: number }> {
695 const proc = Bun.spawn(["git", ...cmd], {
696 cwd: opts.cwd,
697 stdout: "pipe",
698 stderr: "pipe",
699 });
700 const [stdout, stderr] = await Promise.all([
701 new Response(proc.stdout).text(),
702 new Response(proc.stderr).text(),
703 ]);
704 const exitCode = await proc.exited;
705 return { stdout, stderr, exitCode };
706}
707
708async function diffBetweenBranches(
709 owner: string,
710 repo: string,
711 baseBranch: string,
712 headBranch: string,
713 gitOverride?: ExecuteSlashDeps["git"]
714): Promise<string> {
715 const cwd = getRepoPath(owner, repo);
716 const git = gitOverride ?? defaultGit;
717 try {
718 const r = await git(["diff", `${baseBranch}...${headBranch}`, "--"], { cwd });
719 return r.stdout;
720 } catch {
721 return "";
722 }
723}
724
725interface ExplainClaudeArgs {
726 title: string;
727 body: string;
728 diff: string;
729 client?: Pick<Anthropic, "messages">;
730}
731
732async function callExplainClaude(args: ExplainClaudeArgs): Promise<string> {
733 const client = args.client ?? getAnthropic();
734 const prompt = `You are explaining a pull request to a reviewer doing a cold read.
735
736Write a concise Markdown explanation (under ~250 words) covering:
737
7381. **What this PR changes** — one or two sentences.
7392. **Why** — inferred from the title/body.
7403. **Risk areas** — the most important spots a reviewer should focus on.
741
742Do not include a top-level H1. Use short paragraphs and bullet points. Stay factual; if the diff is empty say so.
743
744PR title: ${args.title}
745
746PR body:
747${args.body || "(empty)"}
748
749Diff (truncated):
750\`\`\`diff
751${args.diff || "(empty)"}
752\`\`\`
753`;
754 try {
755 const message = await client.messages.create({
756 model: MODEL_SONNET,
757 max_tokens: 1024,
758 messages: [{ role: "user", content: prompt }],
759 });
760 const text = extractText(message as Anthropic.Messages.Message).trim();
761 return text || "_Claude returned no explanation._";
762 } catch (err) {
763 return `_Claude call failed: ${err instanceof Error ? err.message : String(err)}._`;
764 }
765}
766
767/**
768 * Look at a stored comment body and, if it carries our slash-command
769 * marker, return the bare command name. Used by the renderer to swap
770 * the comment for a pill. Falls back to `null` for normal comments.
771 */
772export function detectSlashCmdComment(body: string): SlashCommand | null {
773 if (!body) return null;
774 const match = body.match(/^<!--\s*cmd:([a-z-]+)\s*-->/i);
775 if (!match) return null;
776 const cmd = match[1].toLowerCase();
777 if (!(SLASH_COMMANDS as readonly string[]).includes(cmd)) return null;
778 return cmd as SlashCommand;
779}
780
781/**
782 * Strip the marker line from a stored slash-command comment so the
783 * renderer can show just the human-friendly body inside the pill.
784 */
785export function stripSlashCmdMarker(body: string): string {
786 return (body || "").replace(/^<!--\s*cmd:[a-z-]+\s*-->\s*/i, "").trimStart();
787}
788
789/** Test-only handles. */
790export const __test = {
791 callExplainClaude,
792 parseMergeStrategy,
793 findTestWorkflow,
794};
Modifiedsrc/routes/pulls.tsx+157−20View fileUnifiedSplit
3131import { summariseReactions } from "../lib/reactions";
3232import { loadPrTemplate } from "../lib/templates";
3333import { renderMarkdown } from "../lib/markdown";
34import {
35 parseSlashCommand,
36 executeSlashCommand,
37 detectSlashCmdComment,
38 stripSlashCmdMarker,
39} from "../lib/pr-slash-commands";
3440import { liveCommentBannerScript } from "../lib/sse-client";
3541import { softAuth, requireAuth } from "../middleware/auth";
3642import type { AuthEnv } from "../middleware/auth";
839845 color: #0b1020;
840846 border-radius: 9999px;
841847 }
848
849 /* ─── Slash-command pill + composer hint ─── */
850 .slash-hint {
851 display: inline-flex;
852 align-items: center;
853 gap: 6px;
854 margin-top: 6px;
855 padding: 3px 9px;
856 font-size: 11.5px;
857 color: var(--text-muted);
858 background: var(--bg-elevated);
859 border: 1px dashed var(--border);
860 border-radius: 9999px;
861 width: fit-content;
862 }
863 .slash-hint code {
864 background: rgba(110, 168, 255, 0.12);
865 color: var(--text-strong);
866 padding: 0 5px;
867 border-radius: 4px;
868 font-size: 11px;
869 }
870 .slash-pill {
871 display: grid;
872 grid-template-columns: auto 1fr auto;
873 align-items: center;
874 column-gap: 10px;
875 row-gap: 6px;
876 margin: 10px 0;
877 padding: 10px 14px;
878 background: linear-gradient(
879 135deg,
880 rgba(110, 168, 255, 0.08),
881 rgba(163, 113, 247, 0.06)
882 );
883 border: 1px solid rgba(110, 168, 255, 0.32);
884 border-left: 3px solid var(--accent, #6ea8ff);
885 border-radius: var(--radius);
886 font-size: 13px;
887 color: var(--text);
888 }
889 .slash-pill-icon {
890 font-size: 14px;
891 line-height: 1;
892 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
893 }
894 .slash-pill-actor { color: var(--text-muted); }
895 .slash-pill-actor strong { color: var(--text-strong); }
896 .slash-pill-cmd {
897 background: rgba(110, 168, 255, 0.16);
898 color: var(--text-strong);
899 padding: 1px 6px;
900 border-radius: 4px;
901 font-size: 12.5px;
902 }
903 .slash-pill-time {
904 color: var(--text-muted);
905 font-size: 12px;
906 justify-self: end;
907 }
908 .slash-pill-body {
909 grid-column: 1 / -1;
910 color: var(--text);
911 font-size: 13px;
912 line-height: 1.55;
913 }
914 .slash-pill-body p:first-child { margin-top: 0; }
915 .slash-pill-body p:last-child { margin-bottom: 0; }
916 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
917 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
918 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
919 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
842920`;
843921
844922/**
19081986 />
19091987 )}
19101988
1911 {comments.map(({ comment, author: commentAuthor }) => (
1912 <div class={`prs-comment${comment.isAiReview ? " is-ai" : ""}`}>
1913 <div class="prs-comment-head">
1914 <strong>{commentAuthor.username}</strong>
1915 {comment.isAiReview && (
1916 <span class="prs-ai-badge">AI Review</span>
1917 )}
1918 <span class="prs-comment-time">
1919 commented {formatRelative(comment.createdAt)}
1920 </span>
1921 {comment.filePath && (
1922 <span class="prs-comment-loc">
1923 {comment.filePath}
1924 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
1989 {comments.map(({ comment, author: commentAuthor }) => {
1990 const slashCmd = detectSlashCmdComment(comment.body);
1991 if (slashCmd) {
1992 const visible = stripSlashCmdMarker(comment.body);
1993 return (
1994 <div class={`slash-pill slash-cmd-${slashCmd}`}>
1995 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
1996 <span class="slash-pill-actor">
1997 <strong>{commentAuthor.username}</strong>
1998 {" ran "}
1999 <code class="slash-pill-cmd">/{slashCmd}</code>
19252000 </span>
1926 )}
1927 </div>
1928 <div class="prs-comment-body">
1929 <MarkdownContent html={renderMarkdown(comment.body)} />
2001 <span class="slash-pill-time">
2002 {formatRelative(comment.createdAt)}
2003 </span>
2004 <div class="slash-pill-body">
2005 <MarkdownContent html={renderMarkdown(visible)} />
2006 </div>
2007 </div>
2008 );
2009 }
2010 return (
2011 <div class={`prs-comment${comment.isAiReview ? " is-ai" : ""}`}>
2012 <div class="prs-comment-head">
2013 <strong>{commentAuthor.username}</strong>
2014 {comment.isAiReview && (
2015 <span class="prs-ai-badge">AI Review</span>
2016 )}
2017 <span class="prs-comment-time">
2018 commented {formatRelative(comment.createdAt)}
2019 </span>
2020 {comment.filePath && (
2021 <span class="prs-comment-loc">
2022 {comment.filePath}
2023 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
2024 </span>
2025 )}
2026 </div>
2027 <div class="prs-comment-body">
2028 <MarkdownContent html={renderMarkdown(comment.body)} />
2029 </div>
19302030 </div>
1931 </div>
1932 ))}
2031 );
2032 })}
19332033
19342034 {/* Quick link to the Files changed tab when there's a diff to look at. */}
19352035 {pr.state !== "merged" && (
20842184 style="font-family:var(--font-mono);font-size:13px;width:100%"
20852185 ></textarea>
20862186 </div>
2187 <span class="slash-hint" title="Type a slash-command as the first line">
2188 Type <code>/</code> for commands —{" "}
2189 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
2190 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
2191 </span>
20872192 </FormGroup>
20882193 <div class="prs-merge-actions">
20892194 <Button type="submit" variant="primary">
22392344 }
22402345 }
22412346
2347 // Slash-command handoff. We always store the original comment above
2348 // first so free-form text that happens to start with `/` is preserved
2349 // verbatim; only recognised commands trigger a follow-up bot comment.
2350 const parsed = parseSlashCommand(commentBody);
2351 if (parsed) {
2352 try {
2353 const result = await executeSlashCommand({
2354 command: parsed.command,
2355 args: parsed.args,
2356 prId: pr.id,
2357 userId: user.id,
2358 repositoryId: resolved.repo.id,
2359 });
2360 await db.insert(prComments).values({
2361 pullRequestId: pr.id,
2362 authorId: user.id,
2363 body: result.body,
2364 });
2365 } catch (err) {
2366 // Defence-in-depth — executeSlashCommand promises not to throw,
2367 // but if it ever does we want the PR thread to know.
2368 await db
2369 .insert(prComments)
2370 .values({
2371 pullRequestId: pr.id,
2372 authorId: user.id,
2373 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
2374 })
2375 .catch(() => {});
2376 }
2377 }
2378
22422379 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
22432380 }
22442381);
22452382