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.tsBlame549 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> {
96 await exec(["git", "worktree", "remove", "--force", worktree], {
97 cwd: repoDir,
98 }).catch(() => {});
99 await rm(worktree, { recursive: true, force: true }).catch(() => {});
100}
101
102/**
103 * Apply patches to a worktree, commit, and update the branch ref in the bare repo.
104 */
105async function applyAndCommit(
106 repoDir: string,
107 worktree: string,
108 branch: string,
109 patches: Patch[],
110 commitMessage: string
111): Promise<{ ok: boolean; sha?: string; error?: string; filesChanged: string[] }> {
112 const filesChanged: string[] = [];
113 for (const patch of patches) {
114 const fullPath = join(worktree, patch.path);
115 try {
116 await mkdir(join(fullPath, "..").replace(/[^/]+\/\.\.$/, ""), { recursive: true }).catch(() => {});
117 await writeFile(fullPath, patch.content, "utf8");
118 filesChanged.push(patch.path);
119 } catch (err) {
120 console.error(`[auto-repair] Failed to write ${patch.path}:`, err);
121 }
122 }
123 if (filesChanged.length === 0) {
124 return { ok: false, error: "No patches applied", filesChanged: [] };
125 }
126
127 const add = await exec(["git", "add", "-A"], { cwd: worktree });
128 if (add.exitCode !== 0) {
129 return { ok: false, error: `git add: ${add.stderr}`, filesChanged };
130 }
131
132 const commit = await exec(
133 ["git", "commit", "-m", commitMessage],
134 { cwd: worktree, env: AUTHOR_ENV }
135 );
136 if (commit.exitCode !== 0) {
137 return { ok: false, error: `git commit: ${commit.stderr}`, filesChanged };
138 }
139
140 const { stdout: sha } = await exec(["git", "rev-parse", "HEAD"], { cwd: worktree });
141
142 // Push the new commit to the branch ref in the bare repo
143 const push = await exec(
144 ["git", "push", "origin", `HEAD:refs/heads/${branch}`],
145 { cwd: worktree }
146 );
147 if (push.exitCode !== 0) {
148 // Fall back to update-ref on bare repo if "origin" isn't the bare repo
149 const upd = await exec(
150 ["git", "update-ref", `refs/heads/${branch}`, sha.trim()],
151 { cwd: repoDir }
152 );
153 if (upd.exitCode !== 0) {
154 return { ok: false, error: `update-ref: ${upd.stderr}`, filesChanged };
155 }
156 }
157
158 return { ok: true, sha: sha.trim(), filesChanged };
159}
160
161/**
162 * Repair secret leaks by redacting the matching lines.
163 * This is a defensive baseline — the secret itself must be rotated manually
164 * because git history already contains it, but removing it from HEAD prevents
165 * further exposure.
166 */
167export async function repairSecrets(
168 owner: string,
169 repo: string,
170 branch: string,
171 findings: SecretFinding[]
172): Promise<RepairResult> {
173 if (findings.length === 0) {
174 return { attempted: false, success: false, filesChanged: [], summary: "no findings" };
175 }
176 const repoDir = getRepoPath(owner, repo);
177 const wt = await createWorktree(repoDir, branch);
178 if (!wt.ok) {
179 return {
180 attempted: true,
181 success: false,
182 filesChanged: [],
183 summary: "could not create worktree",
184 error: wt.error,
185 };
186 }
187
188 try {
189 // Group findings by file
190 const byFile = new Map<string, SecretFinding[]>();
191 for (const f of findings) {
192 if (!byFile.has(f.file)) byFile.set(f.file, []);
193 byFile.get(f.file)!.push(f);
194 }
195
196 const patches: Patch[] = [];
197 for (const [file, fileFindings] of byFile) {
198 try {
199 const content = await readFile(join(wt.path, file), "utf8");
200 const lines = content.split("\n");
201 const badLines = new Set(fileFindings.map((f) => f.line - 1));
202 for (const idx of badLines) {
203 if (idx >= 0 && idx < lines.length) {
204 // Redact everything that looks like a value after = or :
205 lines[idx] = lines[idx].replace(
206 /(['"])[A-Za-z0-9_\-/+=\.]{20,}(['"])/g,
207 '$1REDACTED_BY_GLUECRON$2'
208 );
209 // If the whole line IS the secret (PEM), comment it out
210 if (lines[idx].includes("BEGIN") && lines[idx].includes("PRIVATE KEY")) {
211 lines[idx] = `// ${lines[idx]} // REDACTED_BY_GLUECRON`;
212 }
213 }
214 }
215 patches.push({
216 path: file,
217 content: lines.join("\n"),
218 reason: `Redact ${fileFindings.length} secret${fileFindings.length === 1 ? "" : "s"}`,
219 });
220 } catch (err) {
221 console.error(`[auto-repair] Could not read ${file}:`, err);
222 }
223 }
224
225 if (patches.length === 0) {
226 return {
227 attempted: true,
228 success: false,
229 filesChanged: [],
230 summary: "no files to patch",
231 };
232 }
233
234 const msg = `fix(security): auto-redact leaked secrets
235
236Redacted ${findings.length} secret finding${findings.length === 1 ? "" : "s"} in ${patches.length} file${patches.length === 1 ? "" : "s"}.
237
238ACTION REQUIRED: these credentials must be rotated — they remain visible in git history.
239
240[auto-repair by GlueCron AI]`;
241
242 const result = await applyAndCommit(repoDir, wt.path, branch, patches, msg);
243 if (!result.ok) {
244 return {
245 attempted: true,
246 success: false,
247 filesChanged: result.filesChanged,
248 summary: "commit failed",
249 error: result.error,
250 };
251 }
252 return {
253 attempted: true,
254 success: true,
255 commitSha: result.sha,
256 filesChanged: result.filesChanged,
257 summary: `Redacted ${findings.length} secret${findings.length === 1 ? "" : "s"} across ${patches.length} file${patches.length === 1 ? "" : "s"}`,
258 };
259 } finally {
260 await cleanupWorktree(repoDir, wt.path);
261 }
262}
263
264/**
265 * Use Claude to repair a set of security findings by rewriting affected files.
266 */
267export async function repairSecurityIssues(
268 owner: string,
269 repo: string,
270 branch: string,
271 findings: SecurityFinding[]
272): Promise<RepairResult> {
273 if (findings.length === 0) {
274 return { attempted: false, success: false, filesChanged: [], summary: "no findings" };
275 }
276 if (!isAiAvailable()) {
277 return {
278 attempted: false,
279 success: false,
280 filesChanged: [],
281 summary: "AI not configured",
282 };
283 }
284
285 const repoDir = getRepoPath(owner, repo);
286 const wt = await createWorktree(repoDir, branch);
287 if (!wt.ok) {
288 return {
289 attempted: true,
290 success: false,
291 filesChanged: [],
292 summary: "could not create worktree",
293 error: wt.error,
294 };
295 }
296
297 try {
298 // Group by file, read + patch each
299 const byFile = new Map<string, SecurityFinding[]>();
300 for (const f of findings) {
301 if (!byFile.has(f.file)) byFile.set(f.file, []);
302 byFile.get(f.file)!.push(f);
303 }
304
305 const client = getAnthropic();
306 const patches: Patch[] = [];
307
308 for (const [file, fileFindings] of byFile) {
309 let original: string;
310 try {
311 original = await readFile(join(wt.path, file), "utf8");
312 } catch {
313 continue;
314 }
315 const findingsText = fileFindings
316 .map(
317 (f, i) =>
318 `${i + 1}. [${f.severity}] ${f.type}${f.line ? ` on line ${f.line}` : ""}: ${f.description}${f.suggestion ? `\n Suggestion: ${f.suggestion}` : ""}`
319 )
320 .join("\n");
321
322 const message = await client.messages.create({
323 model: MODEL_SONNET,
324 max_tokens: 8192,
325 messages: [
326 {
327 role: "user",
328 content: `You are a secure-coding assistant. A security scan flagged the following issues in "${file}":
329
330${findingsText}
331
332Rewrite the file to fix ALL flagged issues while preserving existing behaviour. Rules:
333- Output ONLY the full corrected file content. No prose, no code fences.
334- Do not add feature changes unrelated to the findings.
335- Keep imports / exports intact.
336- If an issue genuinely can't be fixed without breaking behaviour, return the file unchanged.
337
338Current file:
339${original}`,
340 },
341 ],
342 });
343 const fixed = extractText(message).replace(/^```[\w]*\n?/, "").replace(/\n?```$/, "");
344 if (fixed && fixed !== original && fixed.length > 10) {
345 patches.push({
346 path: file,
347 content: fixed,
348 reason: `Fix ${fileFindings.length} security finding${fileFindings.length === 1 ? "" : "s"}`,
349 });
350 }
351 }
352
353 if (patches.length === 0) {
354 return {
355 attempted: true,
356 success: false,
357 filesChanged: [],
358 summary: "AI produced no patches",
359 };
360 }
361
362 const msg = `fix(security): auto-repair flagged issues
363
364${patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
365
366[auto-repair by GlueCron AI]`;
367
368 const result = await applyAndCommit(repoDir, wt.path, branch, patches, msg);
369 if (!result.ok) {
370 return {
371 attempted: true,
372 success: false,
373 filesChanged: result.filesChanged,
374 summary: "commit failed",
375 error: result.error,
376 };
377 }
378 return {
379 attempted: true,
380 success: true,
381 commitSha: result.sha,
382 filesChanged: result.filesChanged,
383 summary: `Repaired ${findings.length} security issue${findings.length === 1 ? "" : "s"} in ${patches.length} file${patches.length === 1 ? "" : "s"}`,
384 };
385 } finally {
386 await cleanupWorktree(repoDir, wt.path);
387 }
388}
389
390/**
391 * Given a GateTest failure summary, ask Claude to produce a patch set
392 * that should make the failing check pass.
393 */
394export async function repairGateFailure(
395 owner: string,
396 repo: string,
397 branch: string,
398 gateName: string,
399 failureDetails: string,
400 context: { file: string; content: string }[]
401): Promise<RepairResult> {
b0a3ba2Claude402 // ── Tier 1: try a mechanical repair first (no AI call needed)
403 // Lockfile drift, formatting drift, import-order issues — these have
404 // deterministic fixes. If one matches we save the cost + latency of a
405 // Sonnet round-trip AND get a more reliable patch.
406 const mech = await tryMechanicalRepair(owner, repo, branch, failureDetails);
407 if (mech.attempted && mech.success && mech.commitSha) {
e6bad81Claude408 // Record the win in the flywheel — every successful repair grows the
409 // dataset that makes future repairs faster and cheaper. Fire-and-forget;
410 // recording failures must never block the actual fix from being returned.
411 void recordRepair({
412 repositoryId: null,
413 failureText: failureDetails,
414 classification: classifyMechanical(mech.summary),
415 tier: "mechanical",
416 patchSummary: mech.summary,
417 filesChanged: mech.filesChanged,
418 commitSha: mech.commitSha,
419 outcome: "success",
420 }).catch(() => {});
b0a3ba2Claude421 return {
422 attempted: true,
423 success: true,
424 commitSha: mech.commitSha,
425 filesChanged: mech.filesChanged,
426 summary: `[mechanical] ${mech.summary}`,
427 };
428 }
e6bad81Claude429 if (mech.attempted && !mech.success) {
430 // Mechanical handler triggered but didn't fix it. Log the miss so we can
431 // see classifier false-positives in the admin dashboard.
432 void recordRepair({
433 repositoryId: null,
434 failureText: failureDetails,
435 classification: classifyMechanical(mech.summary),
436 tier: "mechanical",
437 patchSummary: mech.summary,
438 filesChanged: mech.filesChanged,
439 commitSha: null,
440 outcome: "failed",
441 }).catch(() => {});
442 }
b0a3ba2Claude443
444 // ── Tier 2: AI-powered repair via Claude Sonnet
3ef4c9dClaude445 if (!isAiAvailable()) {
446 return { attempted: false, success: false, filesChanged: [], summary: "AI not configured" };
447 }
448 if (context.length === 0) {
449 return { attempted: false, success: false, filesChanged: [], summary: "no files to analyse" };
450 }
451
452 const repoDir = getRepoPath(owner, repo);
453 const wt = await createWorktree(repoDir, branch);
454 if (!wt.ok) {
455 return { attempted: true, success: false, filesChanged: [], summary: "worktree failed", error: wt.error };
456 }
457
458 try {
459 const client = getAnthropic();
460 const contextBlob = context
461 .map((f) => `FILE: ${f.file}\n---\n${f.content.slice(0, 8000)}\n---\n`)
462 .join("\n");
463
464 const message = await client.messages.create({
465 model: MODEL_SONNET,
466 max_tokens: 8192,
467 messages: [
468 {
469 role: "user",
470 content: `A gate named "${gateName}" failed on repository ${owner}/${repo} (branch ${branch}).
471
472Failure details:
473${failureDetails}
474
475Relevant files:
476${contextBlob}
477
478Produce a minimal JSON patch set that fixes the failure. Respond ONLY with JSON:
479{
480 "patches": [
481 { "path": "relative/path.ts", "content": "FULL new file content", "reason": "..." }
482 ],
483 "summary": "One sentence describing the fix"
484}
485
486If you cannot safely fix the failure, respond with { "patches": [], "summary": "Unable to auto-fix: ..." }.`,
487 },
488 ],
489 });
490 const text = extractText(message);
491 const parsed = parseJsonResponse<{ patches: Patch[]; summary: string }>(text);
492 if (!parsed || !Array.isArray(parsed.patches) || parsed.patches.length === 0) {
493 return {
494 attempted: true,
495 success: false,
496 filesChanged: [],
497 summary: parsed?.summary || "AI produced no patches",
498 };
499 }
500
501 const msg = `fix(${gateName.toLowerCase().replace(/\s+/g, "-")}): auto-repair gate failure
502
503${parsed.summary}
504
505${parsed.patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
506
507[auto-repair by GlueCron AI]`;
508
509 const result = await applyAndCommit(repoDir, wt.path, branch, parsed.patches, msg);
510 if (!result.ok) {
e6bad81Claude511 void recordRepair({
512 repositoryId: null,
513 failureText: failureDetails,
514 classification: null,
515 tier: "ai-sonnet",
516 patchSummary: parsed.summary,
517 filesChanged: result.filesChanged,
518 commitSha: null,
519 outcome: "failed",
520 }).catch(() => {});
3ef4c9dClaude521 return {
522 attempted: true,
523 success: false,
524 filesChanged: result.filesChanged,
525 summary: "commit failed",
526 error: result.error,
527 };
528 }
e6bad81Claude529 void recordRepair({
530 repositoryId: null,
531 failureText: failureDetails,
532 classification: null,
533 tier: "ai-sonnet",
534 patchSummary: parsed.summary,
535 filesChanged: result.filesChanged,
2316901Claude536 commitSha: result.sha ?? null,
e6bad81Claude537 outcome: "success",
538 }).catch(() => {});
3ef4c9dClaude539 return {
540 attempted: true,
541 success: true,
542 commitSha: result.sha,
543 filesChanged: result.filesChanged,
544 summary: parsed.summary,
545 };
546 } finally {
547 await cleanupWorktree(repoDir, wt.path);
548 }
549}