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