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

ci-autofix.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.

ci-autofix.tsBlame495 lines · 1 contributor
34e63b9Claude1/**
2 * CI Auto-Fix — when a gate run or workflow run fails on a PR, Claude reads
3 * the error logs, the failing test file, and the PR diff, then posts a
4 * ready-to-apply patch as a comment on the PR.
5 *
6 * Entry points:
7 * triggerCiAutofix(gateRunId) — fire-and-forget; call after a gate_run
8 * row is written with status="failed".
9 * applyAutofix(prCommentId, userId) — apply the patch from a comment onto
10 * a new branch and return the branch name.
11 *
12 * Route wiring (src/routes/pulls.tsx or src/routes/api.ts):
13 * POST /api/pr-comments/:commentId/apply-autofix → applyAutofix
14 */
15
16import { and, eq } from "drizzle-orm";
17import { mkdtemp, rm, writeFile } from "fs/promises";
18import { join } from "path";
19import { tmpdir } from "os";
20import { db } from "../db";
21import {
22 gateRuns,
23 pullRequests,
24 prComments,
25 repositories,
26 users,
27 repoCollaborators,
28} from "../db/schema";
29import { getRepoPath } from "../git/repository";
30import { getBotUserIdOrFallback } from "./bot-user";
31import {
32 getAnthropic,
33 isAiAvailable,
34 MODEL_SONNET,
35 extractText,
36 parseJsonResponse,
37} from "./ai-client";
38
39// ---------------------------------------------------------------------------
40// Types
41// ---------------------------------------------------------------------------
42
43export interface AutofixResult {
44 prNumber: number;
45 repoId: string;
46 gateRunId: string;
47 patch: string; // unified diff format
48 explanation: string; // 2-3 sentence explanation
49 confidence: "high" | "medium" | "low";
50 affectedFiles: string[];
51}
52
53interface ClaudeAutofixResponse {
54 patch: string;
55 explanation: string;
56 confidence: "high" | "medium" | "low";
57 affectedFiles: string[];
58}
59
60// ---------------------------------------------------------------------------
61// Constants
62// ---------------------------------------------------------------------------
63
64/** Idempotency marker embedded in every autofix comment. */
65export const CI_AUTOFIX_MARKER = "<!-- gluecron:ci-autofix:v1 -->";
66
67/** Max bytes of PR diff sent to Claude. */
68const MAX_DIFF_BYTES = 80 * 1024;
69
70/** Max bytes of error log sent to Claude. */
71const MAX_LOG_BYTES = 3 * 1024;
72
73/** Max bytes per test file read. */
74const MAX_FILE_BYTES = 10 * 1024;
75
76/** Max number of failing test files to read. */
77const MAX_TEST_FILES = 3;
78
79// ---------------------------------------------------------------------------
80// Helpers
81// ---------------------------------------------------------------------------
82
83async function spawnGit(
84 args: string[],
85 cwd: string
86): Promise<{ stdout: string; stderr: string; exitCode: number }> {
87 const proc = Bun.spawn(["git", ...args], {
88 cwd,
89 stdout: "pipe",
90 stderr: "pipe",
91 });
92 const [stdout, stderr] = await Promise.all([
93 new Response(proc.stdout).text(),
94 new Response(proc.stderr).text(),
95 ]);
96 const exitCode = await proc.exited;
97 return { stdout, stderr, exitCode };
98}
99
100function truncate(s: string, maxBytes: number): string {
101 const buf = Buffer.from(s, "utf8");
102 if (buf.length <= maxBytes) return s;
103 return buf.slice(0, maxBytes).toString("utf8") + "\n[truncated]";
104}
105
106/**
107 * Extract failing test file paths, error message, and stack trace from a
108 * raw CI error log string. Heuristic — good enough for most JS/TS test
109 * runners (Jest, Vitest, Bun test) and Python pytest output.
110 */
111function parseErrorLog(errorLog: string): {
112 testFiles: string[];
113 errorSummary: string;
114} {
115 const lines = errorLog.split("\n");
116
117 // Collect candidate test file paths:
118 // - lines mentioning .test.ts/.spec.ts/.test.js/.spec.js/.test.py paths
119 // - lines with "FAIL <path>" (Jest pattern)
120 const filePatterns = [
121 /(?:^|\s)([\w./\-]+\.(?:test|spec)\.[jt]sx?)/gm,
122 /(?:^|\s)([\w./\-]+_test\.py)/gm,
123 /(?:^FAIL\s+)([\w./\-]+)/gm,
124 ];
125
126 const filesSet = new Set<string>();
127 for (const pattern of filePatterns) {
128 let m: RegExpExecArray | null;
129 pattern.lastIndex = 0;
130 while ((m = pattern.exec(errorLog)) !== null) {
131 const path = m[1].trim();
132 if (path && !path.startsWith("-") && !path.startsWith("+")) {
133 filesSet.add(path);
134 }
135 }
136 }
137
138 // Error summary: first 3KB of the log (most runners put the error first).
139 const errorSummary = truncate(errorLog, MAX_LOG_BYTES);
140
141 return {
142 testFiles: Array.from(filesSet).slice(0, MAX_TEST_FILES),
143 errorSummary,
144 };
145}
146
147// ---------------------------------------------------------------------------
148// Main entry point
149// ---------------------------------------------------------------------------
150
151/**
152 * Fire-and-forget entry point called after a gate_run row is set to 'failed'.
153 * Never throws — all errors are swallowed after logging.
154 */
155export async function triggerCiAutofix(gateRunId: string): Promise<void> {
156 if (!isAiAvailable()) return;
157
158 try {
159 await _runAutofix(gateRunId);
160 } catch (err) {
161 console.error(
162 "[ci-autofix] crashed:",
163 err instanceof Error ? err.message : err
164 );
165 }
166}
167
168async function _runAutofix(gateRunId: string): Promise<void> {
169 // 1. Load the gate run
170 const [gateRun] = await db
171 .select()
172 .from(gateRuns)
173 .where(eq(gateRuns.id, gateRunId))
174 .limit(1);
175
176 if (!gateRun) return;
177 if (gateRun.status !== "failed") return;
178 if (!gateRun.pullRequestId) return;
179
180 // 2. Load the PR row
181 const [pr] = await db
182 .select()
183 .from(pullRequests)
184 .where(eq(pullRequests.id, gateRun.pullRequestId))
185 .limit(1);
186
187 if (!pr) return;
188
189 // 3. Load repo (owner/name)
190 const [repoRow] = await db
191 .select({
192 id: repositories.id,
193 name: repositories.name,
194 diskPath: repositories.diskPath,
195 ownerUsername: users.username,
196 })
197 .from(repositories)
198 .innerJoin(users, eq(repositories.ownerId, users.id))
199 .where(eq(repositories.id, gateRun.repositoryId))
200 .limit(1);
201
202 if (!repoRow) return;
203
204 // 4. Check idempotency — skip if already posted for this gateRunId
205 const idempotencyMarker = `<!-- gluecron:ci-autofix:run:${gateRunId} -->`;
206 const existing = await db
207 .select({ id: prComments.id })
208 .from(prComments)
209 .where(
210 and(
211 eq(prComments.pullRequestId, gateRun.pullRequestId),
212 // We check by looking for comments with the autofix marker.
213 // drizzle doesn't have LIKE with dynamic params easily, but
214 // we query all AI comments and filter client-side (there won't be many).
215 eq(prComments.isAiReview, true)
216 )
217 )
218 .limit(50);
219
220 for (const row of existing) {
221 // Load the body to check idempotency marker
222 const [full] = await db
223 .select({ body: prComments.body })
224 .from(prComments)
225 .where(eq(prComments.id, row.id))
226 .limit(1);
227 if (full?.body?.includes(idempotencyMarker)) return;
228 }
229
230 const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name);
231
232 // 5. Get the PR diff (max 80KB)
233 const diffResult = await spawnGit(
234 ["diff", `${pr.baseBranch}...${pr.headBranch}`],
235 repoDir
236 );
237 const prDiff = truncate(diffResult.stdout, MAX_DIFF_BYTES);
238
239 if (!prDiff.trim()) return; // nothing to work with
240
241 // 6. Parse errorLog to extract test files + error summary
242 const errorLog = gateRun.summary || gateRun.details || "";
243 const { testFiles, errorSummary } = parseErrorLog(
244 typeof errorLog === "string" ? errorLog : JSON.stringify(errorLog)
245 );
246
247 // 7. Read failing test files via git show HEAD:path
248 let testFileContent = "";
249 for (const filePath of testFiles) {
250 const showResult = await spawnGit(
251 ["show", `${pr.headBranch}:${filePath}`],
252 repoDir
253 );
254 if (showResult.exitCode === 0 && showResult.stdout) {
255 const content = truncate(showResult.stdout, MAX_FILE_BYTES);
256 testFileContent += `\n\n--- ${filePath} ---\n${content}`;
257 }
258 }
259
260 // 8. Call Claude Sonnet 4.6
261 const client = getAnthropic();
262 const prompt = `You are a senior engineer fixing a CI failure.
263
264PR diff (what changed):
265${prDiff}
266
267Failing test output:
268${errorSummary}
269
270Test file content:${testFileContent || "\n(no test files detected)"}
271
272Produce a minimal unified diff patch that fixes the CI failure. The patch must:
2731. Be valid unified diff format (--- a/file, +++ b/file, @@ lines)
2742. Fix only what's needed — no refactoring
2753. Not modify the test itself unless the test expectation is genuinely wrong
276
277Return JSON: {"patch": "...", "explanation": "...", "confidence": "high|medium|low", "affectedFiles": ["..."]}`;
278
279 const message = await client.messages.create({
280 model: MODEL_SONNET,
281 max_tokens: 4096,
282 messages: [{ role: "user", content: prompt }],
283 });
284
285 const rawText = extractText(message);
286 const parsed = parseJsonResponse<ClaudeAutofixResponse>(rawText);
287
288 if (!parsed || !parsed.patch || !parsed.explanation) return;
289
290 // 10. If confidence === 'low' → skip
291 if (parsed.confidence === "low") return;
292
293 // 11. Build and post the comment
294 const commentBody = buildAutofixComment(
295 parsed,
296 idempotencyMarker,
297 gateRunId
298 );
299
300 const botAuthorId = await getBotUserIdOrFallback(repoRow.id);
301 if (!botAuthorId) return;
302
303 await db.insert(prComments).values({
304 pullRequestId: gateRun.pullRequestId,
305 authorId: botAuthorId,
306 body: commentBody,
307 isAiReview: true,
308 });
309}
310
311function buildAutofixComment(
312 result: ClaudeAutofixResponse,
313 idempotencyMarker: string,
314 gateRunId: string
315): string {
316 const confidenceBadge =
317 result.confidence === "high"
318 ? "🟢 High confidence"
319 : result.confidence === "medium"
320 ? "🟡 Medium confidence"
321 : "🔴 Low confidence";
322
323 return `${CI_AUTOFIX_MARKER}
324${idempotencyMarker}
325
326## 🔧 AI Auto-Fix
327
328${result.explanation}
329
330**Confidence:** ${confidenceBadge}
331
332\`\`\`diff
333${result.patch}
334\`\`\`
335
336<details><summary>Apply this fix</summary>
337
338Copy the patch above or click **Apply Fix** to commit it automatically.
339
340<form method="post" action="/api/pr-comments/COMMENT_ID/apply-autofix" style="display:inline">
341 <button type="submit" style="margin-top:8px;padding:6px 14px;background:#6c63ff;color:#fff;border:none;border-radius:6px;cursor:pointer">
342 ⚡ Apply Fix
343 </button>
344</form>
345
346</details>
347
348<sub>Gate run: <code>${gateRunId}</code> · Affected files: ${result.affectedFiles.join(", ") || "see patch above"}</sub>`;
349}
350
351// ---------------------------------------------------------------------------
352// Apply autofix
353// ---------------------------------------------------------------------------
354
355/**
356 * Applies the patch from a PR comment onto a new branch.
357 * Returns the new branch name so the caller can redirect to compare view.
358 */
359export async function applyAutofix(
360 prCommentId: string,
361 userId: string
362): Promise<{ branchName: string }> {
363 // 1. Load the comment
364 const [comment] = await db
365 .select({
366 id: prComments.id,
367 pullRequestId: prComments.pullRequestId,
368 body: prComments.body,
369 isAiReview: prComments.isAiReview,
370 })
371 .from(prComments)
372 .where(eq(prComments.id, prCommentId))
373 .limit(1);
374
375 if (!comment) throw new Error("Comment not found");
376 if (!comment.body.includes(CI_AUTOFIX_MARKER)) {
377 throw new Error("Not an autofix comment");
378 }
379
380 // 2. Load PR + repo for access check
381 const [pr] = await db
382 .select()
383 .from(pullRequests)
384 .where(eq(pullRequests.id, comment.pullRequestId))
385 .limit(1);
386
387 if (!pr) throw new Error("PR not found");
388
389 const [repoRow] = await db
390 .select({
391 id: repositories.id,
392 name: repositories.name,
393 ownerId: repositories.ownerId,
394 ownerUsername: users.username,
395 })
396 .from(repositories)
397 .innerJoin(users, eq(repositories.ownerId, users.id))
398 .where(eq(repositories.id, pr.repositoryId))
399 .limit(1);
400
401 if (!repoRow) throw new Error("Repository not found");
402
403 // Verify write access: must be repo owner or collaborator
404 const isOwner = repoRow.ownerId === userId;
405 if (!isOwner) {
406 const [collab] = await db
407 .select({ id: repoCollaborators.id })
408 .from(repoCollaborators)
409 .where(
410 and(
411 eq(repoCollaborators.repositoryId, repoRow.id),
412 eq(repoCollaborators.userId, userId)
413 )
414 )
415 .limit(1);
416 if (!collab) throw new Error("Forbidden: no write access");
417 }
418
419 // 3. Extract patch from comment body
420 const patchMatch = comment.body.match(/```diff\n([\s\S]*?)```/);
421 if (!patchMatch) throw new Error("No patch found in comment");
422 const patch = patchMatch[1];
423
424 // 4. Create a new branch from the PR's head
425 const branchName = `fix/autofix-${Date.now()}`;
426 const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name);
427
428 // Create the branch at the PR head SHA
429 const headSha = await spawnGit(
430 ["rev-parse", pr.headBranch],
431 repoDir
432 );
433 if (headSha.exitCode !== 0) throw new Error("Cannot resolve head branch");
434
435 await spawnGit(
436 ["branch", branchName, headSha.stdout.trim()],
437 repoDir
438 );
439
440 // 5. Apply the patch via git apply in a temp worktree
441 const tmpDir = await mkdtemp(join(tmpdir(), "autofix-"));
442 try {
443 // Add worktree for the new branch
444 const wtResult = await spawnGit(
445 ["worktree", "add", tmpDir, branchName],
446 repoDir
447 );
448 if (wtResult.exitCode !== 0) {
449 throw new Error(`git worktree add failed: ${wtResult.stderr}`);
450 }
451
452 // Write the patch to a temp file
453 const patchFile = join(tmpDir, "autofix.patch");
454 await writeFile(patchFile, patch, "utf8");
455
456 // Apply the patch
457 const applyResult = await spawnGit(
458 ["apply", "--index", patchFile],
459 tmpDir
460 );
461 if (applyResult.exitCode !== 0) {
462 throw new Error(`git apply failed: ${applyResult.stderr}`);
463 }
464
465 // 6. Commit
466 const commitResult = await spawnGit(
467 [
468 "commit",
469 "-m",
470 "fix: apply AI autofix for CI failure",
471 "--author",
472 "gluecron[bot] <bot@gluecron.com>",
473 ],
474 tmpDir
475 );
476 if (commitResult.exitCode !== 0) {
477 throw new Error(`git commit failed: ${commitResult.stderr}`);
478 }
479
480 // 7. Push the branch back to the bare repo
481 // In a bare-repo + worktree setup the push target is the bare repo itself.
482 await spawnGit(
483 ["push", repoDir, `HEAD:refs/heads/${branchName}`],
484 tmpDir
485 );
486 } finally {
487 // Cleanup worktree
488 await spawnGit(["worktree", "remove", "--force", tmpDir], repoDir).catch(
489 () => {}
490 );
491 await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
492 }
493
494 return { branchName };
495}