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.tsBlame380 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";
e883329Claude15
16interface ReviewComment {
17 filePath: string;
18 lineNumber: number | null;
19 body: string;
20}
21
22interface ReviewResult {
23 summary: string;
24 comments: ReviewComment[];
25 approved: boolean;
26}
27
fd87bb9Claude28/**
29 * Marker we drop into the AI summary comment body. Used to detect a
30 * prior review and short-circuit duplicate runs (e.g. when a PR is
31 * marked draft → ready → draft → ready).
32 */
33export const AI_REVIEW_MARKER = "<!-- gluecron-ai-review:summary -->";
34
35/** Max bytes of diff we send to Claude. Matches reviewDiff's internal cap. */
36const DIFF_BYTE_CAP = 100_000;
37
e883329Claude38let _client: Anthropic | null = null;
39
40function getClient(): Anthropic {
41 if (!_client) {
42 if (!config.anthropicApiKey) {
43 throw new Error("ANTHROPIC_API_KEY is not set");
44 }
45 _client = new Anthropic({ apiKey: config.anthropicApiKey });
46 }
47 return _client;
48}
49
50/**
51 * Run AI code review on a PR diff.
52 */
53export async function reviewDiff(
54 repoFullName: string,
55 prTitle: string,
56 prBody: string | null,
57 baseBranch: string,
58 headBranch: string,
59 diffText: string
60): Promise<ReviewResult> {
61 const client = getClient();
62
0c3eee5Claude63 const REVIEW_MODEL = "claude-sonnet-4-20250514";
e883329Claude64 const message = await client.messages.create({
0c3eee5Claude65 model: REVIEW_MODEL,
e883329Claude66 max_tokens: 4096,
67 messages: [
68 {
69 role: "user",
70 content: `You are reviewing a pull request on the repository "${repoFullName}".
71
72**PR Title:** ${prTitle}
73**PR Description:** ${prBody || "(none)"}
74**Base branch:** ${baseBranch}
75**Head branch:** ${headBranch}
76
77Review the following diff. Look for:
78- Bugs, logic errors, or potential runtime failures
79- Security vulnerabilities (injection, XSS, auth bypasses, secrets in code)
80- Performance issues (N+1 queries, unnecessary allocations, blocking I/O)
81- Missing error handling at system boundaries
82- Breaking changes or API contract violations
83
84Do NOT comment on style, formatting, naming, missing docs, or minor nitpicks. Only flag issues that could cause real problems.
85
86Respond in JSON format:
87{
88 "summary": "1-3 sentence overall assessment",
89 "approved": true/false,
90 "comments": [
91 {
92 "filePath": "path/to/file.ts",
93 "lineNumber": 42,
94 "body": "Explain the issue and suggest a fix"
95 }
96 ]
97}
98
99If the diff looks clean, return approved: true with an empty comments array.
100
101\`\`\`diff
102${diffText.slice(0, 100000)}
103\`\`\``,
104 },
105 ],
106 });
107
108 const text =
109 message.content[0].type === "text" ? message.content[0].text : "";
110
0c3eee5Claude111 // Best-effort cost capture. Caller passes "owner/repo" as repoFullName,
112 // so we can't easily resolve the repo id here; the call site at
113 // `triggerAiReview` records with a repository_id below.
114 try {
115 const usage = extractUsage(message);
116 await recordAiCost({
117 model: REVIEW_MODEL,
118 inputTokens: usage.input,
119 outputTokens: usage.output,
120 category: "ai_review",
121 sourceKind: "pull_request",
122 });
123 } catch {
124 /* never escape — observational only */
125 }
126
e883329Claude127 try {
128 // Extract JSON from response (may be wrapped in markdown code block)
129 const jsonMatch = text.match(/\{[\s\S]*\}/);
130 if (!jsonMatch) {
2e8a4d5Claude131 // FAIL-CLOSED: parsing failure must NOT count as approval. Previously
132 // this returned approved:true, which silently auto-approved PRs
133 // whenever Claude's output drifted off the JSON spec — a real auto-
134 // merge hazard. Surface the failure in the summary instead.
e883329Claude135 return {
2e8a4d5Claude136 summary:
137 "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.",
e883329Claude138 comments: [],
2e8a4d5Claude139 approved: false,
e883329Claude140 };
141 }
142 const parsed = JSON.parse(jsonMatch[0]);
143 return {
144 summary: parsed.summary || "Review complete.",
145 comments: Array.isArray(parsed.comments) ? parsed.comments : [],
2e8a4d5Claude146 // Require explicit `approved: true` — undefined or any other value
147 // is treated as not-approved. Same fail-closed principle as above.
148 approved: parsed.approved === true,
e883329Claude149 };
2e8a4d5Claude150 } catch (err) {
151 // FAIL-CLOSED: JSON.parse threw (truncated output, schema-shaped
152 // garbage, etc.). Echo a short error tail so operators have a
153 // breadcrumb in the comment body, but never approve.
154 const tail = err instanceof Error ? err.message.slice(0, 200) : String(err);
e883329Claude155 return {
2e8a4d5Claude156 summary: `AI review failed to parse model output (${tail}). Treating as not-approved; a human reviewer should look at this PR.`,
e883329Claude157 comments: [],
2e8a4d5Claude158 approved: false,
e883329Claude159 };
160 }
161}
162
163/**
164 * Check if AI review is available (API key configured).
165 */
166export function isAiReviewEnabled(): boolean {
167 return !!config.anthropicApiKey;
168}
0316dbbClaude169
170/**
fd87bb9Claude171 * Compute the merge-base diff between two branches in a bare repo.
172 * Returns "" on any error so callers can no-op cleanly. Uses the
173 * three-dot `base...head` form so the diff is what changed on `head`
174 * relative to the common ancestor with `base` (which is the PR
175 * conventional view, not a literal range diff).
176 */
177async function diffBetweenBranches(
178 ownerName: string,
179 repoName: string,
180 baseBranch: string,
181 headBranch: string
182): Promise<string> {
183 try {
184 const cwd = getRepoPath(ownerName, repoName);
185 const proc = Bun.spawn(
186 [
187 "git",
188 "diff",
189 `${baseBranch}...${headBranch}`,
190 "--",
191 ],
192 { cwd, stdout: "pipe", stderr: "pipe" }
193 );
194 const text = await new Response(proc.stdout).text();
195 await proc.exited;
196 return text;
197 } catch {
198 return "";
199 }
200}
201
202/**
203 * Has this PR already been reviewed by the AI? Detected by an existing
204 * PR comment carrying our summary marker. Cheap LIKE query — if it
205 * fails (DB hiccup) we fall back to "not yet" and re-review, which is
206 * idempotent at worst (a duplicate summary), never destructive.
207 */
208async function alreadyReviewed(prId: string): Promise<boolean> {
209 try {
210 const [row] = await db
211 .select({ id: prComments.id })
212 .from(prComments)
213 .where(
214 and(
215 eq(prComments.pullRequestId, prId),
216 eq(prComments.isAiReview, true),
217 like(prComments.body, `%${AI_REVIEW_MARKER}%`)
218 )
219 )
220 .limit(1);
221 return !!row;
222 } catch {
223 return false;
224 }
225}
226
227/**
228 * Real AI review trigger. Replaces the previous stub. Pipeline:
229 *
230 * 1. Idempotency check — bail if a prior review summary exists.
231 * 2. Compute the base...head diff via the bare repo.
232 * 3. Call reviewDiff for a structured response (summary + per-file
233 * comments + approved boolean).
234 * 4. Persist:
235 * - one summary comment (isAiReview=true, marker embedded), and
236 * - one comment per inline finding (isAiReview=true, filePath +
237 * lineNumber populated).
238 *
239 * Always fire-and-forget at the call site (`.catch(...)`); this
240 * function still never throws so the catch is belt-and-braces. AI
241 * comments are authored by the PR author so the existing comment
242 * rendering can group them naturally — there is no synthetic bot user
243 * yet (tracked alongside H2 app-bot identity work).
0316dbbClaude244 */
245export async function triggerAiReview(
246 ownerName: string,
247 repoName: string,
fd87bb9Claude248 prId: string,
249 title: string,
250 body: string,
251 baseBranch: string,
252 headBranch: string,
c3e0c07Claude253 options: { force?: boolean } = {}
0316dbbClaude254): Promise<void> {
fd87bb9Claude255 try {
256 if (!isAiReviewEnabled()) return;
c3e0c07Claude257 if (!options.force && (await alreadyReviewed(prId))) return;
fd87bb9Claude258
259 const [pr] = await db
260 .select({ id: pullRequests.id, authorId: pullRequests.authorId })
261 .from(pullRequests)
262 .where(eq(pullRequests.id, prId))
263 .limit(1);
264 if (!pr) return;
265
266 let diffText = await diffBetweenBranches(
267 ownerName,
268 repoName,
269 baseBranch,
270 headBranch
271 );
272 if (!diffText.trim()) return;
273 if (diffText.length > DIFF_BYTE_CAP) {
274 diffText = diffText.slice(0, DIFF_BYTE_CAP);
275 }
276
277 let result: ReviewResult;
278 try {
279 result = await reviewDiff(
280 `${ownerName}/${repoName}`,
281 title,
282 body || null,
283 baseBranch,
284 headBranch,
285 diffText
286 );
287 } catch (err) {
288 // Anthropic API failure — degrade to a single advisory comment so
289 // PR authors see the attempt rather than silence.
290 const reason = err instanceof Error ? err.message : "unknown error";
291 await db
292 .insert(prComments)
293 .values({
294 pullRequestId: prId,
295 authorId: pr.authorId,
296 isAiReview: true,
297 body: `${AI_REVIEW_MARKER}\n## AI review unavailable\n\nThe AI review attempt failed: ${reason}. The PR is otherwise unchanged.`,
298 })
976d7f7Claude299 .catch((err) => {
300 // Was a silent .catch(() => {}) — DB blips here meant the user
301 // saw no review comment AND no diagnostic. Log so operators
302 // can investigate, but don't re-throw (caller is fire-and-forget).
303 console.error(
304 `[ai-review] failed to insert API-failure advisory comment for PR ${prId}:`,
305 err instanceof Error ? err.message : err
306 );
307 });
fd87bb9Claude308 return;
309 }
310
311 const verdict = result.approved
312 ? "**AI review:** no blocking issues found."
313 : `**AI review:** flagged ${result.comments.length} item(s) for human attention.`;
314 const summaryBody = `${AI_REVIEW_MARKER}\n## AI Code Review\n\n${verdict}\n\n${result.summary}`;
315 await db
316 .insert(prComments)
317 .values({
318 pullRequestId: prId,
319 authorId: pr.authorId,
320 isAiReview: true,
321 body: summaryBody,
322 })
976d7f7Claude323 .catch((err) => {
324 console.error(
325 `[ai-review] failed to insert summary comment for PR ${prId}:`,
326 err instanceof Error ? err.message : err
327 );
328 });
fd87bb9Claude329
330 for (const c of result.comments) {
331 if (!c || !c.body) continue;
332 const filePath =
333 typeof c.filePath === "string" && c.filePath ? c.filePath : null;
334 const lineNumber =
335 Number.isInteger(c.lineNumber) && (c.lineNumber as number) > 0
336 ? (c.lineNumber as number)
337 : null;
338 await db
339 .insert(prComments)
340 .values({
341 pullRequestId: prId,
342 authorId: pr.authorId,
343 isAiReview: true,
344 body: c.body,
345 filePath,
346 lineNumber,
347 })
976d7f7Claude348 .catch((err) => {
349 console.error(
350 `[ai-review] failed to insert inline comment for PR ${prId} (${filePath}:${lineNumber}):`,
351 err instanceof Error ? err.message : err
352 );
353 });
fd87bb9Claude354 }
355
356 if (process.env.DEBUG_AI_REVIEW === "1") {
357 console.log(
358 "[ai-review] reviewed",
359 ownerName,
360 repoName,
361 prId,
362 `comments=${result.comments.length}`,
363 `approved=${result.approved}`
364 );
365 }
366 } catch (err) {
367 // Belt-and-braces: never escape into the request path.
368 if (process.env.DEBUG_AI_REVIEW === "1") {
369 console.error("[ai-review] crashed:", err);
370 }
0316dbbClaude371 }
372}
fd87bb9Claude373
374/**
375 * Test-only export: the internal helpers. Not part of the public API.
376 */
377export const __test = {
378 diffBetweenBranches,
379 alreadyReviewed,
380};