Commit34e63b9unknown_key
feat: CI auto-fix + proactive pattern recognition — AI patches failing tests, surfaces recurring bugs
feat: CI auto-fix + proactive pattern recognition — AI patches failing tests, surfaces recurring bugs https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
6 files changed+1032−034e63b98cd7ad9d2b7d6c2d0ea92ec991a4f3ca7
6 changed files+1032−0
Addeddrizzle/0096_recurring_patterns.sql+14−0View fileUnifiedSplit
@@ -0,0 +1,14 @@
1CREATE TABLE IF NOT EXISTS recurring_patterns (
2 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
4 title TEXT NOT NULL,
5 occurrences INTEGER NOT NULL DEFAULT 1,
6 commit_shas JSONB NOT NULL DEFAULT '[]',
7 root_cause_hypothesis TEXT,
8 suggested_file TEXT,
9 severity TEXT NOT NULL DEFAULT 'medium',
10 detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
11 expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
12);
13CREATE INDEX IF NOT EXISTS idx_recurring_patterns_repo ON recurring_patterns(repository_id);
14CREATE INDEX IF NOT EXISTS idx_recurring_patterns_expires ON recurring_patterns(expires_at);
Modifiedsrc/db/schema.ts+28−0View fileUnifiedSplit
@@ -4471,3 +4471,31 @@ export type NewCloudDeployConfig = typeof cloudDeployConfigs.$inferInsert;
44714471export type CloudDeployment = typeof cloudDeployments.$inferSelect;
44724472export type NewCloudDeployment = typeof cloudDeployments.$inferInsert;
44734473>>>>>>> b11ffa9 (feat: multi-cloud deploy integration — push to main deploys to Fly/Railway/Render/Vercel)
4474// ---------------------------------------------------------------------------
4475// Recurring pattern detection (migration 0088)
4476// ---------------------------------------------------------------------------
4477
4478export const recurringPatterns = pgTable(
4479 "recurring_patterns",
4480 {
4481 id: uuid("id").primaryKey().defaultRandom(),
4482 repositoryId: uuid("repository_id")
4483 .notNull()
4484 .references(() => repositories.id, { onDelete: "cascade" }),
4485 title: text("title").notNull(),
4486 occurrences: integer("occurrences").notNull().default(1),
4487 commitShas: jsonb("commit_shas").$type<string[]>().notNull().default([]),
4488 rootCauseHypothesis: text("root_cause_hypothesis"),
4489 suggestedFile: text("suggested_file"),
4490 severity: text("severity").notNull().default("medium"),
4491 detectedAt: timestamp("detected_at", { withTimezone: true }).notNull().defaultNow(),
4492 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
4493 },
4494 (table) => [
4495 index("idx_recurring_patterns_repo").on(table.repositoryId),
4496 index("idx_recurring_patterns_expires").on(table.expiresAt),
4497 ]
4498);
4499
4500export type RecurringPattern = typeof recurringPatterns.$inferSelect;
4501export type NewRecurringPattern = typeof recurringPatterns.$inferInsert;
Addedsrc/lib/ci-autofix.ts+495−0View fileUnifiedSplit
@@ -0,0 +1,495 @@
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
16import { and, eq } from "drizzle-orm";
17import { mkdtemp, rm, writeFile } from "fs/promises";
18import { join } from "path";
19import { tmpdir } from "os";
20import { db } from "../db";
21import {
22 gateRuns,
23 pullRequests,
24 prComments,
25 repositories,
26 users,
27 repoCollaborators,
28} from "../db/schema";
29import { getRepoPath } from "../git/repository";
30import { getBotUserIdOrFallback } from "./bot-user";
31import {
32 getAnthropic,
33 isAiAvailable,
34 MODEL_SONNET,
35 extractText,
36 parseJsonResponse,
37} from "./ai-client";
38
39// ---------------------------------------------------------------------------
40// Types
41// ---------------------------------------------------------------------------
42
43export 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
53interface 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. */
65export const CI_AUTOFIX_MARKER = "<!-- gluecron:ci-autofix:v1 -->";
66
67/** Max bytes of PR diff sent to Claude. */
68const MAX_DIFF_BYTES = 80 * 1024;
69
70/** Max bytes of error log sent to Claude. */
71const MAX_LOG_BYTES = 3 * 1024;
72
73/** Max bytes per test file read. */
74const MAX_FILE_BYTES = 10 * 1024;
75
76/** Max number of failing test files to read. */
77const MAX_TEST_FILES = 3;
78
79// ---------------------------------------------------------------------------
80// Helpers
81// ---------------------------------------------------------------------------
82
83async 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
100function 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 */
111function 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 */
155export 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
168async 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
264PR diff (what changed):
265${prDiff}
266
267Failing test output:
268${errorSummary}
269
270Test file content:${testFileContent || "\n(no test files detected)"}
271
272Produce a minimal unified diff patch that fixes the CI failure. The patch must:
2731. Be valid unified diff format (--- a/file, +++ b/file, @@ lines)
2742. Fix only what's needed — no refactoring
2753. Not modify the test itself unless the test expectation is genuinely wrong
276
277Return 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
311function 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
338Copy 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 */
359export 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}
Addedsrc/lib/pattern-detector.ts+370−0View fileUnifiedSplit
@@ -0,0 +1,370 @@
1/**
2 * Proactive Pattern Recognition — detects when the same bug has been fixed
3 * multiple times and surfaces a warning on PR pages.
4 *
5 * Public surface:
6 * detectRecurringPatterns(repoId) — analyse last 90 days of fix commits
7 * and upsert findings into `recurring_patterns` with a 24h TTL.
8 * getPatternWarning(repoId, changedFiles) — return the highest-severity
9 * pattern whose suggestedFile overlaps with changedFiles, or null.
10 *
11 * Both functions are fire-and-forget safe and never throw.
12 */
13
14import { and, eq, gt, desc } from "drizzle-orm";
15import { db } from "../db";
16import { repositories, recurringPatterns, users } from "../db/schema";
17import { getRepoPath } from "../git/repository";
18import {
19 getAnthropic,
20 isAiAvailable,
21 MODEL_SONNET,
22 extractText,
23 parseJsonResponse,
24} from "./ai-client";
25
26// ---------------------------------------------------------------------------
27// Types
28// ---------------------------------------------------------------------------
29
30export interface Pattern {
31 id?: string;
32 title: string;
33 occurrences: number;
34 commits: string[];
35 rootCauseHypothesis: string | null;
36 suggestedFile: string | null;
37 severity: "high" | "medium" | "low";
38}
39
40interface ClaudePatternResponse {
41 title: string;
42 occurrences: number;
43 commits: string[];
44 rootCauseHypothesis: string;
45 suggestedFile: string;
46 severity: "high" | "medium" | "low";
47}
48
49// ---------------------------------------------------------------------------
50// In-memory cache (per-repo, 24h TTL)
51// ---------------------------------------------------------------------------
52
53interface CacheEntry {
54 patterns: Pattern[];
55 expiresAt: number; // epoch ms
56}
57
58const _cache = new Map<string, CacheEntry>();
59
60function getCached(repoId: string): Pattern[] | null {
61 const entry = _cache.get(repoId);
62 if (!entry) return null;
63 if (Date.now() > entry.expiresAt) {
64 _cache.delete(repoId);
65 return null;
66 }
67 return entry.patterns;
68}
69
70function setCached(repoId: string, patterns: Pattern[]): void {
71 _cache.set(repoId, {
72 patterns,
73 expiresAt: Date.now() + 24 * 60 * 60 * 1000,
74 });
75}
76
77// ---------------------------------------------------------------------------
78// Helpers
79// ---------------------------------------------------------------------------
80
81async function spawnGit(
82 args: string[],
83 cwd: string
84): Promise<{ stdout: string; stderr: string; exitCode: number }> {
85 const proc = Bun.spawn(["git", ...args], {
86 cwd,
87 stdout: "pipe",
88 stderr: "pipe",
89 });
90 const [stdout, stderr] = await Promise.all([
91 new Response(proc.stdout).text(),
92 new Response(proc.stderr).text(),
93 ]);
94 const exitCode = await proc.exited;
95 return { stdout, stderr, exitCode };
96}
97
98function truncate(s: string, maxBytes: number): string {
99 const buf = Buffer.from(s, "utf8");
100 if (buf.length <= maxBytes) return s;
101 return buf.slice(0, maxBytes).toString("utf8") + "\n[truncated]";
102}
103
104const FIX_KEYWORDS = /\b(fix|bug|patch|revert|hotfix|fixes|fixed|bugfix)\b/i;
105const MAX_FIX_COMMITS = 30;
106const MAX_DIFF_BYTES_PER_COMMIT = 2 * 1024;
107
108// ---------------------------------------------------------------------------
109// Core detection
110// ---------------------------------------------------------------------------
111
112/**
113 * Analyse the last 90 days of commits in a repo. Finds commits whose messages
114 * suggest a bug fix, gets their diffs, and asks Claude to identify recurring
115 * patterns. Results are cached in-memory for 24h AND persisted to the
116 * `recurring_patterns` table.
117 */
118export async function detectRecurringPatterns(
119 repoId: string
120): Promise<Pattern[]> {
121 if (!isAiAvailable()) return [];
122
123 // Check in-memory cache first
124 const cached = getCached(repoId);
125 if (cached) return cached;
126
127 // Check DB cache (another instance may have already run this recently)
128 const now = new Date();
129 const dbCached = await db
130 .select()
131 .from(recurringPatterns)
132 .where(
133 and(
134 eq(recurringPatterns.repositoryId, repoId),
135 gt(recurringPatterns.expiresAt, now)
136 )
137 )
138 .orderBy(desc(recurringPatterns.detectedAt))
139 .limit(5);
140
141 if (dbCached.length > 0) {
142 const patterns: Pattern[] = dbCached.map((row) => ({
143 id: row.id,
144 title: row.title,
145 occurrences: row.occurrences,
146 commits: (row.commitShas as string[]) ?? [],
147 rootCauseHypothesis: row.rootCauseHypothesis,
148 suggestedFile: row.suggestedFile,
149 severity: row.severity as "high" | "medium" | "low",
150 }));
151 setCached(repoId, patterns);
152 return patterns;
153 }
154
155 try {
156 return await _runDetection(repoId);
157 } catch (err) {
158 console.error(
159 "[pattern-detector] crashed:",
160 err instanceof Error ? err.message : err
161 );
162 return [];
163 }
164}
165
166async function _runDetection(repoId: string): Promise<Pattern[]> {
167 // Resolve repo owner/name for getRepoPath
168 const [repoRow] = await db
169 .select({
170 id: repositories.id,
171 name: repositories.name,
172 ownerUsername: users.username,
173 })
174 .from(repositories)
175 .innerJoin(users, eq(repositories.ownerId, users.id))
176 .where(eq(repositories.id, repoId))
177 .limit(1);
178
179 if (!repoRow) return [];
180
181 const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name);
182
183 // 1. Query last 90 days of commits (SHA + message, one line each)
184 const logResult = await spawnGit(
185 ["log", "--oneline", '--since=90 days ago', "--format=%H %s"],
186 repoDir
187 );
188
189 if (logResult.exitCode !== 0 || !logResult.stdout.trim()) return [];
190
191 const allCommits = logResult.stdout.trim().split("\n");
192
193 // 2. Filter to fix-related commits
194 const fixCommits = allCommits
195 .filter((line) => {
196 const [, ...msgParts] = line.split(" ");
197 return FIX_KEYWORDS.test(msgParts.join(" "));
198 })
199 .slice(0, MAX_FIX_COMMITS);
200
201 if (fixCommits.length < 2) return []; // Not enough data to detect patterns
202
203 // 3. Get diffs for fix commits
204 const commitBlocks: string[] = [];
205 for (const line of fixCommits) {
206 const [sha, ...msgParts] = line.split(" ");
207 const msg = msgParts.join(" ");
208 const diffResult = await spawnGit(
209 ["show", "--stat", "--format=", sha],
210 repoDir
211 );
212 const diff = truncate(diffResult.stdout, MAX_DIFF_BYTES_PER_COMMIT);
213 commitBlocks.push(`Commit ${sha.slice(0, 7)}: ${msg}\n${diff}`);
214 }
215
216 const commitsText = commitBlocks.join("\n\n---\n\n");
217
218 // 4. Call Claude Sonnet 4.6
219 const client = getAnthropic();
220 const prompt = `Analyze these bug-fix commits from a codebase. Identify recurring patterns — bugs that have been fixed multiple times, suggesting a deeper root cause.
221
222Commits:
223${commitsText}
224
225Return JSON array (max 5 patterns):
226[{
227 "title": "Session token not refreshed after password change",
228 "occurrences": 3,
229 "commits": ["abc123", "def456"],
230 "rootCauseHypothesis": "The auth middleware caches tokens without invalidation",
231 "suggestedFile": "src/lib/auth.ts",
232 "severity": "high|medium|low"
233}]
234
235If you cannot identify any recurring patterns, return an empty array [].`;
236
237 const message = await client.messages.create({
238 model: MODEL_SONNET,
239 max_tokens: 2048,
240 messages: [{ role: "user", content: prompt }],
241 });
242
243 const rawText = extractText(message);
244 const parsed = parseJsonResponse<ClaudePatternResponse[]>(rawText);
245
246 if (!parsed || !Array.isArray(parsed) || parsed.length === 0) return [];
247
248 // 5. Persist to DB with 24h TTL
249 const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
250
251 // Delete stale entries for this repo first
252 await db
253 .delete(recurringPatterns)
254 .where(eq(recurringPatterns.repositoryId, repoId));
255
256 const inserted: Pattern[] = [];
257 for (const p of parsed.slice(0, 5)) {
258 if (!p.title || typeof p.occurrences !== "number") continue;
259 const [row] = await db
260 .insert(recurringPatterns)
261 .values({
262 repositoryId: repoId,
263 title: p.title,
264 occurrences: p.occurrences,
265 commitShas: p.commits ?? [],
266 rootCauseHypothesis: p.rootCauseHypothesis || null,
267 suggestedFile: p.suggestedFile || null,
268 severity: p.severity ?? "medium",
269 expiresAt,
270 })
271 .returning();
272
273 if (row) {
274 inserted.push({
275 id: row.id,
276 title: row.title,
277 occurrences: row.occurrences,
278 commits: (row.commitShas as string[]) ?? [],
279 rootCauseHypothesis: row.rootCauseHypothesis,
280 suggestedFile: row.suggestedFile,
281 severity: row.severity as "high" | "medium" | "low",
282 });
283 }
284 }
285
286 setCached(repoId, inserted);
287 return inserted;
288}
289
290// ---------------------------------------------------------------------------
291// Pattern warning for PR pages
292// ---------------------------------------------------------------------------
293
294const SEVERITY_RANK: Record<string, number> = {
295 high: 3,
296 medium: 2,
297 low: 1,
298};
299
300/**
301 * Returns the highest-severity pattern whose suggestedFile overlaps with
302 * the list of changed files in a PR. Returns null if no overlap is found.
303 *
304 * This is designed to be called during PR page load — it hits the in-memory
305 * cache first, then the DB cache, and only triggers a full AI run if the
306 * cache is cold (which is rare for active repos).
307 */
308export async function getPatternWarning(
309 repoId: string,
310 changedFiles: string[]
311): Promise<Pattern | null> {
312 if (!isAiAvailable()) return null;
313 if (changedFiles.length === 0) return null;
314
315 let patterns = getCached(repoId);
316
317 if (!patterns) {
318 // Try DB cache (non-blocking best-effort)
319 try {
320 const now = new Date();
321 const rows = await db
322 .select()
323 .from(recurringPatterns)
324 .where(
325 and(
326 eq(recurringPatterns.repositoryId, repoId),
327 gt(recurringPatterns.expiresAt, now)
328 )
329 )
330 .limit(5);
331
332 if (rows.length > 0) {
333 patterns = rows.map((row) => ({
334 id: row.id,
335 title: row.title,
336 occurrences: row.occurrences,
337 commits: (row.commitShas as string[]) ?? [],
338 rootCauseHypothesis: row.rootCauseHypothesis,
339 suggestedFile: row.suggestedFile,
340 severity: row.severity as "high" | "medium" | "low",
341 }));
342 setCached(repoId, patterns);
343 } else {
344 // Cache is cold — trigger background detection, return null now
345 detectRecurringPatterns(repoId).catch(() => {});
346 return null;
347 }
348 } catch {
349 return null;
350 }
351 }
352
353 // Find the highest-severity pattern that overlaps with changed files
354 const matched = patterns
355 .filter((p) => {
356 if (!p.suggestedFile) return false;
357 return changedFiles.some(
358 (f) =>
359 f === p.suggestedFile ||
360 f.endsWith(p.suggestedFile!) ||
361 p.suggestedFile!.endsWith(f)
362 );
363 })
364 .sort(
365 (a, b) =>
366 (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0)
367 );
368
369 return matched[0] ?? null;
370}
Modifiedsrc/routes/hooks.ts+88−0View fileUnifiedSplit
@@ -33,6 +33,7 @@ import { db } from "../db";
3333import {
3434 apiTokens,
3535 gateRuns,
36 prComments,
3637 pullRequests,
3738 repositories,
3839 users,
@@ -43,6 +44,7 @@ import {
4344 severityAtOrAboveMedium,
4445 type GateTestFinding,
4546} from "../lib/ai-patch-generator";
47import { triggerCiAutofix, applyAutofix } from "../lib/ci-autofix";
4648import { config } from "../lib/config";
4749
4850const hooks = new Hono();
@@ -307,6 +309,17 @@ hooks.post("/api/hooks/gatetest", async (c) => {
307309 }
308310 }
309311
312 // CI auto-fix — if the gate failed on a PR, post a ready-to-apply patch
313 // comment. Fire-and-forget; never blocks the webhook response.
314 if (normalisedStatus === "failed" && gateRunId && pullRequestId) {
315 triggerCiAutofix(gateRunId).catch((err) =>
316 console.error(
317 "[hooks/gatetest] ci-autofix crashed:",
318 err instanceof Error ? err.message : err
319 )
320 );
321 }
322
310323 return c.json({ ok: true, gateRunId });
311324});
312325
@@ -555,6 +568,16 @@ hooks.post("/api/v1/gate-runs", async (c) => {
555568 }
556569 }
557570
571 // CI auto-fix — post a ready-to-apply patch comment on the PR.
572 if (normalisedStatus === "failed" && gateRunId && pullRequestId) {
573 triggerCiAutofix(gateRunId).catch((err) =>
574 console.error(
575 "[hooks/backup] ci-autofix crashed:",
576 err instanceof Error ? err.message : err
577 )
578 );
579 }
580
558581 return c.json({ ok: true, gateRunId });
559582});
560583
@@ -593,4 +616,69 @@ hooks.get("/api/v1/gate-runs", async (c) => {
593616 }
594617});
595618
619/**
620 * POST /api/pr-comments/:commentId/apply-autofix
621 *
622 * Applies the patch embedded in a CI autofix comment to a new branch, then
623 * redirects to the compare view. Authenticated via a personal access token
624 * (same mechanism as /api/v1/gate-runs).
625 *
626 * Response on success (JSON): { ok: true, branchName, compareUrl }
627 * Response on failure (JSON): { ok: false, error }
628 */
629hooks.post("/api/pr-comments/:commentId/apply-autofix", async (c) => {
630 const auth = await verifyPatAuth(c);
631 if (!auth.ok || !auth.userId) {
632 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
633 }
634
635 const { commentId } = c.req.param();
636 if (!commentId) {
637 return c.json({ ok: false, error: "commentId param required" }, 400);
638 }
639
640 let result: { branchName: string };
641 try {
642 result = await applyAutofix(commentId, auth.userId);
643 } catch (err) {
644 const msg = err instanceof Error ? err.message : "Unknown error";
645 return c.json({ ok: false, error: msg }, 400);
646 }
647
648 // Look up the PR to build the compare URL
649 const commentRows = await db
650 .select({ pullRequestId: prComments.pullRequestId })
651 .from(prComments)
652 .where(eq(prComments.id, commentId))
653 .limit(1);
654
655 let compareUrl: string | null = null;
656 if (commentRows[0]) {
657 const prRows = await db
658 .select({
659 repositoryId: pullRequests.repositoryId,
660 number: pullRequests.number,
661 baseBranch: pullRequests.baseBranch,
662 })
663 .from(pullRequests)
664 .where(eq(pullRequests.id, commentRows[0].pullRequestId))
665 .limit(1);
666
667 if (prRows[0]) {
668 const repoRows = await db
669 .select({ name: repositories.name, ownerUsername: users.username })
670 .from(repositories)
671 .innerJoin(users, eq(repositories.ownerId, users.id))
672 .where(eq(repositories.id, prRows[0].repositoryId))
673 .limit(1);
674
675 if (repoRows[0]) {
676 compareUrl = `/${repoRows[0].ownerUsername}/${repoRows[0].name}/compare/${result.branchName}`;
677 }
678 }
679 }
680
681 return c.json({ ok: true, branchName: result.branchName, compareUrl });
682});
683
596684export default hooks;
Modifiedsrc/routes/pulls.tsx+37−0View fileUnifiedSplit
@@ -67,6 +67,7 @@ import {
6767import { triggerPrTriage } from "../lib/pr-triage";
6868import { generatePrSummary } from "../lib/ai-generators";
6969import { isAiAvailable } from "../lib/ai-client";
70import { getPatternWarning, type Pattern } from "../lib/pattern-detector";
7071import {
7172 computePrRiskForPullRequest,
7273 getCachedPrRisk,
@@ -3969,6 +3970,25 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
39693970 }));
39703971 }
39713972
3973 // Proactive pattern warning — get changed file paths and check for recurring
3974 // bug patterns. Fire-and-forget safe; returns null on any error or cache miss.
3975 let patternWarning: Pattern | null = null;
3976 try {
3977 const repoDir = getRepoPath(ownerName, repoName);
3978 const nameOnlyProc = Bun.spawn(
3979 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
3980 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3981 );
3982 const nameOnlyRaw = await new Response(nameOnlyProc.stdout).text();
3983 await nameOnlyProc.exited;
3984 const prChangedFiles = nameOnlyRaw.trim().split("\n").filter(Boolean);
3985 if (prChangedFiles.length > 0) {
3986 patternWarning = await getPatternWarning(resolved.repo.id, prChangedFiles);
3987 }
3988 } catch {
3989 // Non-blocking — swallow
3990 }
3991
39723992 // ─── Derived visual state ───
39733993 const stateKey =
39743994 pr.state === "open"
@@ -4258,6 +4278,23 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
42584278 </a>
42594279 </nav>
42604280
4281 {/* Proactive pattern warning — shown when a known recurring bug pattern
4282 overlaps with the files changed in this PR. */}
4283 {patternWarning && (
4284 <div class="pattern-warning" style="margin:0 0 16px;padding:12px 16px;border-radius:8px;background:var(--bg-elevated);border:1px solid #f59e0b;border-left:4px solid #f59e0b;font-size:13px;line-height:1.5">
4285 <span style="font-size:15px;margin-right:6px" aria-hidden="true">⚠️</span>
4286 <strong>Recurring pattern detected: {patternWarning.title}</strong>
4287 <span style="color:var(--fg-muted)">
4288 {" — "}
4289 This area has had {patternWarning.occurrences} similar fix
4290 {patternWarning.occurrences === 1 ? "" : "es"}.
4291 {patternWarning.rootCauseHypothesis && (
4292 <> Root cause may be in <code style="font-size:12px">{patternWarning.suggestedFile}</code>.</>
4293 )}
4294 </span>
4295 </div>
4296 )}
4297
42614298 {tab === "commits" ? (
42624299 <div class="prs-commits-list">
42634300 {prCommits.length === 0 ? (
42644301