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.tsBlame481 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";
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/**
239 * Has this PR already been reviewed by the AI? Detected by an existing
240 * PR comment carrying our summary marker. Cheap LIKE query — if it
241 * fails (DB hiccup) we fall back to "not yet" and re-review, which is
242 * idempotent at worst (a duplicate summary), never destructive.
243 */
244async function alreadyReviewed(prId: string): Promise<boolean> {
245 try {
246 const [row] = await db
247 .select({ id: prComments.id })
248 .from(prComments)
249 .where(
250 and(
251 eq(prComments.pullRequestId, prId),
252 eq(prComments.isAiReview, true),
253 like(prComments.body, `%${AI_REVIEW_MARKER}%`)
254 )
255 )
256 .limit(1);
257 return !!row;
258 } catch {
259 return false;
260 }
261}
262
263/**
264 * Real AI review trigger. Replaces the previous stub. Pipeline:
265 *
266 * 1. Idempotency check — bail if a prior review summary exists.
267 * 2. Compute the base...head diff via the bare repo.
268 * 3. Call reviewDiff for a structured response (summary + per-file
269 * comments + approved boolean).
270 * 4. Persist:
271 * - one summary comment (isAiReview=true, marker embedded), and
272 * - one comment per inline finding (isAiReview=true, filePath +
273 * lineNumber populated).
274 *
275 * Always fire-and-forget at the call site (`.catch(...)`); this
276 * function still never throws so the catch is belt-and-braces. AI
277 * comments are authored by the PR author so the existing comment
278 * rendering can group them naturally — there is no synthetic bot user
279 * yet (tracked alongside H2 app-bot identity work).
0316dbbClaude280 */
281export async function triggerAiReview(
282 ownerName: string,
283 repoName: string,
fd87bb9Claude284 prId: string,
285 title: string,
286 body: string,
287 baseBranch: string,
288 headBranch: string,
479dcd9Claude289 options: { force?: boolean; loadSettings?: AutomationSettingsLoader } = {}
0316dbbClaude290): Promise<void> {
fd87bb9Claude291 try {
292 if (!isAiReviewEnabled()) return;
422a2d4Claude293 const useTrio = isTrioReviewEnabled();
294 if (
295 !options.force &&
296 (useTrio
297 ? await alreadyTrioReviewed(prId)
298 : await alreadyReviewed(prId))
299 )
300 return;
fd87bb9Claude301
302 const [pr] = await db
422a2d4Claude303 .select({
304 id: pullRequests.id,
305 authorId: pullRequests.authorId,
306 repositoryId: pullRequests.repositoryId,
307 })
fd87bb9Claude308 .from(pullRequests)
309 .where(eq(pullRequests.id, prId))
310 .limit(1);
311 if (!pr) return;
312
479dcd9Claude313 // Per-repo automation gate — 'off' skips AI review entirely. The loader
314 // fails open to the defaults ('suggest' = current behavior), so a broken
315 // settings lookup can never disable reviews.
316 const automation = await (options.loadSettings ?? getAutomationSettings)(
317 pr.repositoryId
318 );
319 if (automation.aiReviewMode === "off") return;
320
0b49751Claude321 // Hard quota gate — post a comment and bail if the user's AI budget is
322 // exhausted. This runs after loading the PR so we have authorId for the
323 // comment insert.
324 try {
325 await assertAiQuota(pr.authorId);
326 } catch (err) {
327 if (err instanceof AiQuotaExceededError) {
328 await db
329 .insert(prComments)
330 .values({
331 pullRequestId: prId,
332 authorId: pr.authorId,
333 isAiReview: true,
334 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.`,
335 })
336 .catch(() => {});
337 return;
338 }
339 // Any other error from assertAiQuota is unexpected — log and proceed
340 // (fail open so a billing glitch never silently kills reviews).
341 console.warn("[ai-review] assertAiQuota failed unexpectedly:", err);
342 }
343
fd87bb9Claude344 let diffText = await diffBetweenBranches(
345 ownerName,
346 repoName,
347 baseBranch,
348 headBranch
349 );
350 if (!diffText.trim()) return;
351 if (diffText.length > DIFF_BYTE_CAP) {
352 diffText = diffText.slice(0, DIFF_BYTE_CAP);
353 }
354
422a2d4Claude355 // Trio path — replaces the single-Claude review when enabled. The
356 // trio helper owns its own persistence + audit; we bail after it.
357 if (useTrio) {
358 try {
359 const headSha = await resolveHeadSha(ownerName, repoName, headBranch);
360 await runTrioReview({
361 pullRequestId: prId,
362 headSha,
363 diff: diffText,
364 repositoryId: pr.repositoryId,
365 });
366 } catch (err) {
367 if (process.env.DEBUG_AI_REVIEW === "1") {
368 console.error("[ai-review] trio crashed:", err);
369 }
370 }
371 return;
372 }
373
a7460bfClaude374 // Resolve the bot user id once; fall back to the PR author so that
375 // comment insertion never fails even before migration 0078 has run.
376 const commentAuthorId = await getBotUserIdOrFallback(pr.authorId);
377
fd87bb9Claude378 let result: ReviewResult;
379 try {
380 result = await reviewDiff(
381 `${ownerName}/${repoName}`,
382 title,
383 body || null,
384 baseBranch,
385 headBranch,
386 diffText
387 );
388 } catch (err) {
389 // Anthropic API failure — degrade to a single advisory comment so
390 // PR authors see the attempt rather than silence.
391 const reason = err instanceof Error ? err.message : "unknown error";
392 await db
393 .insert(prComments)
394 .values({
395 pullRequestId: prId,
a7460bfClaude396 authorId: commentAuthorId,
fd87bb9Claude397 isAiReview: true,
398 body: `${AI_REVIEW_MARKER}\n## AI review unavailable\n\nThe AI review attempt failed: ${reason}. The PR is otherwise unchanged.`,
399 })
976d7f7Claude400 .catch((err) => {
401 // Was a silent .catch(() => {}) — DB blips here meant the user
402 // saw no review comment AND no diagnostic. Log so operators
403 // can investigate, but don't re-throw (caller is fire-and-forget).
404 console.error(
405 `[ai-review] failed to insert API-failure advisory comment for PR ${prId}:`,
406 err instanceof Error ? err.message : err
407 );
408 });
fd87bb9Claude409 return;
410 }
411
412 const verdict = result.approved
413 ? "**AI review:** no blocking issues found."
414 : `**AI review:** flagged ${result.comments.length} item(s) for human attention.`;
415 const summaryBody = `${AI_REVIEW_MARKER}\n## AI Code Review\n\n${verdict}\n\n${result.summary}`;
416 await db
417 .insert(prComments)
418 .values({
419 pullRequestId: prId,
a7460bfClaude420 authorId: commentAuthorId,
fd87bb9Claude421 isAiReview: true,
422 body: summaryBody,
423 })
976d7f7Claude424 .catch((err) => {
425 console.error(
426 `[ai-review] failed to insert summary comment for PR ${prId}:`,
427 err instanceof Error ? err.message : err
428 );
429 });
fd87bb9Claude430
431 for (const c of result.comments) {
432 if (!c || !c.body) continue;
433 const filePath =
434 typeof c.filePath === "string" && c.filePath ? c.filePath : null;
435 const lineNumber =
436 Number.isInteger(c.lineNumber) && (c.lineNumber as number) > 0
437 ? (c.lineNumber as number)
438 : null;
439 await db
440 .insert(prComments)
441 .values({
442 pullRequestId: prId,
a7460bfClaude443 authorId: commentAuthorId,
fd87bb9Claude444 isAiReview: true,
445 body: c.body,
446 filePath,
447 lineNumber,
448 })
976d7f7Claude449 .catch((err) => {
450 console.error(
451 `[ai-review] failed to insert inline comment for PR ${prId} (${filePath}:${lineNumber}):`,
452 err instanceof Error ? err.message : err
453 );
454 });
fd87bb9Claude455 }
456
457 if (process.env.DEBUG_AI_REVIEW === "1") {
458 console.log(
459 "[ai-review] reviewed",
460 ownerName,
461 repoName,
462 prId,
463 `comments=${result.comments.length}`,
464 `approved=${result.approved}`
465 );
466 }
467 } catch (err) {
468 // Belt-and-braces: never escape into the request path.
469 if (process.env.DEBUG_AI_REVIEW === "1") {
470 console.error("[ai-review] crashed:", err);
471 }
0316dbbClaude472 }
473}
fd87bb9Claude474
475/**
476 * Test-only export: the internal helpers. Not part of the public API.
477 */
478export const __test = {
479 diffBetweenBranches,
480 alreadyReviewed,
481};