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.tsBlame464 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";
e883329Claude21
22interface ReviewComment {
23 filePath: string;
24 lineNumber: number | null;
25 body: string;
26}
27
28interface ReviewResult {
29 summary: string;
30 comments: ReviewComment[];
31 approved: boolean;
32}
33
fd87bb9Claude34/**
35 * Marker we drop into the AI summary comment body. Used to detect a
36 * prior review and short-circuit duplicate runs (e.g. when a PR is
37 * marked draft → ready → draft → ready).
38 */
39export const AI_REVIEW_MARKER = "<!-- gluecron-ai-review:summary -->";
40
41/** Max bytes of diff we send to Claude. Matches reviewDiff's internal cap. */
42const DIFF_BYTE_CAP = 100_000;
43
e883329Claude44let _client: Anthropic | null = null;
45
46function getClient(): Anthropic {
47 if (!_client) {
48 if (!config.anthropicApiKey) {
49 throw new Error("ANTHROPIC_API_KEY is not set");
50 }
51 _client = new Anthropic({ apiKey: config.anthropicApiKey });
52 }
53 return _client;
54}
55
56/**
57 * Run AI code review on a PR diff.
58 */
59export async function reviewDiff(
60 repoFullName: string,
61 prTitle: string,
62 prBody: string | null,
63 baseBranch: string,
64 headBranch: string,
65 diffText: string
66): Promise<ReviewResult> {
67 const client = getClient();
68
9769a35Claude69 const REVIEW_MODEL = "claude-sonnet-4-6";
e883329Claude70 const message = await client.messages.create({
0c3eee5Claude71 model: REVIEW_MODEL,
e883329Claude72 max_tokens: 4096,
73 messages: [
74 {
75 role: "user",
76 content: `You are reviewing a pull request on the repository "${repoFullName}".
77
78**PR Title:** ${prTitle}
79**PR Description:** ${prBody || "(none)"}
80**Base branch:** ${baseBranch}
81**Head branch:** ${headBranch}
82
83Review the following diff. Look for:
84- Bugs, logic errors, or potential runtime failures
85- Security vulnerabilities (injection, XSS, auth bypasses, secrets in code)
86- Performance issues (N+1 queries, unnecessary allocations, blocking I/O)
87- Missing error handling at system boundaries
88- Breaking changes or API contract violations
89
90Do NOT comment on style, formatting, naming, missing docs, or minor nitpicks. Only flag issues that could cause real problems.
91
92Respond in JSON format:
93{
94 "summary": "1-3 sentence overall assessment",
95 "approved": true/false,
96 "comments": [
97 {
98 "filePath": "path/to/file.ts",
99 "lineNumber": 42,
100 "body": "Explain the issue and suggest a fix"
101 }
102 ]
103}
104
105If the diff looks clean, return approved: true with an empty comments array.
106
107\`\`\`diff
108${diffText.slice(0, 100000)}
109\`\`\``,
110 },
111 ],
112 });
113
114 const text =
115 message.content[0].type === "text" ? message.content[0].text : "";
116
0c3eee5Claude117 // Best-effort cost capture. Caller passes "owner/repo" as repoFullName,
118 // so we can't easily resolve the repo id here; the call site at
119 // `triggerAiReview` records with a repository_id below.
120 try {
121 const usage = extractUsage(message);
122 await recordAiCost({
123 model: REVIEW_MODEL,
124 inputTokens: usage.input,
125 outputTokens: usage.output,
126 category: "ai_review",
127 sourceKind: "pull_request",
128 });
129 } catch {
130 /* never escape — observational only */
131 }
132
e883329Claude133 try {
134 // Extract JSON from response (may be wrapped in markdown code block)
135 const jsonMatch = text.match(/\{[\s\S]*\}/);
136 if (!jsonMatch) {
2e8a4d5Claude137 // FAIL-CLOSED: parsing failure must NOT count as approval. Previously
138 // this returned approved:true, which silently auto-approved PRs
139 // whenever Claude's output drifted off the JSON spec — a real auto-
140 // merge hazard. Surface the failure in the summary instead.
e883329Claude141 return {
2e8a4d5Claude142 summary:
143 "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.",
e883329Claude144 comments: [],
2e8a4d5Claude145 approved: false,
e883329Claude146 };
147 }
148 const parsed = JSON.parse(jsonMatch[0]);
149 return {
150 summary: parsed.summary || "Review complete.",
151 comments: Array.isArray(parsed.comments) ? parsed.comments : [],
2e8a4d5Claude152 // Require explicit `approved: true` — undefined or any other value
153 // is treated as not-approved. Same fail-closed principle as above.
154 approved: parsed.approved === true,
e883329Claude155 };
2e8a4d5Claude156 } catch (err) {
157 // FAIL-CLOSED: JSON.parse threw (truncated output, schema-shaped
158 // garbage, etc.). Echo a short error tail so operators have a
159 // breadcrumb in the comment body, but never approve.
160 const tail = err instanceof Error ? err.message.slice(0, 200) : String(err);
e883329Claude161 return {
2e8a4d5Claude162 summary: `AI review failed to parse model output (${tail}). Treating as not-approved; a human reviewer should look at this PR.`,
e883329Claude163 comments: [],
2e8a4d5Claude164 approved: false,
e883329Claude165 };
166 }
167}
168
169/**
170 * Check if AI review is available (API key configured).
171 */
172export function isAiReviewEnabled(): boolean {
173 return !!config.anthropicApiKey;
174}
0316dbbClaude175
176/**
fd87bb9Claude177 * Compute the merge-base diff between two branches in a bare repo.
178 * Returns "" on any error so callers can no-op cleanly. Uses the
179 * three-dot `base...head` form so the diff is what changed on `head`
180 * relative to the common ancestor with `base` (which is the PR
181 * conventional view, not a literal range diff).
182 */
183async function diffBetweenBranches(
184 ownerName: string,
185 repoName: string,
186 baseBranch: string,
187 headBranch: string
188): Promise<string> {
189 try {
190 const cwd = getRepoPath(ownerName, repoName);
191 const proc = Bun.spawn(
192 [
193 "git",
194 "diff",
195 `${baseBranch}...${headBranch}`,
196 "--",
197 ],
198 { cwd, stdout: "pipe", stderr: "pipe" }
199 );
200 const text = await new Response(proc.stdout).text();
201 await proc.exited;
202 return text;
203 } catch {
204 return "";
205 }
206}
207
422a2d4Claude208/**
209 * Resolve a branch name to its current commit SHA via `git rev-parse`.
210 * Returns "" on any error — callers should treat that as "unknown" and
211 * carry on; this is only used for audit metadata.
212 */
213async function resolveHeadSha(
214 ownerName: string,
215 repoName: string,
216 branch: string
217): Promise<string> {
218 try {
219 const cwd = getRepoPath(ownerName, repoName);
220 const proc = Bun.spawn(["git", "rev-parse", branch], {
221 cwd,
222 stdout: "pipe",
223 stderr: "pipe",
224 });
225 const text = await new Response(proc.stdout).text();
226 await proc.exited;
227 return text.trim();
228 } catch {
229 return "";
230 }
231}
232
fd87bb9Claude233/**
234 * Has this PR already been reviewed by the AI? Detected by an existing
235 * PR comment carrying our summary marker. Cheap LIKE query — if it
236 * fails (DB hiccup) we fall back to "not yet" and re-review, which is
237 * idempotent at worst (a duplicate summary), never destructive.
238 */
239async function alreadyReviewed(prId: string): Promise<boolean> {
240 try {
241 const [row] = await db
242 .select({ id: prComments.id })
243 .from(prComments)
244 .where(
245 and(
246 eq(prComments.pullRequestId, prId),
247 eq(prComments.isAiReview, true),
248 like(prComments.body, `%${AI_REVIEW_MARKER}%`)
249 )
250 )
251 .limit(1);
252 return !!row;
253 } catch {
254 return false;
255 }
256}
257
258/**
259 * Real AI review trigger. Replaces the previous stub. Pipeline:
260 *
261 * 1. Idempotency check — bail if a prior review summary exists.
262 * 2. Compute the base...head diff via the bare repo.
263 * 3. Call reviewDiff for a structured response (summary + per-file
264 * comments + approved boolean).
265 * 4. Persist:
266 * - one summary comment (isAiReview=true, marker embedded), and
267 * - one comment per inline finding (isAiReview=true, filePath +
268 * lineNumber populated).
269 *
270 * Always fire-and-forget at the call site (`.catch(...)`); this
271 * function still never throws so the catch is belt-and-braces. AI
272 * comments are authored by the PR author so the existing comment
273 * rendering can group them naturally — there is no synthetic bot user
274 * yet (tracked alongside H2 app-bot identity work).
0316dbbClaude275 */
276export async function triggerAiReview(
277 ownerName: string,
278 repoName: string,
fd87bb9Claude279 prId: string,
280 title: string,
281 body: string,
282 baseBranch: string,
283 headBranch: string,
c3e0c07Claude284 options: { force?: boolean } = {}
0316dbbClaude285): Promise<void> {
fd87bb9Claude286 try {
287 if (!isAiReviewEnabled()) return;
422a2d4Claude288 const useTrio = isTrioReviewEnabled();
289 if (
290 !options.force &&
291 (useTrio
292 ? await alreadyTrioReviewed(prId)
293 : await alreadyReviewed(prId))
294 )
295 return;
fd87bb9Claude296
297 const [pr] = await db
422a2d4Claude298 .select({
299 id: pullRequests.id,
300 authorId: pullRequests.authorId,
301 repositoryId: pullRequests.repositoryId,
302 })
fd87bb9Claude303 .from(pullRequests)
304 .where(eq(pullRequests.id, prId))
305 .limit(1);
306 if (!pr) return;
307
0b49751Claude308 // Hard quota gate — post a comment and bail if the user's AI budget is
309 // exhausted. This runs after loading the PR so we have authorId for the
310 // comment insert.
311 try {
312 await assertAiQuota(pr.authorId);
313 } catch (err) {
314 if (err instanceof AiQuotaExceededError) {
315 await db
316 .insert(prComments)
317 .values({
318 pullRequestId: prId,
319 authorId: pr.authorId,
320 isAiReview: true,
321 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.`,
322 })
323 .catch(() => {});
324 return;
325 }
326 // Any other error from assertAiQuota is unexpected — log and proceed
327 // (fail open so a billing glitch never silently kills reviews).
328 console.warn("[ai-review] assertAiQuota failed unexpectedly:", err);
329 }
330
fd87bb9Claude331 let diffText = await diffBetweenBranches(
332 ownerName,
333 repoName,
334 baseBranch,
335 headBranch
336 );
337 if (!diffText.trim()) return;
338 if (diffText.length > DIFF_BYTE_CAP) {
339 diffText = diffText.slice(0, DIFF_BYTE_CAP);
340 }
341
422a2d4Claude342 // Trio path — replaces the single-Claude review when enabled. The
343 // trio helper owns its own persistence + audit; we bail after it.
344 if (useTrio) {
345 try {
346 const headSha = await resolveHeadSha(ownerName, repoName, headBranch);
347 await runTrioReview({
348 pullRequestId: prId,
349 headSha,
350 diff: diffText,
351 repositoryId: pr.repositoryId,
352 });
353 } catch (err) {
354 if (process.env.DEBUG_AI_REVIEW === "1") {
355 console.error("[ai-review] trio crashed:", err);
356 }
357 }
358 return;
359 }
360
fd87bb9Claude361 let result: ReviewResult;
362 try {
363 result = await reviewDiff(
364 `${ownerName}/${repoName}`,
365 title,
366 body || null,
367 baseBranch,
368 headBranch,
369 diffText
370 );
371 } catch (err) {
372 // Anthropic API failure — degrade to a single advisory comment so
373 // PR authors see the attempt rather than silence.
374 const reason = err instanceof Error ? err.message : "unknown error";
375 await db
376 .insert(prComments)
377 .values({
378 pullRequestId: prId,
379 authorId: pr.authorId,
380 isAiReview: true,
381 body: `${AI_REVIEW_MARKER}\n## AI review unavailable\n\nThe AI review attempt failed: ${reason}. The PR is otherwise unchanged.`,
382 })
976d7f7Claude383 .catch((err) => {
384 // Was a silent .catch(() => {}) — DB blips here meant the user
385 // saw no review comment AND no diagnostic. Log so operators
386 // can investigate, but don't re-throw (caller is fire-and-forget).
387 console.error(
388 `[ai-review] failed to insert API-failure advisory comment for PR ${prId}:`,
389 err instanceof Error ? err.message : err
390 );
391 });
fd87bb9Claude392 return;
393 }
394
395 const verdict = result.approved
396 ? "**AI review:** no blocking issues found."
397 : `**AI review:** flagged ${result.comments.length} item(s) for human attention.`;
398 const summaryBody = `${AI_REVIEW_MARKER}\n## AI Code Review\n\n${verdict}\n\n${result.summary}`;
399 await db
400 .insert(prComments)
401 .values({
402 pullRequestId: prId,
403 authorId: pr.authorId,
404 isAiReview: true,
405 body: summaryBody,
406 })
976d7f7Claude407 .catch((err) => {
408 console.error(
409 `[ai-review] failed to insert summary comment for PR ${prId}:`,
410 err instanceof Error ? err.message : err
411 );
412 });
fd87bb9Claude413
414 for (const c of result.comments) {
415 if (!c || !c.body) continue;
416 const filePath =
417 typeof c.filePath === "string" && c.filePath ? c.filePath : null;
418 const lineNumber =
419 Number.isInteger(c.lineNumber) && (c.lineNumber as number) > 0
420 ? (c.lineNumber as number)
421 : null;
422 await db
423 .insert(prComments)
424 .values({
425 pullRequestId: prId,
426 authorId: pr.authorId,
427 isAiReview: true,
428 body: c.body,
429 filePath,
430 lineNumber,
431 })
976d7f7Claude432 .catch((err) => {
433 console.error(
434 `[ai-review] failed to insert inline comment for PR ${prId} (${filePath}:${lineNumber}):`,
435 err instanceof Error ? err.message : err
436 );
437 });
fd87bb9Claude438 }
439
440 if (process.env.DEBUG_AI_REVIEW === "1") {
441 console.log(
442 "[ai-review] reviewed",
443 ownerName,
444 repoName,
445 prId,
446 `comments=${result.comments.length}`,
447 `approved=${result.approved}`
448 );
449 }
450 } catch (err) {
451 // Belt-and-braces: never escape into the request path.
452 if (process.env.DEBUG_AI_REVIEW === "1") {
453 console.error("[ai-review] crashed:", err);
454 }
0316dbbClaude455 }
456}
fd87bb9Claude457
458/**
459 * Test-only export: the internal helpers. Not part of the public API.
460 */
461export const __test = {
462 diffBetweenBranches,
463 alreadyReviewed,
464};