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

ai-review.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.

ai-review.tsBlame555 lines · 2 contributors
e883329Claude1/**
2 * AI-powered code review using Claude.
3 *
4 * Generates inline review comments on pull request diffs.
5 * Reviews are posted as PR comments with isAiReview=true.
6 */
7
8import Anthropic from "@anthropic-ai/sdk";
fd87bb9Claude9import { eq, and, like } from "drizzle-orm";
10import { db } from "../db";
11import { pullRequests, prComments } from "../db/schema";
12import { getRepoPath } from "../git/repository";
e883329Claude13import { config } from "./config";
0c3eee5Claude14import { recordAiCost, extractUsage } from "./ai-cost-tracker";
422a2d4Claude15import {
16 isTrioReviewEnabled,
17 alreadyTrioReviewed,
18 runTrioReview,
19} from "./ai-review-trio";
0b49751Claude20import { assertAiQuota, AiQuotaExceededError } from "./billing";
a7460bfClaude21import { getBotUserIdOrFallback } from "./bot-user";
479dcd9Claude22import {
23 getAutomationSettings,
24 type AutomationSettingsLoader,
25} from "./automation-settings";
e883329Claude26
27interface ReviewComment {
28 filePath: string;
29 lineNumber: number | null;
30 body: string;
31}
32
33interface ReviewResult {
34 summary: string;
35 comments: ReviewComment[];
36 approved: boolean;
37}
38
fd87bb9Claude39/**
40 * Marker we drop into the AI summary comment body. Used to detect a
41 * prior review and short-circuit duplicate runs (e.g. when a PR is
42 * marked draft → ready → draft → ready).
43 */
44export const AI_REVIEW_MARKER = "<!-- gluecron-ai-review:summary -->";
45
46/** Max bytes of diff we send to Claude. Matches reviewDiff's internal cap. */
47const DIFF_BYTE_CAP = 100_000;
48
e883329Claude49let _client: Anthropic | null = null;
50
51function getClient(): Anthropic {
52 if (!_client) {
53 if (!config.anthropicApiKey) {
54 throw new Error("ANTHROPIC_API_KEY is not set");
55 }
56 _client = new Anthropic({ apiKey: config.anthropicApiKey });
57 }
58 return _client;
59}
60
61/**
62 * Run AI code review on a PR diff.
63 */
64export async function reviewDiff(
65 repoFullName: string,
66 prTitle: string,
67 prBody: string | null,
68 baseBranch: string,
69 headBranch: string,
70 diffText: string
71): Promise<ReviewResult> {
72 const client = getClient();
73
9769a35Claude74 const REVIEW_MODEL = "claude-sonnet-4-6";
e883329Claude75 const message = await client.messages.create({
0c3eee5Claude76 model: REVIEW_MODEL,
e883329Claude77 max_tokens: 4096,
78 messages: [
79 {
80 role: "user",
81 content: `You are reviewing a pull request on the repository "${repoFullName}".
82
83**PR Title:** ${prTitle}
84**PR Description:** ${prBody || "(none)"}
85**Base branch:** ${baseBranch}
86**Head branch:** ${headBranch}
87
88Review the following diff. Look for:
89- Bugs, logic errors, or potential runtime failures
90- Security vulnerabilities (injection, XSS, auth bypasses, secrets in code)
91- Performance issues (N+1 queries, unnecessary allocations, blocking I/O)
92- Missing error handling at system boundaries
93- Breaking changes or API contract violations
94
95Do NOT comment on style, formatting, naming, missing docs, or minor nitpicks. Only flag issues that could cause real problems.
96
97Respond in JSON format:
98{
99 "summary": "1-3 sentence overall assessment",
100 "approved": true/false,
101 "comments": [
102 {
103 "filePath": "path/to/file.ts",
104 "lineNumber": 42,
105 "body": "Explain the issue and suggest a fix"
106 }
107 ]
108}
109
110If the diff looks clean, return approved: true with an empty comments array.
111
112\`\`\`diff
113${diffText.slice(0, 100000)}
114\`\`\``,
115 },
116 ],
117 });
118
119 const text =
120 message.content[0].type === "text" ? message.content[0].text : "";
121
0c3eee5Claude122 // Best-effort cost capture. Caller passes "owner/repo" as repoFullName,
123 // so we can't easily resolve the repo id here; the call site at
124 // `triggerAiReview` records with a repository_id below.
125 try {
126 const usage = extractUsage(message);
127 await recordAiCost({
128 model: REVIEW_MODEL,
129 inputTokens: usage.input,
130 outputTokens: usage.output,
131 category: "ai_review",
132 sourceKind: "pull_request",
133 });
134 } catch {
135 /* never escape — observational only */
136 }
137
e883329Claude138 try {
139 // Extract JSON from response (may be wrapped in markdown code block)
140 const jsonMatch = text.match(/\{[\s\S]*\}/);
141 if (!jsonMatch) {
2e8a4d5Claude142 // FAIL-CLOSED: parsing failure must NOT count as approval. Previously
143 // this returned approved:true, which silently auto-approved PRs
144 // whenever Claude's output drifted off the JSON spec — a real auto-
145 // merge hazard. Surface the failure in the summary instead.
e883329Claude146 return {
2e8a4d5Claude147 summary:
148 "AI review failed: model returned output that could not be parsed as JSON. Treating as not-approved; a human reviewer should look at this PR.",
e883329Claude149 comments: [],
2e8a4d5Claude150 approved: false,
e883329Claude151 };
152 }
153 const parsed = JSON.parse(jsonMatch[0]);
154 return {
155 summary: parsed.summary || "Review complete.",
156 comments: Array.isArray(parsed.comments) ? parsed.comments : [],
2e8a4d5Claude157 // Require explicit `approved: true` — undefined or any other value
158 // is treated as not-approved. Same fail-closed principle as above.
159 approved: parsed.approved === true,
e883329Claude160 };
2e8a4d5Claude161 } catch (err) {
162 // FAIL-CLOSED: JSON.parse threw (truncated output, schema-shaped
163 // garbage, etc.). Echo a short error tail so operators have a
164 // breadcrumb in the comment body, but never approve.
165 const tail = err instanceof Error ? err.message.slice(0, 200) : String(err);
e883329Claude166 return {
2e8a4d5Claude167 summary: `AI review failed to parse model output (${tail}). Treating as not-approved; a human reviewer should look at this PR.`,
e883329Claude168 comments: [],
2e8a4d5Claude169 approved: false,
e883329Claude170 };
171 }
172}
173
174/**
175 * Check if AI review is available (API key configured).
176 */
177export function isAiReviewEnabled(): boolean {
178 return !!config.anthropicApiKey;
179}
0316dbbClaude180
181/**
fd87bb9Claude182 * Compute the merge-base diff between two branches in a bare repo.
183 * Returns "" on any error so callers can no-op cleanly. Uses the
184 * three-dot `base...head` form so the diff is what changed on `head`
185 * relative to the common ancestor with `base` (which is the PR
186 * conventional view, not a literal range diff).
187 */
188async function diffBetweenBranches(
189 ownerName: string,
190 repoName: string,
191 baseBranch: string,
192 headBranch: string
193): Promise<string> {
194 try {
195 const cwd = getRepoPath(ownerName, repoName);
196 const proc = Bun.spawn(
197 [
198 "git",
199 "diff",
200 `${baseBranch}...${headBranch}`,
201 "--",
202 ],
203 { cwd, stdout: "pipe", stderr: "pipe" }
204 );
205 const text = await new Response(proc.stdout).text();
206 await proc.exited;
207 return text;
208 } catch {
209 return "";
210 }
211}
212
422a2d4Claude213/**
214 * Resolve a branch name to its current commit SHA via `git rev-parse`.
215 * Returns "" on any error — callers should treat that as "unknown" and
216 * carry on; this is only used for audit metadata.
217 */
218async function resolveHeadSha(
219 ownerName: string,
220 repoName: string,
221 branch: string
222): Promise<string> {
223 try {
224 const cwd = getRepoPath(ownerName, repoName);
225 const proc = Bun.spawn(["git", "rev-parse", branch], {
226 cwd,
227 stdout: "pipe",
228 stderr: "pipe",
229 });
230 const text = await new Response(proc.stdout).text();
231 await proc.exited;
232 return text.trim();
233 } catch {
234 return "";
235 }
236}
237
fd87bb9Claude238/**
58c39f5Claude239 * Has this PR already been reviewed by the AI for the given head SHA?
240 * Detected by an existing PR comment carrying our summary marker that
241 * also embeds a SHA marker matching the current head. If the stored SHA
242 * differs (new commits pushed) we allow a re-review. If no review
243 * exists yet we also allow it. Cheap LIKE query — if it fails (DB
244 * hiccup) we fall back to "not yet" and re-review, which is idempotent
245 * at worst (a duplicate summary), never destructive.
fd87bb9Claude246 */
58c39f5Claude247async function alreadyReviewed(prId: string, headSha: string): Promise<boolean> {
fd87bb9Claude248 try {
249 const [row] = await db
58c39f5Claude250 .select({ body: prComments.body })
fd87bb9Claude251 .from(prComments)
252 .where(
253 and(
254 eq(prComments.pullRequestId, prId),
255 eq(prComments.isAiReview, true),
256 like(prComments.body, `%${AI_REVIEW_MARKER}%`)
257 )
258 )
259 .limit(1);
58c39f5Claude260 if (!row) return false;
261 // Extract the SHA embedded in the comment. If it matches the current
262 // head we skip (same commit reviewed already). If it differs or is
263 // missing we allow re-review (new commits pushed).
264 const shaMatch = row.body.match(/<!-- gluecron-ai-review:sha:([0-9a-f]+) -->/);
265 if (!shaMatch) return false; // Legacy comment without SHA — re-review.
266 return shaMatch[1] === headSha;
fd87bb9Claude267 } catch {
268 return false;
269 }
270}
271
272/**
273 * Real AI review trigger. Replaces the previous stub. Pipeline:
274 *
275 * 1. Idempotency check — bail if a prior review summary exists.
276 * 2. Compute the base...head diff via the bare repo.
277 * 3. Call reviewDiff for a structured response (summary + per-file
278 * comments + approved boolean).
279 * 4. Persist:
280 * - one summary comment (isAiReview=true, marker embedded), and
281 * - one comment per inline finding (isAiReview=true, filePath +
282 * lineNumber populated).
283 *
284 * Always fire-and-forget at the call site (`.catch(...)`); this
285 * function still never throws so the catch is belt-and-braces. AI
286 * comments are authored by the PR author so the existing comment
287 * rendering can group them naturally — there is no synthetic bot user
288 * yet (tracked alongside H2 app-bot identity work).
0316dbbClaude289 */
290export async function triggerAiReview(
291 ownerName: string,
292 repoName: string,
fd87bb9Claude293 prId: string,
294 title: string,
295 body: string,
296 baseBranch: string,
297 headBranch: string,
479dcd9Claude298 options: { force?: boolean; loadSettings?: AutomationSettingsLoader } = {}
0316dbbClaude299): Promise<void> {
fd87bb9Claude300 try {
301 if (!isAiReviewEnabled()) return;
58c39f5Claude302
303 // Resolve the current head SHA early — needed for SHA-based idempotency
304 // on both the single-Claude and trio paths.
305 const headSha = await resolveHeadSha(ownerName, repoName, headBranch);
306
422a2d4Claude307 const useTrio = isTrioReviewEnabled();
308 if (
309 !options.force &&
310 (useTrio
311 ? await alreadyTrioReviewed(prId)
58c39f5Claude312 : await alreadyReviewed(prId, headSha))
422a2d4Claude313 )
314 return;
fd87bb9Claude315
316 const [pr] = await db
422a2d4Claude317 .select({
318 id: pullRequests.id,
319 authorId: pullRequests.authorId,
320 repositoryId: pullRequests.repositoryId,
321 })
fd87bb9Claude322 .from(pullRequests)
323 .where(eq(pullRequests.id, prId))
324 .limit(1);
325 if (!pr) return;
326
479dcd9Claude327 // Per-repo automation gate — 'off' skips AI review entirely. The loader
328 // fails open to the defaults ('suggest' = current behavior), so a broken
329 // settings lookup can never disable reviews.
330 const automation = await (options.loadSettings ?? getAutomationSettings)(
331 pr.repositoryId
332 );
333 if (automation.aiReviewMode === "off") return;
334
0b49751Claude335 // Hard quota gate — post a comment and bail if the user's AI budget is
336 // exhausted. This runs after loading the PR so we have authorId for the
337 // comment insert.
338 try {
339 await assertAiQuota(pr.authorId);
340 } catch (err) {
341 if (err instanceof AiQuotaExceededError) {
342 await db
343 .insert(prComments)
344 .values({
345 pullRequestId: prId,
346 authorId: pr.authorId,
347 isAiReview: true,
348 body: `${AI_REVIEW_MARKER}\n## AI review skipped\n\nYour monthly AI token budget has been reached. Upgrade at [/settings/billing](/settings/billing) to re-enable AI code review.`,
349 })
350 .catch(() => {});
351 return;
352 }
353 // Any other error from assertAiQuota is unexpected — log and proceed
354 // (fail open so a billing glitch never silently kills reviews).
355 console.warn("[ai-review] assertAiQuota failed unexpectedly:", err);
356 }
357
fd87bb9Claude358 let diffText = await diffBetweenBranches(
359 ownerName,
360 repoName,
361 baseBranch,
362 headBranch
363 );
364 if (!diffText.trim()) return;
365 if (diffText.length > DIFF_BYTE_CAP) {
366 diffText = diffText.slice(0, DIFF_BYTE_CAP);
367 }
368
422a2d4Claude369 // Trio path — replaces the single-Claude review when enabled. The
370 // trio helper owns its own persistence + audit; we bail after it.
371 if (useTrio) {
372 try {
373 await runTrioReview({
374 pullRequestId: prId,
375 headSha,
376 diff: diffText,
377 repositoryId: pr.repositoryId,
378 });
379 } catch (err) {
380 if (process.env.DEBUG_AI_REVIEW === "1") {
381 console.error("[ai-review] trio crashed:", err);
382 }
383 }
384 return;
385 }
386
a7460bfClaude387 // Resolve the bot user id once; fall back to the PR author so that
388 // comment insertion never fails even before migration 0078 has run.
389 const commentAuthorId = await getBotUserIdOrFallback(pr.authorId);
390
fd87bb9Claude391 let result: ReviewResult;
392 try {
393 result = await reviewDiff(
394 `${ownerName}/${repoName}`,
395 title,
396 body || null,
397 baseBranch,
398 headBranch,
399 diffText
400 );
401 } catch (err) {
402 // Anthropic API failure — degrade to a single advisory comment so
403 // PR authors see the attempt rather than silence.
404 const reason = err instanceof Error ? err.message : "unknown error";
405 await db
406 .insert(prComments)
407 .values({
408 pullRequestId: prId,
a7460bfClaude409 authorId: commentAuthorId,
fd87bb9Claude410 isAiReview: true,
411 body: `${AI_REVIEW_MARKER}\n## AI review unavailable\n\nThe AI review attempt failed: ${reason}. The PR is otherwise unchanged.`,
412 })
976d7f7Claude413 .catch((err) => {
414 // Was a silent .catch(() => {}) — DB blips here meant the user
415 // saw no review comment AND no diagnostic. Log so operators
416 // can investigate, but don't re-throw (caller is fire-and-forget).
417 console.error(
418 `[ai-review] failed to insert API-failure advisory comment for PR ${prId}:`,
419 err instanceof Error ? err.message : err
420 );
421 });
fd87bb9Claude422 return;
423 }
424
425 const verdict = result.approved
426 ? "**AI review:** no blocking issues found."
427 : `**AI review:** flagged ${result.comments.length} item(s) for human attention.`;
58c39f5Claude428 const shaMarker = headSha ? `<!-- gluecron-ai-review:sha:${headSha} -->` : "";
429 const summaryBody = `${AI_REVIEW_MARKER}${shaMarker ? `\n${shaMarker}` : ""}\n## AI Code Review\n\n${verdict}\n\n${result.summary}`;
fd87bb9Claude430 await db
431 .insert(prComments)
432 .values({
433 pullRequestId: prId,
a7460bfClaude434 authorId: commentAuthorId,
fd87bb9Claude435 isAiReview: true,
436 body: summaryBody,
437 })
976d7f7Claude438 .catch((err) => {
439 console.error(
440 `[ai-review] failed to insert summary comment for PR ${prId}:`,
441 err instanceof Error ? err.message : err
442 );
443 });
fd87bb9Claude444
445 for (const c of result.comments) {
446 if (!c || !c.body) continue;
447 const filePath =
448 typeof c.filePath === "string" && c.filePath ? c.filePath : null;
449 const lineNumber =
450 Number.isInteger(c.lineNumber) && (c.lineNumber as number) > 0
451 ? (c.lineNumber as number)
452 : null;
453 await db
454 .insert(prComments)
455 .values({
456 pullRequestId: prId,
a7460bfClaude457 authorId: commentAuthorId,
fd87bb9Claude458 isAiReview: true,
459 body: c.body,
460 filePath,
461 lineNumber,
462 })
976d7f7Claude463 .catch((err) => {
464 console.error(
465 `[ai-review] failed to insert inline comment for PR ${prId} (${filePath}:${lineNumber}):`,
466 err instanceof Error ? err.message : err
467 );
468 });
fd87bb9Claude469 }
470
471 if (process.env.DEBUG_AI_REVIEW === "1") {
472 console.log(
473 "[ai-review] reviewed",
474 ownerName,
475 repoName,
476 prId,
477 `comments=${result.comments.length}`,
478 `approved=${result.approved}`
479 );
480 }
481 } catch (err) {
482 // Belt-and-braces: never escape into the request path.
483 if (process.env.DEBUG_AI_REVIEW === "1") {
484 console.error("[ai-review] crashed:", err);
485 }
0316dbbClaude486 }
487}
fd87bb9Claude488
c166384ccantynz-alt489/**
490 * Merge-gate signal: did AI review clear this PR?
491 *
492 * The single source of truth for "no blocking issues" is the literal
493 * verdict line `triggerAiReview` writes (`"no blocking issues found"` vs.
494 * `"flagged N item(s) for human attention"`) -- three call sites
495 * (mcp-tools.ts's merge tool, pulls.tsx's PR-page gate preview, and
496 * pulls.tsx's actual merge handler) had each grown their own ad-hoc
497 * heuristic, checking for strings like "**Approved**" / "approved: true"
498 * / "lgtm" that the real generator never produces. Left alone, that meant
499 * a genuinely clean review could still hard-block a merge depending on
500 * which code path ran it.
501 *
502 * Also treats a degraded review ("AI review skipped" on quota exhaustion,
503 * "AI review unavailable" on a provider error -- e.g. the Anthropic
504 * account running out of credit) as non-blocking, same fail-open
505 * precedent already applied to the security-scan gate: a review that
506 * never ran is not the same signal as a review that ran and found real
507 * problems, and treating them identically means any Anthropic-side
508 * hiccup silently blocks every merge platform-wide.
509 *
510 * No comment at all (review disabled, or hasn't posted yet) also
511 * approves -- gating on AI review's mere presence isn't this function's
512 * job, `runAllGateChecks`'s `runAiReview`/settings check already covers
513 * "should this gate even run".
514 */
515export async function isAiReviewApproved(prId: string): Promise<boolean> {
516 const aiComments = await db
517 .select({ body: prComments.body, createdAt: prComments.createdAt })
518 .from(prComments)
519 .where(
520 and(eq(prComments.pullRequestId, prId), eq(prComments.isAiReview, true))
521 );
522 return computeAiReviewApproval(aiComments);
523}
524
525/**
526 * Pure decision logic behind `isAiReviewApproved`, split out so it's
527 * directly unit-testable without a DB connection. Takes the PR's
528 * isAiReview=true comments (any order) and picks the most recent one
529 * carrying the summary marker.
530 */
531export function computeAiReviewApproval(
532 aiComments: Array<{ body: string; createdAt: string | Date }>
533): boolean {
534 const summaryComments = aiComments
535 .filter((c) => c.body.includes(AI_REVIEW_MARKER))
536 .sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
537
538 const latest = summaryComments[0];
539 if (!latest) return true;
540 if (
541 latest.body.includes("AI review skipped") ||
542 latest.body.includes("AI review unavailable")
543 ) {
544 return true;
545 }
546 return latest.body.includes("no blocking issues found");
547}
548
fd87bb9Claude549/**
550 * Test-only export: the internal helpers. Not part of the public API.
551 */
552export const __test = {
553 diffBetweenBranches,
554 alreadyReviewed,
555};