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.tsBlame333 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,
c3e0c07Claude224 options: { force?: boolean } = {}
0316dbbClaude225): Promise<void> {
fd87bb9Claude226 try {
227 if (!isAiReviewEnabled()) return;
c3e0c07Claude228 if (!options.force && (await alreadyReviewed(prId))) return;
fd87bb9Claude229
230 const [pr] = await db
231 .select({ id: pullRequests.id, authorId: pullRequests.authorId })
232 .from(pullRequests)
233 .where(eq(pullRequests.id, prId))
234 .limit(1);
235 if (!pr) return;
236
237 let diffText = await diffBetweenBranches(
238 ownerName,
239 repoName,
240 baseBranch,
241 headBranch
242 );
243 if (!diffText.trim()) return;
244 if (diffText.length > DIFF_BYTE_CAP) {
245 diffText = diffText.slice(0, DIFF_BYTE_CAP);
246 }
247
248 let result: ReviewResult;
249 try {
250 result = await reviewDiff(
251 `${ownerName}/${repoName}`,
252 title,
253 body || null,
254 baseBranch,
255 headBranch,
256 diffText
257 );
258 } catch (err) {
259 // Anthropic API failure — degrade to a single advisory comment so
260 // PR authors see the attempt rather than silence.
261 const reason = err instanceof Error ? err.message : "unknown error";
262 await db
263 .insert(prComments)
264 .values({
265 pullRequestId: prId,
266 authorId: pr.authorId,
267 isAiReview: true,
268 body: `${AI_REVIEW_MARKER}\n## AI review unavailable\n\nThe AI review attempt failed: ${reason}. The PR is otherwise unchanged.`,
269 })
270 .catch(() => {});
271 return;
272 }
273
274 const verdict = result.approved
275 ? "**AI review:** no blocking issues found."
276 : `**AI review:** flagged ${result.comments.length} item(s) for human attention.`;
277 const summaryBody = `${AI_REVIEW_MARKER}\n## AI Code Review\n\n${verdict}\n\n${result.summary}`;
278 await db
279 .insert(prComments)
280 .values({
281 pullRequestId: prId,
282 authorId: pr.authorId,
283 isAiReview: true,
284 body: summaryBody,
285 })
286 .catch(() => {});
287
288 for (const c of result.comments) {
289 if (!c || !c.body) continue;
290 const filePath =
291 typeof c.filePath === "string" && c.filePath ? c.filePath : null;
292 const lineNumber =
293 Number.isInteger(c.lineNumber) && (c.lineNumber as number) > 0
294 ? (c.lineNumber as number)
295 : null;
296 await db
297 .insert(prComments)
298 .values({
299 pullRequestId: prId,
300 authorId: pr.authorId,
301 isAiReview: true,
302 body: c.body,
303 filePath,
304 lineNumber,
305 })
306 .catch(() => {});
307 }
308
309 if (process.env.DEBUG_AI_REVIEW === "1") {
310 console.log(
311 "[ai-review] reviewed",
312 ownerName,
313 repoName,
314 prId,
315 `comments=${result.comments.length}`,
316 `approved=${result.approved}`
317 );
318 }
319 } catch (err) {
320 // Belt-and-braces: never escape into the request path.
321 if (process.env.DEBUG_AI_REVIEW === "1") {
322 console.error("[ai-review] crashed:", err);
323 }
0316dbbClaude324 }
325}
fd87bb9Claude326
327/**
328 * Test-only export: the internal helpers. Not part of the public API.
329 */
330export const __test = {
331 diffBetweenBranches,
332 alreadyReviewed,
333};