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.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.tsBlame794 lines · 1 contributor
15db0e0Claude1/**
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};