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.tsBlame489 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";
3ef4c9dClaude22import type { SecurityFinding, SecretFinding } from "./security-scan";
23
24export interface RepairResult {
25 attempted: boolean;
26 success: boolean;
27 commitSha?: string;
28 filesChanged: string[];
29 summary: string;
30 error?: string;
31}
32
33async function exec(
34 cmd: string[],
35 opts?: { cwd?: string; env?: Record<string, string> }
36): Promise<{ stdout: string; stderr: string; exitCode: number }> {
37 const proc = spawn(cmd, {
38 cwd: opts?.cwd,
39 env: { ...process.env, ...opts?.env },
40 stdout: "pipe",
41 stderr: "pipe",
42 });
43 const [stdout, stderr] = await Promise.all([
44 new Response(proc.stdout).text(),
45 new Response(proc.stderr).text(),
46 ]);
47 const exitCode = await proc.exited;
48 return { stdout, stderr, exitCode };
49}
50
51const AUTHOR_ENV = {
52 GIT_AUTHOR_NAME: "GlueCron AI",
53 GIT_AUTHOR_EMAIL: "ai@gluecron.com",
54 GIT_COMMITTER_NAME: "GlueCron AI",
55 GIT_COMMITTER_EMAIL: "ai@gluecron.com",
56};
57
58interface Patch {
59 path: string;
60 /** Full replacement content. Preferred for simplicity + correctness. */
61 content: string;
62 /** Short rationale for the change — included in the commit message. */
63 reason: string;
64}
65
66/**
67 * Create a disposable worktree at the given branch head.
68 * Returns the worktree path; caller MUST call cleanupWorktree when done.
69 */
70async function createWorktree(
71 repoDir: string,
72 branch: string
73): Promise<{ path: string; ok: boolean; error?: string }> {
2c3ba6ecopilot-swe-agent[bot]74 const path = join(repoDir, `_repair_${Date.now()}_${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`);
3ef4c9dClaude75 const res = await exec(["git", "worktree", "add", path, branch], { cwd: repoDir });
76 if (res.exitCode !== 0) {
77 return { path, ok: false, error: res.stderr };
78 }
79 return { path, ok: true };
80}
81
82async function cleanupWorktree(repoDir: string, worktree: string): Promise<void> {
83 await exec(["git", "worktree", "remove", "--force", worktree], {
84 cwd: repoDir,
85 }).catch(() => {});
86 await rm(worktree, { recursive: true, force: true }).catch(() => {});
87}
88
89/**
90 * Apply patches to a worktree, commit, and update the branch ref in the bare repo.
91 */
92async function applyAndCommit(
93 repoDir: string,
94 worktree: string,
95 branch: string,
96 patches: Patch[],
97 commitMessage: string
98): Promise<{ ok: boolean; sha?: string; error?: string; filesChanged: string[] }> {
99 const filesChanged: string[] = [];
100 for (const patch of patches) {
101 const fullPath = join(worktree, patch.path);
102 try {
103 await mkdir(join(fullPath, "..").replace(/[^/]+\/\.\.$/, ""), { recursive: true }).catch(() => {});
104 await writeFile(fullPath, patch.content, "utf8");
105 filesChanged.push(patch.path);
106 } catch (err) {
107 console.error(`[auto-repair] Failed to write ${patch.path}:`, err);
108 }
109 }
110 if (filesChanged.length === 0) {
111 return { ok: false, error: "No patches applied", filesChanged: [] };
112 }
113
114 const add = await exec(["git", "add", "-A"], { cwd: worktree });
115 if (add.exitCode !== 0) {
116 return { ok: false, error: `git add: ${add.stderr}`, filesChanged };
117 }
118
119 const commit = await exec(
120 ["git", "commit", "-m", commitMessage],
121 { cwd: worktree, env: AUTHOR_ENV }
122 );
123 if (commit.exitCode !== 0) {
124 return { ok: false, error: `git commit: ${commit.stderr}`, filesChanged };
125 }
126
127 const { stdout: sha } = await exec(["git", "rev-parse", "HEAD"], { cwd: worktree });
128
129 // Push the new commit to the branch ref in the bare repo
130 const push = await exec(
131 ["git", "push", "origin", `HEAD:refs/heads/${branch}`],
132 { cwd: worktree }
133 );
134 if (push.exitCode !== 0) {
135 // Fall back to update-ref on bare repo if "origin" isn't the bare repo
136 const upd = await exec(
137 ["git", "update-ref", `refs/heads/${branch}`, sha.trim()],
138 { cwd: repoDir }
139 );
140 if (upd.exitCode !== 0) {
141 return { ok: false, error: `update-ref: ${upd.stderr}`, filesChanged };
142 }
143 }
144
145 return { ok: true, sha: sha.trim(), filesChanged };
146}
147
148/**
149 * Repair secret leaks by redacting the matching lines.
150 * This is a defensive baseline — the secret itself must be rotated manually
151 * because git history already contains it, but removing it from HEAD prevents
152 * further exposure.
153 */
154export async function repairSecrets(
155 owner: string,
156 repo: string,
157 branch: string,
158 findings: SecretFinding[]
159): Promise<RepairResult> {
160 if (findings.length === 0) {
161 return { attempted: false, success: false, filesChanged: [], summary: "no findings" };
162 }
163 const repoDir = getRepoPath(owner, repo);
164 const wt = await createWorktree(repoDir, branch);
165 if (!wt.ok) {
166 return {
167 attempted: true,
168 success: false,
169 filesChanged: [],
170 summary: "could not create worktree",
171 error: wt.error,
172 };
173 }
174
175 try {
176 // Group findings by file
177 const byFile = new Map<string, SecretFinding[]>();
178 for (const f of findings) {
179 if (!byFile.has(f.file)) byFile.set(f.file, []);
180 byFile.get(f.file)!.push(f);
181 }
182
183 const patches: Patch[] = [];
184 for (const [file, fileFindings] of byFile) {
185 try {
186 const content = await readFile(join(wt.path, file), "utf8");
187 const lines = content.split("\n");
188 const badLines = new Set(fileFindings.map((f) => f.line - 1));
189 for (const idx of badLines) {
190 if (idx >= 0 && idx < lines.length) {
191 // Redact everything that looks like a value after = or :
192 lines[idx] = lines[idx].replace(
193 /(['"])[A-Za-z0-9_\-/+=\.]{20,}(['"])/g,
194 '$1REDACTED_BY_GLUECRON$2'
195 );
196 // If the whole line IS the secret (PEM), comment it out
197 if (lines[idx].includes("BEGIN") && lines[idx].includes("PRIVATE KEY")) {
198 lines[idx] = `// ${lines[idx]} // REDACTED_BY_GLUECRON`;
199 }
200 }
201 }
202 patches.push({
203 path: file,
204 content: lines.join("\n"),
205 reason: `Redact ${fileFindings.length} secret${fileFindings.length === 1 ? "" : "s"}`,
206 });
207 } catch (err) {
208 console.error(`[auto-repair] Could not read ${file}:`, err);
209 }
210 }
211
212 if (patches.length === 0) {
213 return {
214 attempted: true,
215 success: false,
216 filesChanged: [],
217 summary: "no files to patch",
218 };
219 }
220
221 const msg = `fix(security): auto-redact leaked secrets
222
223Redacted ${findings.length} secret finding${findings.length === 1 ? "" : "s"} in ${patches.length} file${patches.length === 1 ? "" : "s"}.
224
225ACTION REQUIRED: these credentials must be rotated — they remain visible in git history.
226
227[auto-repair by GlueCron AI]`;
228
229 const result = await applyAndCommit(repoDir, wt.path, branch, patches, msg);
230 if (!result.ok) {
231 return {
232 attempted: true,
233 success: false,
234 filesChanged: result.filesChanged,
235 summary: "commit failed",
236 error: result.error,
237 };
238 }
239 return {
240 attempted: true,
241 success: true,
242 commitSha: result.sha,
243 filesChanged: result.filesChanged,
244 summary: `Redacted ${findings.length} secret${findings.length === 1 ? "" : "s"} across ${patches.length} file${patches.length === 1 ? "" : "s"}`,
245 };
246 } finally {
247 await cleanupWorktree(repoDir, wt.path);
248 }
249}
250
251/**
252 * Use Claude to repair a set of security findings by rewriting affected files.
253 */
254export async function repairSecurityIssues(
255 owner: string,
256 repo: string,
257 branch: string,
258 findings: SecurityFinding[]
259): Promise<RepairResult> {
260 if (findings.length === 0) {
261 return { attempted: false, success: false, filesChanged: [], summary: "no findings" };
262 }
263 if (!isAiAvailable()) {
264 return {
265 attempted: false,
266 success: false,
267 filesChanged: [],
268 summary: "AI not configured",
269 };
270 }
271
272 const repoDir = getRepoPath(owner, repo);
273 const wt = await createWorktree(repoDir, branch);
274 if (!wt.ok) {
275 return {
276 attempted: true,
277 success: false,
278 filesChanged: [],
279 summary: "could not create worktree",
280 error: wt.error,
281 };
282 }
283
284 try {
285 // Group by file, read + patch each
286 const byFile = new Map<string, SecurityFinding[]>();
287 for (const f of findings) {
288 if (!byFile.has(f.file)) byFile.set(f.file, []);
289 byFile.get(f.file)!.push(f);
290 }
291
292 const client = getAnthropic();
293 const patches: Patch[] = [];
294
295 for (const [file, fileFindings] of byFile) {
296 let original: string;
297 try {
298 original = await readFile(join(wt.path, file), "utf8");
299 } catch {
300 continue;
301 }
302 const findingsText = fileFindings
303 .map(
304 (f, i) =>
305 `${i + 1}. [${f.severity}] ${f.type}${f.line ? ` on line ${f.line}` : ""}: ${f.description}${f.suggestion ? `\n Suggestion: ${f.suggestion}` : ""}`
306 )
307 .join("\n");
308
309 const message = await client.messages.create({
310 model: MODEL_SONNET,
311 max_tokens: 8192,
312 messages: [
313 {
314 role: "user",
315 content: `You are a secure-coding assistant. A security scan flagged the following issues in "${file}":
316
317${findingsText}
318
319Rewrite the file to fix ALL flagged issues while preserving existing behaviour. Rules:
320- Output ONLY the full corrected file content. No prose, no code fences.
321- Do not add feature changes unrelated to the findings.
322- Keep imports / exports intact.
323- If an issue genuinely can't be fixed without breaking behaviour, return the file unchanged.
324
325Current file:
326${original}`,
327 },
328 ],
329 });
330 const fixed = extractText(message).replace(/^```[\w]*\n?/, "").replace(/\n?```$/, "");
331 if (fixed && fixed !== original && fixed.length > 10) {
332 patches.push({
333 path: file,
334 content: fixed,
335 reason: `Fix ${fileFindings.length} security finding${fileFindings.length === 1 ? "" : "s"}`,
336 });
337 }
338 }
339
340 if (patches.length === 0) {
341 return {
342 attempted: true,
343 success: false,
344 filesChanged: [],
345 summary: "AI produced no patches",
346 };
347 }
348
349 const msg = `fix(security): auto-repair flagged issues
350
351${patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
352
353[auto-repair by GlueCron AI]`;
354
355 const result = await applyAndCommit(repoDir, wt.path, branch, patches, msg);
356 if (!result.ok) {
357 return {
358 attempted: true,
359 success: false,
360 filesChanged: result.filesChanged,
361 summary: "commit failed",
362 error: result.error,
363 };
364 }
365 return {
366 attempted: true,
367 success: true,
368 commitSha: result.sha,
369 filesChanged: result.filesChanged,
370 summary: `Repaired ${findings.length} security issue${findings.length === 1 ? "" : "s"} in ${patches.length} file${patches.length === 1 ? "" : "s"}`,
371 };
372 } finally {
373 await cleanupWorktree(repoDir, wt.path);
374 }
375}
376
377/**
378 * Given a GateTest failure summary, ask Claude to produce a patch set
379 * that should make the failing check pass.
380 */
381export async function repairGateFailure(
382 owner: string,
383 repo: string,
384 branch: string,
385 gateName: string,
386 failureDetails: string,
387 context: { file: string; content: string }[]
388): Promise<RepairResult> {
b0a3ba2Claude389 // ── Tier 1: try a mechanical repair first (no AI call needed)
390 // Lockfile drift, formatting drift, import-order issues — these have
391 // deterministic fixes. If one matches we save the cost + latency of a
392 // Sonnet round-trip AND get a more reliable patch.
393 const mech = await tryMechanicalRepair(owner, repo, branch, failureDetails);
394 if (mech.attempted && mech.success && mech.commitSha) {
395 return {
396 attempted: true,
397 success: true,
398 commitSha: mech.commitSha,
399 filesChanged: mech.filesChanged,
400 summary: `[mechanical] ${mech.summary}`,
401 };
402 }
403
404 // ── Tier 2: AI-powered repair via Claude Sonnet
3ef4c9dClaude405 if (!isAiAvailable()) {
406 return { attempted: false, success: false, filesChanged: [], summary: "AI not configured" };
407 }
408 if (context.length === 0) {
409 return { attempted: false, success: false, filesChanged: [], summary: "no files to analyse" };
410 }
411
412 const repoDir = getRepoPath(owner, repo);
413 const wt = await createWorktree(repoDir, branch);
414 if (!wt.ok) {
415 return { attempted: true, success: false, filesChanged: [], summary: "worktree failed", error: wt.error };
416 }
417
418 try {
419 const client = getAnthropic();
420 const contextBlob = context
421 .map((f) => `FILE: ${f.file}\n---\n${f.content.slice(0, 8000)}\n---\n`)
422 .join("\n");
423
424 const message = await client.messages.create({
425 model: MODEL_SONNET,
426 max_tokens: 8192,
427 messages: [
428 {
429 role: "user",
430 content: `A gate named "${gateName}" failed on repository ${owner}/${repo} (branch ${branch}).
431
432Failure details:
433${failureDetails}
434
435Relevant files:
436${contextBlob}
437
438Produce a minimal JSON patch set that fixes the failure. Respond ONLY with JSON:
439{
440 "patches": [
441 { "path": "relative/path.ts", "content": "FULL new file content", "reason": "..." }
442 ],
443 "summary": "One sentence describing the fix"
444}
445
446If you cannot safely fix the failure, respond with { "patches": [], "summary": "Unable to auto-fix: ..." }.`,
447 },
448 ],
449 });
450 const text = extractText(message);
451 const parsed = parseJsonResponse<{ patches: Patch[]; summary: string }>(text);
452 if (!parsed || !Array.isArray(parsed.patches) || parsed.patches.length === 0) {
453 return {
454 attempted: true,
455 success: false,
456 filesChanged: [],
457 summary: parsed?.summary || "AI produced no patches",
458 };
459 }
460
461 const msg = `fix(${gateName.toLowerCase().replace(/\s+/g, "-")}): auto-repair gate failure
462
463${parsed.summary}
464
465${parsed.patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
466
467[auto-repair by GlueCron AI]`;
468
469 const result = await applyAndCommit(repoDir, wt.path, branch, parsed.patches, msg);
470 if (!result.ok) {
471 return {
472 attempted: true,
473 success: false,
474 filesChanged: result.filesChanged,
475 summary: "commit failed",
476 error: result.error,
477 };
478 }
479 return {
480 attempted: true,
481 success: true,
482 commitSha: result.sha,
483 filesChanged: result.filesChanged,
484 summary: parsed.summary,
485 };
486 } finally {
487 await cleanupWorktree(repoDir, wt.path);
488 }
489}