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