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