Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame483 lines · 1 contributor
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";
e883329Claude22
23interface ReviewComment {
24 filePath: string;
25 lineNumber: number | null;
26 body: string;
27}
28
29interface ReviewResult {
30 summary: string;
31 comments: ReviewComment[];
32 approved: boolean;
33}
34
fd87bb9Claude35/**
36 * Marker we drop into the AI summary comment body. Used to detect a
37 * prior review and short-circuit duplicate runs (e.g. when a PR is
38 * marked draft → ready → draft → ready).
39 */
40export const AI_REVIEW_MARKER = "<!-- gluecron-ai-review:summary -->";
41
42/** Max bytes of diff we send to Claude. Matches reviewDiff's internal cap. */
43const DIFF_BYTE_CAP = 100_000;
44
e883329Claude45let _client: Anthropic | null = null;
46
47function getClient(): Anthropic {
48 if (!_client) {
49 if (!config.anthropicApiKey) {
50 throw new Error("ANTHROPIC_API_KEY is not set");
51 }
52 _client = new Anthropic({ apiKey: config.anthropicApiKey });
53 }
54 return _client;
55}
56
57/**
58 * Run AI code review on a PR diff.
59 */
60export async function reviewDiff(
61 repoFullName: string,
62 prTitle: string,
63 prBody: string | null,
64 baseBranch: string,
65 headBranch: string,
66 diffText: string
67): Promise<ReviewResult> {
68 const client = getClient();
69
9769a35Claude70 const REVIEW_MODEL = "claude-sonnet-4-6";
e883329Claude71 const message = await client.messages.create({
0c3eee5Claude72 model: REVIEW_MODEL,
e883329Claude73 max_tokens: 4096,
74 messages: [
75 {
76 role: "user",
77 content: `You are reviewing a pull request on the repository "${repoFullName}".
78
79**PR Title:** ${prTitle}
80**PR Description:** ${prBody || "(none)"}
81**Base branch:** ${baseBranch}
82**Head branch:** ${headBranch}
83
84Review the following diff. Look for:
85- Bugs, logic errors, or potential runtime failures
86- Security vulnerabilities (injection, XSS, auth bypasses, secrets in code)
87- Performance issues (N+1 queries, unnecessary allocations, blocking I/O)
88- Missing error handling at system boundaries
89- Breaking changes or API contract violations
90
91Do NOT comment on style, formatting, naming, missing docs, or minor nitpicks. Only flag issues that could cause real problems.
92
93Respond in JSON format:
94{
95 "summary": "1-3 sentence overall assessment",
96 "approved": true/false,
97 "comments": [
98 {
99 "filePath": "path/to/file.ts",
100 "lineNumber": 42,
101 "body": "Explain the issue and suggest a fix"
102 }
103 ]
104}
105
106If the diff looks clean, return approved: true with an empty comments array.
107
108\`\`\`diff
109${diffText.slice(0, 100000)}
110\`\`\``,
111 },
112 ],
113 });
114
115 const text =
116 message.content[0].type === "text" ? message.content[0].text : "";
117
0c3eee5Claude118 // Best-effort cost capture. Caller passes "owner/repo" as repoFullName,
119 // so we can't easily resolve the repo id here; the call site at
120 // `triggerAiReview` records with a repository_id below.
121 try {
122 const usage = extractUsage(message);
123 await recordAiCost({
124 model: REVIEW_MODEL,
125 inputTokens: usage.input,
126 outputTokens: usage.output,
127 category: "ai_review",
128 sourceKind: "pull_request",
129 });
130 } catch {
131 /* never escape — observational only */
132 }
133
e883329Claude134 try {
135 // Extract JSON from response (may be wrapped in markdown code block)
136 const jsonMatch = text.match(/\{[\s\S]*\}/);
137 if (!jsonMatch) {
2e8a4d5Claude138 // FAIL-CLOSED: parsing failure must NOT count as approval. Previously
139 // this returned approved:true, which silently auto-approved PRs
140 // whenever Claude's output drifted off the JSON spec — a real auto-
141 // merge hazard. Surface the failure in the summary instead.
e883329Claude142 return {
2e8a4d5Claude143 summary:
144 "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.",
e883329Claude145 comments: [],
2e8a4d5Claude146 approved: false,
e883329Claude147 };
148 }
149 const parsed = JSON.parse(jsonMatch[0]);
150 return {
151 summary: parsed.summary || "Review complete.",
152 comments: Array.isArray(parsed.comments) ? parsed.comments : [],
2e8a4d5Claude153 // Require explicit `approved: true` — undefined or any other value
154 // is treated as not-approved. Same fail-closed principle as above.
155 approved: parsed.approved === true,
e883329Claude156 };
2e8a4d5Claude157 } catch (err) {
158 // FAIL-CLOSED: JSON.parse threw (truncated output, schema-shaped
159 // garbage, etc.). Echo a short error tail so operators have a
160 // breadcrumb in the comment body, but never approve.
161 const tail = err instanceof Error ? err.message.slice(0, 200) : String(err);
e883329Claude162 return {
2e8a4d5Claude163 summary: `AI review failed to parse model output (${tail}). Treating as not-approved; a human reviewer should look at this PR.`,
e883329Claude164 comments: [],
2e8a4d5Claude165 approved: false,
e883329Claude166 };
167 }
168}
169
170/**
171 * Check if AI review is available (API key configured).
172 */
173export function isAiReviewEnabled(): boolean {
174 return !!config.anthropicApiKey;
175}
0316dbbClaude176
177/**
fd87bb9Claude178 * Compute the merge-base diff between two branches in a bare repo.
179 * Returns "" on any error so callers can no-op cleanly. Uses the
180 * three-dot `base...head` form so the diff is what changed on `head`
181 * relative to the common ancestor with `base` (which is the PR
182 * conventional view, not a literal range diff).
183 */
184async function diffBetweenBranches(
185 ownerName: string,
186 repoName: string,
187 baseBranch: string,
188 headBranch: string
189): Promise<string> {
190 try {
191 const cwd = getRepoPath(ownerName, repoName);
192 const proc = Bun.spawn(
193 [
194 "git",
195 "diff",
196 `${baseBranch}...${headBranch}`,
197 "--",
198 ],
199 { cwd, stdout: "pipe", stderr: "pipe" }
200 );
201 const text = await new Response(proc.stdout).text();
202 await proc.exited;
203 return text;
204 } catch {
205 return "";
206 }
207}
208
422a2d4Claude209/**
210 * Resolve a branch name to its current commit SHA via `git rev-parse`.
211 * Returns "" on any error — callers should treat that as "unknown" and
212 * carry on; this is only used for audit metadata.
213 */
214async function resolveHeadSha(
215 ownerName: string,
216 repoName: string,
217 branch: string
218): Promise<string> {
219 try {
220 const cwd = getRepoPath(ownerName, repoName);
221 const proc = Bun.spawn(["git", "rev-parse", branch], {
222 cwd,
223 stdout: "pipe",
224 stderr: "pipe",
225 });
226 const text = await new Response(proc.stdout).text();
227 await proc.exited;
228 return text.trim();
229 } catch {
230 return "";
231 }
232}
233
fd87bb9Claude234/**
58c39f5Claude235 * Has this PR already been reviewed by the AI for the given head SHA?
236 * Detected by an existing PR comment carrying our summary marker that
237 * also embeds a SHA marker matching the current head. If the stored SHA
238 * differs (new commits pushed) we allow a re-review. If no review
239 * exists yet we also allow it. Cheap LIKE query — if it fails (DB
240 * hiccup) we fall back to "not yet" and re-review, which is idempotent
241 * at worst (a duplicate summary), never destructive.
fd87bb9Claude242 */
58c39f5Claude243async function alreadyReviewed(prId: string, headSha: string): Promise<boolean> {
fd87bb9Claude244 try {
245 const [row] = await db
58c39f5Claude246 .select({ body: prComments.body })
fd87bb9Claude247 .from(prComments)
248 .where(
249 and(
250 eq(prComments.pullRequestId, prId),
251 eq(prComments.isAiReview, true),
252 like(prComments.body, `%${AI_REVIEW_MARKER}%`)
253 )
254 )
255 .limit(1);
58c39f5Claude256 if (!row) return false;
257 // Extract the SHA embedded in the comment. If it matches the current
258 // head we skip (same commit reviewed already). If it differs or is
259 // missing we allow re-review (new commits pushed).
260 const shaMatch = row.body.match(/<!-- gluecron-ai-review:sha:([0-9a-f]+) -->/);
261 if (!shaMatch) return false; // Legacy comment without SHA — re-review.
262 return shaMatch[1] === headSha;
fd87bb9Claude263 } catch {
264 return false;
265 }
266}
267
268/**
269 * Real AI review trigger. Replaces the previous stub. Pipeline:
270 *
271 * 1. Idempotency check — bail if a prior review summary exists.
272 * 2. Compute the base...head diff via the bare repo.
273 * 3. Call reviewDiff for a structured response (summary + per-file
274 * comments + approved boolean).
275 * 4. Persist:
276 * - one summary comment (isAiReview=true, marker embedded), and
277 * - one comment per inline finding (isAiReview=true, filePath +
278 * lineNumber populated).
279 *
280 * Always fire-and-forget at the call site (`.catch(...)`); this
281 * function still never throws so the catch is belt-and-braces. AI
282 * comments are authored by the PR author so the existing comment
283 * rendering can group them naturally — there is no synthetic bot user
284 * yet (tracked alongside H2 app-bot identity work).
0316dbbClaude285 */
286export async function triggerAiReview(
287 ownerName: string,
288 repoName: string,
fd87bb9Claude289 prId: string,
290 title: string,
291 body: string,
292 baseBranch: string,
293 headBranch: string,
c3e0c07Claude294 options: { force?: boolean } = {}
0316dbbClaude295): Promise<void> {
fd87bb9Claude296 try {
297 if (!isAiReviewEnabled()) return;
58c39f5Claude298
299 // Resolve the current head SHA early — needed for SHA-based idempotency
300 // on both the single-Claude and trio paths.
301 const headSha = await resolveHeadSha(ownerName, repoName, headBranch);
302
422a2d4Claude303 const useTrio = isTrioReviewEnabled();
304 if (
305 !options.force &&
306 (useTrio
307 ? await alreadyTrioReviewed(prId)
58c39f5Claude308 : await alreadyReviewed(prId, headSha))
422a2d4Claude309 )
310 return;
fd87bb9Claude311
312 const [pr] = await db
422a2d4Claude313 .select({
314 id: pullRequests.id,
315 authorId: pullRequests.authorId,
316 repositoryId: pullRequests.repositoryId,
317 })
fd87bb9Claude318 .from(pullRequests)
319 .where(eq(pullRequests.id, prId))
320 .limit(1);
321 if (!pr) 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
477/**
478 * Test-only export: the internal helpers. Not part of the public API.
479 */
480export const __test = {
481 diffBetweenBranches,
482 alreadyReviewed,
483};