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.tsBlame332 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) {
113 return {
114 summary: "AI review completed but could not parse structured output.",
115 comments: [],
116 approved: true,
117 };
118 }
119 const parsed = JSON.parse(jsonMatch[0]);
120 return {
121 summary: parsed.summary || "Review complete.",
122 comments: Array.isArray(parsed.comments) ? parsed.comments : [],
123 approved: parsed.approved !== false,
124 };
125 } catch {
126 return {
127 summary: text.slice(0, 500),
128 comments: [],
129 approved: true,
130 };
131 }
132}
133
134/**
135 * Check if AI review is available (API key configured).
136 */
137export function isAiReviewEnabled(): boolean {
138 return !!config.anthropicApiKey;
139}
0316dbbClaude140
141/**
fd87bb9Claude142 * Compute the merge-base diff between two branches in a bare repo.
143 * Returns "" on any error so callers can no-op cleanly. Uses the
144 * three-dot `base...head` form so the diff is what changed on `head`
145 * relative to the common ancestor with `base` (which is the PR
146 * conventional view, not a literal range diff).
147 */
148async function diffBetweenBranches(
149 ownerName: string,
150 repoName: string,
151 baseBranch: string,
152 headBranch: string
153): Promise<string> {
154 try {
155 const cwd = getRepoPath(ownerName, repoName);
156 const proc = Bun.spawn(
157 [
158 "git",
159 "diff",
160 `${baseBranch}...${headBranch}`,
161 "--",
162 ],
163 { cwd, stdout: "pipe", stderr: "pipe" }
164 );
165 const text = await new Response(proc.stdout).text();
166 await proc.exited;
167 return text;
168 } catch {
169 return "";
170 }
171}
172
173/**
174 * Has this PR already been reviewed by the AI? Detected by an existing
175 * PR comment carrying our summary marker. Cheap LIKE query — if it
176 * fails (DB hiccup) we fall back to "not yet" and re-review, which is
177 * idempotent at worst (a duplicate summary), never destructive.
178 */
179async function alreadyReviewed(prId: string): Promise<boolean> {
180 try {
181 const [row] = await db
182 .select({ id: prComments.id })
183 .from(prComments)
184 .where(
185 and(
186 eq(prComments.pullRequestId, prId),
187 eq(prComments.isAiReview, true),
188 like(prComments.body, `%${AI_REVIEW_MARKER}%`)
189 )
190 )
191 .limit(1);
192 return !!row;
193 } catch {
194 return false;
195 }
196}
197
198/**
199 * Real AI review trigger. Replaces the previous stub. Pipeline:
200 *
201 * 1. Idempotency check — bail if a prior review summary exists.
202 * 2. Compute the base...head diff via the bare repo.
203 * 3. Call reviewDiff for a structured response (summary + per-file
204 * comments + approved boolean).
205 * 4. Persist:
206 * - one summary comment (isAiReview=true, marker embedded), and
207 * - one comment per inline finding (isAiReview=true, filePath +
208 * lineNumber populated).
209 *
210 * Always fire-and-forget at the call site (`.catch(...)`); this
211 * function still never throws so the catch is belt-and-braces. AI
212 * comments are authored by the PR author so the existing comment
213 * rendering can group them naturally — there is no synthetic bot user
214 * yet (tracked alongside H2 app-bot identity work).
0316dbbClaude215 */
216export async function triggerAiReview(
217 ownerName: string,
218 repoName: string,
fd87bb9Claude219 prId: string,
220 title: string,
221 body: string,
222 baseBranch: string,
223 headBranch: string,
0316dbbClaude224): Promise<void> {
fd87bb9Claude225 try {
226 if (!isAiReviewEnabled()) return;
227 if (await alreadyReviewed(prId)) return;
228
229 const [pr] = await db
230 .select({ id: pullRequests.id, authorId: pullRequests.authorId })
231 .from(pullRequests)
232 .where(eq(pullRequests.id, prId))
233 .limit(1);
234 if (!pr) return;
235
236 let diffText = await diffBetweenBranches(
237 ownerName,
238 repoName,
239 baseBranch,
240 headBranch
241 );
242 if (!diffText.trim()) return;
243 if (diffText.length > DIFF_BYTE_CAP) {
244 diffText = diffText.slice(0, DIFF_BYTE_CAP);
245 }
246
247 let result: ReviewResult;
248 try {
249 result = await reviewDiff(
250 `${ownerName}/${repoName}`,
251 title,
252 body || null,
253 baseBranch,
254 headBranch,
255 diffText
256 );
257 } catch (err) {
258 // Anthropic API failure — degrade to a single advisory comment so
259 // PR authors see the attempt rather than silence.
260 const reason = err instanceof Error ? err.message : "unknown error";
261 await db
262 .insert(prComments)
263 .values({
264 pullRequestId: prId,
265 authorId: pr.authorId,
266 isAiReview: true,
267 body: `${AI_REVIEW_MARKER}\n## AI review unavailable\n\nThe AI review attempt failed: ${reason}. The PR is otherwise unchanged.`,
268 })
269 .catch(() => {});
270 return;
271 }
272
273 const verdict = result.approved
274 ? "**AI review:** no blocking issues found."
275 : `**AI review:** flagged ${result.comments.length} item(s) for human attention.`;
276 const summaryBody = `${AI_REVIEW_MARKER}\n## AI Code Review\n\n${verdict}\n\n${result.summary}`;
277 await db
278 .insert(prComments)
279 .values({
280 pullRequestId: prId,
281 authorId: pr.authorId,
282 isAiReview: true,
283 body: summaryBody,
284 })
285 .catch(() => {});
286
287 for (const c of result.comments) {
288 if (!c || !c.body) continue;
289 const filePath =
290 typeof c.filePath === "string" && c.filePath ? c.filePath : null;
291 const lineNumber =
292 Number.isInteger(c.lineNumber) && (c.lineNumber as number) > 0
293 ? (c.lineNumber as number)
294 : null;
295 await db
296 .insert(prComments)
297 .values({
298 pullRequestId: prId,
299 authorId: pr.authorId,
300 isAiReview: true,
301 body: c.body,
302 filePath,
303 lineNumber,
304 })
305 .catch(() => {});
306 }
307
308 if (process.env.DEBUG_AI_REVIEW === "1") {
309 console.log(
310 "[ai-review] reviewed",
311 ownerName,
312 repoName,
313 prId,
314 `comments=${result.comments.length}`,
315 `approved=${result.approved}`
316 );
317 }
318 } catch (err) {
319 // Belt-and-braces: never escape into the request path.
320 if (process.env.DEBUG_AI_REVIEW === "1") {
321 console.error("[ai-review] crashed:", err);
322 }
0316dbbClaude323 }
324}
fd87bb9Claude325
326/**
327 * Test-only export: the internal helpers. Not part of the public API.
328 */
329export const __test = {
330 diffBetweenBranches,
331 alreadyReviewed,
332};