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.
| b0a3ba2 | 1 | /** |
| 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 | ||
| 22 | import { spawn } from "bun"; | |
| 23 | import { join } from "path"; | |
| 24 | import { getRepoPath } from "../git/repository"; | |
| 25 | ||
| 26 | export 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 | */ | |
| 40 | export 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 | */ | |
| 88 | export 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 | ||
| 152 | async 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 | ||
| 209 | async 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 | ||
| 260 | async 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 | ||
| 317 | async 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 | ||
| 333 | async function cleanupWorktree(bareRepoDir: string, wtPath: string) { | |
| 334 | await runCmd(["git", "worktree", "remove", "-f", wtPath], bareRepoDir, 30_000); | |
| 335 | } | |
| 336 | ||
| 337 | async 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 | ||
| 351 | async 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 | ||
| 399 | async 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 | ||
| 408 | async 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 | } |