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

auto-repair.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.

auto-repair.tsBlame596 lines · 2 contributors
3ef4c9dClaude1/**
2 * AI-powered auto-repair engine.
3 *
4 * When a gate fails, this engine attempts to automatically fix the problem
5 * and push the fix back to the branch. Covers:
6 * - Failing tests → analyse + patch source
7 * - Type errors → fix type signatures
8 * - Lint errors → apply fixes or reformat
9 * - Secret leaks → redact secret, add to .gitignore, force-push fix
10 * - Security issues → patch vulnerable code
11 *
12 * Works in a temporary worktree so the bare repo is never corrupted.
13 * All repair commits are authored by "GlueCron AI" and recorded in gate_runs.
14 */
15
16import { spawn } from "bun";
17import { mkdir, rm, readFile, writeFile } from "fs/promises";
18import { join } from "path";
19import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
20import { getRepoPath } from "../git/repository";
b0a3ba2Claude21import { tryMechanicalRepair } from "./auto-repair-mechanical";
e6bad81Claude22import { recordRepair } from "./repair-flywheel";
3ef4c9dClaude23import type { SecurityFinding, SecretFinding } from "./security-scan";
24
e6bad81Claude25// Best-effort classification of a mechanical repair summary so the flywheel
26// stats can group by failure type. The mechanical tier writes summaries like
27// "regenerated bun.lock" / "reformatted N file(s) with biome" / "organised
28// imports in N file(s)" — this turns those into a one-word tag.
29function classifyMechanical(summary: string): string | null {
30 const s = summary.toLowerCase();
31 if (s.includes("lockfile") || s.includes("regenerated") && s.includes(".lock")) return "lockfile";
32 if (s.includes("reformatted") || s.includes("formatted")) return "formatting";
33 if (s.includes("imports")) return "imports";
34 return null;
35}
36
3ef4c9dClaude37export interface RepairResult {
38 attempted: boolean;
39 success: boolean;
40 commitSha?: string;
41 filesChanged: string[];
42 summary: string;
43 error?: string;
44}
45
46async function exec(
47 cmd: string[],
48 opts?: { cwd?: string; env?: Record<string, string> }
49): Promise<{ stdout: string; stderr: string; exitCode: number }> {
50 const proc = spawn(cmd, {
51 cwd: opts?.cwd,
52 env: { ...process.env, ...opts?.env },
53 stdout: "pipe",
54 stderr: "pipe",
55 });
56 const [stdout, stderr] = await Promise.all([
57 new Response(proc.stdout).text(),
58 new Response(proc.stderr).text(),
59 ]);
60 const exitCode = await proc.exited;
61 return { stdout, stderr, exitCode };
62}
63
64const AUTHOR_ENV = {
65 GIT_AUTHOR_NAME: "GlueCron AI",
66 GIT_AUTHOR_EMAIL: "ai@gluecron.com",
67 GIT_COMMITTER_NAME: "GlueCron AI",
68 GIT_COMMITTER_EMAIL: "ai@gluecron.com",
69};
70
71interface Patch {
72 path: string;
73 /** Full replacement content. Preferred for simplicity + correctness. */
74 content: string;
75 /** Short rationale for the change — included in the commit message. */
76 reason: string;
77}
78
79/**
80 * Create a disposable worktree at the given branch head.
81 * Returns the worktree path; caller MUST call cleanupWorktree when done.
82 */
83async function createWorktree(
84 repoDir: string,
85 branch: string
86): Promise<{ path: string; ok: boolean; error?: string }> {
2c3ba6ecopilot-swe-agent[bot]87 const path = join(repoDir, `_repair_${Date.now()}_${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`);
3ef4c9dClaude88 const res = await exec(["git", "worktree", "add", path, branch], { cwd: repoDir });
89 if (res.exitCode !== 0) {
90 return { path, ok: false, error: res.stderr };
91 }
92 return { path, ok: true };
93}
94
95async function cleanupWorktree(repoDir: string, worktree: string): Promise<void> {
a28cedeClaude96 // Cleanup failures are non-fatal but should be visible — stale worktrees
97 // eat disk space and `git worktree prune` won't recover them if the
98 // directory itself is left behind.
3ef4c9dClaude99 await exec(["git", "worktree", "remove", "--force", worktree], {
100 cwd: repoDir,
a28cedeClaude101 }).catch((err) => {
102 console.warn(
103 `[auto-repair] git worktree remove failed for ${worktree}:`,
104 err instanceof Error ? err.message : err
105 );
106 });
107 await rm(worktree, { recursive: true, force: true }).catch((err) => {
108 console.warn(
109 `[auto-repair] rm worktree failed for ${worktree}:`,
110 err instanceof Error ? err.message : err
111 );
112 });
3ef4c9dClaude113}
114
115/**
116 * Apply patches to a worktree, commit, and update the branch ref in the bare repo.
117 */
118async function applyAndCommit(
119 repoDir: string,
120 worktree: string,
121 branch: string,
122 patches: Patch[],
123 commitMessage: string
124): Promise<{ ok: boolean; sha?: string; error?: string; filesChanged: string[] }> {
125 const filesChanged: string[] = [];
126 for (const patch of patches) {
127 const fullPath = join(worktree, patch.path);
128 try {
a28cedeClaude129 await mkdir(join(fullPath, "..").replace(/[^/]+\/\.\.$/, ""), { recursive: true }).catch((err) => {
130 console.warn(
131 `[auto-repair] mkdir parent failed for ${patch.path}:`,
132 err instanceof Error ? err.message : err
133 );
134 });
3ef4c9dClaude135 await writeFile(fullPath, patch.content, "utf8");
136 filesChanged.push(patch.path);
137 } catch (err) {
138 console.error(`[auto-repair] Failed to write ${patch.path}:`, err);
139 }
140 }
141 if (filesChanged.length === 0) {
142 return { ok: false, error: "No patches applied", filesChanged: [] };
143 }
144
145 const add = await exec(["git", "add", "-A"], { cwd: worktree });
146 if (add.exitCode !== 0) {
147 return { ok: false, error: `git add: ${add.stderr}`, filesChanged };
148 }
149
150 const commit = await exec(
151 ["git", "commit", "-m", commitMessage],
152 { cwd: worktree, env: AUTHOR_ENV }
153 );
154 if (commit.exitCode !== 0) {
155 return { ok: false, error: `git commit: ${commit.stderr}`, filesChanged };
156 }
157
158 const { stdout: sha } = await exec(["git", "rev-parse", "HEAD"], { cwd: worktree });
159
160 // Push the new commit to the branch ref in the bare repo
161 const push = await exec(
162 ["git", "push", "origin", `HEAD:refs/heads/${branch}`],
163 { cwd: worktree }
164 );
165 if (push.exitCode !== 0) {
166 // Fall back to update-ref on bare repo if "origin" isn't the bare repo
167 const upd = await exec(
168 ["git", "update-ref", `refs/heads/${branch}`, sha.trim()],
169 { cwd: repoDir }
170 );
171 if (upd.exitCode !== 0) {
172 return { ok: false, error: `update-ref: ${upd.stderr}`, filesChanged };
173 }
174 }
175
176 return { ok: true, sha: sha.trim(), filesChanged };
177}
178
179/**
180 * Repair secret leaks by redacting the matching lines.
181 * This is a defensive baseline — the secret itself must be rotated manually
182 * because git history already contains it, but removing it from HEAD prevents
183 * further exposure.
184 */
185export async function repairSecrets(
186 owner: string,
187 repo: string,
188 branch: string,
189 findings: SecretFinding[]
190): Promise<RepairResult> {
191 if (findings.length === 0) {
192 return { attempted: false, success: false, filesChanged: [], summary: "no findings" };
193 }
194 const repoDir = getRepoPath(owner, repo);
195 const wt = await createWorktree(repoDir, branch);
196 if (!wt.ok) {
197 return {
198 attempted: true,
199 success: false,
200 filesChanged: [],
201 summary: "could not create worktree",
202 error: wt.error,
203 };
204 }
205
206 try {
207 // Group findings by file
208 const byFile = new Map<string, SecretFinding[]>();
209 for (const f of findings) {
210 if (!byFile.has(f.file)) byFile.set(f.file, []);
211 byFile.get(f.file)!.push(f);
212 }
213
214 const patches: Patch[] = [];
215 for (const [file, fileFindings] of byFile) {
216 try {
217 const content = await readFile(join(wt.path, file), "utf8");
218 const lines = content.split("\n");
219 const badLines = new Set(fileFindings.map((f) => f.line - 1));
220 for (const idx of badLines) {
221 if (idx >= 0 && idx < lines.length) {
222 // Redact everything that looks like a value after = or :
223 lines[idx] = lines[idx].replace(
224 /(['"])[A-Za-z0-9_\-/+=\.]{20,}(['"])/g,
225 '$1REDACTED_BY_GLUECRON$2'
226 );
227 // If the whole line IS the secret (PEM), comment it out
228 if (lines[idx].includes("BEGIN") && lines[idx].includes("PRIVATE KEY")) {
229 lines[idx] = `// ${lines[idx]} // REDACTED_BY_GLUECRON`;
230 }
231 }
232 }
233 patches.push({
234 path: file,
235 content: lines.join("\n"),
236 reason: `Redact ${fileFindings.length} secret${fileFindings.length === 1 ? "" : "s"}`,
237 });
238 } catch (err) {
239 console.error(`[auto-repair] Could not read ${file}:`, err);
240 }
241 }
242
243 if (patches.length === 0) {
244 return {
245 attempted: true,
246 success: false,
247 filesChanged: [],
248 summary: "no files to patch",
249 };
250 }
251
252 const msg = `fix(security): auto-redact leaked secrets
253
254Redacted ${findings.length} secret finding${findings.length === 1 ? "" : "s"} in ${patches.length} file${patches.length === 1 ? "" : "s"}.
255
256ACTION REQUIRED: these credentials must be rotated — they remain visible in git history.
257
258[auto-repair by GlueCron AI]`;
259
260 const result = await applyAndCommit(repoDir, wt.path, branch, patches, msg);
261 if (!result.ok) {
262 return {
263 attempted: true,
264 success: false,
265 filesChanged: result.filesChanged,
266 summary: "commit failed",
267 error: result.error,
268 };
269 }
270 return {
271 attempted: true,
272 success: true,
273 commitSha: result.sha,
274 filesChanged: result.filesChanged,
275 summary: `Redacted ${findings.length} secret${findings.length === 1 ? "" : "s"} across ${patches.length} file${patches.length === 1 ? "" : "s"}`,
276 };
277 } finally {
278 await cleanupWorktree(repoDir, wt.path);
279 }
280}
281
282/**
283 * Use Claude to repair a set of security findings by rewriting affected files.
284 */
285export async function repairSecurityIssues(
286 owner: string,
287 repo: string,
288 branch: string,
289 findings: SecurityFinding[]
290): Promise<RepairResult> {
291 if (findings.length === 0) {
292 return { attempted: false, success: false, filesChanged: [], summary: "no findings" };
293 }
294 if (!isAiAvailable()) {
295 return {
296 attempted: false,
297 success: false,
298 filesChanged: [],
299 summary: "AI not configured",
300 };
301 }
302
303 const repoDir = getRepoPath(owner, repo);
304 const wt = await createWorktree(repoDir, branch);
305 if (!wt.ok) {
306 return {
307 attempted: true,
308 success: false,
309 filesChanged: [],
310 summary: "could not create worktree",
311 error: wt.error,
312 };
313 }
314
315 try {
316 // Group by file, read + patch each
317 const byFile = new Map<string, SecurityFinding[]>();
318 for (const f of findings) {
319 if (!byFile.has(f.file)) byFile.set(f.file, []);
320 byFile.get(f.file)!.push(f);
321 }
322
323 const client = getAnthropic();
324 const patches: Patch[] = [];
325
326 for (const [file, fileFindings] of byFile) {
327 let original: string;
328 try {
329 original = await readFile(join(wt.path, file), "utf8");
330 } catch {
331 continue;
332 }
333 const findingsText = fileFindings
334 .map(
335 (f, i) =>
336 `${i + 1}. [${f.severity}] ${f.type}${f.line ? ` on line ${f.line}` : ""}: ${f.description}${f.suggestion ? `\n Suggestion: ${f.suggestion}` : ""}`
337 )
338 .join("\n");
339
340 const message = await client.messages.create({
341 model: MODEL_SONNET,
342 max_tokens: 8192,
343 messages: [
344 {
345 role: "user",
346 content: `You are a secure-coding assistant. A security scan flagged the following issues in "${file}":
347
348${findingsText}
349
350Rewrite the file to fix ALL flagged issues while preserving existing behaviour. Rules:
351- Output ONLY the full corrected file content. No prose, no code fences.
352- Do not add feature changes unrelated to the findings.
353- Keep imports / exports intact.
354- If an issue genuinely can't be fixed without breaking behaviour, return the file unchanged.
355
356Current file:
357${original}`,
358 },
359 ],
360 });
361 const fixed = extractText(message).replace(/^```[\w]*\n?/, "").replace(/\n?```$/, "");
362 if (fixed && fixed !== original && fixed.length > 10) {
363 patches.push({
364 path: file,
365 content: fixed,
366 reason: `Fix ${fileFindings.length} security finding${fileFindings.length === 1 ? "" : "s"}`,
367 });
368 }
369 }
370
371 if (patches.length === 0) {
372 return {
373 attempted: true,
374 success: false,
375 filesChanged: [],
376 summary: "AI produced no patches",
377 };
378 }
379
380 const msg = `fix(security): auto-repair flagged issues
381
382${patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
383
384[auto-repair by GlueCron AI]`;
385
386 const result = await applyAndCommit(repoDir, wt.path, branch, patches, msg);
387 if (!result.ok) {
388 return {
389 attempted: true,
390 success: false,
391 filesChanged: result.filesChanged,
392 summary: "commit failed",
393 error: result.error,
394 };
395 }
396 return {
397 attempted: true,
398 success: true,
399 commitSha: result.sha,
400 filesChanged: result.filesChanged,
401 summary: `Repaired ${findings.length} security issue${findings.length === 1 ? "" : "s"} in ${patches.length} file${patches.length === 1 ? "" : "s"}`,
402 };
403 } finally {
404 await cleanupWorktree(repoDir, wt.path);
405 }
406}
407
408/**
409 * Given a GateTest failure summary, ask Claude to produce a patch set
410 * that should make the failing check pass.
bc519aeClaude411 *
412 * NOTE (2026-06-12, BUILD_BIBLE §7 finding 1): the LIVE gate/CI repair path
413 * is `triggerCiAutofix()` in src/lib/ci-autofix.ts, which consults the
414 * repair-flywheel Tier-0 cache (`findCachedRepair`) before any AI call and
415 * settles outcomes via `updateOutcome()`. This function currently has no
416 * route/hook callers but is retained per the do-not-undo rule (§4.4 locked):
417 * it remains the worktree-backed Tier-1→2 orchestrator should a direct
418 * commit-back repair path be wired again. Do not delete; do not dedupe with
419 * src/lib/autorepair.ts (both are load-bearing per the Bible).
3ef4c9dClaude420 */
421export async function repairGateFailure(
422 owner: string,
423 repo: string,
424 branch: string,
425 gateName: string,
426 failureDetails: string,
427 context: { file: string; content: string }[]
428): Promise<RepairResult> {
b0a3ba2Claude429 // ── Tier 1: try a mechanical repair first (no AI call needed)
430 // Lockfile drift, formatting drift, import-order issues — these have
431 // deterministic fixes. If one matches we save the cost + latency of a
432 // Sonnet round-trip AND get a more reliable patch.
433 const mech = await tryMechanicalRepair(owner, repo, branch, failureDetails);
434 if (mech.attempted && mech.success && mech.commitSha) {
e6bad81Claude435 // Record the win in the flywheel — every successful repair grows the
436 // dataset that makes future repairs faster and cheaper. Fire-and-forget;
437 // recording failures must never block the actual fix from being returned.
438 void recordRepair({
439 repositoryId: null,
440 failureText: failureDetails,
441 classification: classifyMechanical(mech.summary),
442 tier: "mechanical",
443 patchSummary: mech.summary,
444 filesChanged: mech.filesChanged,
445 commitSha: mech.commitSha,
446 outcome: "success",
a28cedeClaude447 }).catch((err) => {
448 console.warn(
449 "[auto-repair] mechanical-tier audit-log insert failed:",
450 err instanceof Error ? err.message : err
451 );
452 });
b0a3ba2Claude453 return {
454 attempted: true,
455 success: true,
456 commitSha: mech.commitSha,
457 filesChanged: mech.filesChanged,
458 summary: `[mechanical] ${mech.summary}`,
459 };
460 }
e6bad81Claude461 if (mech.attempted && !mech.success) {
462 // Mechanical handler triggered but didn't fix it. Log the miss so we can
463 // see classifier false-positives in the admin dashboard.
464 void recordRepair({
465 repositoryId: null,
466 failureText: failureDetails,
467 classification: classifyMechanical(mech.summary),
468 tier: "mechanical",
469 patchSummary: mech.summary,
470 filesChanged: mech.filesChanged,
471 commitSha: null,
472 outcome: "failed",
a28cedeClaude473 }).catch((err) => {
474 console.warn(
475 "[auto-repair] mechanical-tier failure-audit insert failed:",
476 err instanceof Error ? err.message : err
477 );
478 });
e6bad81Claude479 }
b0a3ba2Claude480
481 // ── Tier 2: AI-powered repair via Claude Sonnet
3ef4c9dClaude482 if (!isAiAvailable()) {
483 return { attempted: false, success: false, filesChanged: [], summary: "AI not configured" };
484 }
485 if (context.length === 0) {
486 return { attempted: false, success: false, filesChanged: [], summary: "no files to analyse" };
487 }
488
489 const repoDir = getRepoPath(owner, repo);
490 const wt = await createWorktree(repoDir, branch);
491 if (!wt.ok) {
492 return { attempted: true, success: false, filesChanged: [], summary: "worktree failed", error: wt.error };
493 }
494
495 try {
496 const client = getAnthropic();
497 const contextBlob = context
498 .map((f) => `FILE: ${f.file}\n---\n${f.content.slice(0, 8000)}\n---\n`)
499 .join("\n");
500
501 const message = await client.messages.create({
502 model: MODEL_SONNET,
503 max_tokens: 8192,
504 messages: [
505 {
506 role: "user",
507 content: `A gate named "${gateName}" failed on repository ${owner}/${repo} (branch ${branch}).
508
509Failure details:
510${failureDetails}
511
512Relevant files:
513${contextBlob}
514
515Produce a minimal JSON patch set that fixes the failure. Respond ONLY with JSON:
516{
517 "patches": [
518 { "path": "relative/path.ts", "content": "FULL new file content", "reason": "..." }
519 ],
520 "summary": "One sentence describing the fix"
521}
522
523If you cannot safely fix the failure, respond with { "patches": [], "summary": "Unable to auto-fix: ..." }.`,
524 },
525 ],
526 });
527 const text = extractText(message);
528 const parsed = parseJsonResponse<{ patches: Patch[]; summary: string }>(text);
529 if (!parsed || !Array.isArray(parsed.patches) || parsed.patches.length === 0) {
530 return {
531 attempted: true,
532 success: false,
533 filesChanged: [],
534 summary: parsed?.summary || "AI produced no patches",
535 };
536 }
537
538 const msg = `fix(${gateName.toLowerCase().replace(/\s+/g, "-")}): auto-repair gate failure
539
540${parsed.summary}
541
542${parsed.patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
543
544[auto-repair by GlueCron AI]`;
545
546 const result = await applyAndCommit(repoDir, wt.path, branch, parsed.patches, msg);
547 if (!result.ok) {
e6bad81Claude548 void recordRepair({
549 repositoryId: null,
550 failureText: failureDetails,
551 classification: null,
552 tier: "ai-sonnet",
553 patchSummary: parsed.summary,
554 filesChanged: result.filesChanged,
555 commitSha: null,
556 outcome: "failed",
a28cedeClaude557 }).catch((err) => {
558 console.warn(
559 "[auto-repair] ai-tier failure-audit insert failed:",
560 err instanceof Error ? err.message : err
561 );
562 });
3ef4c9dClaude563 return {
564 attempted: true,
565 success: false,
566 filesChanged: result.filesChanged,
567 summary: "commit failed",
568 error: result.error,
569 };
570 }
e6bad81Claude571 void recordRepair({
572 repositoryId: null,
573 failureText: failureDetails,
574 classification: null,
575 tier: "ai-sonnet",
576 patchSummary: parsed.summary,
577 filesChanged: result.filesChanged,
2316901Claude578 commitSha: result.sha ?? null,
e6bad81Claude579 outcome: "success",
a28cedeClaude580 }).catch((err) => {
581 console.warn(
582 "[auto-repair] ai-tier success-audit insert failed:",
583 err instanceof Error ? err.message : err
584 );
585 });
3ef4c9dClaude586 return {
587 attempted: true,
588 success: true,
589 commitSha: result.sha,
590 filesChanged: result.filesChanged,
591 summary: parsed.summary,
592 };
593 } finally {
594 await cleanupWorktree(repoDir, wt.path);
595 }
596}