Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

pattern-detector.tsBlame370 lines · 1 contributor
34e63b9Claude1/**
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}