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 | /** |
| bc519ae | 2 | * CI Auto-Fix — when a gate run or workflow run fails on a PR, the repair |
| 3 | * flywheel cache is consulted first (Tier 0); on a miss Claude reads the | |
| 4 | * error logs, the failing test file, and the PR diff. Either way a | |
| 5 | * ready-to-apply patch is posted as a comment on the PR. | |
| 34e63b9 | 6 | * |
| 7 | * Entry points: | |
| 8 | * triggerCiAutofix(gateRunId) — fire-and-forget; call after a gate_run | |
| 9 | * row is written with status="failed". | |
| 10 | * applyAutofix(prCommentId, userId) — apply the patch from a comment onto | |
| bc519ae | 11 | * a new branch and return the branch name. Settles the flywheel entry |
| 12 | * (success/failed) so the cache's confidence accumulates. | |
| 34e63b9 | 13 | * |
| 14 | * Route wiring (src/routes/pulls.tsx or src/routes/api.ts): | |
| 15 | * POST /api/pr-comments/:commentId/apply-autofix → applyAutofix | |
| bc519ae | 16 | * |
| 17 | * Flywheel wiring (BUILD_BIBLE §7 finding 1): every failure is fingerprinted | |
| 18 | * via repair-flywheel.ts; a previously-successful patch with the same | |
| 19 | * signature and a good success rate is served WITHOUT an AI call. All served | |
| 20 | * fixes are recorded as 'pending' flywheel rows and settled on apply. | |
| 21 | * Flywheel/DB errors never break this path — a broken cache degrades to the | |
| 22 | * old always-call-AI behaviour. | |
| 34e63b9 | 23 | */ |
| 24 | ||
| 25 | import { and, eq } from "drizzle-orm"; | |
| 26 | import { mkdtemp, rm, writeFile } from "fs/promises"; | |
| 27 | import { join } from "path"; | |
| 28 | import { tmpdir } from "os"; | |
| 29 | import { db } from "../db"; | |
| bc519ae | 30 | import { |
| 31 | findCachedRepair, | |
| 32 | recordRepair, | |
| 33 | updateOutcome, | |
| 34 | type CachedRepair, | |
| 35 | type RecordRepairInput, | |
| 36 | } from "./repair-flywheel"; | |
| 34e63b9 | 37 | import { |
| 38 | gateRuns, | |
| 39 | pullRequests, | |
| 40 | prComments, | |
| 41 | repositories, | |
| 42 | users, | |
| 43 | repoCollaborators, | |
| 6682dbe | 44 | flywheelTelemetry, |
| 34e63b9 | 45 | } from "../db/schema"; |
| 46 | import { getRepoPath } from "../git/repository"; | |
| 47 | import { getBotUserIdOrFallback } from "./bot-user"; | |
| 48 | import { | |
| 49 | getAnthropic, | |
| 50 | isAiAvailable, | |
| 51 | MODEL_SONNET, | |
| 52 | extractText, | |
| 53 | parseJsonResponse, | |
| 54 | } from "./ai-client"; | |
| 479dcd9 | 55 | import { |
| 56 | getAutomationSettings, | |
| 57 | type AutomationSettingsLoader, | |
| 58 | } from "./automation-settings"; | |
| 34e63b9 | 59 | |
| 60 | // --------------------------------------------------------------------------- | |
| 61 | // Types | |
| 62 | // --------------------------------------------------------------------------- | |
| 63 | ||
| 64 | export interface AutofixResult { | |
| 65 | prNumber: number; | |
| 66 | repoId: string; | |
| 67 | gateRunId: string; | |
| 68 | patch: string; // unified diff format | |
| 69 | explanation: string; // 2-3 sentence explanation | |
| 70 | confidence: "high" | "medium" | "low"; | |
| 71 | affectedFiles: string[]; | |
| 72 | } | |
| 73 | ||
| bc519ae | 74 | export interface ClaudeAutofixResponse { |
| 34e63b9 | 75 | patch: string; |
| 76 | explanation: string; | |
| 77 | confidence: "high" | "medium" | "low"; | |
| 78 | affectedFiles: string[]; | |
| 79 | } | |
| 80 | ||
| bc519ae | 81 | /** |
| 82 | * DI seam for the repair-flywheel wiring. Production callers omit this and | |
| 83 | * get the real implementations; tests inject fakes so no DB is touched. | |
| 84 | */ | |
| 85 | export interface CiAutofixDeps { | |
| 86 | findCachedRepair?: typeof findCachedRepair; | |
| 87 | recordRepair?: typeof recordRepair; | |
| 88 | updateOutcome?: typeof updateOutcome; | |
| 89 | aiAvailable?: () => boolean; | |
| 479dcd9 | 90 | /** Inject the per-repo automation settings loader (tests). */ |
| 91 | loadAutomationSettings?: AutomationSettingsLoader; | |
| bc519ae | 92 | } |
| 93 | ||
| 94 | /** The fix triggerCiAutofix decided to post, plus its flywheel bookkeeping. */ | |
| 95 | export interface AutofixPlan { | |
| 96 | /** 'cache' = Tier-0 flywheel replay (no AI call); 'ai' = fresh Sonnet patch. */ | |
| 97 | source: "cache" | "ai"; | |
| 98 | fix: ClaudeAutofixResponse; | |
| 99 | /** Flywheel row recorded as 'pending' for this attempt; null if recording failed. */ | |
| 100 | flywheelEntryId: string | null; | |
| 101 | /** On a cache hit: the parent pattern that was replayed. */ | |
| 102 | cachedPatternId: string | null; | |
| 103 | } | |
| 104 | ||
| 34e63b9 | 105 | // --------------------------------------------------------------------------- |
| 106 | // Constants | |
| 107 | // --------------------------------------------------------------------------- | |
| 108 | ||
| 109 | /** Idempotency marker embedded in every autofix comment. */ | |
| 110 | export const CI_AUTOFIX_MARKER = "<!-- gluecron:ci-autofix:v1 -->"; | |
| 111 | ||
| bc519ae | 112 | /** |
| 113 | * Marker carrying the pending flywheel entry id, embedded in the autofix | |
| 114 | * comment so applyAutofix can settle the outcome (success/failed) later. | |
| 115 | */ | |
| 116 | export const FLYWHEEL_MARKER_PREFIX = "<!-- gluecron:ci-autofix:flywheel:"; | |
| 117 | ||
| 118 | /** | |
| 119 | * Minimum settled success rate before a cached pattern is replayed instead | |
| 120 | * of calling the AI. Below this the cache is considered unreliable for the | |
| 121 | * signature and we fall through to a fresh Sonnet patch. | |
| 122 | */ | |
| 123 | export const CACHE_MIN_SUCCESS_RATE = 0.5; | |
| 124 | ||
| 6682dbe | 125 | /** Maximum number of autofix iterations before handing off to human review. */ |
| 126 | export const MAX_AUTOFIX_ITERATIONS = 3; | |
| 127 | ||
| 34e63b9 | 128 | /** Max bytes of PR diff sent to Claude. */ |
| 129 | const MAX_DIFF_BYTES = 80 * 1024; | |
| 130 | ||
| 131 | /** Max bytes of error log sent to Claude. */ | |
| 132 | const MAX_LOG_BYTES = 3 * 1024; | |
| 133 | ||
| 134 | /** Max bytes per test file read. */ | |
| 135 | const MAX_FILE_BYTES = 10 * 1024; | |
| 136 | ||
| 137 | /** Max number of failing test files to read. */ | |
| 138 | const MAX_TEST_FILES = 3; | |
| 139 | ||
| 140 | // --------------------------------------------------------------------------- | |
| 141 | // Helpers | |
| 142 | // --------------------------------------------------------------------------- | |
| 143 | ||
| 144 | async function spawnGit( | |
| 145 | args: string[], | |
| 146 | cwd: string | |
| 147 | ): Promise<{ stdout: string; stderr: string; exitCode: number }> { | |
| 148 | const proc = Bun.spawn(["git", ...args], { | |
| 149 | cwd, | |
| 150 | stdout: "pipe", | |
| 151 | stderr: "pipe", | |
| 152 | }); | |
| 153 | const [stdout, stderr] = await Promise.all([ | |
| 154 | new Response(proc.stdout).text(), | |
| 155 | new Response(proc.stderr).text(), | |
| 156 | ]); | |
| 157 | const exitCode = await proc.exited; | |
| 158 | return { stdout, stderr, exitCode }; | |
| 159 | } | |
| 160 | ||
| 161 | function truncate(s: string, maxBytes: number): string { | |
| 162 | const buf = Buffer.from(s, "utf8"); | |
| 163 | if (buf.length <= maxBytes) return s; | |
| 164 | return buf.slice(0, maxBytes).toString("utf8") + "\n[truncated]"; | |
| 165 | } | |
| 166 | ||
| 167 | /** | |
| 168 | * Extract failing test file paths, error message, and stack trace from a | |
| 169 | * raw CI error log string. Heuristic — good enough for most JS/TS test | |
| 170 | * runners (Jest, Vitest, Bun test) and Python pytest output. | |
| 171 | */ | |
| 172 | function parseErrorLog(errorLog: string): { | |
| 173 | testFiles: string[]; | |
| 174 | errorSummary: string; | |
| 175 | } { | |
| 176 | const lines = errorLog.split("\n"); | |
| 177 | ||
| 178 | // Collect candidate test file paths: | |
| 179 | // - lines mentioning .test.ts/.spec.ts/.test.js/.spec.js/.test.py paths | |
| 180 | // - lines with "FAIL <path>" (Jest pattern) | |
| 181 | const filePatterns = [ | |
| 182 | /(?:^|\s)([\w./\-]+\.(?:test|spec)\.[jt]sx?)/gm, | |
| 183 | /(?:^|\s)([\w./\-]+_test\.py)/gm, | |
| 184 | /(?:^FAIL\s+)([\w./\-]+)/gm, | |
| 185 | ]; | |
| 186 | ||
| 187 | const filesSet = new Set<string>(); | |
| 188 | for (const pattern of filePatterns) { | |
| 189 | let m: RegExpExecArray | null; | |
| 190 | pattern.lastIndex = 0; | |
| 191 | while ((m = pattern.exec(errorLog)) !== null) { | |
| 192 | const path = m[1].trim(); | |
| 193 | if (path && !path.startsWith("-") && !path.startsWith("+")) { | |
| 194 | filesSet.add(path); | |
| 195 | } | |
| 196 | } | |
| 197 | } | |
| 198 | ||
| 199 | // Error summary: first 3KB of the log (most runners put the error first). | |
| 200 | const errorSummary = truncate(errorLog, MAX_LOG_BYTES); | |
| 201 | ||
| 202 | return { | |
| 203 | testFiles: Array.from(filesSet).slice(0, MAX_TEST_FILES), | |
| 204 | errorSummary, | |
| 205 | }; | |
| 206 | } | |
| 207 | ||
| bc519ae | 208 | // --------------------------------------------------------------------------- |
| 209 | // Repair flywheel wiring (Tier 0) | |
| 210 | // --------------------------------------------------------------------------- | |
| 211 | ||
| 212 | /** Matches the id inside a FLYWHEEL_MARKER_PREFIX comment marker. */ | |
| 213 | const FLYWHEEL_MARKER_RE = /<!-- gluecron:ci-autofix:flywheel:([0-9a-fA-F-]{8,64}) -->/; | |
| 214 | ||
| 215 | /** Parse the flywheel entry id out of an autofix comment body, if present. */ | |
| 216 | export function extractFlywheelEntryId(commentBody: string): string | null { | |
| 217 | const m = commentBody.match(FLYWHEEL_MARKER_RE); | |
| 218 | return m ? m[1] : null; | |
| 219 | } | |
| 220 | ||
| 221 | /** | |
| 222 | * Record a 'pending' flywheel row for a fix we're about to post. Never | |
| 223 | * throws — a flywheel write failure must not stop the fix from shipping, | |
| 224 | * it only means this attempt won't contribute to the cache's learning. | |
| 225 | */ | |
| 226 | async function safeRecordRepair( | |
| 227 | input: RecordRepairInput, | |
| 228 | deps: CiAutofixDeps | |
| 229 | ): Promise<string | null> { | |
| 230 | const record = deps.recordRepair ?? recordRepair; | |
| 231 | try { | |
| 232 | return await record(input); | |
| 233 | } catch (err) { | |
| 234 | console.warn( | |
| 235 | "[ci-autofix] flywheel record failed (fix still served):", | |
| 236 | err instanceof Error ? err.message : err | |
| 237 | ); | |
| 238 | return null; | |
| 239 | } | |
| 240 | } | |
| 241 | ||
| 242 | /** | |
| 243 | * Settle the flywheel entry referenced by an autofix comment (if any) so | |
| 244 | * the pattern's success rate accumulates. Never throws — flywheel | |
| 245 | * bookkeeping must not break the apply path. | |
| 246 | */ | |
| 247 | export async function recordAutofixOutcome( | |
| 248 | commentBody: string, | |
| 249 | outcome: "success" | "failed", | |
| 250 | deps: CiAutofixDeps = {} | |
| 251 | ): Promise<void> { | |
| 252 | const entryId = extractFlywheelEntryId(commentBody); | |
| 253 | if (!entryId) return; | |
| 254 | const settle = deps.updateOutcome ?? updateOutcome; | |
| 255 | try { | |
| 256 | await settle(entryId, outcome); | |
| 257 | } catch (err) { | |
| 258 | console.warn( | |
| 259 | "[ci-autofix] flywheel outcome update failed:", | |
| 260 | err instanceof Error ? err.message : err | |
| 261 | ); | |
| 262 | } | |
| 263 | } | |
| 264 | ||
| 6682dbe | 265 | /** |
| 266 | * Count how many autofix comments have been posted to a PR (for circuit breaker). | |
| 267 | */ | |
| 268 | export async function countAutofixAttemptsForPr(pullRequestId: string): Promise<number> { | |
| 269 | try { | |
| 270 | const comments = await db | |
| 271 | .select({ body: prComments.body }) | |
| 272 | .from(prComments) | |
| 273 | .where(and(eq(prComments.pullRequestId, pullRequestId), eq(prComments.isAiReview, true))) | |
| 274 | .limit(20); | |
| 275 | return comments.filter((c) => c.body?.includes(CI_AUTOFIX_MARKER)).length; | |
| 276 | } catch { | |
| 277 | return 0; | |
| 278 | } | |
| 279 | } | |
| 280 | ||
| 281 | /** | |
| 282 | * Build the exhaustion comment posted when the circuit breaker trips. | |
| 283 | */ | |
| 284 | export function buildExhaustionComment(gateRunId: string, attempts: number): string { | |
| 285 | return `${CI_AUTOFIX_MARKER} | |
| 286 | <!-- gluecron:ci-autofix:exhausted:${gateRunId} --> | |
| 287 | ||
| 288 | ## 🔍 Execution Failure Profile | |
| 289 | ||
| 290 | The CI auto-repair engine has reached its iteration limit (**${attempts}** attempts) without resolving the failure. Handing off to human review. | |
| 291 | ||
| 292 | **What was tried:** The repair flywheel exhausted its Tier-0 cache and Tier-2 AI patch options across ${attempts} iterations. | |
| 293 | ||
| 294 | **Recommended next steps:** | |
| 295 | - Review the full error log in the gate run details | |
| 296 | - Inspect the diff manually for root causes not surfaced by the AI | |
| 297 | - Consider if the test expectations need updating rather than the source code | |
| 298 | ||
| 299 | _This diagnosis was generated automatically by the Gluecron repair flywheel._`; | |
| 300 | } | |
| 301 | ||
| 302 | async function recordAutofixTelemetry(args: { | |
| 303 | gateRunId: string; | |
| 304 | repositoryId: string; | |
| 305 | errorRawText: string; | |
| 306 | modelRoute: string; | |
| 307 | attemptsCount: number; | |
| 308 | executionTimeMs: number; | |
| 309 | outcome: "cache_hit" | "ai_success" | "ai_failed"; | |
| 310 | }): Promise<void> { | |
| 311 | try { | |
| 312 | const { createHash } = await import("crypto"); | |
| 313 | const errorSignatureHash = createHash("sha256") | |
| 314 | .update(args.errorRawText.slice(0, 4096)) | |
| 315 | .digest("hex"); | |
| 316 | await db.insert(flywheelTelemetry).values({ | |
| 317 | gateRunId: args.gateRunId, | |
| 318 | repositoryId: args.repositoryId, | |
| 319 | errorSignatureHash, | |
| 320 | errorRawText: args.errorRawText.slice(0, 8192), | |
| 321 | modelRoute: args.modelRoute, | |
| 322 | attemptsCount: args.attemptsCount, | |
| 323 | executionTimeMs: args.executionTimeMs, | |
| 324 | outcome: args.outcome, | |
| 325 | reworkRateStatus: "untouched", | |
| 326 | }); | |
| 327 | } catch (err) { | |
| 328 | console.warn("[ci-autofix] telemetry write failed:", err instanceof Error ? err.message : err); | |
| 329 | } | |
| 330 | } | |
| 331 | ||
| bc519ae | 332 | /** |
| 333 | * Tier-0-then-AI resolution for a CI failure (BUILD_BIBLE §7 finding 1). | |
| 334 | * | |
| 335 | * 1. Tier 0 — consult the repair flywheel for a previously-successful fix | |
| 336 | * with the same failure signature. On a usable hit (success rate ≥ | |
| 337 | * CACHE_MIN_SUCCESS_RATE and a stored patch) the cached patch is | |
| 338 | * served directly: no Anthropic call at all. | |
| 339 | * 2. Tier 2 — fall through to a fresh Claude Sonnet patch via the | |
| 340 | * `generateAiFix` thunk (guarded by isAiAvailable(); the thunk also | |
| 341 | * keeps the expensive git context-gathering off the cache-hit path). | |
| 342 | * | |
| 343 | * Every served fix is recorded as a 'pending' flywheel row; applyAutofix | |
| 344 | * settles it via recordAutofixOutcome. Flywheel/DB failures NEVER break | |
| 345 | * this path — a broken cache degrades to the old always-call-AI behaviour. | |
| 346 | */ | |
| 347 | export async function resolveAutofix(args: { | |
| 348 | repositoryId: string; | |
| 349 | failureText: string; | |
| 350 | generateAiFix: () => Promise<ClaudeAutofixResponse | null>; | |
| 351 | deps?: CiAutofixDeps; | |
| 352 | }): Promise<AutofixPlan | null> { | |
| 353 | const deps = args.deps ?? {}; | |
| 354 | const lookup = deps.findCachedRepair ?? findCachedRepair; | |
| 355 | const aiOk = deps.aiAvailable ?? isAiAvailable; | |
| 356 | ||
| 357 | // ── Tier 0: flywheel cache. Fail open — any error counts as a miss. | |
| 358 | let cached: CachedRepair | null = null; | |
| 359 | if (args.failureText.trim()) { | |
| 360 | try { | |
| 361 | cached = await lookup(args.repositoryId, args.failureText); | |
| 362 | } catch (err) { | |
| 363 | console.warn( | |
| 364 | "[ci-autofix] flywheel lookup failed (falling through to AI):", | |
| 365 | err instanceof Error ? err.message : err | |
| 366 | ); | |
| 367 | } | |
| 368 | } | |
| 369 | ||
| 370 | // A usable hit needs a stored full patch (pre-0105 rows have none) AND a | |
| 371 | // good settled success rate; anything else falls through to the AI tier. | |
| 372 | if ( | |
| 373 | cached && | |
| 374 | cached.patch?.trim() && | |
| 375 | cached.successRate >= CACHE_MIN_SUCCESS_RATE | |
| 376 | ) { | |
| 377 | const fix: ClaudeAutofixResponse = { | |
| 378 | patch: cached.patch, | |
| 379 | explanation: | |
| 380 | cached.patchSummary || | |
| 381 | "Replayed a previously-successful repair for this failure signature.", | |
| 382 | // A pattern that keeps working is high confidence; anything that has | |
| 383 | // failed at least occasionally is medium. | |
| 384 | confidence: cached.successRate >= 0.9 ? "high" : "medium", | |
| 385 | affectedFiles: cached.filesChanged, | |
| 386 | }; | |
| 387 | const entryId = await safeRecordRepair( | |
| 388 | { | |
| 389 | repositoryId: args.repositoryId, | |
| 390 | failureText: args.failureText, | |
| 391 | classification: cached.classification, | |
| 392 | tier: "cached", | |
| 393 | patchSummary: fix.explanation, | |
| 394 | // Carry the patch onto the new row so it is itself replayable once | |
| 395 | // it settles (findCachedRepair prefers the most recent success). | |
| 396 | patch: cached.patch, | |
| 397 | filesChanged: fix.affectedFiles, | |
| 398 | commitSha: null, | |
| 399 | parentPatternId: cached.id, | |
| 400 | }, | |
| 401 | deps | |
| 402 | ); | |
| 403 | // Audit the saved AI call — this line is the flywheel's whole point. | |
| 404 | console.log( | |
| 405 | `[ci-autofix] flywheel cache HIT — pattern ${cached.id} (success rate ${(cached.successRate * 100).toFixed(0)}%, ${cached.hitCount} prior hits) served without an AI call` | |
| 406 | ); | |
| 407 | return { source: "cache", fix, flywheelEntryId: entryId, cachedPatternId: cached.id }; | |
| 408 | } | |
| 409 | ||
| 410 | // ── Tier 2: fresh AI patch. All AI features degrade gracefully when no | |
| 411 | // API key is configured — cached fixes above still work without one. | |
| 412 | if (!aiOk()) return null; | |
| 413 | ||
| 414 | const fix = await args.generateAiFix(); | |
| 415 | if (!fix || !fix.patch || !fix.explanation) return null; | |
| 416 | if (fix.confidence === "low") return null; | |
| 417 | ||
| 418 | const entryId = await safeRecordRepair( | |
| 419 | { | |
| 420 | repositoryId: args.repositoryId, | |
| 421 | failureText: args.failureText, | |
| 422 | classification: null, | |
| 423 | tier: "ai-sonnet", | |
| 424 | patchSummary: fix.explanation, | |
| 425 | // Stored so the entry is replayable by the Tier-0 cache once it | |
| 426 | // settles to 'success'. | |
| 427 | patch: fix.patch, | |
| 428 | filesChanged: fix.affectedFiles, | |
| 429 | commitSha: null, | |
| 430 | }, | |
| 431 | deps | |
| 432 | ); | |
| 433 | return { source: "ai", fix, flywheelEntryId: entryId, cachedPatternId: null }; | |
| 434 | } | |
| 435 | ||
| 34e63b9 | 436 | // --------------------------------------------------------------------------- |
| 437 | // Main entry point | |
| 438 | // --------------------------------------------------------------------------- | |
| 439 | ||
| 440 | /** | |
| 441 | * Fire-and-forget entry point called after a gate_run row is set to 'failed'. | |
| 442 | * Never throws — all errors are swallowed after logging. | |
| bc519ae | 443 | * |
| 444 | * Note: no isAiAvailable() gate here — the Tier-0 flywheel cache needs no | |
| 445 | * API key, so cached fixes still flow when AI is unconfigured. The actual | |
| 446 | * Anthropic call is guarded inside resolveAutofix (graceful degradation). | |
| 34e63b9 | 447 | */ |
| bc519ae | 448 | export async function triggerCiAutofix( |
| 449 | gateRunId: string, | |
| 450 | deps: CiAutofixDeps = {} | |
| 451 | ): Promise<void> { | |
| 34e63b9 | 452 | try { |
| bc519ae | 453 | await _runAutofix(gateRunId, deps); |
| 34e63b9 | 454 | } catch (err) { |
| 455 | console.error( | |
| 456 | "[ci-autofix] crashed:", | |
| 457 | err instanceof Error ? err.message : err | |
| 458 | ); | |
| 459 | } | |
| 460 | } | |
| 461 | ||
| bc519ae | 462 | async function _runAutofix( |
| 463 | gateRunId: string, | |
| 464 | deps: CiAutofixDeps = {} | |
| 465 | ): Promise<void> { | |
| 34e63b9 | 466 | // 1. Load the gate run |
| 467 | const [gateRun] = await db | |
| 468 | .select() | |
| 469 | .from(gateRuns) | |
| 470 | .where(eq(gateRuns.id, gateRunId)) | |
| 471 | .limit(1); | |
| 472 | ||
| 473 | if (!gateRun) return; | |
| 474 | if (gateRun.status !== "failed") return; | |
| 475 | if (!gateRun.pullRequestId) return; | |
| 476 | ||
| 477 | // 2. Load the PR row | |
| 478 | const [pr] = await db | |
| 479 | .select() | |
| 480 | .from(pullRequests) | |
| 481 | .where(eq(pullRequests.id, gateRun.pullRequestId)) | |
| 482 | .limit(1); | |
| 483 | ||
| 484 | if (!pr) return; | |
| 485 | ||
| 486 | // 3. Load repo (owner/name) | |
| 487 | const [repoRow] = await db | |
| 488 | .select({ | |
| 489 | id: repositories.id, | |
| 490 | name: repositories.name, | |
| 491 | diskPath: repositories.diskPath, | |
| 479dcd9 | 492 | ownerId: repositories.ownerId, |
| 34e63b9 | 493 | ownerUsername: users.username, |
| 494 | }) | |
| 495 | .from(repositories) | |
| 496 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 497 | .where(eq(repositories.id, gateRun.repositoryId)) | |
| 498 | .limit(1); | |
| 499 | ||
| 500 | if (!repoRow) return; | |
| 501 | ||
| 479dcd9 | 502 | // Per-repo automation gate — 'off' skips CI autofix entirely; 'suggest' |
| 503 | // (the fail-open default) posts the patch comment as before; 'auto' | |
| 504 | // additionally applies the patch onto a fix/ branch below. | |
| 505 | const automation = await (deps.loadAutomationSettings ?? getAutomationSettings)( | |
| 506 | repoRow.id | |
| 507 | ); | |
| 508 | if (automation.ciAutofixMode === "off") return; | |
| 509 | ||
| 34e63b9 | 510 | // 4. Check idempotency — skip if already posted for this gateRunId |
| 511 | const idempotencyMarker = `<!-- gluecron:ci-autofix:run:${gateRunId} -->`; | |
| 512 | const existing = await db | |
| 513 | .select({ id: prComments.id }) | |
| 514 | .from(prComments) | |
| 515 | .where( | |
| 516 | and( | |
| 517 | eq(prComments.pullRequestId, gateRun.pullRequestId), | |
| 518 | // We check by looking for comments with the autofix marker. | |
| 519 | // drizzle doesn't have LIKE with dynamic params easily, but | |
| 520 | // we query all AI comments and filter client-side (there won't be many). | |
| 521 | eq(prComments.isAiReview, true) | |
| 522 | ) | |
| 523 | ) | |
| 524 | .limit(50); | |
| 525 | ||
| 526 | for (const row of existing) { | |
| 527 | // Load the body to check idempotency marker | |
| 528 | const [full] = await db | |
| 529 | .select({ body: prComments.body }) | |
| 530 | .from(prComments) | |
| 531 | .where(eq(prComments.id, row.id)) | |
| 532 | .limit(1); | |
| 533 | if (full?.body?.includes(idempotencyMarker)) return; | |
| 534 | } | |
| 535 | ||
| 6682dbe | 536 | // Token circuit breaker — cap repair iterations at MAX_AUTOFIX_ITERATIONS. |
| 537 | // If we've already posted that many autofix comments on this PR, post an | |
| 538 | // exhaustion diagnosis and bail out instead of spending more tokens. | |
| 539 | const prAttempts = await countAutofixAttemptsForPr(gateRun.pullRequestId); | |
| 540 | if (prAttempts >= MAX_AUTOFIX_ITERATIONS) { | |
| 541 | const botAuthorId = await getBotUserIdOrFallback(repoRow.id); | |
| 542 | if (botAuthorId) { | |
| 543 | const exhaustedMarker = `<!-- gluecron:ci-autofix:exhausted:${gateRunId} -->`; | |
| 544 | const alreadyExhausted = existing.some(async (row) => { | |
| 545 | const [full] = await db | |
| 546 | .select({ body: prComments.body }) | |
| 547 | .from(prComments) | |
| 548 | .where(eq(prComments.id, row.id)) | |
| 549 | .limit(1); | |
| 550 | return full?.body?.includes(exhaustedMarker); | |
| 551 | }); | |
| 552 | if (!alreadyExhausted) { | |
| 553 | const exhaustionBody = buildExhaustionComment(gateRunId, prAttempts); | |
| 554 | await db.insert(prComments).values({ | |
| 555 | pullRequestId: gateRun.pullRequestId, | |
| 556 | authorId: botAuthorId, | |
| 557 | body: exhaustionBody, | |
| 558 | isAiReview: true, | |
| 559 | }); | |
| 560 | } | |
| 561 | } | |
| 562 | return; | |
| 563 | } | |
| 564 | ||
| 34e63b9 | 565 | const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name); |
| 566 | ||
| bc519ae | 567 | // 5. Failure text — drives both the flywheel signature and the AI prompt. |
| 34e63b9 | 568 | const errorLog = gateRun.summary || gateRun.details || ""; |
| bc519ae | 569 | const failureText = |
| 570 | typeof errorLog === "string" ? errorLog : JSON.stringify(errorLog); | |
| 571 | ||
| 572 | // 6. Tier 0 (flywheel cache) first, then the AI fallback. The expensive | |
| 573 | // context gathering (PR diff + failing test files + Sonnet call) lives | |
| 574 | // inside the thunk so a cache hit never touches git or the API. | |
| 575 | const plan = await resolveAutofix({ | |
| 576 | repositoryId: repoRow.id, | |
| 577 | failureText, | |
| 578 | deps, | |
| 579 | generateAiFix: async () => { | |
| 580 | // 6a. Get the PR diff (max 80KB) | |
| 581 | const diffResult = await spawnGit( | |
| 582 | ["diff", `${pr.baseBranch}...${pr.headBranch}`], | |
| 583 | repoDir | |
| 584 | ); | |
| 585 | const prDiff = truncate(diffResult.stdout, MAX_DIFF_BYTES); | |
| 586 | ||
| 587 | if (!prDiff.trim()) return null; // nothing to work with | |
| 588 | ||
| 589 | // 6b. Parse errorLog to extract test files + error summary | |
| 590 | const { testFiles, errorSummary } = parseErrorLog(failureText); | |
| 591 | ||
| 592 | // 6c. Read failing test files via git show HEAD:path | |
| 593 | let testFileContent = ""; | |
| 594 | for (const filePath of testFiles) { | |
| 595 | const showResult = await spawnGit( | |
| 596 | ["show", `${pr.headBranch}:${filePath}`], | |
| 597 | repoDir | |
| 598 | ); | |
| 599 | if (showResult.exitCode === 0 && showResult.stdout) { | |
| 600 | const content = truncate(showResult.stdout, MAX_FILE_BYTES); | |
| 601 | testFileContent += `\n\n--- ${filePath} ---\n${content}`; | |
| 602 | } | |
| 603 | } | |
| 34e63b9 | 604 | |
| bc519ae | 605 | // 6d. Call Claude Sonnet 4.6 |
| 606 | const client = getAnthropic(); | |
| 607 | const prompt = `You are a senior engineer fixing a CI failure. | |
| 34e63b9 | 608 | |
| 609 | PR diff (what changed): | |
| 610 | ${prDiff} | |
| 611 | ||
| 612 | Failing test output: | |
| 613 | ${errorSummary} | |
| 614 | ||
| 615 | Test file content:${testFileContent || "\n(no test files detected)"} | |
| 616 | ||
| 617 | Produce a minimal unified diff patch that fixes the CI failure. The patch must: | |
| 618 | 1. Be valid unified diff format (--- a/file, +++ b/file, @@ lines) | |
| 619 | 2. Fix only what's needed — no refactoring | |
| 620 | 3. Not modify the test itself unless the test expectation is genuinely wrong | |
| 621 | ||
| 622 | Return JSON: {"patch": "...", "explanation": "...", "confidence": "high|medium|low", "affectedFiles": ["..."]}`; | |
| 623 | ||
| bc519ae | 624 | const message = await client.messages.create({ |
| 625 | model: MODEL_SONNET, | |
| 626 | max_tokens: 4096, | |
| 627 | messages: [{ role: "user", content: prompt }], | |
| 628 | }); | |
| 34e63b9 | 629 | |
| bc519ae | 630 | const rawText = extractText(message); |
| 631 | return parseJsonResponse<ClaudeAutofixResponse>(rawText); | |
| 632 | }, | |
| 633 | }); | |
| 34e63b9 | 634 | |
| bc519ae | 635 | // No usable fix (cache miss + AI declined/low-confidence/unavailable). |
| 636 | if (!plan) return; | |
| 34e63b9 | 637 | |
| bc519ae | 638 | // 7. Build and post the comment |
| 639 | const commentBody = buildAutofixComment(plan, idempotencyMarker, gateRunId); | |
| 34e63b9 | 640 | |
| 641 | const botAuthorId = await getBotUserIdOrFallback(repoRow.id); | |
| 642 | if (!botAuthorId) return; | |
| 643 | ||
| 479dcd9 | 644 | const [posted] = await db |
| 645 | .insert(prComments) | |
| 646 | .values({ | |
| 647 | pullRequestId: gateRun.pullRequestId, | |
| 648 | authorId: botAuthorId, | |
| 649 | body: commentBody, | |
| 650 | isAiReview: true, | |
| 651 | }) | |
| 652 | .returning({ id: prComments.id }); | |
| 653 | ||
| 6682dbe | 654 | // Record flywheel telemetry for observability (fire-and-forget). |
| 655 | void recordAutofixTelemetry({ | |
| 656 | gateRunId, | |
| 657 | repositoryId: repoRow.id, | |
| 658 | errorRawText: failureText, | |
| 659 | modelRoute: plan.source === "cache" ? "flywheel-cache" : MODEL_SONNET, | |
| 660 | attemptsCount: prAttempts + 1, | |
| 661 | executionTimeMs: 0, | |
| 662 | outcome: plan.source === "cache" ? "cache_hit" : "ai_success", | |
| 663 | }); | |
| 664 | ||
| 479dcd9 | 665 | // 'auto' mode — also apply the patch onto a fix/ branch via the same |
| 666 | // path the Apply Fix button drives. Best-effort: an apply failure (it | |
| 667 | // also settles the flywheel entry as 'failed') leaves the suggest-mode | |
| 668 | // comment in place for a human to act on. | |
| 669 | if (automation.ciAutofixMode === "auto" && posted) { | |
| 670 | try { | |
| 671 | const { branchName } = await applyAutofix(posted.id, repoRow.ownerId, deps); | |
| 672 | console.log( | |
| 673 | `[ci-autofix] auto-applied patch from comment ${posted.id} onto ${branchName}` | |
| 674 | ); | |
| 675 | } catch (err) { | |
| 676 | console.warn( | |
| 677 | "[ci-autofix] auto-apply failed (patch comment still posted):", | |
| 678 | err instanceof Error ? err.message : err | |
| 679 | ); | |
| 680 | } | |
| 681 | } | |
| 34e63b9 | 682 | } |
| 683 | ||
| 684 | function buildAutofixComment( | |
| bc519ae | 685 | plan: AutofixPlan, |
| 34e63b9 | 686 | idempotencyMarker: string, |
| 687 | gateRunId: string | |
| 688 | ): string { | |
| bc519ae | 689 | const result = plan.fix; |
| 34e63b9 | 690 | const confidenceBadge = |
| 691 | result.confidence === "high" | |
| 692 | ? "🟢 High confidence" | |
| 693 | : result.confidence === "medium" | |
| 694 | ? "🟡 Medium confidence" | |
| 695 | : "🔴 Low confidence"; | |
| 696 | ||
| bc519ae | 697 | // Flywheel bookkeeping marker — applyAutofix parses this to settle the |
| 698 | // pending entry's outcome. Absent when the flywheel write failed. | |
| 699 | const flywheelMarker = plan.flywheelEntryId | |
| 700 | ? `\n${FLYWHEEL_MARKER_PREFIX}${plan.flywheelEntryId} -->` | |
| 701 | : ""; | |
| 702 | ||
| 703 | const sourceNote = | |
| 704 | plan.source === "cache" | |
| 705 | ? `\n\n♻️ **Served from the repair cache** — this failure signature was fixed successfully before; no AI call was made.` | |
| 706 | : ""; | |
| 707 | ||
| 34e63b9 | 708 | return `${CI_AUTOFIX_MARKER} |
| bc519ae | 709 | ${idempotencyMarker}${flywheelMarker} |
| 34e63b9 | 710 | |
| 711 | ## 🔧 AI Auto-Fix | |
| 712 | ||
| bc519ae | 713 | ${result.explanation}${sourceNote} |
| 34e63b9 | 714 | |
| 715 | **Confidence:** ${confidenceBadge} | |
| 716 | ||
| 717 | \`\`\`diff | |
| 718 | ${result.patch} | |
| 719 | \`\`\` | |
| 720 | ||
| 721 | <details><summary>Apply this fix</summary> | |
| 722 | ||
| 723 | Copy the patch above or click **Apply Fix** to commit it automatically. | |
| 724 | ||
| 725 | <form method="post" action="/api/pr-comments/COMMENT_ID/apply-autofix" style="display:inline"> | |
| 726 | <button type="submit" style="margin-top:8px;padding:6px 14px;background:#6c63ff;color:#fff;border:none;border-radius:6px;cursor:pointer"> | |
| 727 | ⚡ Apply Fix | |
| 728 | </button> | |
| 729 | </form> | |
| 730 | ||
| 731 | </details> | |
| 732 | ||
| 733 | <sub>Gate run: <code>${gateRunId}</code> · Affected files: ${result.affectedFiles.join(", ") || "see patch above"}</sub>`; | |
| 734 | } | |
| 735 | ||
| 736 | // --------------------------------------------------------------------------- | |
| 737 | // Apply autofix | |
| 738 | // --------------------------------------------------------------------------- | |
| 739 | ||
| 740 | /** | |
| 741 | * Applies the patch from a PR comment onto a new branch. | |
| 742 | * Returns the new branch name so the caller can redirect to compare view. | |
| bc519ae | 743 | * |
| 744 | * Also settles the comment's pending flywheel entry: a clean apply+commit | |
| 745 | * records 'success' (the pattern becomes replayable by the Tier-0 cache), | |
| 746 | * an apply failure records 'failed' so unreliable patterns lose confidence. | |
| 34e63b9 | 747 | */ |
| 748 | export async function applyAutofix( | |
| 749 | prCommentId: string, | |
| bc519ae | 750 | userId: string, |
| 751 | deps: CiAutofixDeps = {} | |
| 34e63b9 | 752 | ): Promise<{ branchName: string }> { |
| 753 | // 1. Load the comment | |
| 754 | const [comment] = await db | |
| 755 | .select({ | |
| 756 | id: prComments.id, | |
| 757 | pullRequestId: prComments.pullRequestId, | |
| 758 | body: prComments.body, | |
| 759 | isAiReview: prComments.isAiReview, | |
| 760 | }) | |
| 761 | .from(prComments) | |
| 762 | .where(eq(prComments.id, prCommentId)) | |
| 763 | .limit(1); | |
| 764 | ||
| 765 | if (!comment) throw new Error("Comment not found"); | |
| 766 | if (!comment.body.includes(CI_AUTOFIX_MARKER)) { | |
| 767 | throw new Error("Not an autofix comment"); | |
| 768 | } | |
| 769 | ||
| 770 | // 2. Load PR + repo for access check | |
| 771 | const [pr] = await db | |
| 772 | .select() | |
| 773 | .from(pullRequests) | |
| 774 | .where(eq(pullRequests.id, comment.pullRequestId)) | |
| 775 | .limit(1); | |
| 776 | ||
| 777 | if (!pr) throw new Error("PR not found"); | |
| 778 | ||
| 779 | const [repoRow] = await db | |
| 780 | .select({ | |
| 781 | id: repositories.id, | |
| 782 | name: repositories.name, | |
| 783 | ownerId: repositories.ownerId, | |
| 784 | ownerUsername: users.username, | |
| 785 | }) | |
| 786 | .from(repositories) | |
| 787 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 788 | .where(eq(repositories.id, pr.repositoryId)) | |
| 789 | .limit(1); | |
| 790 | ||
| 791 | if (!repoRow) throw new Error("Repository not found"); | |
| 792 | ||
| 793 | // Verify write access: must be repo owner or collaborator | |
| 794 | const isOwner = repoRow.ownerId === userId; | |
| 795 | if (!isOwner) { | |
| 796 | const [collab] = await db | |
| 797 | .select({ id: repoCollaborators.id }) | |
| 798 | .from(repoCollaborators) | |
| 799 | .where( | |
| 800 | and( | |
| 801 | eq(repoCollaborators.repositoryId, repoRow.id), | |
| 802 | eq(repoCollaborators.userId, userId) | |
| 803 | ) | |
| 804 | ) | |
| 805 | .limit(1); | |
| 806 | if (!collab) throw new Error("Forbidden: no write access"); | |
| 807 | } | |
| 808 | ||
| 809 | // 3. Extract patch from comment body | |
| 810 | const patchMatch = comment.body.match(/```diff\n([\s\S]*?)```/); | |
| 811 | if (!patchMatch) throw new Error("No patch found in comment"); | |
| 812 | const patch = patchMatch[1]; | |
| 813 | ||
| 814 | // 4. Create a new branch from the PR's head | |
| 815 | const branchName = `fix/autofix-${Date.now()}`; | |
| 816 | const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name); | |
| 817 | ||
| 818 | // Create the branch at the PR head SHA | |
| 819 | const headSha = await spawnGit( | |
| 820 | ["rev-parse", pr.headBranch], | |
| 821 | repoDir | |
| 822 | ); | |
| 823 | if (headSha.exitCode !== 0) throw new Error("Cannot resolve head branch"); | |
| 824 | ||
| 825 | await spawnGit( | |
| 826 | ["branch", branchName, headSha.stdout.trim()], | |
| 827 | repoDir | |
| 828 | ); | |
| 829 | ||
| 830 | // 5. Apply the patch via git apply in a temp worktree | |
| 831 | const tmpDir = await mkdtemp(join(tmpdir(), "autofix-")); | |
| 832 | try { | |
| 833 | // Add worktree for the new branch | |
| 834 | const wtResult = await spawnGit( | |
| 835 | ["worktree", "add", tmpDir, branchName], | |
| 836 | repoDir | |
| 837 | ); | |
| 838 | if (wtResult.exitCode !== 0) { | |
| 839 | throw new Error(`git worktree add failed: ${wtResult.stderr}`); | |
| 840 | } | |
| 841 | ||
| 842 | // Write the patch to a temp file | |
| 843 | const patchFile = join(tmpDir, "autofix.patch"); | |
| 844 | await writeFile(patchFile, patch, "utf8"); | |
| 845 | ||
| 846 | // Apply the patch | |
| 847 | const applyResult = await spawnGit( | |
| 848 | ["apply", "--index", patchFile], | |
| 849 | tmpDir | |
| 850 | ); | |
| 851 | if (applyResult.exitCode !== 0) { | |
| 852 | throw new Error(`git apply failed: ${applyResult.stderr}`); | |
| 853 | } | |
| 854 | ||
| 855 | // 6. Commit | |
| 856 | const commitResult = await spawnGit( | |
| 857 | [ | |
| 858 | "commit", | |
| 859 | "-m", | |
| 860 | "fix: apply AI autofix for CI failure", | |
| 861 | "--author", | |
| 862 | "gluecron[bot] <bot@gluecron.com>", | |
| 863 | ], | |
| 864 | tmpDir | |
| 865 | ); | |
| 866 | if (commitResult.exitCode !== 0) { | |
| 867 | throw new Error(`git commit failed: ${commitResult.stderr}`); | |
| 868 | } | |
| 869 | ||
| 870 | // 7. Push the branch back to the bare repo | |
| 871 | // In a bare-repo + worktree setup the push target is the bare repo itself. | |
| 872 | await spawnGit( | |
| 873 | ["push", repoDir, `HEAD:refs/heads/${branchName}`], | |
| 874 | tmpDir | |
| 875 | ); | |
| bc519ae | 876 | |
| 877 | // 8. The repair landed — settle the flywheel entry so this pattern's | |
| 878 | // success rate climbs and future identical failures hit the Tier-0 cache. | |
| 879 | await recordAutofixOutcome(comment.body, "success", deps); | |
| 880 | } catch (err) { | |
| 881 | // The patch failed to apply/commit/push — settle as 'failed' so the | |
| 882 | // flywheel learns this pattern is unreliable. Best-effort: the original | |
| 883 | // error is always rethrown for the route to surface. | |
| 884 | await recordAutofixOutcome(comment.body, "failed", deps); | |
| 885 | throw err; | |
| 34e63b9 | 886 | } finally { |
| 887 | // Cleanup worktree | |
| 888 | await spawnGit(["worktree", "remove", "--force", tmpDir], repoDir).catch( | |
| 889 | () => {} | |
| 890 | ); | |
| 891 | await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); | |
| 892 | } | |
| 893 | ||
| 894 | return { branchName }; | |
| 895 | } |