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-mechanical.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-mechanical.tsBlame447 lines · 1 contributor
b0a3ba2Claude1/**
2 * Mechanical auto-repair — Tier 1 of the auto-repair stack.
3 *
4 * Most CI failures are NOT logic bugs. They're mechanical: a lockfile
5 * drifted because someone forgot to commit it, formatting got out of
6 * sync, imports are ordered wrong. None of these need an AI call to
7 * fix — running the right deterministic command produces the patch.
8 *
9 * This module is consulted FIRST. If a mechanical fix lands, we save
10 * the cost + latency of a Claude round-trip. If not, the caller falls
11 * through to ai-powered repairGateFailure() in auto-repair.ts.
12 *
13 * Every function returns {attempted, success, filesChanged, summary,
14 * commitSha?} matching auto-repair.ts shape so callers can swap in/out
15 * uniformly.
16 *
17 * Safety: each repair runs in a temporary worktree so the bare repo
18 * stays pristine. Commits are signed "GlueCron AI [mechanical]" so
19 * the audit trail distinguishes them from Tier-2 AI patches.
20 */
21
22import { spawn } from "bun";
23import { join } from "path";
24import { getRepoPath } from "../git/repository";
25
26export interface MechanicalRepairResult {
27 attempted: boolean;
28 success: boolean;
29 filesChanged: string[];
30 summary: string;
31 commitSha?: string;
32 error?: string;
33}
34
35/**
36 * What kind of failure are we dealing with? Cheap heuristic match on
37 * failure text before deciding which mechanical repair (if any) to try.
38 * Returns null if no mechanical pattern matches.
39 */
40export function classifyFailure(
41 failureText: string,
42): "lockfile" | "formatting" | "imports" | null {
43 const t = failureText.toLowerCase();
44
45 // Lockfile drift signals
46 if (
47 t.includes("lockfile is out of sync") ||
48 t.includes("lockfile mismatch") ||
49 t.includes("frozen lockfile failed") ||
50 t.includes("frozen-lockfile") ||
51 t.includes("package-lock.json is not in sync") ||
52 t.includes("bun.lock") &&
53 (t.includes("outdated") || t.includes("mismatch"))
54 ) {
55 return "lockfile";
56 }
57
58 // Formatting signals (Prettier / Biome / Bun fmt)
59 if (
60 t.includes("would be reformatted") ||
61 t.includes("style/formatting") ||
62 t.includes("prettier") ||
63 t.includes("formatting check failed") ||
64 /\bbiome\b.*\bformat\b/.test(t) ||
65 t.includes("bun fmt")
66 ) {
67 return "formatting";
68 }
69
70 // Import-order signals (eslint-plugin-import, biome organize-imports)
71 if (
72 t.includes("imports are not sorted") ||
73 t.includes("organize-imports") ||
74 t.includes("import/order") ||
75 t.includes("unused-imports")
76 ) {
77 return "imports";
78 }
79
80 return null;
81}
82
83/**
84 * Attempt a mechanical repair based on the failure classification.
85 * Returns {success: false, attempted: false} if no mechanical handler
86 * matches — caller should fall through to AI repair.
87 */
88export async function tryMechanicalRepair(
89 owner: string,
90 repo: string,
91 branch: string,
92 failureText: string,
93): Promise<MechanicalRepairResult> {
94 const kind = classifyFailure(failureText);
95 if (!kind) {
96 return {
97 attempted: false,
98 success: false,
99 filesChanged: [],
100 summary: "no mechanical pattern matched",
101 };
102 }
103
104 const repoDir = getRepoPath(owner, repo);
105 const wt = await createWorktree(repoDir, branch);
106 if (!wt.ok) {
107 return {
108 attempted: true,
109 success: false,
110 filesChanged: [],
111 summary: `worktree failed: ${wt.error}`,
112 error: wt.error,
113 };
114 }
115
116 try {
117 let result: MechanicalRepairResult;
118 switch (kind) {
119 case "lockfile":
120 result = await repairLockfile(wt.path);
121 break;
122 case "formatting":
123 result = await repairFormatting(wt.path);
124 break;
125 case "imports":
126 result = await repairImports(wt.path);
127 break;
128 }
129
130 if (!result.success || result.filesChanged.length === 0) {
131 return result;
132 }
133
134 const sha = await commitChanges(
135 repoDir,
136 wt.path,
137 branch,
138 result.filesChanged,
139 `fix(${kind}): mechanical auto-repair\n\n${result.summary}\n\n[auto-repair by GlueCron AI / mechanical tier]`,
140 );
141
142 return { ...result, commitSha: sha ?? undefined };
143 } finally {
144 await cleanupWorktree(repoDir, wt.path);
145 }
146}
147
148// ─────────────────────────────────────────────────────────────────────────
149// Individual repair handlers
150// ─────────────────────────────────────────────────────────────────────────
151
152async function repairLockfile(
153 worktreePath: string,
154): Promise<MechanicalRepairResult> {
155 // Try bun first (this is a Bun repo)
156 const hasBunLock = await fileExists(join(worktreePath, "bun.lock"));
157 const hasPackageLock = await fileExists(join(worktreePath, "package-lock.json"));
158 const hasYarnLock = await fileExists(join(worktreePath, "yarn.lock"));
159 const hasPnpmLock = await fileExists(join(worktreePath, "pnpm-lock.yaml"));
160
161 if (!hasBunLock && !hasPackageLock && !hasYarnLock && !hasPnpmLock) {
162 return {
163 attempted: true,
164 success: false,
165 filesChanged: [],
166 summary: "no lockfile detected — nothing to regenerate",
167 };
168 }
169
170 let cmd: string[];
171 let lockfileName: string;
172 if (hasBunLock) {
173 cmd = ["bun", "install", "--lockfile-only"];
174 lockfileName = "bun.lock";
175 } else if (hasPackageLock) {
176 cmd = ["npm", "install", "--package-lock-only", "--no-audit", "--no-fund"];
177 lockfileName = "package-lock.json";
178 } else if (hasYarnLock) {
179 cmd = ["yarn", "install", "--mode", "update-lockfile"];
180 lockfileName = "yarn.lock";
181 } else {
182 cmd = ["pnpm", "install", "--lockfile-only"];
183 lockfileName = "pnpm-lock.yaml";
184 }
185
186 const { code, stderr } = await runCmd(cmd, worktreePath, 120_000);
187 if (code !== 0) {
188 return {
189 attempted: true,
190 success: false,
191 filesChanged: [],
192 summary: `lockfile regeneration failed (exit ${code})`,
193 error: stderr.slice(0, 400),
194 };
195 }
196
197 const changed = await dirtyFiles(worktreePath);
198 return {
199 attempted: true,
200 success: changed.length > 0,
201 filesChanged: changed,
202 summary:
203 changed.length > 0
204 ? `regenerated ${lockfileName}`
205 : `lockfile already in sync`,
206 };
207}
208
209async function repairFormatting(
210 worktreePath: string,
211): Promise<MechanicalRepairResult> {
212 // Try formatters in priority order: biome (fastest, growing adoption),
213 // prettier (industry standard), bun fmt (built-in fallback).
214 const tools: Array<{ check: string[]; cmd: string[]; name: string }> = [
215 {
216 check: ["bunx", "--bun", "biome", "--version"],
217 cmd: ["bunx", "--bun", "biome", "format", "--write", "."],
218 name: "biome",
219 },
220 {
221 check: ["bunx", "prettier", "--version"],
222 cmd: ["bunx", "prettier", "--write", "."],
223 name: "prettier",
224 },
225 ];
226
227 for (const tool of tools) {
228 const probe = await runCmd(tool.check, worktreePath, 15_000);
229 if (probe.code !== 0) continue;
230 const apply = await runCmd(tool.cmd, worktreePath, 90_000);
231 if (apply.code !== 0) {
232 return {
233 attempted: true,
234 success: false,
235 filesChanged: [],
236 summary: `${tool.name} returned non-zero (${apply.code})`,
237 error: apply.stderr.slice(0, 400),
238 };
239 }
240 const changed = await dirtyFiles(worktreePath);
241 return {
242 attempted: true,
243 success: changed.length > 0,
244 filesChanged: changed,
245 summary:
246 changed.length > 0
247 ? `reformatted ${changed.length} file(s) with ${tool.name}`
248 : `code already formatted (${tool.name} clean)`,
249 };
250 }
251
252 return {
253 attempted: true,
254 success: false,
255 filesChanged: [],
256 summary: "no formatter available (biome / prettier not installed)",
257 };
258}
259
260async function repairImports(
261 worktreePath: string,
262): Promise<MechanicalRepairResult> {
263 // Prefer biome's organize-imports — single command, fast.
264 const probe = await runCmd(
265 ["bunx", "--bun", "biome", "--version"],
266 worktreePath,
267 15_000,
268 );
269 if (probe.code !== 0) {
270 return {
271 attempted: true,
272 success: false,
273 filesChanged: [],
274 summary: "no import organiser available (biome not installed)",
275 };
276 }
277
278 const apply = await runCmd(
279 [
280 "bunx",
281 "--bun",
282 "biome",
283 "check",
284 "--write",
285 "--unsafe",
286 ".",
287 ],
288 worktreePath,
289 90_000,
290 );
291 if (apply.code !== 0) {
292 return {
293 attempted: true,
294 success: false,
295 filesChanged: [],
296 summary: `biome check exit ${apply.code}`,
297 error: apply.stderr.slice(0, 400),
298 };
299 }
300
301 const changed = await dirtyFiles(worktreePath);
302 return {
303 attempted: true,
304 success: changed.length > 0,
305 filesChanged: changed,
306 summary:
307 changed.length > 0
308 ? `organised imports in ${changed.length} file(s)`
309 : `imports already organised`,
310 };
311}
312
313// ─────────────────────────────────────────────────────────────────────────
314// helpers — worktree, git, fs
315// ─────────────────────────────────────────────────────────────────────────
316
317async function createWorktree(
318 bareRepoDir: string,
319 branch: string,
320): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
321 const wtPath = `/tmp/gluecron-mechrepair-${Date.now()}-${Math.random()
322 .toString(36)
323 .slice(2, 8)}`;
324 const { code, stderr } = await runCmd(
325 ["git", "worktree", "add", "-f", wtPath, branch],
326 bareRepoDir,
327 30_000,
328 );
329 if (code !== 0) return { ok: false, error: stderr.slice(0, 400) };
330 return { ok: true, path: wtPath };
331}
332
333async function cleanupWorktree(bareRepoDir: string, wtPath: string) {
334 await runCmd(["git", "worktree", "remove", "-f", wtPath], bareRepoDir, 30_000);
335}
336
337async function dirtyFiles(worktreePath: string): Promise<string[]> {
338 const { stdout, code } = await runCmd(
339 ["git", "status", "--porcelain"],
340 worktreePath,
341 15_000,
342 );
343 if (code !== 0) return [];
344 return stdout
345 .split("\n")
346 .map((line) => line.trim())
347 .filter((line) => line.length > 0)
348 .map((line) => line.replace(/^.{2,3}\s+/, ""));
349}
350
351async function commitChanges(
352 bareRepoDir: string,
353 worktreePath: string,
354 branch: string,
355 files: string[],
356 message: string,
357): Promise<string | null> {
358 if (files.length === 0) return null;
359
360 const addRes = await runCmd(
361 ["git", "add", "--", ...files],
362 worktreePath,
363 30_000,
364 );
365 if (addRes.code !== 0) return null;
366
367 const commitRes = await runCmd(
368 [
369 "git",
370 "-c",
371 "user.name=GlueCron AI",
372 "-c",
373 "user.email=ai-bot@gluecron.com",
374 "commit",
375 "-m",
376 message,
377 ],
378 worktreePath,
379 30_000,
380 );
381 if (commitRes.code !== 0) return null;
382
383 const shaRes = await runCmd(["git", "rev-parse", "HEAD"], worktreePath, 15_000);
384 if (shaRes.code !== 0) return null;
385
386 // Push from the worktree back to the bare via direct branch update.
387 // The worktree shares object storage with the bare, so we just update
388 // the bare's branch ref.
389 const push = await runCmd(
390 ["git", "push", bareRepoDir, `HEAD:${branch}`],
391 worktreePath,
392 30_000,
393 );
394 if (push.code !== 0) return null;
395
396 return shaRes.stdout.trim();
397}
398
399async function fileExists(path: string): Promise<boolean> {
400 try {
401 const f = Bun.file(path);
402 return await f.exists();
403 } catch {
404 return false;
405 }
406}
407
408async function runCmd(
409 cmd: string[],
410 cwd: string,
411 timeoutMs: number,
412): Promise<{ code: number; stdout: string; stderr: string }> {
413 try {
414 const proc = spawn({
415 cmd,
416 cwd,
417 stdout: "pipe",
418 stderr: "pipe",
419 env: {
420 ...process.env,
421 // Don't write to the user's HOME during install / format
422 HOME: "/tmp",
423 // Stop interactive prompts dead
424 CI: "true",
425 GIT_TERMINAL_PROMPT: "0",
426 },
427 });
428 const timer = setTimeout(() => {
429 try {
430 proc.kill("SIGKILL");
431 } catch {
432 /* already dead */
433 }
434 }, timeoutMs);
435 const code = await proc.exited;
436 clearTimeout(timer);
437 const stdout = await new Response(proc.stdout).text();
438 const stderr = await new Response(proc.stderr).text();
439 return { code: code ?? 1, stdout, stderr };
440 } catch (err) {
441 return {
442 code: 1,
443 stdout: "",
444 stderr: err instanceof Error ? err.message : String(err),
445 };
446 }
447}