CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
pattern-detector.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 | * 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 | ||
| 14 | import { and, eq, gt, desc } from "drizzle-orm"; | |
| 15 | import { db } from "../db"; | |
| 16 | import { repositories, recurringPatterns, users } from "../db/schema"; | |
| 17 | import { getRepoPath } from "../git/repository"; | |
| 18 | import { | |
| 19 | getAnthropic, | |
| 20 | isAiAvailable, | |
| 21 | MODEL_SONNET, | |
| 22 | extractText, | |
| 23 | parseJsonResponse, | |
| 24 | } from "./ai-client"; | |
| 25 | ||
| 26 | // --------------------------------------------------------------------------- | |
| 27 | // Types | |
| 28 | // --------------------------------------------------------------------------- | |
| 29 | ||
| 30 | export 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 | ||
| 40 | interface 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 | ||
| 53 | interface CacheEntry { | |
| 54 | patterns: Pattern[]; | |
| 55 | expiresAt: number; // epoch ms | |
| 56 | } | |
| 57 | ||
| 58 | const _cache = new Map<string, CacheEntry>(); | |
| 59 | ||
| 60 | function 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 | ||
| 70 | function 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 | ||
| 81 | async 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 | ||
| 98 | function 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 | ||
| 104 | const FIX_KEYWORDS = /\b(fix|bug|patch|revert|hotfix|fixes|fixed|bugfix)\b/i; | |
| 105 | const MAX_FIX_COMMITS = 30; | |
| 106 | const 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 | */ | |
| 118 | export 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 | ||
| 166 | async 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 | ||
| 222 | Commits: | |
| 223 | ${commitsText} | |
| 224 | ||
| 225 | Return 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 | ||
| 235 | If 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 | ||
| 294 | const 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 | */ | |
| 308 | export 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 | } |