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.tsBlame784 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,
44} from "../db/schema";
45import { getRepoPath } from "../git/repository";
46import { getBotUserIdOrFallback } from "./bot-user";
47import {
48 getAnthropic,
49 isAiAvailable,
50 MODEL_SONNET,
51 extractText,
52 parseJsonResponse,
53} from "./ai-client";
479dcd9Claude54import {
55 getAutomationSettings,
56 type AutomationSettingsLoader,
57} from "./automation-settings";
34e63b9Claude58
59// ---------------------------------------------------------------------------
60// Types
61// ---------------------------------------------------------------------------
62
63export interface AutofixResult {
64 prNumber: number;
65 repoId: string;
66 gateRunId: string;
67 patch: string; // unified diff format
68 explanation: string; // 2-3 sentence explanation
69 confidence: "high" | "medium" | "low";
70 affectedFiles: string[];
71}
72
bc519aeClaude73export interface ClaudeAutofixResponse {
34e63b9Claude74 patch: string;
75 explanation: string;
76 confidence: "high" | "medium" | "low";
77 affectedFiles: string[];
78}
79
bc519aeClaude80/**
81 * DI seam for the repair-flywheel wiring. Production callers omit this and
82 * get the real implementations; tests inject fakes so no DB is touched.
83 */
84export interface CiAutofixDeps {
85 findCachedRepair?: typeof findCachedRepair;
86 recordRepair?: typeof recordRepair;
87 updateOutcome?: typeof updateOutcome;
88 aiAvailable?: () => boolean;
479dcd9Claude89 /** Inject the per-repo automation settings loader (tests). */
90 loadAutomationSettings?: AutomationSettingsLoader;
bc519aeClaude91}
92
93/** The fix triggerCiAutofix decided to post, plus its flywheel bookkeeping. */
94export interface AutofixPlan {
95 /** 'cache' = Tier-0 flywheel replay (no AI call); 'ai' = fresh Sonnet patch. */
96 source: "cache" | "ai";
97 fix: ClaudeAutofixResponse;
98 /** Flywheel row recorded as 'pending' for this attempt; null if recording failed. */
99 flywheelEntryId: string | null;
100 /** On a cache hit: the parent pattern that was replayed. */
101 cachedPatternId: string | null;
102}
103
34e63b9Claude104// ---------------------------------------------------------------------------
105// Constants
106// ---------------------------------------------------------------------------
107
108/** Idempotency marker embedded in every autofix comment. */
109export const CI_AUTOFIX_MARKER = "<!-- gluecron:ci-autofix:v1 -->";
110
bc519aeClaude111/**
112 * Marker carrying the pending flywheel entry id, embedded in the autofix
113 * comment so applyAutofix can settle the outcome (success/failed) later.
114 */
115export const FLYWHEEL_MARKER_PREFIX = "<!-- gluecron:ci-autofix:flywheel:";
116
117/**
118 * Minimum settled success rate before a cached pattern is replayed instead
119 * of calling the AI. Below this the cache is considered unreliable for the
120 * signature and we fall through to a fresh Sonnet patch.
121 */
122export const CACHE_MIN_SUCCESS_RATE = 0.5;
123
34e63b9Claude124/** Max bytes of PR diff sent to Claude. */
125const MAX_DIFF_BYTES = 80 * 1024;
126
127/** Max bytes of error log sent to Claude. */
128const MAX_LOG_BYTES = 3 * 1024;
129
130/** Max bytes per test file read. */
131const MAX_FILE_BYTES = 10 * 1024;
132
133/** Max number of failing test files to read. */
134const MAX_TEST_FILES = 3;
135
136// ---------------------------------------------------------------------------
137// Helpers
138// ---------------------------------------------------------------------------
139
140async function spawnGit(
141 args: string[],
142 cwd: string
143): Promise<{ stdout: string; stderr: string; exitCode: number }> {
144 const proc = Bun.spawn(["git", ...args], {
145 cwd,
146 stdout: "pipe",
147 stderr: "pipe",
148 });
149 const [stdout, stderr] = await Promise.all([
150 new Response(proc.stdout).text(),
151 new Response(proc.stderr).text(),
152 ]);
153 const exitCode = await proc.exited;
154 return { stdout, stderr, exitCode };
155}
156
157function truncate(s: string, maxBytes: number): string {
158 const buf = Buffer.from(s, "utf8");
159 if (buf.length <= maxBytes) return s;
160 return buf.slice(0, maxBytes).toString("utf8") + "\n[truncated]";
161}
162
163/**
164 * Extract failing test file paths, error message, and stack trace from a
165 * raw CI error log string. Heuristic — good enough for most JS/TS test
166 * runners (Jest, Vitest, Bun test) and Python pytest output.
167 */
168function parseErrorLog(errorLog: string): {
169 testFiles: string[];
170 errorSummary: string;
171} {
172 const lines = errorLog.split("\n");
173
174 // Collect candidate test file paths:
175 // - lines mentioning .test.ts/.spec.ts/.test.js/.spec.js/.test.py paths
176 // - lines with "FAIL <path>" (Jest pattern)
177 const filePatterns = [
178 /(?:^|\s)([\w./\-]+\.(?:test|spec)\.[jt]sx?)/gm,
179 /(?:^|\s)([\w./\-]+_test\.py)/gm,
180 /(?:^FAIL\s+)([\w./\-]+)/gm,
181 ];
182
183 const filesSet = new Set<string>();
184 for (const pattern of filePatterns) {
185 let m: RegExpExecArray | null;
186 pattern.lastIndex = 0;
187 while ((m = pattern.exec(errorLog)) !== null) {
188 const path = m[1].trim();
189 if (path && !path.startsWith("-") && !path.startsWith("+")) {
190 filesSet.add(path);
191 }
192 }
193 }
194
195 // Error summary: first 3KB of the log (most runners put the error first).
196 const errorSummary = truncate(errorLog, MAX_LOG_BYTES);
197
198 return {
199 testFiles: Array.from(filesSet).slice(0, MAX_TEST_FILES),
200 errorSummary,
201 };
202}
203
bc519aeClaude204// ---------------------------------------------------------------------------
205// Repair flywheel wiring (Tier 0)
206// ---------------------------------------------------------------------------
207
208/** Matches the id inside a FLYWHEEL_MARKER_PREFIX comment marker. */
209const FLYWHEEL_MARKER_RE = /<!-- gluecron:ci-autofix:flywheel:([0-9a-fA-F-]{8,64}) -->/;
210
211/** Parse the flywheel entry id out of an autofix comment body, if present. */
212export function extractFlywheelEntryId(commentBody: string): string | null {
213 const m = commentBody.match(FLYWHEEL_MARKER_RE);
214 return m ? m[1] : null;
215}
216
217/**
218 * Record a 'pending' flywheel row for a fix we're about to post. Never
219 * throws — a flywheel write failure must not stop the fix from shipping,
220 * it only means this attempt won't contribute to the cache's learning.
221 */
222async function safeRecordRepair(
223 input: RecordRepairInput,
224 deps: CiAutofixDeps
225): Promise<string | null> {
226 const record = deps.recordRepair ?? recordRepair;
227 try {
228 return await record(input);
229 } catch (err) {
230 console.warn(
231 "[ci-autofix] flywheel record failed (fix still served):",
232 err instanceof Error ? err.message : err
233 );
234 return null;
235 }
236}
237
238/**
239 * Settle the flywheel entry referenced by an autofix comment (if any) so
240 * the pattern's success rate accumulates. Never throws — flywheel
241 * bookkeeping must not break the apply path.
242 */
243export async function recordAutofixOutcome(
244 commentBody: string,
245 outcome: "success" | "failed",
246 deps: CiAutofixDeps = {}
247): Promise<void> {
248 const entryId = extractFlywheelEntryId(commentBody);
249 if (!entryId) return;
250 const settle = deps.updateOutcome ?? updateOutcome;
251 try {
252 await settle(entryId, outcome);
253 } catch (err) {
254 console.warn(
255 "[ci-autofix] flywheel outcome update failed:",
256 err instanceof Error ? err.message : err
257 );
258 }
259}
260
261/**
262 * Tier-0-then-AI resolution for a CI failure (BUILD_BIBLE §7 finding 1).
263 *
264 * 1. Tier 0 — consult the repair flywheel for a previously-successful fix
265 * with the same failure signature. On a usable hit (success rate ≥
266 * CACHE_MIN_SUCCESS_RATE and a stored patch) the cached patch is
267 * served directly: no Anthropic call at all.
268 * 2. Tier 2 — fall through to a fresh Claude Sonnet patch via the
269 * `generateAiFix` thunk (guarded by isAiAvailable(); the thunk also
270 * keeps the expensive git context-gathering off the cache-hit path).
271 *
272 * Every served fix is recorded as a 'pending' flywheel row; applyAutofix
273 * settles it via recordAutofixOutcome. Flywheel/DB failures NEVER break
274 * this path — a broken cache degrades to the old always-call-AI behaviour.
275 */
276export async function resolveAutofix(args: {
277 repositoryId: string;
278 failureText: string;
279 generateAiFix: () => Promise<ClaudeAutofixResponse | null>;
280 deps?: CiAutofixDeps;
281}): Promise<AutofixPlan | null> {
282 const deps = args.deps ?? {};
283 const lookup = deps.findCachedRepair ?? findCachedRepair;
284 const aiOk = deps.aiAvailable ?? isAiAvailable;
285
286 // ── Tier 0: flywheel cache. Fail open — any error counts as a miss.
287 let cached: CachedRepair | null = null;
288 if (args.failureText.trim()) {
289 try {
290 cached = await lookup(args.repositoryId, args.failureText);
291 } catch (err) {
292 console.warn(
293 "[ci-autofix] flywheel lookup failed (falling through to AI):",
294 err instanceof Error ? err.message : err
295 );
296 }
297 }
298
299 // A usable hit needs a stored full patch (pre-0105 rows have none) AND a
300 // good settled success rate; anything else falls through to the AI tier.
301 if (
302 cached &&
303 cached.patch?.trim() &&
304 cached.successRate >= CACHE_MIN_SUCCESS_RATE
305 ) {
306 const fix: ClaudeAutofixResponse = {
307 patch: cached.patch,
308 explanation:
309 cached.patchSummary ||
310 "Replayed a previously-successful repair for this failure signature.",
311 // A pattern that keeps working is high confidence; anything that has
312 // failed at least occasionally is medium.
313 confidence: cached.successRate >= 0.9 ? "high" : "medium",
314 affectedFiles: cached.filesChanged,
315 };
316 const entryId = await safeRecordRepair(
317 {
318 repositoryId: args.repositoryId,
319 failureText: args.failureText,
320 classification: cached.classification,
321 tier: "cached",
322 patchSummary: fix.explanation,
323 // Carry the patch onto the new row so it is itself replayable once
324 // it settles (findCachedRepair prefers the most recent success).
325 patch: cached.patch,
326 filesChanged: fix.affectedFiles,
327 commitSha: null,
328 parentPatternId: cached.id,
329 },
330 deps
331 );
332 // Audit the saved AI call — this line is the flywheel's whole point.
333 console.log(
334 `[ci-autofix] flywheel cache HIT — pattern ${cached.id} (success rate ${(cached.successRate * 100).toFixed(0)}%, ${cached.hitCount} prior hits) served without an AI call`
335 );
336 return { source: "cache", fix, flywheelEntryId: entryId, cachedPatternId: cached.id };
337 }
338
339 // ── Tier 2: fresh AI patch. All AI features degrade gracefully when no
340 // API key is configured — cached fixes above still work without one.
341 if (!aiOk()) return null;
342
343 const fix = await args.generateAiFix();
344 if (!fix || !fix.patch || !fix.explanation) return null;
345 if (fix.confidence === "low") return null;
346
347 const entryId = await safeRecordRepair(
348 {
349 repositoryId: args.repositoryId,
350 failureText: args.failureText,
351 classification: null,
352 tier: "ai-sonnet",
353 patchSummary: fix.explanation,
354 // Stored so the entry is replayable by the Tier-0 cache once it
355 // settles to 'success'.
356 patch: fix.patch,
357 filesChanged: fix.affectedFiles,
358 commitSha: null,
359 },
360 deps
361 );
362 return { source: "ai", fix, flywheelEntryId: entryId, cachedPatternId: null };
363}
364
34e63b9Claude365// ---------------------------------------------------------------------------
366// Main entry point
367// ---------------------------------------------------------------------------
368
369/**
370 * Fire-and-forget entry point called after a gate_run row is set to 'failed'.
371 * Never throws — all errors are swallowed after logging.
bc519aeClaude372 *
373 * Note: no isAiAvailable() gate here — the Tier-0 flywheel cache needs no
374 * API key, so cached fixes still flow when AI is unconfigured. The actual
375 * Anthropic call is guarded inside resolveAutofix (graceful degradation).
34e63b9Claude376 */
bc519aeClaude377export async function triggerCiAutofix(
378 gateRunId: string,
379 deps: CiAutofixDeps = {}
380): Promise<void> {
34e63b9Claude381 try {
bc519aeClaude382 await _runAutofix(gateRunId, deps);
34e63b9Claude383 } catch (err) {
384 console.error(
385 "[ci-autofix] crashed:",
386 err instanceof Error ? err.message : err
387 );
388 }
389}
390
bc519aeClaude391async function _runAutofix(
392 gateRunId: string,
393 deps: CiAutofixDeps = {}
394): Promise<void> {
34e63b9Claude395 // 1. Load the gate run
396 const [gateRun] = await db
397 .select()
398 .from(gateRuns)
399 .where(eq(gateRuns.id, gateRunId))
400 .limit(1);
401
402 if (!gateRun) return;
403 if (gateRun.status !== "failed") return;
404 if (!gateRun.pullRequestId) return;
405
406 // 2. Load the PR row
407 const [pr] = await db
408 .select()
409 .from(pullRequests)
410 .where(eq(pullRequests.id, gateRun.pullRequestId))
411 .limit(1);
412
413 if (!pr) return;
414
415 // 3. Load repo (owner/name)
416 const [repoRow] = await db
417 .select({
418 id: repositories.id,
419 name: repositories.name,
420 diskPath: repositories.diskPath,
479dcd9Claude421 ownerId: repositories.ownerId,
34e63b9Claude422 ownerUsername: users.username,
423 })
424 .from(repositories)
425 .innerJoin(users, eq(repositories.ownerId, users.id))
426 .where(eq(repositories.id, gateRun.repositoryId))
427 .limit(1);
428
429 if (!repoRow) return;
430
479dcd9Claude431 // Per-repo automation gate — 'off' skips CI autofix entirely; 'suggest'
432 // (the fail-open default) posts the patch comment as before; 'auto'
433 // additionally applies the patch onto a fix/ branch below.
434 const automation = await (deps.loadAutomationSettings ?? getAutomationSettings)(
435 repoRow.id
436 );
437 if (automation.ciAutofixMode === "off") return;
438
34e63b9Claude439 // 4. Check idempotency — skip if already posted for this gateRunId
440 const idempotencyMarker = `<!-- gluecron:ci-autofix:run:${gateRunId} -->`;
441 const existing = await db
442 .select({ id: prComments.id })
443 .from(prComments)
444 .where(
445 and(
446 eq(prComments.pullRequestId, gateRun.pullRequestId),
447 // We check by looking for comments with the autofix marker.
448 // drizzle doesn't have LIKE with dynamic params easily, but
449 // we query all AI comments and filter client-side (there won't be many).
450 eq(prComments.isAiReview, true)
451 )
452 )
453 .limit(50);
454
455 for (const row of existing) {
456 // Load the body to check idempotency marker
457 const [full] = await db
458 .select({ body: prComments.body })
459 .from(prComments)
460 .where(eq(prComments.id, row.id))
461 .limit(1);
462 if (full?.body?.includes(idempotencyMarker)) return;
463 }
464
465 const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name);
466
bc519aeClaude467 // 5. Failure text — drives both the flywheel signature and the AI prompt.
34e63b9Claude468 const errorLog = gateRun.summary || gateRun.details || "";
bc519aeClaude469 const failureText =
470 typeof errorLog === "string" ? errorLog : JSON.stringify(errorLog);
471
472 // 6. Tier 0 (flywheel cache) first, then the AI fallback. The expensive
473 // context gathering (PR diff + failing test files + Sonnet call) lives
474 // inside the thunk so a cache hit never touches git or the API.
475 const plan = await resolveAutofix({
476 repositoryId: repoRow.id,
477 failureText,
478 deps,
479 generateAiFix: async () => {
480 // 6a. Get the PR diff (max 80KB)
481 const diffResult = await spawnGit(
482 ["diff", `${pr.baseBranch}...${pr.headBranch}`],
483 repoDir
484 );
485 const prDiff = truncate(diffResult.stdout, MAX_DIFF_BYTES);
486
487 if (!prDiff.trim()) return null; // nothing to work with
488
489 // 6b. Parse errorLog to extract test files + error summary
490 const { testFiles, errorSummary } = parseErrorLog(failureText);
491
492 // 6c. Read failing test files via git show HEAD:path
493 let testFileContent = "";
494 for (const filePath of testFiles) {
495 const showResult = await spawnGit(
496 ["show", `${pr.headBranch}:${filePath}`],
497 repoDir
498 );
499 if (showResult.exitCode === 0 && showResult.stdout) {
500 const content = truncate(showResult.stdout, MAX_FILE_BYTES);
501 testFileContent += `\n\n--- ${filePath} ---\n${content}`;
502 }
503 }
34e63b9Claude504
bc519aeClaude505 // 6d. Call Claude Sonnet 4.6
506 const client = getAnthropic();
507 const prompt = `You are a senior engineer fixing a CI failure.
34e63b9Claude508
509PR diff (what changed):
510${prDiff}
511
512Failing test output:
513${errorSummary}
514
515Test file content:${testFileContent || "\n(no test files detected)"}
516
517Produce a minimal unified diff patch that fixes the CI failure. The patch must:
5181. Be valid unified diff format (--- a/file, +++ b/file, @@ lines)
5192. Fix only what's needed — no refactoring
5203. Not modify the test itself unless the test expectation is genuinely wrong
521
522Return JSON: {"patch": "...", "explanation": "...", "confidence": "high|medium|low", "affectedFiles": ["..."]}`;
523
bc519aeClaude524 const message = await client.messages.create({
525 model: MODEL_SONNET,
526 max_tokens: 4096,
527 messages: [{ role: "user", content: prompt }],
528 });
34e63b9Claude529
bc519aeClaude530 const rawText = extractText(message);
531 return parseJsonResponse<ClaudeAutofixResponse>(rawText);
532 },
533 });
34e63b9Claude534
bc519aeClaude535 // No usable fix (cache miss + AI declined/low-confidence/unavailable).
536 if (!plan) return;
34e63b9Claude537
bc519aeClaude538 // 7. Build and post the comment
539 const commentBody = buildAutofixComment(plan, idempotencyMarker, gateRunId);
34e63b9Claude540
541 const botAuthorId = await getBotUserIdOrFallback(repoRow.id);
542 if (!botAuthorId) return;
543
479dcd9Claude544 const [posted] = await db
545 .insert(prComments)
546 .values({
547 pullRequestId: gateRun.pullRequestId,
548 authorId: botAuthorId,
549 body: commentBody,
550 isAiReview: true,
551 })
552 .returning({ id: prComments.id });
553
554 // 'auto' mode — also apply the patch onto a fix/ branch via the same
555 // path the Apply Fix button drives. Best-effort: an apply failure (it
556 // also settles the flywheel entry as 'failed') leaves the suggest-mode
557 // comment in place for a human to act on.
558 if (automation.ciAutofixMode === "auto" && posted) {
559 try {
560 const { branchName } = await applyAutofix(posted.id, repoRow.ownerId, deps);
561 console.log(
562 `[ci-autofix] auto-applied patch from comment ${posted.id} onto ${branchName}`
563 );
564 } catch (err) {
565 console.warn(
566 "[ci-autofix] auto-apply failed (patch comment still posted):",
567 err instanceof Error ? err.message : err
568 );
569 }
570 }
34e63b9Claude571}
572
573function buildAutofixComment(
bc519aeClaude574 plan: AutofixPlan,
34e63b9Claude575 idempotencyMarker: string,
576 gateRunId: string
577): string {
bc519aeClaude578 const result = plan.fix;
34e63b9Claude579 const confidenceBadge =
580 result.confidence === "high"
581 ? "🟢 High confidence"
582 : result.confidence === "medium"
583 ? "🟡 Medium confidence"
584 : "🔴 Low confidence";
585
bc519aeClaude586 // Flywheel bookkeeping marker — applyAutofix parses this to settle the
587 // pending entry's outcome. Absent when the flywheel write failed.
588 const flywheelMarker = plan.flywheelEntryId
589 ? `\n${FLYWHEEL_MARKER_PREFIX}${plan.flywheelEntryId} -->`
590 : "";
591
592 const sourceNote =
593 plan.source === "cache"
594 ? `\n\n♻️ **Served from the repair cache** — this failure signature was fixed successfully before; no AI call was made.`
595 : "";
596
34e63b9Claude597 return `${CI_AUTOFIX_MARKER}
bc519aeClaude598${idempotencyMarker}${flywheelMarker}
34e63b9Claude599
600## 🔧 AI Auto-Fix
601
bc519aeClaude602${result.explanation}${sourceNote}
34e63b9Claude603
604**Confidence:** ${confidenceBadge}
605
606\`\`\`diff
607${result.patch}
608\`\`\`
609
610<details><summary>Apply this fix</summary>
611
612Copy the patch above or click **Apply Fix** to commit it automatically.
613
614<form method="post" action="/api/pr-comments/COMMENT_ID/apply-autofix" style="display:inline">
615 <button type="submit" style="margin-top:8px;padding:6px 14px;background:#6c63ff;color:#fff;border:none;border-radius:6px;cursor:pointer">
616 ⚡ Apply Fix
617 </button>
618</form>
619
620</details>
621
622<sub>Gate run: <code>${gateRunId}</code> · Affected files: ${result.affectedFiles.join(", ") || "see patch above"}</sub>`;
623}
624
625// ---------------------------------------------------------------------------
626// Apply autofix
627// ---------------------------------------------------------------------------
628
629/**
630 * Applies the patch from a PR comment onto a new branch.
631 * Returns the new branch name so the caller can redirect to compare view.
bc519aeClaude632 *
633 * Also settles the comment's pending flywheel entry: a clean apply+commit
634 * records 'success' (the pattern becomes replayable by the Tier-0 cache),
635 * an apply failure records 'failed' so unreliable patterns lose confidence.
34e63b9Claude636 */
637export async function applyAutofix(
638 prCommentId: string,
bc519aeClaude639 userId: string,
640 deps: CiAutofixDeps = {}
34e63b9Claude641): Promise<{ branchName: string }> {
642 // 1. Load the comment
643 const [comment] = await db
644 .select({
645 id: prComments.id,
646 pullRequestId: prComments.pullRequestId,
647 body: prComments.body,
648 isAiReview: prComments.isAiReview,
649 })
650 .from(prComments)
651 .where(eq(prComments.id, prCommentId))
652 .limit(1);
653
654 if (!comment) throw new Error("Comment not found");
655 if (!comment.body.includes(CI_AUTOFIX_MARKER)) {
656 throw new Error("Not an autofix comment");
657 }
658
659 // 2. Load PR + repo for access check
660 const [pr] = await db
661 .select()
662 .from(pullRequests)
663 .where(eq(pullRequests.id, comment.pullRequestId))
664 .limit(1);
665
666 if (!pr) throw new Error("PR not found");
667
668 const [repoRow] = await db
669 .select({
670 id: repositories.id,
671 name: repositories.name,
672 ownerId: repositories.ownerId,
673 ownerUsername: users.username,
674 })
675 .from(repositories)
676 .innerJoin(users, eq(repositories.ownerId, users.id))
677 .where(eq(repositories.id, pr.repositoryId))
678 .limit(1);
679
680 if (!repoRow) throw new Error("Repository not found");
681
682 // Verify write access: must be repo owner or collaborator
683 const isOwner = repoRow.ownerId === userId;
684 if (!isOwner) {
685 const [collab] = await db
686 .select({ id: repoCollaborators.id })
687 .from(repoCollaborators)
688 .where(
689 and(
690 eq(repoCollaborators.repositoryId, repoRow.id),
691 eq(repoCollaborators.userId, userId)
692 )
693 )
694 .limit(1);
695 if (!collab) throw new Error("Forbidden: no write access");
696 }
697
698 // 3. Extract patch from comment body
699 const patchMatch = comment.body.match(/```diff\n([\s\S]*?)```/);
700 if (!patchMatch) throw new Error("No patch found in comment");
701 const patch = patchMatch[1];
702
703 // 4. Create a new branch from the PR's head
704 const branchName = `fix/autofix-${Date.now()}`;
705 const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name);
706
707 // Create the branch at the PR head SHA
708 const headSha = await spawnGit(
709 ["rev-parse", pr.headBranch],
710 repoDir
711 );
712 if (headSha.exitCode !== 0) throw new Error("Cannot resolve head branch");
713
714 await spawnGit(
715 ["branch", branchName, headSha.stdout.trim()],
716 repoDir
717 );
718
719 // 5. Apply the patch via git apply in a temp worktree
720 const tmpDir = await mkdtemp(join(tmpdir(), "autofix-"));
721 try {
722 // Add worktree for the new branch
723 const wtResult = await spawnGit(
724 ["worktree", "add", tmpDir, branchName],
725 repoDir
726 );
727 if (wtResult.exitCode !== 0) {
728 throw new Error(`git worktree add failed: ${wtResult.stderr}`);
729 }
730
731 // Write the patch to a temp file
732 const patchFile = join(tmpDir, "autofix.patch");
733 await writeFile(patchFile, patch, "utf8");
734
735 // Apply the patch
736 const applyResult = await spawnGit(
737 ["apply", "--index", patchFile],
738 tmpDir
739 );
740 if (applyResult.exitCode !== 0) {
741 throw new Error(`git apply failed: ${applyResult.stderr}`);
742 }
743
744 // 6. Commit
745 const commitResult = await spawnGit(
746 [
747 "commit",
748 "-m",
749 "fix: apply AI autofix for CI failure",
750 "--author",
751 "gluecron[bot] <bot@gluecron.com>",
752 ],
753 tmpDir
754 );
755 if (commitResult.exitCode !== 0) {
756 throw new Error(`git commit failed: ${commitResult.stderr}`);
757 }
758
759 // 7. Push the branch back to the bare repo
760 // In a bare-repo + worktree setup the push target is the bare repo itself.
761 await spawnGit(
762 ["push", repoDir, `HEAD:refs/heads/${branchName}`],
763 tmpDir
764 );
bc519aeClaude765
766 // 8. The repair landed — settle the flywheel entry so this pattern's
767 // success rate climbs and future identical failures hit the Tier-0 cache.
768 await recordAutofixOutcome(comment.body, "success", deps);
769 } catch (err) {
770 // The patch failed to apply/commit/push — settle as 'failed' so the
771 // flywheel learns this pattern is unreliable. Best-effort: the original
772 // error is always rethrown for the route to surface.
773 await recordAutofixOutcome(comment.body, "failed", deps);
774 throw err;
34e63b9Claude775 } finally {
776 // Cleanup worktree
777 await spawnGit(["worktree", "remove", "--force", tmpDir], repoDir).catch(
778 () => {}
779 );
780 await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
781 }
782
783 return { branchName };
784}