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

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.tsBlame895 lines · 1 contributor
34e63b9Claude1/**
bc519aeClaude2 * CI Auto-Fix — when a gate run or workflow run fails on a PR, the repair
3 * flywheel cache is consulted first (Tier 0); on a miss Claude reads the
4 * error logs, the failing test file, and the PR diff. Either way a
5 * ready-to-apply patch is posted as a comment on the PR.
34e63b9Claude6 *
7 * Entry points:
8 * triggerCiAutofix(gateRunId) — fire-and-forget; call after a gate_run
9 * row is written with status="failed".
10 * applyAutofix(prCommentId, userId) — apply the patch from a comment onto
bc519aeClaude11 * a new branch and return the branch name. Settles the flywheel entry
12 * (success/failed) so the cache's confidence accumulates.
34e63b9Claude13 *
14 * Route wiring (src/routes/pulls.tsx or src/routes/api.ts):
15 * POST /api/pr-comments/:commentId/apply-autofix → applyAutofix
bc519aeClaude16 *
17 * Flywheel wiring (BUILD_BIBLE §7 finding 1): every failure is fingerprinted
18 * via repair-flywheel.ts; a previously-successful patch with the same
19 * signature and a good success rate is served WITHOUT an AI call. All served
20 * fixes are recorded as 'pending' flywheel rows and settled on apply.
21 * Flywheel/DB errors never break this path — a broken cache degrades to the
22 * old always-call-AI behaviour.
34e63b9Claude23 */
24
25import { and, eq } from "drizzle-orm";
26import { mkdtemp, rm, writeFile } from "fs/promises";
27import { join } from "path";
28import { tmpdir } from "os";
29import { db } from "../db";
bc519aeClaude30import {
31 findCachedRepair,
32 recordRepair,
33 updateOutcome,
34 type CachedRepair,
35 type RecordRepairInput,
36} from "./repair-flywheel";
34e63b9Claude37import {
38 gateRuns,
39 pullRequests,
40 prComments,
41 repositories,
42 users,
43 repoCollaborators,
6682dbeClaude44 flywheelTelemetry,
34e63b9Claude45} from "../db/schema";
46import { getRepoPath } from "../git/repository";
47import { getBotUserIdOrFallback } from "./bot-user";
48import {
49 getAnthropic,
50 isAiAvailable,
51 MODEL_SONNET,
52 extractText,
53 parseJsonResponse,
54} from "./ai-client";
479dcd9Claude55import {
56 getAutomationSettings,
57 type AutomationSettingsLoader,
58} from "./automation-settings";
34e63b9Claude59
60// ---------------------------------------------------------------------------
61// Types
62// ---------------------------------------------------------------------------
63
64export interface AutofixResult {
65 prNumber: number;
66 repoId: string;
67 gateRunId: string;
68 patch: string; // unified diff format
69 explanation: string; // 2-3 sentence explanation
70 confidence: "high" | "medium" | "low";
71 affectedFiles: string[];
72}
73
bc519aeClaude74export interface ClaudeAutofixResponse {
34e63b9Claude75 patch: string;
76 explanation: string;
77 confidence: "high" | "medium" | "low";
78 affectedFiles: string[];
79}
80
bc519aeClaude81/**
82 * DI seam for the repair-flywheel wiring. Production callers omit this and
83 * get the real implementations; tests inject fakes so no DB is touched.
84 */
85export interface CiAutofixDeps {
86 findCachedRepair?: typeof findCachedRepair;
87 recordRepair?: typeof recordRepair;
88 updateOutcome?: typeof updateOutcome;
89 aiAvailable?: () => boolean;
479dcd9Claude90 /** Inject the per-repo automation settings loader (tests). */
91 loadAutomationSettings?: AutomationSettingsLoader;
bc519aeClaude92}
93
94/** The fix triggerCiAutofix decided to post, plus its flywheel bookkeeping. */
95export interface AutofixPlan {
96 /** 'cache' = Tier-0 flywheel replay (no AI call); 'ai' = fresh Sonnet patch. */
97 source: "cache" | "ai";
98 fix: ClaudeAutofixResponse;
99 /** Flywheel row recorded as 'pending' for this attempt; null if recording failed. */
100 flywheelEntryId: string | null;
101 /** On a cache hit: the parent pattern that was replayed. */
102 cachedPatternId: string | null;
103}
104
34e63b9Claude105// ---------------------------------------------------------------------------
106// Constants
107// ---------------------------------------------------------------------------
108
109/** Idempotency marker embedded in every autofix comment. */
110export const CI_AUTOFIX_MARKER = "<!-- gluecron:ci-autofix:v1 -->";
111
bc519aeClaude112/**
113 * Marker carrying the pending flywheel entry id, embedded in the autofix
114 * comment so applyAutofix can settle the outcome (success/failed) later.
115 */
116export const FLYWHEEL_MARKER_PREFIX = "<!-- gluecron:ci-autofix:flywheel:";
117
118/**
119 * Minimum settled success rate before a cached pattern is replayed instead
120 * of calling the AI. Below this the cache is considered unreliable for the
121 * signature and we fall through to a fresh Sonnet patch.
122 */
123export const CACHE_MIN_SUCCESS_RATE = 0.5;
124
6682dbeClaude125/** Maximum number of autofix iterations before handing off to human review. */
126export const MAX_AUTOFIX_ITERATIONS = 3;
127
34e63b9Claude128/** Max bytes of PR diff sent to Claude. */
129const MAX_DIFF_BYTES = 80 * 1024;
130
131/** Max bytes of error log sent to Claude. */
132const MAX_LOG_BYTES = 3 * 1024;
133
134/** Max bytes per test file read. */
135const MAX_FILE_BYTES = 10 * 1024;
136
137/** Max number of failing test files to read. */
138const MAX_TEST_FILES = 3;
139
140// ---------------------------------------------------------------------------
141// Helpers
142// ---------------------------------------------------------------------------
143
144async function spawnGit(
145 args: string[],
146 cwd: string
147): Promise<{ stdout: string; stderr: string; exitCode: number }> {
148 const proc = Bun.spawn(["git", ...args], {
149 cwd,
150 stdout: "pipe",
151 stderr: "pipe",
152 });
153 const [stdout, stderr] = await Promise.all([
154 new Response(proc.stdout).text(),
155 new Response(proc.stderr).text(),
156 ]);
157 const exitCode = await proc.exited;
158 return { stdout, stderr, exitCode };
159}
160
161function truncate(s: string, maxBytes: number): string {
162 const buf = Buffer.from(s, "utf8");
163 if (buf.length <= maxBytes) return s;
164 return buf.slice(0, maxBytes).toString("utf8") + "\n[truncated]";
165}
166
167/**
168 * Extract failing test file paths, error message, and stack trace from a
169 * raw CI error log string. Heuristic — good enough for most JS/TS test
170 * runners (Jest, Vitest, Bun test) and Python pytest output.
171 */
172function parseErrorLog(errorLog: string): {
173 testFiles: string[];
174 errorSummary: string;
175} {
176 const lines = errorLog.split("\n");
177
178 // Collect candidate test file paths:
179 // - lines mentioning .test.ts/.spec.ts/.test.js/.spec.js/.test.py paths
180 // - lines with "FAIL <path>" (Jest pattern)
181 const filePatterns = [
182 /(?:^|\s)([\w./\-]+\.(?:test|spec)\.[jt]sx?)/gm,
183 /(?:^|\s)([\w./\-]+_test\.py)/gm,
184 /(?:^FAIL\s+)([\w./\-]+)/gm,
185 ];
186
187 const filesSet = new Set<string>();
188 for (const pattern of filePatterns) {
189 let m: RegExpExecArray | null;
190 pattern.lastIndex = 0;
191 while ((m = pattern.exec(errorLog)) !== null) {
192 const path = m[1].trim();
193 if (path && !path.startsWith("-") && !path.startsWith("+")) {
194 filesSet.add(path);
195 }
196 }
197 }
198
199 // Error summary: first 3KB of the log (most runners put the error first).
200 const errorSummary = truncate(errorLog, MAX_LOG_BYTES);
201
202 return {
203 testFiles: Array.from(filesSet).slice(0, MAX_TEST_FILES),
204 errorSummary,
205 };
206}
207
bc519aeClaude208// ---------------------------------------------------------------------------
209// Repair flywheel wiring (Tier 0)
210// ---------------------------------------------------------------------------
211
212/** Matches the id inside a FLYWHEEL_MARKER_PREFIX comment marker. */
213const FLYWHEEL_MARKER_RE = /<!-- gluecron:ci-autofix:flywheel:([0-9a-fA-F-]{8,64}) -->/;
214
215/** Parse the flywheel entry id out of an autofix comment body, if present. */
216export function extractFlywheelEntryId(commentBody: string): string | null {
217 const m = commentBody.match(FLYWHEEL_MARKER_RE);
218 return m ? m[1] : null;
219}
220
221/**
222 * Record a 'pending' flywheel row for a fix we're about to post. Never
223 * throws — a flywheel write failure must not stop the fix from shipping,
224 * it only means this attempt won't contribute to the cache's learning.
225 */
226async function safeRecordRepair(
227 input: RecordRepairInput,
228 deps: CiAutofixDeps
229): Promise<string | null> {
230 const record = deps.recordRepair ?? recordRepair;
231 try {
232 return await record(input);
233 } catch (err) {
234 console.warn(
235 "[ci-autofix] flywheel record failed (fix still served):",
236 err instanceof Error ? err.message : err
237 );
238 return null;
239 }
240}
241
242/**
243 * Settle the flywheel entry referenced by an autofix comment (if any) so
244 * the pattern's success rate accumulates. Never throws — flywheel
245 * bookkeeping must not break the apply path.
246 */
247export async function recordAutofixOutcome(
248 commentBody: string,
249 outcome: "success" | "failed",
250 deps: CiAutofixDeps = {}
251): Promise<void> {
252 const entryId = extractFlywheelEntryId(commentBody);
253 if (!entryId) return;
254 const settle = deps.updateOutcome ?? updateOutcome;
255 try {
256 await settle(entryId, outcome);
257 } catch (err) {
258 console.warn(
259 "[ci-autofix] flywheel outcome update failed:",
260 err instanceof Error ? err.message : err
261 );
262 }
263}
264
6682dbeClaude265/**
266 * Count how many autofix comments have been posted to a PR (for circuit breaker).
267 */
268export async function countAutofixAttemptsForPr(pullRequestId: string): Promise<number> {
269 try {
270 const comments = await db
271 .select({ body: prComments.body })
272 .from(prComments)
273 .where(and(eq(prComments.pullRequestId, pullRequestId), eq(prComments.isAiReview, true)))
274 .limit(20);
275 return comments.filter((c) => c.body?.includes(CI_AUTOFIX_MARKER)).length;
276 } catch {
277 return 0;
278 }
279}
280
281/**
282 * Build the exhaustion comment posted when the circuit breaker trips.
283 */
284export function buildExhaustionComment(gateRunId: string, attempts: number): string {
285 return `${CI_AUTOFIX_MARKER}
286<!-- gluecron:ci-autofix:exhausted:${gateRunId} -->
287
288## 🔍 Execution Failure Profile
289
290The CI auto-repair engine has reached its iteration limit (**${attempts}** attempts) without resolving the failure. Handing off to human review.
291
292**What was tried:** The repair flywheel exhausted its Tier-0 cache and Tier-2 AI patch options across ${attempts} iterations.
293
294**Recommended next steps:**
295- Review the full error log in the gate run details
296- Inspect the diff manually for root causes not surfaced by the AI
297- Consider if the test expectations need updating rather than the source code
298
299_This diagnosis was generated automatically by the Gluecron repair flywheel._`;
300}
301
302async function recordAutofixTelemetry(args: {
303 gateRunId: string;
304 repositoryId: string;
305 errorRawText: string;
306 modelRoute: string;
307 attemptsCount: number;
308 executionTimeMs: number;
309 outcome: "cache_hit" | "ai_success" | "ai_failed";
310}): Promise<void> {
311 try {
312 const { createHash } = await import("crypto");
313 const errorSignatureHash = createHash("sha256")
314 .update(args.errorRawText.slice(0, 4096))
315 .digest("hex");
316 await db.insert(flywheelTelemetry).values({
317 gateRunId: args.gateRunId,
318 repositoryId: args.repositoryId,
319 errorSignatureHash,
320 errorRawText: args.errorRawText.slice(0, 8192),
321 modelRoute: args.modelRoute,
322 attemptsCount: args.attemptsCount,
323 executionTimeMs: args.executionTimeMs,
324 outcome: args.outcome,
325 reworkRateStatus: "untouched",
326 });
327 } catch (err) {
328 console.warn("[ci-autofix] telemetry write failed:", err instanceof Error ? err.message : err);
329 }
330}
331
bc519aeClaude332/**
333 * Tier-0-then-AI resolution for a CI failure (BUILD_BIBLE §7 finding 1).
334 *
335 * 1. Tier 0 — consult the repair flywheel for a previously-successful fix
336 * with the same failure signature. On a usable hit (success rate ≥
337 * CACHE_MIN_SUCCESS_RATE and a stored patch) the cached patch is
338 * served directly: no Anthropic call at all.
339 * 2. Tier 2 — fall through to a fresh Claude Sonnet patch via the
340 * `generateAiFix` thunk (guarded by isAiAvailable(); the thunk also
341 * keeps the expensive git context-gathering off the cache-hit path).
342 *
343 * Every served fix is recorded as a 'pending' flywheel row; applyAutofix
344 * settles it via recordAutofixOutcome. Flywheel/DB failures NEVER break
345 * this path — a broken cache degrades to the old always-call-AI behaviour.
346 */
347export async function resolveAutofix(args: {
348 repositoryId: string;
349 failureText: string;
350 generateAiFix: () => Promise<ClaudeAutofixResponse | null>;
351 deps?: CiAutofixDeps;
352}): Promise<AutofixPlan | null> {
353 const deps = args.deps ?? {};
354 const lookup = deps.findCachedRepair ?? findCachedRepair;
355 const aiOk = deps.aiAvailable ?? isAiAvailable;
356
357 // ── Tier 0: flywheel cache. Fail open — any error counts as a miss.
358 let cached: CachedRepair | null = null;
359 if (args.failureText.trim()) {
360 try {
361 cached = await lookup(args.repositoryId, args.failureText);
362 } catch (err) {
363 console.warn(
364 "[ci-autofix] flywheel lookup failed (falling through to AI):",
365 err instanceof Error ? err.message : err
366 );
367 }
368 }
369
370 // A usable hit needs a stored full patch (pre-0105 rows have none) AND a
371 // good settled success rate; anything else falls through to the AI tier.
372 if (
373 cached &&
374 cached.patch?.trim() &&
375 cached.successRate >= CACHE_MIN_SUCCESS_RATE
376 ) {
377 const fix: ClaudeAutofixResponse = {
378 patch: cached.patch,
379 explanation:
380 cached.patchSummary ||
381 "Replayed a previously-successful repair for this failure signature.",
382 // A pattern that keeps working is high confidence; anything that has
383 // failed at least occasionally is medium.
384 confidence: cached.successRate >= 0.9 ? "high" : "medium",
385 affectedFiles: cached.filesChanged,
386 };
387 const entryId = await safeRecordRepair(
388 {
389 repositoryId: args.repositoryId,
390 failureText: args.failureText,
391 classification: cached.classification,
392 tier: "cached",
393 patchSummary: fix.explanation,
394 // Carry the patch onto the new row so it is itself replayable once
395 // it settles (findCachedRepair prefers the most recent success).
396 patch: cached.patch,
397 filesChanged: fix.affectedFiles,
398 commitSha: null,
399 parentPatternId: cached.id,
400 },
401 deps
402 );
403 // Audit the saved AI call — this line is the flywheel's whole point.
404 console.log(
405 `[ci-autofix] flywheel cache HIT — pattern ${cached.id} (success rate ${(cached.successRate * 100).toFixed(0)}%, ${cached.hitCount} prior hits) served without an AI call`
406 );
407 return { source: "cache", fix, flywheelEntryId: entryId, cachedPatternId: cached.id };
408 }
409
410 // ── Tier 2: fresh AI patch. All AI features degrade gracefully when no
411 // API key is configured — cached fixes above still work without one.
412 if (!aiOk()) return null;
413
414 const fix = await args.generateAiFix();
415 if (!fix || !fix.patch || !fix.explanation) return null;
416 if (fix.confidence === "low") return null;
417
418 const entryId = await safeRecordRepair(
419 {
420 repositoryId: args.repositoryId,
421 failureText: args.failureText,
422 classification: null,
423 tier: "ai-sonnet",
424 patchSummary: fix.explanation,
425 // Stored so the entry is replayable by the Tier-0 cache once it
426 // settles to 'success'.
427 patch: fix.patch,
428 filesChanged: fix.affectedFiles,
429 commitSha: null,
430 },
431 deps
432 );
433 return { source: "ai", fix, flywheelEntryId: entryId, cachedPatternId: null };
434}
435
34e63b9Claude436// ---------------------------------------------------------------------------
437// Main entry point
438// ---------------------------------------------------------------------------
439
440/**
441 * Fire-and-forget entry point called after a gate_run row is set to 'failed'.
442 * Never throws — all errors are swallowed after logging.
bc519aeClaude443 *
444 * Note: no isAiAvailable() gate here — the Tier-0 flywheel cache needs no
445 * API key, so cached fixes still flow when AI is unconfigured. The actual
446 * Anthropic call is guarded inside resolveAutofix (graceful degradation).
34e63b9Claude447 */
bc519aeClaude448export async function triggerCiAutofix(
449 gateRunId: string,
450 deps: CiAutofixDeps = {}
451): Promise<void> {
34e63b9Claude452 try {
bc519aeClaude453 await _runAutofix(gateRunId, deps);
34e63b9Claude454 } catch (err) {
455 console.error(
456 "[ci-autofix] crashed:",
457 err instanceof Error ? err.message : err
458 );
459 }
460}
461
bc519aeClaude462async function _runAutofix(
463 gateRunId: string,
464 deps: CiAutofixDeps = {}
465): Promise<void> {
34e63b9Claude466 // 1. Load the gate run
467 const [gateRun] = await db
468 .select()
469 .from(gateRuns)
470 .where(eq(gateRuns.id, gateRunId))
471 .limit(1);
472
473 if (!gateRun) return;
474 if (gateRun.status !== "failed") return;
475 if (!gateRun.pullRequestId) return;
476
477 // 2. Load the PR row
478 const [pr] = await db
479 .select()
480 .from(pullRequests)
481 .where(eq(pullRequests.id, gateRun.pullRequestId))
482 .limit(1);
483
484 if (!pr) return;
485
486 // 3. Load repo (owner/name)
487 const [repoRow] = await db
488 .select({
489 id: repositories.id,
490 name: repositories.name,
491 diskPath: repositories.diskPath,
479dcd9Claude492 ownerId: repositories.ownerId,
34e63b9Claude493 ownerUsername: users.username,
494 })
495 .from(repositories)
496 .innerJoin(users, eq(repositories.ownerId, users.id))
497 .where(eq(repositories.id, gateRun.repositoryId))
498 .limit(1);
499
500 if (!repoRow) return;
501
479dcd9Claude502 // Per-repo automation gate — 'off' skips CI autofix entirely; 'suggest'
503 // (the fail-open default) posts the patch comment as before; 'auto'
504 // additionally applies the patch onto a fix/ branch below.
505 const automation = await (deps.loadAutomationSettings ?? getAutomationSettings)(
506 repoRow.id
507 );
508 if (automation.ciAutofixMode === "off") return;
509
34e63b9Claude510 // 4. Check idempotency — skip if already posted for this gateRunId
511 const idempotencyMarker = `<!-- gluecron:ci-autofix:run:${gateRunId} -->`;
512 const existing = await db
513 .select({ id: prComments.id })
514 .from(prComments)
515 .where(
516 and(
517 eq(prComments.pullRequestId, gateRun.pullRequestId),
518 // We check by looking for comments with the autofix marker.
519 // drizzle doesn't have LIKE with dynamic params easily, but
520 // we query all AI comments and filter client-side (there won't be many).
521 eq(prComments.isAiReview, true)
522 )
523 )
524 .limit(50);
525
526 for (const row of existing) {
527 // Load the body to check idempotency marker
528 const [full] = await db
529 .select({ body: prComments.body })
530 .from(prComments)
531 .where(eq(prComments.id, row.id))
532 .limit(1);
533 if (full?.body?.includes(idempotencyMarker)) return;
534 }
535
6682dbeClaude536 // Token circuit breaker — cap repair iterations at MAX_AUTOFIX_ITERATIONS.
537 // If we've already posted that many autofix comments on this PR, post an
538 // exhaustion diagnosis and bail out instead of spending more tokens.
539 const prAttempts = await countAutofixAttemptsForPr(gateRun.pullRequestId);
540 if (prAttempts >= MAX_AUTOFIX_ITERATIONS) {
541 const botAuthorId = await getBotUserIdOrFallback(repoRow.id);
542 if (botAuthorId) {
543 const exhaustedMarker = `<!-- gluecron:ci-autofix:exhausted:${gateRunId} -->`;
544 const alreadyExhausted = existing.some(async (row) => {
545 const [full] = await db
546 .select({ body: prComments.body })
547 .from(prComments)
548 .where(eq(prComments.id, row.id))
549 .limit(1);
550 return full?.body?.includes(exhaustedMarker);
551 });
552 if (!alreadyExhausted) {
553 const exhaustionBody = buildExhaustionComment(gateRunId, prAttempts);
554 await db.insert(prComments).values({
555 pullRequestId: gateRun.pullRequestId,
556 authorId: botAuthorId,
557 body: exhaustionBody,
558 isAiReview: true,
559 });
560 }
561 }
562 return;
563 }
564
34e63b9Claude565 const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name);
566
bc519aeClaude567 // 5. Failure text — drives both the flywheel signature and the AI prompt.
34e63b9Claude568 const errorLog = gateRun.summary || gateRun.details || "";
bc519aeClaude569 const failureText =
570 typeof errorLog === "string" ? errorLog : JSON.stringify(errorLog);
571
572 // 6. Tier 0 (flywheel cache) first, then the AI fallback. The expensive
573 // context gathering (PR diff + failing test files + Sonnet call) lives
574 // inside the thunk so a cache hit never touches git or the API.
575 const plan = await resolveAutofix({
576 repositoryId: repoRow.id,
577 failureText,
578 deps,
579 generateAiFix: async () => {
580 // 6a. Get the PR diff (max 80KB)
581 const diffResult = await spawnGit(
582 ["diff", `${pr.baseBranch}...${pr.headBranch}`],
583 repoDir
584 );
585 const prDiff = truncate(diffResult.stdout, MAX_DIFF_BYTES);
586
587 if (!prDiff.trim()) return null; // nothing to work with
588
589 // 6b. Parse errorLog to extract test files + error summary
590 const { testFiles, errorSummary } = parseErrorLog(failureText);
591
592 // 6c. Read failing test files via git show HEAD:path
593 let testFileContent = "";
594 for (const filePath of testFiles) {
595 const showResult = await spawnGit(
596 ["show", `${pr.headBranch}:${filePath}`],
597 repoDir
598 );
599 if (showResult.exitCode === 0 && showResult.stdout) {
600 const content = truncate(showResult.stdout, MAX_FILE_BYTES);
601 testFileContent += `\n\n--- ${filePath} ---\n${content}`;
602 }
603 }
34e63b9Claude604
bc519aeClaude605 // 6d. Call Claude Sonnet 4.6
606 const client = getAnthropic();
607 const prompt = `You are a senior engineer fixing a CI failure.
34e63b9Claude608
609PR diff (what changed):
610${prDiff}
611
612Failing test output:
613${errorSummary}
614
615Test file content:${testFileContent || "\n(no test files detected)"}
616
617Produce a minimal unified diff patch that fixes the CI failure. The patch must:
6181. Be valid unified diff format (--- a/file, +++ b/file, @@ lines)
6192. Fix only what's needed — no refactoring
6203. Not modify the test itself unless the test expectation is genuinely wrong
621
622Return JSON: {"patch": "...", "explanation": "...", "confidence": "high|medium|low", "affectedFiles": ["..."]}`;
623
bc519aeClaude624 const message = await client.messages.create({
625 model: MODEL_SONNET,
626 max_tokens: 4096,
627 messages: [{ role: "user", content: prompt }],
628 });
34e63b9Claude629
bc519aeClaude630 const rawText = extractText(message);
631 return parseJsonResponse<ClaudeAutofixResponse>(rawText);
632 },
633 });
34e63b9Claude634
bc519aeClaude635 // No usable fix (cache miss + AI declined/low-confidence/unavailable).
636 if (!plan) return;
34e63b9Claude637
bc519aeClaude638 // 7. Build and post the comment
639 const commentBody = buildAutofixComment(plan, idempotencyMarker, gateRunId);
34e63b9Claude640
641 const botAuthorId = await getBotUserIdOrFallback(repoRow.id);
642 if (!botAuthorId) return;
643
479dcd9Claude644 const [posted] = await db
645 .insert(prComments)
646 .values({
647 pullRequestId: gateRun.pullRequestId,
648 authorId: botAuthorId,
649 body: commentBody,
650 isAiReview: true,
651 })
652 .returning({ id: prComments.id });
653
6682dbeClaude654 // Record flywheel telemetry for observability (fire-and-forget).
655 void recordAutofixTelemetry({
656 gateRunId,
657 repositoryId: repoRow.id,
658 errorRawText: failureText,
659 modelRoute: plan.source === "cache" ? "flywheel-cache" : MODEL_SONNET,
660 attemptsCount: prAttempts + 1,
661 executionTimeMs: 0,
662 outcome: plan.source === "cache" ? "cache_hit" : "ai_success",
663 });
664
479dcd9Claude665 // 'auto' mode — also apply the patch onto a fix/ branch via the same
666 // path the Apply Fix button drives. Best-effort: an apply failure (it
667 // also settles the flywheel entry as 'failed') leaves the suggest-mode
668 // comment in place for a human to act on.
669 if (automation.ciAutofixMode === "auto" && posted) {
670 try {
671 const { branchName } = await applyAutofix(posted.id, repoRow.ownerId, deps);
672 console.log(
673 `[ci-autofix] auto-applied patch from comment ${posted.id} onto ${branchName}`
674 );
675 } catch (err) {
676 console.warn(
677 "[ci-autofix] auto-apply failed (patch comment still posted):",
678 err instanceof Error ? err.message : err
679 );
680 }
681 }
34e63b9Claude682}
683
684function buildAutofixComment(
bc519aeClaude685 plan: AutofixPlan,
34e63b9Claude686 idempotencyMarker: string,
687 gateRunId: string
688): string {
bc519aeClaude689 const result = plan.fix;
34e63b9Claude690 const confidenceBadge =
691 result.confidence === "high"
692 ? "🟢 High confidence"
693 : result.confidence === "medium"
694 ? "🟡 Medium confidence"
695 : "🔴 Low confidence";
696
bc519aeClaude697 // Flywheel bookkeeping marker — applyAutofix parses this to settle the
698 // pending entry's outcome. Absent when the flywheel write failed.
699 const flywheelMarker = plan.flywheelEntryId
700 ? `\n${FLYWHEEL_MARKER_PREFIX}${plan.flywheelEntryId} -->`
701 : "";
702
703 const sourceNote =
704 plan.source === "cache"
705 ? `\n\n♻️ **Served from the repair cache** — this failure signature was fixed successfully before; no AI call was made.`
706 : "";
707
34e63b9Claude708 return `${CI_AUTOFIX_MARKER}
bc519aeClaude709${idempotencyMarker}${flywheelMarker}
34e63b9Claude710
711## 🔧 AI Auto-Fix
712
bc519aeClaude713${result.explanation}${sourceNote}
34e63b9Claude714
715**Confidence:** ${confidenceBadge}
716
717\`\`\`diff
718${result.patch}
719\`\`\`
720
721<details><summary>Apply this fix</summary>
722
723Copy the patch above or click **Apply Fix** to commit it automatically.
724
725<form method="post" action="/api/pr-comments/COMMENT_ID/apply-autofix" style="display:inline">
726 <button type="submit" style="margin-top:8px;padding:6px 14px;background:#6c63ff;color:#fff;border:none;border-radius:6px;cursor:pointer">
727 ⚡ Apply Fix
728 </button>
729</form>
730
731</details>
732
733<sub>Gate run: <code>${gateRunId}</code> · Affected files: ${result.affectedFiles.join(", ") || "see patch above"}</sub>`;
734}
735
736// ---------------------------------------------------------------------------
737// Apply autofix
738// ---------------------------------------------------------------------------
739
740/**
741 * Applies the patch from a PR comment onto a new branch.
742 * Returns the new branch name so the caller can redirect to compare view.
bc519aeClaude743 *
744 * Also settles the comment's pending flywheel entry: a clean apply+commit
745 * records 'success' (the pattern becomes replayable by the Tier-0 cache),
746 * an apply failure records 'failed' so unreliable patterns lose confidence.
34e63b9Claude747 */
748export async function applyAutofix(
749 prCommentId: string,
bc519aeClaude750 userId: string,
751 deps: CiAutofixDeps = {}
34e63b9Claude752): Promise<{ branchName: string }> {
753 // 1. Load the comment
754 const [comment] = await db
755 .select({
756 id: prComments.id,
757 pullRequestId: prComments.pullRequestId,
758 body: prComments.body,
759 isAiReview: prComments.isAiReview,
760 })
761 .from(prComments)
762 .where(eq(prComments.id, prCommentId))
763 .limit(1);
764
765 if (!comment) throw new Error("Comment not found");
766 if (!comment.body.includes(CI_AUTOFIX_MARKER)) {
767 throw new Error("Not an autofix comment");
768 }
769
770 // 2. Load PR + repo for access check
771 const [pr] = await db
772 .select()
773 .from(pullRequests)
774 .where(eq(pullRequests.id, comment.pullRequestId))
775 .limit(1);
776
777 if (!pr) throw new Error("PR not found");
778
779 const [repoRow] = await db
780 .select({
781 id: repositories.id,
782 name: repositories.name,
783 ownerId: repositories.ownerId,
784 ownerUsername: users.username,
785 })
786 .from(repositories)
787 .innerJoin(users, eq(repositories.ownerId, users.id))
788 .where(eq(repositories.id, pr.repositoryId))
789 .limit(1);
790
791 if (!repoRow) throw new Error("Repository not found");
792
793 // Verify write access: must be repo owner or collaborator
794 const isOwner = repoRow.ownerId === userId;
795 if (!isOwner) {
796 const [collab] = await db
797 .select({ id: repoCollaborators.id })
798 .from(repoCollaborators)
799 .where(
800 and(
801 eq(repoCollaborators.repositoryId, repoRow.id),
802 eq(repoCollaborators.userId, userId)
803 )
804 )
805 .limit(1);
806 if (!collab) throw new Error("Forbidden: no write access");
807 }
808
809 // 3. Extract patch from comment body
810 const patchMatch = comment.body.match(/```diff\n([\s\S]*?)```/);
811 if (!patchMatch) throw new Error("No patch found in comment");
812 const patch = patchMatch[1];
813
814 // 4. Create a new branch from the PR's head
815 const branchName = `fix/autofix-${Date.now()}`;
816 const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name);
817
818 // Create the branch at the PR head SHA
819 const headSha = await spawnGit(
820 ["rev-parse", pr.headBranch],
821 repoDir
822 );
823 if (headSha.exitCode !== 0) throw new Error("Cannot resolve head branch");
824
825 await spawnGit(
826 ["branch", branchName, headSha.stdout.trim()],
827 repoDir
828 );
829
830 // 5. Apply the patch via git apply in a temp worktree
831 const tmpDir = await mkdtemp(join(tmpdir(), "autofix-"));
832 try {
833 // Add worktree for the new branch
834 const wtResult = await spawnGit(
835 ["worktree", "add", tmpDir, branchName],
836 repoDir
837 );
838 if (wtResult.exitCode !== 0) {
839 throw new Error(`git worktree add failed: ${wtResult.stderr}`);
840 }
841
842 // Write the patch to a temp file
843 const patchFile = join(tmpDir, "autofix.patch");
844 await writeFile(patchFile, patch, "utf8");
845
846 // Apply the patch
847 const applyResult = await spawnGit(
848 ["apply", "--index", patchFile],
849 tmpDir
850 );
851 if (applyResult.exitCode !== 0) {
852 throw new Error(`git apply failed: ${applyResult.stderr}`);
853 }
854
855 // 6. Commit
856 const commitResult = await spawnGit(
857 [
858 "commit",
859 "-m",
860 "fix: apply AI autofix for CI failure",
861 "--author",
862 "gluecron[bot] <bot@gluecron.com>",
863 ],
864 tmpDir
865 );
866 if (commitResult.exitCode !== 0) {
867 throw new Error(`git commit failed: ${commitResult.stderr}`);
868 }
869
870 // 7. Push the branch back to the bare repo
871 // In a bare-repo + worktree setup the push target is the bare repo itself.
872 await spawnGit(
873 ["push", repoDir, `HEAD:refs/heads/${branchName}`],
874 tmpDir
875 );
bc519aeClaude876
877 // 8. The repair landed — settle the flywheel entry so this pattern's
878 // success rate climbs and future identical failures hit the Tier-0 cache.
879 await recordAutofixOutcome(comment.body, "success", deps);
880 } catch (err) {
881 // The patch failed to apply/commit/push — settle as 'failed' so the
882 // flywheel learns this pattern is unreliable. Best-effort: the original
883 // error is always rethrown for the route to surface.
884 await recordAutofixOutcome(comment.body, "failed", deps);
885 throw err;
34e63b9Claude886 } finally {
887 // Cleanup worktree
888 await spawnGit(["worktree", "remove", "--force", tmpDir], repoDir).catch(
889 () => {}
890 );
891 await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
892 }
893
894 return { branchName };
895}