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.tsBlame469 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/**
235 * Has this PR already been reviewed by the AI? Detected by an existing
236 * PR comment carrying our summary marker. Cheap LIKE query — if it
237 * fails (DB hiccup) we fall back to "not yet" and re-review, which is
238 * idempotent at worst (a duplicate summary), never destructive.
239 */
240async function alreadyReviewed(prId: string): Promise<boolean> {
241 try {
242 const [row] = await db
243 .select({ id: prComments.id })
244 .from(prComments)
245 .where(
246 and(
247 eq(prComments.pullRequestId, prId),
248 eq(prComments.isAiReview, true),
249 like(prComments.body, `%${AI_REVIEW_MARKER}%`)
250 )
251 )
252 .limit(1);
253 return !!row;
254 } catch {
255 return false;
256 }
257}
258
259/**
260 * Real AI review trigger. Replaces the previous stub. Pipeline:
261 *
262 * 1. Idempotency check — bail if a prior review summary exists.
263 * 2. Compute the base...head diff via the bare repo.
264 * 3. Call reviewDiff for a structured response (summary + per-file
265 * comments + approved boolean).
266 * 4. Persist:
267 * - one summary comment (isAiReview=true, marker embedded), and
268 * - one comment per inline finding (isAiReview=true, filePath +
269 * lineNumber populated).
270 *
271 * Always fire-and-forget at the call site (`.catch(...)`); this
272 * function still never throws so the catch is belt-and-braces. AI
273 * comments are authored by the PR author so the existing comment
274 * rendering can group them naturally — there is no synthetic bot user
275 * yet (tracked alongside H2 app-bot identity work).
0316dbbClaude276 */
277export async function triggerAiReview(
278 ownerName: string,
279 repoName: string,
fd87bb9Claude280 prId: string,
281 title: string,
282 body: string,
283 baseBranch: string,
284 headBranch: string,
c3e0c07Claude285 options: { force?: boolean } = {}
0316dbbClaude286): Promise<void> {
fd87bb9Claude287 try {
288 if (!isAiReviewEnabled()) return;
422a2d4Claude289 const useTrio = isTrioReviewEnabled();
290 if (
291 !options.force &&
292 (useTrio
293 ? await alreadyTrioReviewed(prId)
294 : await alreadyReviewed(prId))
295 )
296 return;
fd87bb9Claude297
298 const [pr] = await db
422a2d4Claude299 .select({
300 id: pullRequests.id,
301 authorId: pullRequests.authorId,
302 repositoryId: pullRequests.repositoryId,
303 })
fd87bb9Claude304 .from(pullRequests)
305 .where(eq(pullRequests.id, prId))
306 .limit(1);
307 if (!pr) return;
308
0b49751Claude309 // Hard quota gate — post a comment and bail if the user's AI budget is
310 // exhausted. This runs after loading the PR so we have authorId for the
311 // comment insert.
312 try {
313 await assertAiQuota(pr.authorId);
314 } catch (err) {
315 if (err instanceof AiQuotaExceededError) {
316 await db
317 .insert(prComments)
318 .values({
319 pullRequestId: prId,
320 authorId: pr.authorId,
321 isAiReview: true,
322 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.`,
323 })
324 .catch(() => {});
325 return;
326 }
327 // Any other error from assertAiQuota is unexpected — log and proceed
328 // (fail open so a billing glitch never silently kills reviews).
329 console.warn("[ai-review] assertAiQuota failed unexpectedly:", err);
330 }
331
fd87bb9Claude332 let diffText = await diffBetweenBranches(
333 ownerName,
334 repoName,
335 baseBranch,
336 headBranch
337 );
338 if (!diffText.trim()) return;
339 if (diffText.length > DIFF_BYTE_CAP) {
340 diffText = diffText.slice(0, DIFF_BYTE_CAP);
341 }
342
422a2d4Claude343 // Trio path — replaces the single-Claude review when enabled. The
344 // trio helper owns its own persistence + audit; we bail after it.
345 if (useTrio) {
346 try {
347 const headSha = await resolveHeadSha(ownerName, repoName, headBranch);
348 await runTrioReview({
349 pullRequestId: prId,
350 headSha,
351 diff: diffText,
352 repositoryId: pr.repositoryId,
353 });
354 } catch (err) {
355 if (process.env.DEBUG_AI_REVIEW === "1") {
356 console.error("[ai-review] trio crashed:", err);
357 }
358 }
359 return;
360 }
361
a7460bfClaude362 // Resolve the bot user id once; fall back to the PR author so that
363 // comment insertion never fails even before migration 0078 has run.
364 const commentAuthorId = await getBotUserIdOrFallback(pr.authorId);
365
fd87bb9Claude366 let result: ReviewResult;
367 try {
368 result = await reviewDiff(
369 `${ownerName}/${repoName}`,
370 title,
371 body || null,
372 baseBranch,
373 headBranch,
374 diffText
375 );
376 } catch (err) {
377 // Anthropic API failure — degrade to a single advisory comment so
378 // PR authors see the attempt rather than silence.
379 const reason = err instanceof Error ? err.message : "unknown error";
380 await db
381 .insert(prComments)
382 .values({
383 pullRequestId: prId,
a7460bfClaude384 authorId: commentAuthorId,
fd87bb9Claude385 isAiReview: true,
386 body: `${AI_REVIEW_MARKER}\n## AI review unavailable\n\nThe AI review attempt failed: ${reason}. The PR is otherwise unchanged.`,
387 })
976d7f7Claude388 .catch((err) => {
389 // Was a silent .catch(() => {}) — DB blips here meant the user
390 // saw no review comment AND no diagnostic. Log so operators
391 // can investigate, but don't re-throw (caller is fire-and-forget).
392 console.error(
393 `[ai-review] failed to insert API-failure advisory comment for PR ${prId}:`,
394 err instanceof Error ? err.message : err
395 );
396 });
fd87bb9Claude397 return;
398 }
399
400 const verdict = result.approved
401 ? "**AI review:** no blocking issues found."
402 : `**AI review:** flagged ${result.comments.length} item(s) for human attention.`;
403 const summaryBody = `${AI_REVIEW_MARKER}\n## AI Code Review\n\n${verdict}\n\n${result.summary}`;
404 await db
405 .insert(prComments)
406 .values({
407 pullRequestId: prId,
a7460bfClaude408 authorId: commentAuthorId,
fd87bb9Claude409 isAiReview: true,
410 body: summaryBody,
411 })
976d7f7Claude412 .catch((err) => {
413 console.error(
414 `[ai-review] failed to insert summary comment for PR ${prId}:`,
415 err instanceof Error ? err.message : err
416 );
417 });
fd87bb9Claude418
419 for (const c of result.comments) {
420 if (!c || !c.body) continue;
421 const filePath =
422 typeof c.filePath === "string" && c.filePath ? c.filePath : null;
423 const lineNumber =
424 Number.isInteger(c.lineNumber) && (c.lineNumber as number) > 0
425 ? (c.lineNumber as number)
426 : null;
427 await db
428 .insert(prComments)
429 .values({
430 pullRequestId: prId,
a7460bfClaude431 authorId: commentAuthorId,
fd87bb9Claude432 isAiReview: true,
433 body: c.body,
434 filePath,
435 lineNumber,
436 })
976d7f7Claude437 .catch((err) => {
438 console.error(
439 `[ai-review] failed to insert inline comment for PR ${prId} (${filePath}:${lineNumber}):`,
440 err instanceof Error ? err.message : err
441 );
442 });
fd87bb9Claude443 }
444
445 if (process.env.DEBUG_AI_REVIEW === "1") {
446 console.log(
447 "[ai-review] reviewed",
448 ownerName,
449 repoName,
450 prId,
451 `comments=${result.comments.length}`,
452 `approved=${result.approved}`
453 );
454 }
455 } catch (err) {
456 // Belt-and-braces: never escape into the request path.
457 if (process.env.DEBUG_AI_REVIEW === "1") {
458 console.error("[ai-review] crashed:", err);
459 }
0316dbbClaude460 }
461}
fd87bb9Claude462
463/**
464 * Test-only export: the internal helpers. Not part of the public API.
465 */
466export const __test = {
467 diffBetweenBranches,
468 alreadyReviewed,
469};