CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ci-autofix.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.
| 34e63b9 | 1 | /** |
| 2 | * CI Auto-Fix — when a gate run or workflow run fails on a PR, Claude reads | |
| 3 | * the error logs, the failing test file, and the PR diff, then posts a | |
| 4 | * ready-to-apply patch as a comment on the PR. | |
| 5 | * | |
| 6 | * Entry points: | |
| 7 | * triggerCiAutofix(gateRunId) — fire-and-forget; call after a gate_run | |
| 8 | * row is written with status="failed". | |
| 9 | * applyAutofix(prCommentId, userId) — apply the patch from a comment onto | |
| 10 | * a new branch and return the branch name. | |
| 11 | * | |
| 12 | * Route wiring (src/routes/pulls.tsx or src/routes/api.ts): | |
| 13 | * POST /api/pr-comments/:commentId/apply-autofix → applyAutofix | |
| 14 | */ | |
| 15 | ||
| 16 | import { and, eq } from "drizzle-orm"; | |
| 17 | import { mkdtemp, rm, writeFile } from "fs/promises"; | |
| 18 | import { join } from "path"; | |
| 19 | import { tmpdir } from "os"; | |
| 20 | import { db } from "../db"; | |
| 21 | import { | |
| 22 | gateRuns, | |
| 23 | pullRequests, | |
| 24 | prComments, | |
| 25 | repositories, | |
| 26 | users, | |
| 27 | repoCollaborators, | |
| 28 | } from "../db/schema"; | |
| 29 | import { getRepoPath } from "../git/repository"; | |
| 30 | import { getBotUserIdOrFallback } from "./bot-user"; | |
| 31 | import { | |
| 32 | getAnthropic, | |
| 33 | isAiAvailable, | |
| 34 | MODEL_SONNET, | |
| 35 | extractText, | |
| 36 | parseJsonResponse, | |
| 37 | } from "./ai-client"; | |
| 38 | ||
| 39 | // --------------------------------------------------------------------------- | |
| 40 | // Types | |
| 41 | // --------------------------------------------------------------------------- | |
| 42 | ||
| 43 | export interface AutofixResult { | |
| 44 | prNumber: number; | |
| 45 | repoId: string; | |
| 46 | gateRunId: string; | |
| 47 | patch: string; // unified diff format | |
| 48 | explanation: string; // 2-3 sentence explanation | |
| 49 | confidence: "high" | "medium" | "low"; | |
| 50 | affectedFiles: string[]; | |
| 51 | } | |
| 52 | ||
| 53 | interface ClaudeAutofixResponse { | |
| 54 | patch: string; | |
| 55 | explanation: string; | |
| 56 | confidence: "high" | "medium" | "low"; | |
| 57 | affectedFiles: string[]; | |
| 58 | } | |
| 59 | ||
| 60 | // --------------------------------------------------------------------------- | |
| 61 | // Constants | |
| 62 | // --------------------------------------------------------------------------- | |
| 63 | ||
| 64 | /** Idempotency marker embedded in every autofix comment. */ | |
| 65 | export const CI_AUTOFIX_MARKER = "<!-- gluecron:ci-autofix:v1 -->"; | |
| 66 | ||
| 67 | /** Max bytes of PR diff sent to Claude. */ | |
| 68 | const MAX_DIFF_BYTES = 80 * 1024; | |
| 69 | ||
| 70 | /** Max bytes of error log sent to Claude. */ | |
| 71 | const MAX_LOG_BYTES = 3 * 1024; | |
| 72 | ||
| 73 | /** Max bytes per test file read. */ | |
| 74 | const MAX_FILE_BYTES = 10 * 1024; | |
| 75 | ||
| 76 | /** Max number of failing test files to read. */ | |
| 77 | const MAX_TEST_FILES = 3; | |
| 78 | ||
| 79 | // --------------------------------------------------------------------------- | |
| 80 | // Helpers | |
| 81 | // --------------------------------------------------------------------------- | |
| 82 | ||
| 83 | async function spawnGit( | |
| 84 | args: string[], | |
| 85 | cwd: string | |
| 86 | ): Promise<{ stdout: string; stderr: string; exitCode: number }> { | |
| 87 | const proc = Bun.spawn(["git", ...args], { | |
| 88 | cwd, | |
| 89 | stdout: "pipe", | |
| 90 | stderr: "pipe", | |
| 91 | }); | |
| 92 | const [stdout, stderr] = await Promise.all([ | |
| 93 | new Response(proc.stdout).text(), | |
| 94 | new Response(proc.stderr).text(), | |
| 95 | ]); | |
| 96 | const exitCode = await proc.exited; | |
| 97 | return { stdout, stderr, exitCode }; | |
| 98 | } | |
| 99 | ||
| 100 | function truncate(s: string, maxBytes: number): string { | |
| 101 | const buf = Buffer.from(s, "utf8"); | |
| 102 | if (buf.length <= maxBytes) return s; | |
| 103 | return buf.slice(0, maxBytes).toString("utf8") + "\n[truncated]"; | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * Extract failing test file paths, error message, and stack trace from a | |
| 108 | * raw CI error log string. Heuristic — good enough for most JS/TS test | |
| 109 | * runners (Jest, Vitest, Bun test) and Python pytest output. | |
| 110 | */ | |
| 111 | function parseErrorLog(errorLog: string): { | |
| 112 | testFiles: string[]; | |
| 113 | errorSummary: string; | |
| 114 | } { | |
| 115 | const lines = errorLog.split("\n"); | |
| 116 | ||
| 117 | // Collect candidate test file paths: | |
| 118 | // - lines mentioning .test.ts/.spec.ts/.test.js/.spec.js/.test.py paths | |
| 119 | // - lines with "FAIL <path>" (Jest pattern) | |
| 120 | const filePatterns = [ | |
| 121 | /(?:^|\s)([\w./\-]+\.(?:test|spec)\.[jt]sx?)/gm, | |
| 122 | /(?:^|\s)([\w./\-]+_test\.py)/gm, | |
| 123 | /(?:^FAIL\s+)([\w./\-]+)/gm, | |
| 124 | ]; | |
| 125 | ||
| 126 | const filesSet = new Set<string>(); | |
| 127 | for (const pattern of filePatterns) { | |
| 128 | let m: RegExpExecArray | null; | |
| 129 | pattern.lastIndex = 0; | |
| 130 | while ((m = pattern.exec(errorLog)) !== null) { | |
| 131 | const path = m[1].trim(); | |
| 132 | if (path && !path.startsWith("-") && !path.startsWith("+")) { | |
| 133 | filesSet.add(path); | |
| 134 | } | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | // Error summary: first 3KB of the log (most runners put the error first). | |
| 139 | const errorSummary = truncate(errorLog, MAX_LOG_BYTES); | |
| 140 | ||
| 141 | return { | |
| 142 | testFiles: Array.from(filesSet).slice(0, MAX_TEST_FILES), | |
| 143 | errorSummary, | |
| 144 | }; | |
| 145 | } | |
| 146 | ||
| 147 | // --------------------------------------------------------------------------- | |
| 148 | // Main entry point | |
| 149 | // --------------------------------------------------------------------------- | |
| 150 | ||
| 151 | /** | |
| 152 | * Fire-and-forget entry point called after a gate_run row is set to 'failed'. | |
| 153 | * Never throws — all errors are swallowed after logging. | |
| 154 | */ | |
| 155 | export async function triggerCiAutofix(gateRunId: string): Promise<void> { | |
| 156 | if (!isAiAvailable()) return; | |
| 157 | ||
| 158 | try { | |
| 159 | await _runAutofix(gateRunId); | |
| 160 | } catch (err) { | |
| 161 | console.error( | |
| 162 | "[ci-autofix] crashed:", | |
| 163 | err instanceof Error ? err.message : err | |
| 164 | ); | |
| 165 | } | |
| 166 | } | |
| 167 | ||
| 168 | async function _runAutofix(gateRunId: string): Promise<void> { | |
| 169 | // 1. Load the gate run | |
| 170 | const [gateRun] = await db | |
| 171 | .select() | |
| 172 | .from(gateRuns) | |
| 173 | .where(eq(gateRuns.id, gateRunId)) | |
| 174 | .limit(1); | |
| 175 | ||
| 176 | if (!gateRun) return; | |
| 177 | if (gateRun.status !== "failed") return; | |
| 178 | if (!gateRun.pullRequestId) return; | |
| 179 | ||
| 180 | // 2. Load the PR row | |
| 181 | const [pr] = await db | |
| 182 | .select() | |
| 183 | .from(pullRequests) | |
| 184 | .where(eq(pullRequests.id, gateRun.pullRequestId)) | |
| 185 | .limit(1); | |
| 186 | ||
| 187 | if (!pr) return; | |
| 188 | ||
| 189 | // 3. Load repo (owner/name) | |
| 190 | const [repoRow] = await db | |
| 191 | .select({ | |
| 192 | id: repositories.id, | |
| 193 | name: repositories.name, | |
| 194 | diskPath: repositories.diskPath, | |
| 195 | ownerUsername: users.username, | |
| 196 | }) | |
| 197 | .from(repositories) | |
| 198 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 199 | .where(eq(repositories.id, gateRun.repositoryId)) | |
| 200 | .limit(1); | |
| 201 | ||
| 202 | if (!repoRow) return; | |
| 203 | ||
| 204 | // 4. Check idempotency — skip if already posted for this gateRunId | |
| 205 | const idempotencyMarker = `<!-- gluecron:ci-autofix:run:${gateRunId} -->`; | |
| 206 | const existing = await db | |
| 207 | .select({ id: prComments.id }) | |
| 208 | .from(prComments) | |
| 209 | .where( | |
| 210 | and( | |
| 211 | eq(prComments.pullRequestId, gateRun.pullRequestId), | |
| 212 | // We check by looking for comments with the autofix marker. | |
| 213 | // drizzle doesn't have LIKE with dynamic params easily, but | |
| 214 | // we query all AI comments and filter client-side (there won't be many). | |
| 215 | eq(prComments.isAiReview, true) | |
| 216 | ) | |
| 217 | ) | |
| 218 | .limit(50); | |
| 219 | ||
| 220 | for (const row of existing) { | |
| 221 | // Load the body to check idempotency marker | |
| 222 | const [full] = await db | |
| 223 | .select({ body: prComments.body }) | |
| 224 | .from(prComments) | |
| 225 | .where(eq(prComments.id, row.id)) | |
| 226 | .limit(1); | |
| 227 | if (full?.body?.includes(idempotencyMarker)) return; | |
| 228 | } | |
| 229 | ||
| 230 | const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name); | |
| 231 | ||
| 232 | // 5. Get the PR diff (max 80KB) | |
| 233 | const diffResult = await spawnGit( | |
| 234 | ["diff", `${pr.baseBranch}...${pr.headBranch}`], | |
| 235 | repoDir | |
| 236 | ); | |
| 237 | const prDiff = truncate(diffResult.stdout, MAX_DIFF_BYTES); | |
| 238 | ||
| 239 | if (!prDiff.trim()) return; // nothing to work with | |
| 240 | ||
| 241 | // 6. Parse errorLog to extract test files + error summary | |
| 242 | const errorLog = gateRun.summary || gateRun.details || ""; | |
| 243 | const { testFiles, errorSummary } = parseErrorLog( | |
| 244 | typeof errorLog === "string" ? errorLog : JSON.stringify(errorLog) | |
| 245 | ); | |
| 246 | ||
| 247 | // 7. Read failing test files via git show HEAD:path | |
| 248 | let testFileContent = ""; | |
| 249 | for (const filePath of testFiles) { | |
| 250 | const showResult = await spawnGit( | |
| 251 | ["show", `${pr.headBranch}:${filePath}`], | |
| 252 | repoDir | |
| 253 | ); | |
| 254 | if (showResult.exitCode === 0 && showResult.stdout) { | |
| 255 | const content = truncate(showResult.stdout, MAX_FILE_BYTES); | |
| 256 | testFileContent += `\n\n--- ${filePath} ---\n${content}`; | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | // 8. Call Claude Sonnet 4.6 | |
| 261 | const client = getAnthropic(); | |
| 262 | const prompt = `You are a senior engineer fixing a CI failure. | |
| 263 | ||
| 264 | PR diff (what changed): | |
| 265 | ${prDiff} | |
| 266 | ||
| 267 | Failing test output: | |
| 268 | ${errorSummary} | |
| 269 | ||
| 270 | Test file content:${testFileContent || "\n(no test files detected)"} | |
| 271 | ||
| 272 | Produce a minimal unified diff patch that fixes the CI failure. The patch must: | |
| 273 | 1. Be valid unified diff format (--- a/file, +++ b/file, @@ lines) | |
| 274 | 2. Fix only what's needed — no refactoring | |
| 275 | 3. Not modify the test itself unless the test expectation is genuinely wrong | |
| 276 | ||
| 277 | Return JSON: {"patch": "...", "explanation": "...", "confidence": "high|medium|low", "affectedFiles": ["..."]}`; | |
| 278 | ||
| 279 | const message = await client.messages.create({ | |
| 280 | model: MODEL_SONNET, | |
| 281 | max_tokens: 4096, | |
| 282 | messages: [{ role: "user", content: prompt }], | |
| 283 | }); | |
| 284 | ||
| 285 | const rawText = extractText(message); | |
| 286 | const parsed = parseJsonResponse<ClaudeAutofixResponse>(rawText); | |
| 287 | ||
| 288 | if (!parsed || !parsed.patch || !parsed.explanation) return; | |
| 289 | ||
| 290 | // 10. If confidence === 'low' → skip | |
| 291 | if (parsed.confidence === "low") return; | |
| 292 | ||
| 293 | // 11. Build and post the comment | |
| 294 | const commentBody = buildAutofixComment( | |
| 295 | parsed, | |
| 296 | idempotencyMarker, | |
| 297 | gateRunId | |
| 298 | ); | |
| 299 | ||
| 300 | const botAuthorId = await getBotUserIdOrFallback(repoRow.id); | |
| 301 | if (!botAuthorId) return; | |
| 302 | ||
| 303 | await db.insert(prComments).values({ | |
| 304 | pullRequestId: gateRun.pullRequestId, | |
| 305 | authorId: botAuthorId, | |
| 306 | body: commentBody, | |
| 307 | isAiReview: true, | |
| 308 | }); | |
| 309 | } | |
| 310 | ||
| 311 | function buildAutofixComment( | |
| 312 | result: ClaudeAutofixResponse, | |
| 313 | idempotencyMarker: string, | |
| 314 | gateRunId: string | |
| 315 | ): string { | |
| 316 | const confidenceBadge = | |
| 317 | result.confidence === "high" | |
| 318 | ? "🟢 High confidence" | |
| 319 | : result.confidence === "medium" | |
| 320 | ? "🟡 Medium confidence" | |
| 321 | : "🔴 Low confidence"; | |
| 322 | ||
| 323 | return `${CI_AUTOFIX_MARKER} | |
| 324 | ${idempotencyMarker} | |
| 325 | ||
| 326 | ## 🔧 AI Auto-Fix | |
| 327 | ||
| 328 | ${result.explanation} | |
| 329 | ||
| 330 | **Confidence:** ${confidenceBadge} | |
| 331 | ||
| 332 | \`\`\`diff | |
| 333 | ${result.patch} | |
| 334 | \`\`\` | |
| 335 | ||
| 336 | <details><summary>Apply this fix</summary> | |
| 337 | ||
| 338 | Copy the patch above or click **Apply Fix** to commit it automatically. | |
| 339 | ||
| 340 | <form method="post" action="/api/pr-comments/COMMENT_ID/apply-autofix" style="display:inline"> | |
| 341 | <button type="submit" style="margin-top:8px;padding:6px 14px;background:#6c63ff;color:#fff;border:none;border-radius:6px;cursor:pointer"> | |
| 342 | ⚡ Apply Fix | |
| 343 | </button> | |
| 344 | </form> | |
| 345 | ||
| 346 | </details> | |
| 347 | ||
| 348 | <sub>Gate run: <code>${gateRunId}</code> · Affected files: ${result.affectedFiles.join(", ") || "see patch above"}</sub>`; | |
| 349 | } | |
| 350 | ||
| 351 | // --------------------------------------------------------------------------- | |
| 352 | // Apply autofix | |
| 353 | // --------------------------------------------------------------------------- | |
| 354 | ||
| 355 | /** | |
| 356 | * Applies the patch from a PR comment onto a new branch. | |
| 357 | * Returns the new branch name so the caller can redirect to compare view. | |
| 358 | */ | |
| 359 | export async function applyAutofix( | |
| 360 | prCommentId: string, | |
| 361 | userId: string | |
| 362 | ): Promise<{ branchName: string }> { | |
| 363 | // 1. Load the comment | |
| 364 | const [comment] = await db | |
| 365 | .select({ | |
| 366 | id: prComments.id, | |
| 367 | pullRequestId: prComments.pullRequestId, | |
| 368 | body: prComments.body, | |
| 369 | isAiReview: prComments.isAiReview, | |
| 370 | }) | |
| 371 | .from(prComments) | |
| 372 | .where(eq(prComments.id, prCommentId)) | |
| 373 | .limit(1); | |
| 374 | ||
| 375 | if (!comment) throw new Error("Comment not found"); | |
| 376 | if (!comment.body.includes(CI_AUTOFIX_MARKER)) { | |
| 377 | throw new Error("Not an autofix comment"); | |
| 378 | } | |
| 379 | ||
| 380 | // 2. Load PR + repo for access check | |
| 381 | const [pr] = await db | |
| 382 | .select() | |
| 383 | .from(pullRequests) | |
| 384 | .where(eq(pullRequests.id, comment.pullRequestId)) | |
| 385 | .limit(1); | |
| 386 | ||
| 387 | if (!pr) throw new Error("PR not found"); | |
| 388 | ||
| 389 | const [repoRow] = await db | |
| 390 | .select({ | |
| 391 | id: repositories.id, | |
| 392 | name: repositories.name, | |
| 393 | ownerId: repositories.ownerId, | |
| 394 | ownerUsername: users.username, | |
| 395 | }) | |
| 396 | .from(repositories) | |
| 397 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 398 | .where(eq(repositories.id, pr.repositoryId)) | |
| 399 | .limit(1); | |
| 400 | ||
| 401 | if (!repoRow) throw new Error("Repository not found"); | |
| 402 | ||
| 403 | // Verify write access: must be repo owner or collaborator | |
| 404 | const isOwner = repoRow.ownerId === userId; | |
| 405 | if (!isOwner) { | |
| 406 | const [collab] = await db | |
| 407 | .select({ id: repoCollaborators.id }) | |
| 408 | .from(repoCollaborators) | |
| 409 | .where( | |
| 410 | and( | |
| 411 | eq(repoCollaborators.repositoryId, repoRow.id), | |
| 412 | eq(repoCollaborators.userId, userId) | |
| 413 | ) | |
| 414 | ) | |
| 415 | .limit(1); | |
| 416 | if (!collab) throw new Error("Forbidden: no write access"); | |
| 417 | } | |
| 418 | ||
| 419 | // 3. Extract patch from comment body | |
| 420 | const patchMatch = comment.body.match(/```diff\n([\s\S]*?)```/); | |
| 421 | if (!patchMatch) throw new Error("No patch found in comment"); | |
| 422 | const patch = patchMatch[1]; | |
| 423 | ||
| 424 | // 4. Create a new branch from the PR's head | |
| 425 | const branchName = `fix/autofix-${Date.now()}`; | |
| 426 | const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name); | |
| 427 | ||
| 428 | // Create the branch at the PR head SHA | |
| 429 | const headSha = await spawnGit( | |
| 430 | ["rev-parse", pr.headBranch], | |
| 431 | repoDir | |
| 432 | ); | |
| 433 | if (headSha.exitCode !== 0) throw new Error("Cannot resolve head branch"); | |
| 434 | ||
| 435 | await spawnGit( | |
| 436 | ["branch", branchName, headSha.stdout.trim()], | |
| 437 | repoDir | |
| 438 | ); | |
| 439 | ||
| 440 | // 5. Apply the patch via git apply in a temp worktree | |
| 441 | const tmpDir = await mkdtemp(join(tmpdir(), "autofix-")); | |
| 442 | try { | |
| 443 | // Add worktree for the new branch | |
| 444 | const wtResult = await spawnGit( | |
| 445 | ["worktree", "add", tmpDir, branchName], | |
| 446 | repoDir | |
| 447 | ); | |
| 448 | if (wtResult.exitCode !== 0) { | |
| 449 | throw new Error(`git worktree add failed: ${wtResult.stderr}`); | |
| 450 | } | |
| 451 | ||
| 452 | // Write the patch to a temp file | |
| 453 | const patchFile = join(tmpDir, "autofix.patch"); | |
| 454 | await writeFile(patchFile, patch, "utf8"); | |
| 455 | ||
| 456 | // Apply the patch | |
| 457 | const applyResult = await spawnGit( | |
| 458 | ["apply", "--index", patchFile], | |
| 459 | tmpDir | |
| 460 | ); | |
| 461 | if (applyResult.exitCode !== 0) { | |
| 462 | throw new Error(`git apply failed: ${applyResult.stderr}`); | |
| 463 | } | |
| 464 | ||
| 465 | // 6. Commit | |
| 466 | const commitResult = await spawnGit( | |
| 467 | [ | |
| 468 | "commit", | |
| 469 | "-m", | |
| 470 | "fix: apply AI autofix for CI failure", | |
| 471 | "--author", | |
| 472 | "gluecron[bot] <bot@gluecron.com>", | |
| 473 | ], | |
| 474 | tmpDir | |
| 475 | ); | |
| 476 | if (commitResult.exitCode !== 0) { | |
| 477 | throw new Error(`git commit failed: ${commitResult.stderr}`); | |
| 478 | } | |
| 479 | ||
| 480 | // 7. Push the branch back to the bare repo | |
| 481 | // In a bare-repo + worktree setup the push target is the bare repo itself. | |
| 482 | await spawnGit( | |
| 483 | ["push", repoDir, `HEAD:refs/heads/${branchName}`], | |
| 484 | tmpDir | |
| 485 | ); | |
| 486 | } finally { | |
| 487 | // Cleanup worktree | |
| 488 | await spawnGit(["worktree", "remove", "--force", tmpDir], repoDir).catch( | |
| 489 | () => {} | |
| 490 | ); | |
| 491 | await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); | |
| 492 | } | |
| 493 | ||
| 494 | return { branchName }; | |
| 495 | } |