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.tsBlame821 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",
09d5f39Claude74 "stage",
15db0e0Claude75 "help",
76] as const;
77export type SlashCommand = (typeof SLASH_COMMANDS)[number];
78
79export interface ParsedSlash {
80 command: SlashCommand;
81 args: string[];
82 /** The full raw command line (without the leading `/`). */
83 raw: string;
84}
85
86/**
87 * Parse the first line of a comment as a slash command.
88 *
89 * Returns `null` when the comment doesn't begin with `/<recognised-word>`.
90 * Free-form text that happens to begin with a `/` (e.g. a Unix path) is
91 * deliberately NOT matched — the recogniser is whitelist-based.
92 *
93 * The trailing rest of the line is split on whitespace into `args`.
94 * Examples:
95 *
96 * parseSlashCommand("/merge squash") → { command: "merge", args: ["squash"] }
97 * parseSlashCommand("/cc @alice @bob") → { command: "cc", args: ["@alice", "@bob"] }
98 * parseSlashCommand("/help") → { command: "help", args: [] }
99 * parseSlashCommand("hey /merge") → null (must start at column 0)
100 * parseSlashCommand("/usr/local/bin/foo") → null (`usr` not whitelisted)
101 * parseSlashCommand("") → null
102 */
103export function parseSlashCommand(comment: string): ParsedSlash | null {
104 if (!comment) return null;
105 // Only consider the first non-blank line. The body may carry context
106 // below the command (e.g. `/needs-work\nplease tighten the loop`).
107 const firstLine = comment.split(/\r?\n/, 1)[0]?.trim() ?? "";
108 if (!firstLine.startsWith("/")) return null;
109 // Strip the leading slash and split on whitespace.
110 const rest = firstLine.slice(1);
111 // Reject leading whitespace ("/ merge" is not a command).
112 if (!rest || /^\s/.test(rest)) return null;
113 const tokens = rest.split(/\s+/);
114 const head = tokens.shift() ?? "";
115 // Normalise: lower-case + strip any trailing punctuation a user might
116 // type by reflex ("/help.").
117 const normalised = head.toLowerCase().replace(/[.,!?]+$/, "");
118 if (!(SLASH_COMMANDS as readonly string[]).includes(normalised)) return null;
119 return {
120 command: normalised as SlashCommand,
121 args: tokens,
122 raw: rest,
123 };
124}
125
126// ---------------------------------------------------------------------------
127// Executor surface
128// ---------------------------------------------------------------------------
129
130export interface ExecuteSlashArgs {
131 command: SlashCommand;
132 args: string[];
133 prId: string;
134 userId: string;
135 repositoryId: string;
136 /**
137 * Test-only injection points. Production callers leave these blank and
138 * the helpers below fall back to the real Anthropic client + git
139 * subprocess.
140 */
141 deps?: ExecuteSlashDeps;
142}
143
144export interface ExecuteSlashDeps {
145 /** Override the Anthropic client used by `/explain`. */
146 anthropic?: Pick<Anthropic, "messages">;
147 /** Override the git subprocess runner used by `/rebase`. */
148 git?: (
149 args: string[],
150 opts: { cwd: string }
151 ) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
152 /** Override the merge executor used by `/merge`. */
153 merge?: typeof performMerge;
154 /**
155 * Override the access resolver. Real production uses the middleware's
156 * `resolveRepoAccess`, which talks to the DB. Tests can pin a level.
157 */
158 resolveAccess?: (args: {
159 repoId: string;
160 userId: string;
161 isPublic: boolean;
162 }) => Promise<"none" | "read" | "write" | "admin" | "owner">;
163}
164
165export interface SlashResult {
166 /** Markdown body to post as the follow-up comment. */
167 body: string;
168 /** Whether the action actually fired (false = "I tried, but…"). */
169 ok: boolean;
170 /** Convenience: the audit marker the renderer will look for. */
171 marker: string;
172}
173
174/**
175 * Execute a parsed slash command. Always resolves; never throws.
176 *
177 * Production callers should:
178 * 1. Insert the user's original comment unchanged.
179 * 2. Call this function.
180 * 3. Insert a second comment with `body = result.body` so the timeline
181 * shows both the user input and the bot's response.
182 */
183export async function executeSlashCommand(
184 args: ExecuteSlashArgs
185): Promise<SlashResult> {
186 const marker = slashCmdMarker(args.command);
187 try {
188 switch (args.command) {
189 case "help":
190 return { ok: true, marker, body: renderHelp(marker) };
191 case "lgtm":
192 return { ...(await runLgtm(args)), marker };
193 case "needs-work":
194 return { ...(await runNeedsWork(args)), marker };
195 case "cc":
196 return { ...(await runCc(args)), marker };
197 case "explain":
198 return { ...(await runExplain(args)), marker };
199 case "merge":
200 return { ...(await runMerge(args)), marker };
201 case "rebase":
202 return { ...(await runRebase(args)), marker };
203 case "test":
204 return { ...(await runTest(args)), marker };
09d5f39Claude205 case "stage":
206 return { ...(await runStage(args)), marker };
15db0e0Claude207 default:
208 return {
209 ok: false,
210 marker,
211 body: `${marker}\n\nUnrecognised slash command: \`/${args.command}\`. Type \`/help\` for the list.`,
212 };
213 }
214 } catch (err) {
215 return {
216 ok: false,
217 marker,
218 body: `${marker}\n\nSlash-command \`/${args.command}\` failed: ${
219 err instanceof Error ? err.message : String(err)
220 }`,
221 };
222 }
223}
224
225// ---------------------------------------------------------------------------
226// Individual command implementations
227// ---------------------------------------------------------------------------
228
229function renderHelp(marker: string): string {
230 const lines = [
231 marker,
232 "",
233 "**PR slash commands**",
234 "",
235 "Type any of these as the first line of a PR comment:",
236 "",
237 "- `/rebase` — rebase the PR's head onto its base and force-push",
238 "- `/merge [squash|rebase|merge]` — merge the PR using the requested strategy",
239 "- `/explain` — Claude posts a high-level explanation of this PR",
240 "- `/test` — kick the repo's CI test workflow",
241 "- `/lgtm` — approve the PR (adds an approval comment)",
242 "- `/needs-work` — request changes",
243 "- `/cc @user1 @user2` — request reviewers",
09d5f39Claude244 "- `/stage` — deploy a preview environment and reply with the live URL",
15db0e0Claude245 "- `/help` — show this list",
246 ];
247 return lines.join("\n");
248}
249
250async function runLgtm(args: ExecuteSlashArgs): Promise<Omit<SlashResult, "marker">> {
251 const marker = slashCmdMarker("lgtm");
252 const username = await usernameFor(args.userId);
253 const body = `${marker}\n\n**Approved** — ${username ? `@${username}` : "a reviewer"} signed off via \`/lgtm\`.`;
254 await audit({
255 userId: args.userId,
256 repositoryId: args.repositoryId,
257 action: "pr.slash.lgtm",
258 targetType: "pr",
259 targetId: args.prId,
260 });
261 return { ok: true, body };
262}
263
264async function runNeedsWork(
265 args: ExecuteSlashArgs
266): Promise<Omit<SlashResult, "marker">> {
267 const marker = slashCmdMarker("needs-work");
268 const username = await usernameFor(args.userId);
269 const reason = args.args.join(" ").trim();
270 const body = [
271 marker,
272 "",
273 `**Changes requested** — ${username ? `@${username}` : "a reviewer"} flagged this PR via \`/needs-work\`.`,
274 reason ? `\n> ${reason}` : "",
275 ]
276 .join("\n")
277 .trim();
278 await audit({
279 userId: args.userId,
280 repositoryId: args.repositoryId,
281 action: "pr.slash.needs_work",
282 targetType: "pr",
283 targetId: args.prId,
284 metadata: reason ? { reason } : undefined,
285 });
286 return { ok: true, body };
287}
288
289async function runCc(args: ExecuteSlashArgs): Promise<Omit<SlashResult, "marker">> {
290 const marker = slashCmdMarker("cc");
291 // Normalise tokens: accept "@user", "user," or bare "user".
292 const candidates = args.args
293 .map((t) => t.replace(/^@+/, "").replace(/[,;]+$/, "").trim())
294 .filter(Boolean);
295 if (candidates.length === 0) {
296 return {
297 ok: false,
298 body: `${marker}\n\n\`/cc\` requires one or more @usernames. Example: \`/cc @alice @bob\`.`,
299 };
300 }
301 // Resolve which of the requested usernames actually exist. Unknown
302 // names are still listed so the requester knows what was skipped.
303 const found = await usernamesExist(candidates);
304 const known = candidates.filter((u) => found.has(u.toLowerCase()));
305 const unknown = candidates.filter((u) => !found.has(u.toLowerCase()));
306 await audit({
307 userId: args.userId,
308 repositoryId: args.repositoryId,
309 action: "pr.slash.cc",
310 targetType: "pr",
311 targetId: args.prId,
312 metadata: { requested: candidates, known, unknown },
313 });
314 const lines = [marker, ""];
315 if (known.length > 0) {
316 lines.push(
317 `**Reviewers requested:** ${known.map((u) => `@${u}`).join(", ")}`
318 );
319 }
320 if (unknown.length > 0) {
321 lines.push(
322 `_Skipped (no such user):_ ${unknown.map((u) => `\`${u}\``).join(", ")}`
323 );
324 }
325 return { ok: known.length > 0, body: lines.join("\n") };
326}
327
328async function runExplain(
329 args: ExecuteSlashArgs
330): Promise<Omit<SlashResult, "marker">> {
331 const marker = slashCmdMarker("explain");
332 const pr = await loadPr(args.prId);
333 if (!pr) {
334 return { ok: false, body: `${marker}\n\nCould not load PR #${args.prId}.` };
335 }
336 const repoInfo = await loadRepoOwner(pr.repositoryId);
337 if (!repoInfo) {
338 return { ok: false, body: `${marker}\n\nCould not resolve repository.` };
339 }
340
341 if (!args.deps?.anthropic && !isAiAvailable()) {
342 return {
343 ok: false,
344 body: `${marker}\n\nAI is not configured (\`ANTHROPIC_API_KEY\` unset) — \`/explain\` is unavailable.`,
345 };
346 }
347
348 const diff = await diffBetweenBranches(
349 repoInfo.ownerName,
350 repoInfo.repoName,
351 pr.baseBranch,
352 pr.headBranch,
353 args.deps?.git
354 );
355 // Hard cap to keep prompt sizes sane.
356 const diffSnippet = diff.slice(0, 60_000);
357 const explanation = await callExplainClaude({
358 title: pr.title,
359 body: pr.body || "",
360 diff: diffSnippet,
361 client: args.deps?.anthropic,
362 });
363
364 await audit({
365 userId: args.userId,
366 repositoryId: args.repositoryId,
367 action: "pr.slash.explain",
368 targetType: "pr",
369 targetId: args.prId,
370 });
371
372 return {
373 ok: true,
374 body: `${marker}\n\n**PR explanation** (via \`/explain\`)\n\n${explanation}`,
375 };
376}
377
378async function runMerge(args: ExecuteSlashArgs): Promise<Omit<SlashResult, "marker">> {
379 const marker = slashCmdMarker("merge");
380 const strategy = parseMergeStrategy(args.args[0]);
381
382 const pr = await loadPr(args.prId);
383 if (!pr) {
384 return { ok: false, body: `${marker}\n\nCould not load PR.` };
385 }
386 const repoInfo = await loadRepoOwner(pr.repositoryId);
387 if (!repoInfo) {
388 return { ok: false, body: `${marker}\n\nCould not resolve repository.` };
389 }
390
391 // Access check — base-branch write access is required.
392 const access = await (args.deps?.resolveAccess ?? resolveRepoAccess)({
393 repoId: pr.repositoryId,
394 userId: args.userId,
395 isPublic: repoInfo.isPublic,
396 });
397 if (!satisfiesAccess(access, "write")) {
398 return {
399 ok: false,
400 body: `${marker}\n\n\`/merge\` denied — write access to \`${repoInfo.ownerName}/${repoInfo.repoName}\` is required (you have \`${access}\`).`,
401 };
402 }
403
404 const merge = args.deps?.merge ?? performMerge;
405 const result = await merge({
406 pr: {
407 id: pr.id,
408 number: pr.number,
409 title: pr.title,
410 body: pr.body,
411 baseBranch: pr.baseBranch,
412 headBranch: pr.headBranch,
413 repositoryId: pr.repositoryId,
414 authorId: pr.authorId,
415 state: pr.state,
416 isDraft: pr.isDraft,
417 },
418 ownerName: repoInfo.ownerName,
419 repoName: repoInfo.repoName,
420 actorUserId: args.userId,
421 });
422
423 await audit({
424 userId: args.userId,
425 repositoryId: args.repositoryId,
426 action: "pr.slash.merge",
427 targetType: "pr",
428 targetId: args.prId,
429 metadata: {
430 strategy,
431 ok: result.ok,
432 error: result.error,
433 closed: result.closedIssueNumbers,
434 },
435 });
436
437 if (!result.ok) {
438 return {
439 ok: false,
440 body: `${marker}\n\n\`/merge\` failed: ${result.error}`,
441 };
442 }
443 const closed =
444 result.closedIssueNumbers.length > 0
445 ? ` Closed issues: ${result.closedIssueNumbers.map((n) => `#${n}`).join(", ")}.`
446 : "";
447 return {
448 ok: true,
449 body: `${marker}\n\n**Merged** — \`${pr.headBranch}\` → \`${pr.baseBranch}\` via \`/merge ${strategy}\`.${closed}`,
450 };
451}
452
453async function runRebase(args: ExecuteSlashArgs): Promise<Omit<SlashResult, "marker">> {
454 const marker = slashCmdMarker("rebase");
455 const pr = await loadPr(args.prId);
456 if (!pr) {
457 return { ok: false, body: `${marker}\n\nCould not load PR.` };
458 }
459 if (pr.state !== "open") {
460 return {
461 ok: false,
462 body: `${marker}\n\n\`/rebase\` only works on open PRs (state=${pr.state}).`,
463 };
464 }
465 const repoInfo = await loadRepoOwner(pr.repositoryId);
466 if (!repoInfo) {
467 return { ok: false, body: `${marker}\n\nCould not resolve repository.` };
468 }
469
470 const access = await (args.deps?.resolveAccess ?? resolveRepoAccess)({
471 repoId: pr.repositoryId,
472 userId: args.userId,
473 isPublic: repoInfo.isPublic,
474 });
475 if (!satisfiesAccess(access, "write")) {
476 return {
477 ok: false,
478 body: `${marker}\n\n\`/rebase\` denied — write access required (you have \`${access}\`).`,
479 };
480 }
481
482 const cwd = getRepoPath(repoInfo.ownerName, repoInfo.repoName);
483 const git = args.deps?.git ?? defaultGit;
484
485 // Use a fresh worktree so we don't disturb the bare-repo state.
486 const worktree = `${cwd}/_rebase_worktree_${Date.now()}_${Math.random()
487 .toString(36)
488 .slice(2, 6)}`;
489 try {
490 const add = await git(["worktree", "add", "-f", worktree, pr.headBranch], {
491 cwd,
492 });
493 if (add.exitCode !== 0) {
494 return {
495 ok: false,
496 body: `${marker}\n\n\`/rebase\` could not create worktree: ${add.stderr.trim() || `exit ${add.exitCode}`}`,
497 };
498 }
499 const rebase = await git(["rebase", pr.baseBranch], { cwd: worktree });
500 if (rebase.exitCode !== 0) {
501 await git(["rebase", "--abort"], { cwd: worktree }).catch(() => {});
502 return {
503 ok: false,
504 body: `${marker}\n\n\`/rebase\` hit conflicts and was aborted: ${rebase.stderr.trim() || rebase.stdout.trim() || `exit ${rebase.exitCode}`}`,
505 };
506 }
507 const head = await git(["rev-parse", "HEAD"], { cwd: worktree });
508 if (head.exitCode !== 0) {
509 return {
510 ok: false,
511 body: `${marker}\n\n\`/rebase\` could not read new head SHA.`,
512 };
513 }
514 const newSha = head.stdout.trim();
515 // Force-update the head ref in the bare repo (the "force push").
516 const update = await git(
517 ["update-ref", `refs/heads/${pr.headBranch}`, newSha],
518 { cwd }
519 );
520 if (update.exitCode !== 0) {
521 return {
522 ok: false,
523 body: `${marker}\n\n\`/rebase\` could not update head ref: ${update.stderr.trim() || `exit ${update.exitCode}`}`,
524 };
525 }
526 await audit({
527 userId: args.userId,
528 repositoryId: args.repositoryId,
529 action: "pr.slash.rebase",
530 targetType: "pr",
531 targetId: args.prId,
532 metadata: { newSha, base: pr.baseBranch, head: pr.headBranch },
533 });
534 return {
535 ok: true,
536 body: `${marker}\n\n**Rebased** \`${pr.headBranch}\` onto \`${pr.baseBranch}\` and force-pushed → \`${newSha.slice(0, 7)}\`.`,
537 };
538 } finally {
539 await git(["worktree", "remove", "--force", worktree], { cwd }).catch(
540 () => {}
541 );
542 }
543}
544
545async function runTest(args: ExecuteSlashArgs): Promise<Omit<SlashResult, "marker">> {
546 const marker = slashCmdMarker("test");
547 const pr = await loadPr(args.prId);
548 if (!pr) {
549 return { ok: false, body: `${marker}\n\nCould not load PR.` };
550 }
551 // Find the repo's test workflow. We accept either `.gluecron/workflows/test.yml`
552 // or `.gluecron/workflows/ci.yml` (matching the spec). Repo owners can
553 // alias their workflow by naming the file accordingly.
554 const wf = await findTestWorkflow(pr.repositoryId);
555 if (!wf) {
556 return {
557 ok: false,
558 body: `${marker}\n\n\`/test\` could not find a test workflow — add \`.gluecron/workflows/test.yml\` or \`ci.yml\` to enable.`,
559 };
560 }
561 // Lazy import so this module stays cheap to load. The runner manages
562 // its own DB writes; we just enqueue.
563 let runId = "";
564 try {
565 const { enqueueRun } = await import("./workflow-runner");
566 runId = await enqueueRun({
567 workflowId: wf.id,
568 repositoryId: pr.repositoryId,
569 event: "workflow_dispatch",
570 ref: `refs/heads/${pr.headBranch}`,
571 triggeredBy: args.userId,
572 });
573 } catch (err) {
574 return {
575 ok: false,
576 body: `${marker}\n\n\`/test\` could not enqueue: ${err instanceof Error ? err.message : String(err)}`,
577 };
578 }
579 await audit({
580 userId: args.userId,
581 repositoryId: args.repositoryId,
582 action: "pr.slash.test",
583 targetType: "pr",
584 targetId: args.prId,
585 metadata: { workflowId: wf.id, runId },
586 });
587 return {
588 ok: !!runId,
589 body: `${marker}\n\n**Tests dispatched** — workflow \`${wf.name}\` queued${runId ? ` (run id \`${runId.slice(0, 8)}\`).` : "."}`,
590 };
591}
592
09d5f39Claude593async function runStage(
594 args: ExecuteSlashArgs
595): Promise<Omit<SlashResult, "marker">> {
596 const marker = slashCmdMarker("stage");
597 try {
598 // Lazy import to avoid circular dependency at module load time
599 const { triggerStage } = await import("./pr-stage");
600 // Fire-and-forget — the stage pipeline posts its own reply comment
601 // with the preview URL once live. We immediately return a "queued"
602 // acknowledgement so the user knows the command was accepted.
603 triggerStage(args.prId, args.userId).catch(() => {});
604 return {
605 ok: true,
606 body: `${marker}\n\n**Preview queued** — detecting framework and deploying. A follow-up comment will appear with the live URL in a few seconds.`,
607 };
608 } catch (err) {
609 return {
610 ok: false,
611 body: `${marker}\n\n\`/stage\` failed to queue: ${err instanceof Error ? err.message : String(err)}`,
612 };
613 }
614}
615
15db0e0Claude616// ---------------------------------------------------------------------------
617// Internal helpers
618// ---------------------------------------------------------------------------
619
620function parseMergeStrategy(raw: string | undefined): "squash" | "rebase" | "merge" {
621 const candidate = (raw || "").toLowerCase().trim();
622 if (candidate === "squash" || candidate === "rebase" || candidate === "merge") {
623 return candidate;
624 }
625 // Default — match the existing UI button which performs a clean merge.
626 return "merge";
627}
628
629async function loadPr(prId: string): Promise<PullRequest | null> {
630 try {
631 const [pr] = await db
632 .select()
633 .from(pullRequests)
634 .where(eq(pullRequests.id, prId))
635 .limit(1);
636 return pr ?? null;
637 } catch {
638 return null;
639 }
640}
641
642async function loadRepoOwner(
643 repositoryId: string
644): Promise<{ ownerName: string; repoName: string; isPublic: boolean } | null> {
645 try {
646 const [row] = await db
647 .select({
648 repoName: repositories.name,
649 isPrivate: repositories.isPrivate,
650 ownerName: users.username,
651 })
652 .from(repositories)
653 .innerJoin(users, eq(repositories.ownerId, users.id))
654 .where(eq(repositories.id, repositoryId))
655 .limit(1);
656 if (!row) return null;
657 return {
658 ownerName: row.ownerName,
659 repoName: row.repoName,
660 isPublic: !row.isPrivate,
661 };
662 } catch {
663 return null;
664 }
665}
666
667async function usernameFor(userId: string): Promise<string | null> {
668 try {
669 const [row] = await db
670 .select({ username: users.username })
671 .from(users)
672 .where(eq(users.id, userId))
673 .limit(1);
674 return row?.username ?? null;
675 } catch {
676 return null;
677 }
678}
679
680async function usernamesExist(candidates: string[]): Promise<Set<string>> {
681 const lowered = new Set<string>();
682 if (candidates.length === 0) return lowered;
683 try {
684 const rows = await db
685 .select({ username: users.username })
686 .from(users);
687 const known = new Set(rows.map((r) => r.username.toLowerCase()));
688 for (const c of candidates) {
689 if (known.has(c.toLowerCase())) lowered.add(c.toLowerCase());
690 }
691 } catch {
692 /* swallow — empty set means we report all as unknown */
693 }
694 return lowered;
695}
696
697async function findTestWorkflow(
698 repositoryId: string
699): Promise<{ id: string; name: string; path: string } | null> {
700 try {
701 const rows = await db
702 .select({ id: workflows.id, name: workflows.name, path: workflows.path })
703 .from(workflows)
704 .where(eq(workflows.repositoryId, repositoryId));
705 // Prefer test.yml > test.yaml > ci.yml > ci.yaml. Repo path is
706 // `.gluecron/workflows/<file>`.
707 const order = ["test.yml", "test.yaml", "ci.yml", "ci.yaml"];
708 for (const file of order) {
709 const hit = rows.find((r) => r.path.endsWith(`/${file}`) || r.path === file);
710 if (hit) return hit;
711 }
712 return null;
713 } catch {
714 return null;
715 }
716}
717
718async function defaultGit(
719 cmd: string[],
720 opts: { cwd: string }
721): Promise<{ stdout: string; stderr: string; exitCode: number }> {
722 const proc = Bun.spawn(["git", ...cmd], {
723 cwd: opts.cwd,
724 stdout: "pipe",
725 stderr: "pipe",
726 });
727 const [stdout, stderr] = await Promise.all([
728 new Response(proc.stdout).text(),
729 new Response(proc.stderr).text(),
730 ]);
731 const exitCode = await proc.exited;
732 return { stdout, stderr, exitCode };
733}
734
735async function diffBetweenBranches(
736 owner: string,
737 repo: string,
738 baseBranch: string,
739 headBranch: string,
740 gitOverride?: ExecuteSlashDeps["git"]
741): Promise<string> {
742 const cwd = getRepoPath(owner, repo);
743 const git = gitOverride ?? defaultGit;
744 try {
745 const r = await git(["diff", `${baseBranch}...${headBranch}`, "--"], { cwd });
746 return r.stdout;
747 } catch {
748 return "";
749 }
750}
751
752interface ExplainClaudeArgs {
753 title: string;
754 body: string;
755 diff: string;
756 client?: Pick<Anthropic, "messages">;
757}
758
759async function callExplainClaude(args: ExplainClaudeArgs): Promise<string> {
760 const client = args.client ?? getAnthropic();
761 const prompt = `You are explaining a pull request to a reviewer doing a cold read.
762
763Write a concise Markdown explanation (under ~250 words) covering:
764
7651. **What this PR changes** — one or two sentences.
7662. **Why** — inferred from the title/body.
7673. **Risk areas** — the most important spots a reviewer should focus on.
768
769Do not include a top-level H1. Use short paragraphs and bullet points. Stay factual; if the diff is empty say so.
770
771PR title: ${args.title}
772
773PR body:
774${args.body || "(empty)"}
775
776Diff (truncated):
777\`\`\`diff
778${args.diff || "(empty)"}
779\`\`\`
780`;
781 try {
782 const message = await client.messages.create({
783 model: MODEL_SONNET,
784 max_tokens: 1024,
785 messages: [{ role: "user", content: prompt }],
786 });
787 const text = extractText(message as Anthropic.Messages.Message).trim();
788 return text || "_Claude returned no explanation._";
789 } catch (err) {
790 return `_Claude call failed: ${err instanceof Error ? err.message : String(err)}._`;
791 }
792}
793
794/**
795 * Look at a stored comment body and, if it carries our slash-command
796 * marker, return the bare command name. Used by the renderer to swap
797 * the comment for a pill. Falls back to `null` for normal comments.
798 */
799export function detectSlashCmdComment(body: string): SlashCommand | null {
800 if (!body) return null;
801 const match = body.match(/^<!--\s*cmd:([a-z-]+)\s*-->/i);
802 if (!match) return null;
803 const cmd = match[1].toLowerCase();
804 if (!(SLASH_COMMANDS as readonly string[]).includes(cmd)) return null;
805 return cmd as SlashCommand;
806}
807
808/**
809 * Strip the marker line from a stored slash-command comment so the
810 * renderer can show just the human-friendly body inside the pill.
811 */
812export function stripSlashCmdMarker(body: string): string {
813 return (body || "").replace(/^<!--\s*cmd:[a-z-]+\s*-->\s*/i, "").trimStart();
814}
815
816/** Test-only handles. */
817export const __test = {
818 callExplainClaude,
819 parseMergeStrategy,
820 findTestWorkflow,
821};