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

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.tsBlame587 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.
411 */
412export async function repairGateFailure(
413 owner: string,
414 repo: string,
415 branch: string,
416 gateName: string,
417 failureDetails: string,
418 context: { file: string; content: string }[]
419): Promise<RepairResult> {
b0a3ba2Claude420 // ── Tier 1: try a mechanical repair first (no AI call needed)
421 // Lockfile drift, formatting drift, import-order issues — these have
422 // deterministic fixes. If one matches we save the cost + latency of a
423 // Sonnet round-trip AND get a more reliable patch.
424 const mech = await tryMechanicalRepair(owner, repo, branch, failureDetails);
425 if (mech.attempted && mech.success && mech.commitSha) {
e6bad81Claude426 // Record the win in the flywheel — every successful repair grows the
427 // dataset that makes future repairs faster and cheaper. Fire-and-forget;
428 // recording failures must never block the actual fix from being returned.
429 void recordRepair({
430 repositoryId: null,
431 failureText: failureDetails,
432 classification: classifyMechanical(mech.summary),
433 tier: "mechanical",
434 patchSummary: mech.summary,
435 filesChanged: mech.filesChanged,
436 commitSha: mech.commitSha,
437 outcome: "success",
a28cedeClaude438 }).catch((err) => {
439 console.warn(
440 "[auto-repair] mechanical-tier audit-log insert failed:",
441 err instanceof Error ? err.message : err
442 );
443 });
b0a3ba2Claude444 return {
445 attempted: true,
446 success: true,
447 commitSha: mech.commitSha,
448 filesChanged: mech.filesChanged,
449 summary: `[mechanical] ${mech.summary}`,
450 };
451 }
e6bad81Claude452 if (mech.attempted && !mech.success) {
453 // Mechanical handler triggered but didn't fix it. Log the miss so we can
454 // see classifier false-positives in the admin dashboard.
455 void recordRepair({
456 repositoryId: null,
457 failureText: failureDetails,
458 classification: classifyMechanical(mech.summary),
459 tier: "mechanical",
460 patchSummary: mech.summary,
461 filesChanged: mech.filesChanged,
462 commitSha: null,
463 outcome: "failed",
a28cedeClaude464 }).catch((err) => {
465 console.warn(
466 "[auto-repair] mechanical-tier failure-audit insert failed:",
467 err instanceof Error ? err.message : err
468 );
469 });
e6bad81Claude470 }
b0a3ba2Claude471
472 // ── Tier 2: AI-powered repair via Claude Sonnet
3ef4c9dClaude473 if (!isAiAvailable()) {
474 return { attempted: false, success: false, filesChanged: [], summary: "AI not configured" };
475 }
476 if (context.length === 0) {
477 return { attempted: false, success: false, filesChanged: [], summary: "no files to analyse" };
478 }
479
480 const repoDir = getRepoPath(owner, repo);
481 const wt = await createWorktree(repoDir, branch);
482 if (!wt.ok) {
483 return { attempted: true, success: false, filesChanged: [], summary: "worktree failed", error: wt.error };
484 }
485
486 try {
487 const client = getAnthropic();
488 const contextBlob = context
489 .map((f) => `FILE: ${f.file}\n---\n${f.content.slice(0, 8000)}\n---\n`)
490 .join("\n");
491
492 const message = await client.messages.create({
493 model: MODEL_SONNET,
494 max_tokens: 8192,
495 messages: [
496 {
497 role: "user",
498 content: `A gate named "${gateName}" failed on repository ${owner}/${repo} (branch ${branch}).
499
500Failure details:
501${failureDetails}
502
503Relevant files:
504${contextBlob}
505
506Produce a minimal JSON patch set that fixes the failure. Respond ONLY with JSON:
507{
508 "patches": [
509 { "path": "relative/path.ts", "content": "FULL new file content", "reason": "..." }
510 ],
511 "summary": "One sentence describing the fix"
512}
513
514If you cannot safely fix the failure, respond with { "patches": [], "summary": "Unable to auto-fix: ..." }.`,
515 },
516 ],
517 });
518 const text = extractText(message);
519 const parsed = parseJsonResponse<{ patches: Patch[]; summary: string }>(text);
520 if (!parsed || !Array.isArray(parsed.patches) || parsed.patches.length === 0) {
521 return {
522 attempted: true,
523 success: false,
524 filesChanged: [],
525 summary: parsed?.summary || "AI produced no patches",
526 };
527 }
528
529 const msg = `fix(${gateName.toLowerCase().replace(/\s+/g, "-")}): auto-repair gate failure
530
531${parsed.summary}
532
533${parsed.patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
534
535[auto-repair by GlueCron AI]`;
536
537 const result = await applyAndCommit(repoDir, wt.path, branch, parsed.patches, msg);
538 if (!result.ok) {
e6bad81Claude539 void recordRepair({
540 repositoryId: null,
541 failureText: failureDetails,
542 classification: null,
543 tier: "ai-sonnet",
544 patchSummary: parsed.summary,
545 filesChanged: result.filesChanged,
546 commitSha: null,
547 outcome: "failed",
a28cedeClaude548 }).catch((err) => {
549 console.warn(
550 "[auto-repair] ai-tier failure-audit insert failed:",
551 err instanceof Error ? err.message : err
552 );
553 });
3ef4c9dClaude554 return {
555 attempted: true,
556 success: false,
557 filesChanged: result.filesChanged,
558 summary: "commit failed",
559 error: result.error,
560 };
561 }
e6bad81Claude562 void recordRepair({
563 repositoryId: null,
564 failureText: failureDetails,
565 classification: null,
566 tier: "ai-sonnet",
567 patchSummary: parsed.summary,
568 filesChanged: result.filesChanged,
2316901Claude569 commitSha: result.sha ?? null,
e6bad81Claude570 outcome: "success",
a28cedeClaude571 }).catch((err) => {
572 console.warn(
573 "[auto-repair] ai-tier success-audit insert failed:",
574 err instanceof Error ? err.message : err
575 );
576 });
3ef4c9dClaude577 return {
578 attempted: true,
579 success: true,
580 commitSha: result.sha,
581 filesChanged: result.filesChanged,
582 summary: parsed.summary,
583 };
584 } finally {
585 await cleanupWorktree(repoDir, wt.path);
586 }
587}