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.tsBlame543 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
fd87bb9Claude8import { eq, and, like } from "drizzle-orm";
9import { db } from "../db";
10import { pullRequests, prComments } from "../db/schema";
11import { getRepoPath } from "../git/repository";
e883329Claude12import { config } from "./config";
242097bccantynz-alt13import { getAnthropic, modelForTask } from "./ai-client";
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
e883329Claude49/**
50 * Run AI code review on a PR diff.
51 */
52export async function reviewDiff(
53 repoFullName: string,
54 prTitle: string,
55 prBody: string | null,
56 baseBranch: string,
57 headBranch: string,
58 diffText: string
59): Promise<ReviewResult> {
242097bccantynz-alt60 const client = getAnthropic();
e883329Claude61
242097bccantynz-alt62 const REVIEW_MODEL = modelForTask("code-review");
e883329Claude63 const message = await client.messages.create({
0c3eee5Claude64 model: REVIEW_MODEL,
e883329Claude65 max_tokens: 4096,
66 messages: [
67 {
68 role: "user",
69 content: `You are reviewing a pull request on the repository "${repoFullName}".
70
71**PR Title:** ${prTitle}
72**PR Description:** ${prBody || "(none)"}
73**Base branch:** ${baseBranch}
74**Head branch:** ${headBranch}
75
76Review the following diff. Look for:
77- Bugs, logic errors, or potential runtime failures
78- Security vulnerabilities (injection, XSS, auth bypasses, secrets in code)
79- Performance issues (N+1 queries, unnecessary allocations, blocking I/O)
80- Missing error handling at system boundaries
81- Breaking changes or API contract violations
82
83Do NOT comment on style, formatting, naming, missing docs, or minor nitpicks. Only flag issues that could cause real problems.
84
85Respond in JSON format:
86{
87 "summary": "1-3 sentence overall assessment",
88 "approved": true/false,
89 "comments": [
90 {
91 "filePath": "path/to/file.ts",
92 "lineNumber": 42,
93 "body": "Explain the issue and suggest a fix"
94 }
95 ]
96}
97
98If the diff looks clean, return approved: true with an empty comments array.
99
100\`\`\`diff
101${diffText.slice(0, 100000)}
102\`\`\``,
103 },
104 ],
105 });
106
107 const text =
108 message.content[0].type === "text" ? message.content[0].text : "";
109
0c3eee5Claude110 // Best-effort cost capture. Caller passes "owner/repo" as repoFullName,
111 // so we can't easily resolve the repo id here; the call site at
112 // `triggerAiReview` records with a repository_id below.
113 try {
114 const usage = extractUsage(message);
115 await recordAiCost({
116 model: REVIEW_MODEL,
117 inputTokens: usage.input,
118 outputTokens: usage.output,
119 category: "ai_review",
120 sourceKind: "pull_request",
121 });
122 } catch {
123 /* never escape — observational only */
124 }
125
e883329Claude126 try {
127 // Extract JSON from response (may be wrapped in markdown code block)
128 const jsonMatch = text.match(/\{[\s\S]*\}/);
129 if (!jsonMatch) {
2e8a4d5Claude130 // FAIL-CLOSED: parsing failure must NOT count as approval. Previously
131 // this returned approved:true, which silently auto-approved PRs
132 // whenever Claude's output drifted off the JSON spec — a real auto-
133 // merge hazard. Surface the failure in the summary instead.
e883329Claude134 return {
2e8a4d5Claude135 summary:
136 "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.",
e883329Claude137 comments: [],
2e8a4d5Claude138 approved: false,
e883329Claude139 };
140 }
141 const parsed = JSON.parse(jsonMatch[0]);
142 return {
143 summary: parsed.summary || "Review complete.",
144 comments: Array.isArray(parsed.comments) ? parsed.comments : [],
2e8a4d5Claude145 // Require explicit `approved: true` — undefined or any other value
146 // is treated as not-approved. Same fail-closed principle as above.
147 approved: parsed.approved === true,
e883329Claude148 };
2e8a4d5Claude149 } catch (err) {
150 // FAIL-CLOSED: JSON.parse threw (truncated output, schema-shaped
151 // garbage, etc.). Echo a short error tail so operators have a
152 // breadcrumb in the comment body, but never approve.
153 const tail = err instanceof Error ? err.message.slice(0, 200) : String(err);
e883329Claude154 return {
2e8a4d5Claude155 summary: `AI review failed to parse model output (${tail}). Treating as not-approved; a human reviewer should look at this PR.`,
e883329Claude156 comments: [],
2e8a4d5Claude157 approved: false,
e883329Claude158 };
159 }
160}
161
162/**
163 * Check if AI review is available (API key configured).
164 */
165export function isAiReviewEnabled(): boolean {
166 return !!config.anthropicApiKey;
167}
0316dbbClaude168
169/**
fd87bb9Claude170 * Compute the merge-base diff between two branches in a bare repo.
171 * Returns "" on any error so callers can no-op cleanly. Uses the
172 * three-dot `base...head` form so the diff is what changed on `head`
173 * relative to the common ancestor with `base` (which is the PR
174 * conventional view, not a literal range diff).
175 */
176async function diffBetweenBranches(
177 ownerName: string,
178 repoName: string,
179 baseBranch: string,
180 headBranch: string
181): Promise<string> {
182 try {
183 const cwd = getRepoPath(ownerName, repoName);
184 const proc = Bun.spawn(
185 [
186 "git",
187 "diff",
188 `${baseBranch}...${headBranch}`,
189 "--",
190 ],
191 { cwd, stdout: "pipe", stderr: "pipe" }
192 );
193 const text = await new Response(proc.stdout).text();
194 await proc.exited;
195 return text;
196 } catch {
197 return "";
198 }
199}
200
422a2d4Claude201/**
202 * Resolve a branch name to its current commit SHA via `git rev-parse`.
203 * Returns "" on any error — callers should treat that as "unknown" and
204 * carry on; this is only used for audit metadata.
205 */
206async function resolveHeadSha(
207 ownerName: string,
208 repoName: string,
209 branch: string
210): Promise<string> {
211 try {
212 const cwd = getRepoPath(ownerName, repoName);
213 const proc = Bun.spawn(["git", "rev-parse", branch], {
214 cwd,
215 stdout: "pipe",
216 stderr: "pipe",
217 });
218 const text = await new Response(proc.stdout).text();
219 await proc.exited;
220 return text.trim();
221 } catch {
222 return "";
223 }
224}
225
fd87bb9Claude226/**
58c39f5Claude227 * Has this PR already been reviewed by the AI for the given head SHA?
228 * Detected by an existing PR comment carrying our summary marker that
229 * also embeds a SHA marker matching the current head. If the stored SHA
230 * differs (new commits pushed) we allow a re-review. If no review
231 * exists yet we also allow it. Cheap LIKE query — if it fails (DB
232 * hiccup) we fall back to "not yet" and re-review, which is idempotent
233 * at worst (a duplicate summary), never destructive.
fd87bb9Claude234 */
58c39f5Claude235async function alreadyReviewed(prId: string, headSha: string): Promise<boolean> {
fd87bb9Claude236 try {
237 const [row] = await db
58c39f5Claude238 .select({ body: prComments.body })
fd87bb9Claude239 .from(prComments)
240 .where(
241 and(
242 eq(prComments.pullRequestId, prId),
243 eq(prComments.isAiReview, true),
244 like(prComments.body, `%${AI_REVIEW_MARKER}%`)
245 )
246 )
247 .limit(1);
58c39f5Claude248 if (!row) return false;
249 // Extract the SHA embedded in the comment. If it matches the current
250 // head we skip (same commit reviewed already). If it differs or is
251 // missing we allow re-review (new commits pushed).
252 const shaMatch = row.body.match(/<!-- gluecron-ai-review:sha:([0-9a-f]+) -->/);
253 if (!shaMatch) return false; // Legacy comment without SHA — re-review.
254 return shaMatch[1] === headSha;
fd87bb9Claude255 } catch {
256 return false;
257 }
258}
259
260/**
261 * Real AI review trigger. Replaces the previous stub. Pipeline:
262 *
263 * 1. Idempotency check — bail if a prior review summary exists.
264 * 2. Compute the base...head diff via the bare repo.
265 * 3. Call reviewDiff for a structured response (summary + per-file
266 * comments + approved boolean).
267 * 4. Persist:
268 * - one summary comment (isAiReview=true, marker embedded), and
269 * - one comment per inline finding (isAiReview=true, filePath +
270 * lineNumber populated).
271 *
272 * Always fire-and-forget at the call site (`.catch(...)`); this
273 * function still never throws so the catch is belt-and-braces. AI
274 * comments are authored by the PR author so the existing comment
275 * rendering can group them naturally — there is no synthetic bot user
276 * yet (tracked alongside H2 app-bot identity work).
0316dbbClaude277 */
278export async function triggerAiReview(
279 ownerName: string,
280 repoName: string,
fd87bb9Claude281 prId: string,
282 title: string,
283 body: string,
284 baseBranch: string,
285 headBranch: string,
479dcd9Claude286 options: { force?: boolean; loadSettings?: AutomationSettingsLoader } = {}
0316dbbClaude287): Promise<void> {
fd87bb9Claude288 try {
289 if (!isAiReviewEnabled()) return;
58c39f5Claude290
291 // Resolve the current head SHA early — needed for SHA-based idempotency
292 // on both the single-Claude and trio paths.
293 const headSha = await resolveHeadSha(ownerName, repoName, headBranch);
294
422a2d4Claude295 const useTrio = isTrioReviewEnabled();
296 if (
297 !options.force &&
298 (useTrio
299 ? await alreadyTrioReviewed(prId)
58c39f5Claude300 : await alreadyReviewed(prId, headSha))
422a2d4Claude301 )
302 return;
fd87bb9Claude303
304 const [pr] = await db
422a2d4Claude305 .select({
306 id: pullRequests.id,
307 authorId: pullRequests.authorId,
308 repositoryId: pullRequests.repositoryId,
309 })
fd87bb9Claude310 .from(pullRequests)
311 .where(eq(pullRequests.id, prId))
312 .limit(1);
313 if (!pr) return;
314
479dcd9Claude315 // Per-repo automation gate — 'off' skips AI review entirely. The loader
316 // fails open to the defaults ('suggest' = current behavior), so a broken
317 // settings lookup can never disable reviews.
318 const automation = await (options.loadSettings ?? getAutomationSettings)(
319 pr.repositoryId
320 );
321 if (automation.aiReviewMode === "off") return;
322
0b49751Claude323 // Hard quota gate — post a comment and bail if the user's AI budget is
324 // exhausted. This runs after loading the PR so we have authorId for the
325 // comment insert.
326 try {
327 await assertAiQuota(pr.authorId);
328 } catch (err) {
329 if (err instanceof AiQuotaExceededError) {
330 await db
331 .insert(prComments)
332 .values({
333 pullRequestId: prId,
334 authorId: pr.authorId,
335 isAiReview: true,
336 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.`,
337 })
338 .catch(() => {});
339 return;
340 }
341 // Any other error from assertAiQuota is unexpected — log and proceed
342 // (fail open so a billing glitch never silently kills reviews).
343 console.warn("[ai-review] assertAiQuota failed unexpectedly:", err);
344 }
345
fd87bb9Claude346 let diffText = await diffBetweenBranches(
347 ownerName,
348 repoName,
349 baseBranch,
350 headBranch
351 );
352 if (!diffText.trim()) return;
353 if (diffText.length > DIFF_BYTE_CAP) {
354 diffText = diffText.slice(0, DIFF_BYTE_CAP);
355 }
356
422a2d4Claude357 // Trio path — replaces the single-Claude review when enabled. The
358 // trio helper owns its own persistence + audit; we bail after it.
359 if (useTrio) {
360 try {
361 await runTrioReview({
362 pullRequestId: prId,
363 headSha,
364 diff: diffText,
365 repositoryId: pr.repositoryId,
366 });
367 } catch (err) {
368 if (process.env.DEBUG_AI_REVIEW === "1") {
369 console.error("[ai-review] trio crashed:", err);
370 }
371 }
372 return;
373 }
374
a7460bfClaude375 // Resolve the bot user id once; fall back to the PR author so that
376 // comment insertion never fails even before migration 0078 has run.
377 const commentAuthorId = await getBotUserIdOrFallback(pr.authorId);
378
fd87bb9Claude379 let result: ReviewResult;
380 try {
381 result = await reviewDiff(
382 `${ownerName}/${repoName}`,
383 title,
384 body || null,
385 baseBranch,
386 headBranch,
387 diffText
388 );
389 } catch (err) {
390 // Anthropic API failure — degrade to a single advisory comment so
391 // PR authors see the attempt rather than silence.
392 const reason = err instanceof Error ? err.message : "unknown error";
393 await db
394 .insert(prComments)
395 .values({
396 pullRequestId: prId,
a7460bfClaude397 authorId: commentAuthorId,
fd87bb9Claude398 isAiReview: true,
399 body: `${AI_REVIEW_MARKER}\n## AI review unavailable\n\nThe AI review attempt failed: ${reason}. The PR is otherwise unchanged.`,
400 })
976d7f7Claude401 .catch((err) => {
402 // Was a silent .catch(() => {}) — DB blips here meant the user
403 // saw no review comment AND no diagnostic. Log so operators
404 // can investigate, but don't re-throw (caller is fire-and-forget).
405 console.error(
406 `[ai-review] failed to insert API-failure advisory comment for PR ${prId}:`,
407 err instanceof Error ? err.message : err
408 );
409 });
fd87bb9Claude410 return;
411 }
412
413 const verdict = result.approved
414 ? "**AI review:** no blocking issues found."
415 : `**AI review:** flagged ${result.comments.length} item(s) for human attention.`;
58c39f5Claude416 const shaMarker = headSha ? `<!-- gluecron-ai-review:sha:${headSha} -->` : "";
417 const summaryBody = `${AI_REVIEW_MARKER}${shaMarker ? `\n${shaMarker}` : ""}\n## AI Code Review\n\n${verdict}\n\n${result.summary}`;
fd87bb9Claude418 await db
419 .insert(prComments)
420 .values({
421 pullRequestId: prId,
a7460bfClaude422 authorId: commentAuthorId,
fd87bb9Claude423 isAiReview: true,
424 body: summaryBody,
425 })
976d7f7Claude426 .catch((err) => {
427 console.error(
428 `[ai-review] failed to insert summary comment for PR ${prId}:`,
429 err instanceof Error ? err.message : err
430 );
431 });
fd87bb9Claude432
433 for (const c of result.comments) {
434 if (!c || !c.body) continue;
435 const filePath =
436 typeof c.filePath === "string" && c.filePath ? c.filePath : null;
437 const lineNumber =
438 Number.isInteger(c.lineNumber) && (c.lineNumber as number) > 0
439 ? (c.lineNumber as number)
440 : null;
441 await db
442 .insert(prComments)
443 .values({
444 pullRequestId: prId,
a7460bfClaude445 authorId: commentAuthorId,
fd87bb9Claude446 isAiReview: true,
447 body: c.body,
448 filePath,
449 lineNumber,
450 })
976d7f7Claude451 .catch((err) => {
452 console.error(
453 `[ai-review] failed to insert inline comment for PR ${prId} (${filePath}:${lineNumber}):`,
454 err instanceof Error ? err.message : err
455 );
456 });
fd87bb9Claude457 }
458
459 if (process.env.DEBUG_AI_REVIEW === "1") {
460 console.log(
461 "[ai-review] reviewed",
462 ownerName,
463 repoName,
464 prId,
465 `comments=${result.comments.length}`,
466 `approved=${result.approved}`
467 );
468 }
469 } catch (err) {
470 // Belt-and-braces: never escape into the request path.
471 if (process.env.DEBUG_AI_REVIEW === "1") {
472 console.error("[ai-review] crashed:", err);
473 }
0316dbbClaude474 }
475}
fd87bb9Claude476
c166384ccantynz-alt477/**
478 * Merge-gate signal: did AI review clear this PR?
479 *
480 * The single source of truth for "no blocking issues" is the literal
481 * verdict line `triggerAiReview` writes (`"no blocking issues found"` vs.
482 * `"flagged N item(s) for human attention"`) -- three call sites
483 * (mcp-tools.ts's merge tool, pulls.tsx's PR-page gate preview, and
484 * pulls.tsx's actual merge handler) had each grown their own ad-hoc
485 * heuristic, checking for strings like "**Approved**" / "approved: true"
486 * / "lgtm" that the real generator never produces. Left alone, that meant
487 * a genuinely clean review could still hard-block a merge depending on
488 * which code path ran it.
489 *
490 * Also treats a degraded review ("AI review skipped" on quota exhaustion,
491 * "AI review unavailable" on a provider error -- e.g. the Anthropic
492 * account running out of credit) as non-blocking, same fail-open
493 * precedent already applied to the security-scan gate: a review that
494 * never ran is not the same signal as a review that ran and found real
495 * problems, and treating them identically means any Anthropic-side
496 * hiccup silently blocks every merge platform-wide.
497 *
498 * No comment at all (review disabled, or hasn't posted yet) also
499 * approves -- gating on AI review's mere presence isn't this function's
500 * job, `runAllGateChecks`'s `runAiReview`/settings check already covers
501 * "should this gate even run".
502 */
503export async function isAiReviewApproved(prId: string): Promise<boolean> {
504 const aiComments = await db
505 .select({ body: prComments.body, createdAt: prComments.createdAt })
506 .from(prComments)
507 .where(
508 and(eq(prComments.pullRequestId, prId), eq(prComments.isAiReview, true))
509 );
510 return computeAiReviewApproval(aiComments);
511}
512
513/**
514 * Pure decision logic behind `isAiReviewApproved`, split out so it's
515 * directly unit-testable without a DB connection. Takes the PR's
516 * isAiReview=true comments (any order) and picks the most recent one
517 * carrying the summary marker.
518 */
519export function computeAiReviewApproval(
520 aiComments: Array<{ body: string; createdAt: string | Date }>
521): boolean {
522 const summaryComments = aiComments
523 .filter((c) => c.body.includes(AI_REVIEW_MARKER))
524 .sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
525
526 const latest = summaryComments[0];
527 if (!latest) return true;
528 if (
529 latest.body.includes("AI review skipped") ||
530 latest.body.includes("AI review unavailable")
531 ) {
532 return true;
533 }
534 return latest.body.includes("no blocking issues found");
535}
536
fd87bb9Claude537/**
538 * Test-only export: the internal helpers. Not part of the public API.
539 */
540export const __test = {
541 diffBetweenBranches,
542 alreadyReviewed,
543};