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