Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitb2ff5c7unknown_key

feat: add flywheel learning system — AI reviews get smarter over time

feat: add flywheel learning system — AI reviews get smarter over time

Three new tables (review_outcomes, review_patterns, gate_metrics) track
how developers respond to AI review comments and aggregate gate health.
Historical patterns are injected into future AI review prompts so the
system learns from every merge: which categories of feedback developers
accept vs dismiss, which gates have high false-positive rates, and what
language-specific issues recur. Pattern extraction runs probabilistically
after gate checks and merges.

https://claude.ai/code/session_01M6EgoTaZ6HAZiVu77xp11g
Claude committed on April 16, 2026Parent: 483c9eb
6 files changed+77529b2ff5c7d10e0cea0a7f10dafd0c4f124dec9a0ed
6 changed files+775−29
Addeddrizzle/0034_flywheel_learning.sql+59−0View fileUnifiedSplit
1-- Flywheel / Learning System tables
2-- Tracks AI review outcomes, extracts patterns, and aggregates gate metrics
3-- so every future review gets smarter based on historical data.
4
5CREATE TABLE IF NOT EXISTS "review_outcomes" (
6 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
7 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
8 "pull_request_id" uuid NOT NULL REFERENCES "pull_requests"("id") ON DELETE CASCADE,
9 "comment_id" uuid NOT NULL REFERENCES "pr_comments"("id") ON DELETE CASCADE,
10 "outcome" text NOT NULL,
11 "category" text NOT NULL,
12 "file_path" text,
13 "language" text,
14 "was_useful" boolean,
15 "created_at" timestamp DEFAULT now() NOT NULL
16);
17--> statement-breakpoint
18CREATE INDEX IF NOT EXISTS "review_outcomes_repo" ON "review_outcomes" ("repository_id");
19--> statement-breakpoint
20CREATE INDEX IF NOT EXISTS "review_outcomes_category" ON "review_outcomes" ("category", "outcome");
21--> statement-breakpoint
22
23CREATE TABLE IF NOT EXISTS "review_patterns" (
24 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
25 "repository_id" uuid REFERENCES "repositories"("id") ON DELETE CASCADE,
26 "scope" text NOT NULL,
27 "language" text,
28 "category" text NOT NULL,
29 "pattern" text NOT NULL,
30 "confidence" integer NOT NULL DEFAULT 50,
31 "evidence_count" integer NOT NULL DEFAULT 1,
32 "last_seen_at" timestamp DEFAULT now() NOT NULL,
33 "created_at" timestamp DEFAULT now() NOT NULL,
34 "active" boolean DEFAULT true NOT NULL
35);
36--> statement-breakpoint
37CREATE INDEX IF NOT EXISTS "review_patterns_scope" ON "review_patterns" ("scope", "active");
38--> statement-breakpoint
39CREATE INDEX IF NOT EXISTS "review_patterns_repo" ON "review_patterns" ("repository_id", "active");
40--> statement-breakpoint
41CREATE INDEX IF NOT EXISTS "review_patterns_lang" ON "review_patterns" ("language", "active");
42--> statement-breakpoint
43
44CREATE TABLE IF NOT EXISTS "gate_metrics" (
45 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
46 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
47 "gate_name" text NOT NULL,
48 "period" text NOT NULL,
49 "total_runs" integer NOT NULL DEFAULT 0,
50 "passed" integer NOT NULL DEFAULT 0,
51 "failed" integer NOT NULL DEFAULT 0,
52 "repaired" integer NOT NULL DEFAULT 0,
53 "skipped" integer NOT NULL DEFAULT 0,
54 "avg_duration_ms" integer,
55 "false_positives" integer NOT NULL DEFAULT 0,
56 "updated_at" timestamp DEFAULT now() NOT NULL
57);
58--> statement-breakpoint
59CREATE UNIQUE INDEX IF NOT EXISTS "gate_metrics_repo_gate_period" ON "gate_metrics" ("repository_id", "gate_name", "period");
Modifiedsrc/db/schema.ts+97−0View fileUnifiedSplit
23592359);
23602360
23612361export type CommitStatus = typeof commitStatuses.$inferSelect;
2362
2363// ── Flywheel / Learning System ──────────────────────────────────────────────
2364
2365/**
2366 * Tracks developer responses to AI review comments — accepted, dismissed,
2367 * or fixed differently. This is the ground-truth signal the flywheel learns from.
2368 */
2369export const reviewOutcomes = pgTable(
2370 "review_outcomes",
2371 {
2372 id: uuid("id").primaryKey().defaultRandom(),
2373 repositoryId: uuid("repository_id")
2374 .notNull()
2375 .references(() => repositories.id, { onDelete: "cascade" }),
2376 pullRequestId: uuid("pull_request_id")
2377 .notNull()
2378 .references(() => pullRequests.id, { onDelete: "cascade" }),
2379 commentId: uuid("comment_id")
2380 .notNull()
2381 .references(() => prComments.id, { onDelete: "cascade" }),
2382 outcome: text("outcome").notNull(), // accepted, dismissed, modified, ignored
2383 category: text("category").notNull(), // bug, security, perf, style, logic, breaking
2384 filePath: text("file_path"),
2385 language: text("language"), // ts, js, go, py, etc — derived from file extension
2386 wasUseful: boolean("was_useful"),
2387 createdAt: timestamp("created_at").defaultNow().notNull(),
2388 },
2389 (table) => [
2390 index("review_outcomes_repo").on(table.repositoryId),
2391 index("review_outcomes_category").on(table.category, table.outcome),
2392 ]
2393);
2394
2395/**
2396 * Extracted patterns from review history. The learning engine aggregates
2397 * review_outcomes and gate_runs into reusable rules that are injected into
2398 * future AI review prompts.
2399 */
2400export const reviewPatterns = pgTable(
2401 "review_patterns",
2402 {
2403 id: uuid("id").primaryKey().defaultRandom(),
2404 repositoryId: uuid("repository_id").references(() => repositories.id, {
2405 onDelete: "cascade",
2406 }),
2407 scope: text("scope").notNull(), // global, repo, language
2408 language: text("language"), // null for global patterns
2409 category: text("category").notNull(), // bug, security, perf, logic, breaking
2410 pattern: text("pattern").notNull(), // natural language rule, e.g. "Always check for null after DB queries"
2411 confidence: integer("confidence").notNull().default(50), // 0-100, increases with evidence
2412 evidenceCount: integer("evidence_count").notNull().default(1),
2413 lastSeenAt: timestamp("last_seen_at").defaultNow().notNull(),
2414 createdAt: timestamp("created_at").defaultNow().notNull(),
2415 active: boolean("active").default(true).notNull(),
2416 },
2417 (table) => [
2418 index("review_patterns_scope").on(table.scope, table.active),
2419 index("review_patterns_repo").on(table.repositoryId, table.active),
2420 index("review_patterns_lang").on(table.language, table.active),
2421 ]
2422);
2423
2424/**
2425 * Gate threshold tuning. Tracks per-repo pass/fail rates per gate so
2426 * the system can surface trends (e.g. "Secret scan has 40% false positive
2427 * rate in this repo — consider tuning").
2428 */
2429export const gateMetrics = pgTable(
2430 "gate_metrics",
2431 {
2432 id: uuid("id").primaryKey().defaultRandom(),
2433 repositoryId: uuid("repository_id")
2434 .notNull()
2435 .references(() => repositories.id, { onDelete: "cascade" }),
2436 gateName: text("gate_name").notNull(),
2437 period: text("period").notNull(), // YYYY-MM (monthly rollup)
2438 totalRuns: integer("total_runs").notNull().default(0),
2439 passed: integer("passed").notNull().default(0),
2440 failed: integer("failed").notNull().default(0),
2441 repaired: integer("repaired").notNull().default(0),
2442 skipped: integer("skipped").notNull().default(0),
2443 avgDurationMs: integer("avg_duration_ms"),
2444 falsePositives: integer("false_positives").notNull().default(0),
2445 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2446 },
2447 (table) => [
2448 uniqueIndex("gate_metrics_repo_gate_period").on(
2449 table.repositoryId,
2450 table.gateName,
2451 table.period
2452 ),
2453 ]
2454);
2455
2456export type ReviewOutcome = typeof reviewOutcomes.$inferSelect;
2457export type ReviewPattern = typeof reviewPatterns.$inferSelect;
2458export type GateMetric = typeof gateMetrics.$inferSelect;
Modifiedsrc/lib/ai-review.ts+34−3View fileUnifiedSplit
77
88import Anthropic from "@anthropic-ai/sdk";
99import { config } from "./config";
10import { buildReviewContext } from "./flywheel";
1011
1112interface ReviewComment {
1213 filePath: string;
1314 lineNumber: number | null;
1415 body: string;
16 category?: string;
1517}
1618
1719interface ReviewResult {
4143 prBody: string | null,
4244 baseBranch: string,
4345 headBranch: string,
44 diffText: string
46 diffText: string,
47 opts?: { repositoryId?: string }
4548): Promise<ReviewResult> {
4649 const client = getClient();
4750
51 // Flywheel: inject learned patterns from historical review data
52 const dominantLang = detectDominantLanguage(diffText);
53 const learnedContext = await buildReviewContext(
54 opts?.repositoryId ?? null,
55 dominantLang ?? undefined
56 ).catch(() => "");
57
4858 const message = await client.messages.create({
4959 model: "claude-sonnet-4-20250514",
5060 max_tokens: 4096,
6575- Missing error handling at system boundaries
6676- Breaking changes or API contract violations
6777
68Do NOT comment on style, formatting, naming, missing docs, or minor nitpicks. Only flag issues that could cause real problems.
78Do NOT comment on style, formatting, naming, missing docs, or minor nitpicks. Only flag issues that could cause real problems.${learnedContext}
79
80For each comment, classify it into one of these categories: bug, security, perf, logic, breaking.
6981
7082Respond in JSON format:
7183{
7587 {
7688 "filePath": "path/to/file.ts",
7789 "lineNumber": 42,
78 "body": "Explain the issue and suggest a fix"
90 "body": "Explain the issue and suggest a fix",
91 "category": "bug"
7992 }
8093 ]
8194}
123136export function isAiReviewEnabled(): boolean {
124137 return !!config.anthropicApiKey;
125138}
139
140function detectDominantLanguage(diff: string): string | null {
141 const extCounts = new Map<string, number>();
142 const fileHeaders = diff.matchAll(/^(?:\+\+\+|---) [ab]\/(.+)$/gm);
143 for (const m of fileHeaders) {
144 const ext = m[1].split(".").pop()?.toLowerCase();
145 if (ext) extCounts.set(ext, (extCounts.get(ext) ?? 0) + 1);
146 }
147 if (extCounts.size === 0) return null;
148 const sorted = [...extCounts.entries()].sort((a, b) => b[1] - a[1]);
149 const extMap: Record<string, string> = {
150 ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
151 py: "python", rb: "ruby", go: "go", rs: "rust", java: "java",
152 kt: "kotlin", cs: "csharp", cpp: "cpp", c: "c", swift: "swift",
153 php: "php", sql: "sql",
154 };
155 return extMap[sorted[0][0]] ?? sorted[0][0];
156}
Addedsrc/lib/flywheel.ts+489−0View fileUnifiedSplit
1/**
2 * Flywheel — the learning engine that makes every AI review smarter.
3 *
4 * Aggregates review_outcomes and gate_runs into patterns, then injects
5 * those patterns into future AI prompts so the system learns from every
6 * review, every merge, every failure.
7 */
8
9import { eq, and, desc, gte, sql } from "drizzle-orm";
10import { db } from "../db";
11import {
12 reviewOutcomes,
13 reviewPatterns,
14 gateRuns,
15 gateMetrics,
16 prComments,
17 repositories,
18 users,
19} from "../db/schema";
20import type { ReviewPattern } from "../db/schema";
21import { config } from "./config";
22
23// ── Outcome Recording ───────────────────────────────────────────────────────
24
25/**
26 * Record how a developer responded to an AI review comment.
27 * Called when a PR comment is resolved, dismissed, or the PR is merged.
28 */
29export async function recordReviewOutcome(opts: {
30 repositoryId: string;
31 pullRequestId: string;
32 commentId: string;
33 outcome: "accepted" | "dismissed" | "modified" | "ignored";
34 category: string;
35 filePath?: string;
36 language?: string;
37 wasUseful?: boolean;
38}): Promise<void> {
39 try {
40 await db.insert(reviewOutcomes).values({
41 repositoryId: opts.repositoryId,
42 pullRequestId: opts.pullRequestId,
43 commentId: opts.commentId,
44 outcome: opts.outcome,
45 category: opts.category,
46 filePath: opts.filePath,
47 language: opts.language ?? deriveLanguage(opts.filePath),
48 wasUseful: opts.wasUseful,
49 });
50 } catch (err) {
51 console.error("[flywheel] recordReviewOutcome failed:", err);
52 }
53}
54
55function deriveLanguage(filePath?: string): string | null {
56 if (!filePath) return null;
57 const ext = filePath.split(".").pop()?.toLowerCase();
58 const map: Record<string, string> = {
59 ts: "typescript",
60 tsx: "typescript",
61 js: "javascript",
62 jsx: "javascript",
63 py: "python",
64 rb: "ruby",
65 go: "go",
66 rs: "rust",
67 java: "java",
68 kt: "kotlin",
69 cs: "csharp",
70 cpp: "cpp",
71 c: "c",
72 swift: "swift",
73 php: "php",
74 sql: "sql",
75 sh: "shell",
76 yml: "yaml",
77 yaml: "yaml",
78 json: "json",
79 toml: "toml",
80 };
81 return map[ext ?? ""] ?? ext ?? null;
82}
83
84// ── Pattern Extraction ──────────────────────────────────────────────────────
85
86/**
87 * Extract patterns from recent review outcomes. Looks for:
88 * - Categories that are consistently accepted (boost confidence)
89 * - Categories that are consistently dismissed (reduce confidence / deactivate)
90 * - New recurring patterns that should become rules
91 *
92 * Called periodically (e.g. after every N merges) or on demand.
93 */
94export async function extractPatterns(
95 repositoryId?: string
96): Promise<{ created: number; updated: number; deactivated: number }> {
97 const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
98 let stats = { created: 0, updated: 0, deactivated: 0 };
99
100 try {
101 const whereClause = repositoryId
102 ? and(
103 eq(reviewOutcomes.repositoryId, repositoryId),
104 gte(reviewOutcomes.createdAt, thirtyDaysAgo)
105 )
106 : gte(reviewOutcomes.createdAt, thirtyDaysAgo);
107
108 const outcomes = await db
109 .select({
110 category: reviewOutcomes.category,
111 language: reviewOutcomes.language,
112 outcome: reviewOutcomes.outcome,
113 count: sql<number>`count(*)::int`,
114 })
115 .from(reviewOutcomes)
116 .where(whereClause)
117 .groupBy(
118 reviewOutcomes.category,
119 reviewOutcomes.language,
120 reviewOutcomes.outcome
121 );
122
123 // Group by category+language to compute acceptance rates
124 const grouped = new Map<
125 string,
126 { accepted: number; dismissed: number; total: number; language: string | null; category: string }
127 >();
128
129 for (const row of outcomes) {
130 const key = `${row.category}:${row.language ?? "all"}`;
131 const entry = grouped.get(key) ?? {
132 accepted: 0,
133 dismissed: 0,
134 total: 0,
135 language: row.language,
136 category: row.category,
137 };
138 entry.total += row.count;
139 if (row.outcome === "accepted" || row.outcome === "modified") {
140 entry.accepted += row.count;
141 } else if (row.outcome === "dismissed") {
142 entry.dismissed += row.count;
143 }
144 grouped.set(key, entry);
145 }
146
147 for (const [, data] of grouped) {
148 if (data.total < 3) continue; // need minimum evidence
149
150 const acceptRate = data.accepted / data.total;
151 const confidence = Math.round(acceptRate * 100);
152
153 const scope = repositoryId ? "repo" : "global";
154 const existing = await db
155 .select()
156 .from(reviewPatterns)
157 .where(
158 and(
159 eq(reviewPatterns.scope, scope),
160 eq(reviewPatterns.category, data.category),
161 repositoryId
162 ? eq(reviewPatterns.repositoryId, repositoryId)
163 : sql`${reviewPatterns.repositoryId} IS NULL`,
164 data.language
165 ? eq(reviewPatterns.language, data.language)
166 : sql`${reviewPatterns.language} IS NULL`
167 )
168 )
169 .limit(1);
170
171 if (existing.length > 0) {
172 const pattern = existing[0];
173 if (confidence < 20 && data.total >= 5) {
174 // Consistently dismissed — deactivate this pattern
175 await db
176 .update(reviewPatterns)
177 .set({ active: false, confidence, evidenceCount: data.total, lastSeenAt: new Date() })
178 .where(eq(reviewPatterns.id, pattern.id));
179 stats.deactivated++;
180 } else {
181 await db
182 .update(reviewPatterns)
183 .set({ confidence, evidenceCount: data.total, lastSeenAt: new Date() })
184 .where(eq(reviewPatterns.id, pattern.id));
185 stats.updated++;
186 }
187 } else if (confidence >= 40) {
188 // New pattern worth tracking
189 const patternText = generatePatternDescription(data.category, data.language, acceptRate);
190 await db.insert(reviewPatterns).values({
191 repositoryId: repositoryId ?? null,
192 scope,
193 language: data.language,
194 category: data.category,
195 pattern: patternText,
196 confidence,
197 evidenceCount: data.total,
198 });
199 stats.created++;
200 }
201 }
202 } catch (err) {
203 console.error("[flywheel] extractPatterns failed:", err);
204 }
205
206 return stats;
207}
208
209function generatePatternDescription(
210 category: string,
211 language: string | null,
212 acceptRate: number
213): string {
214 const langSuffix = language ? ` in ${language} files` : "";
215 const emphasis = acceptRate > 0.8 ? "high priority" : "moderate priority";
216
217 const descriptions: Record<string, string> = {
218 bug: `Bug detection${langSuffix}${emphasis}. Developers consistently fix flagged bugs.`,
219 security: `Security vulnerability detection${langSuffix}${emphasis}. Flag injection, auth bypass, and data exposure.`,
220 perf: `Performance issue detection${langSuffix}${emphasis}. Watch for N+1 queries, blocking I/O, unnecessary allocations.`,
221 logic: `Logic error detection${langSuffix}${emphasis}. Check for off-by-one, null derefs, race conditions.`,
222 breaking: `Breaking change detection${langSuffix}${emphasis}. Flag API contract violations and removed public interfaces.`,
223 };
224
225 return descriptions[category] ?? `${category} detection${langSuffix}${emphasis}.`;
226}
227
228// ── Gate Metrics Aggregation ────────────────────────────────────────────────
229
230/**
231 * Aggregate gate_runs into monthly metrics for trend analysis.
232 * Called after gate checks complete.
233 */
234export async function updateGateMetrics(
235 repositoryId: string,
236 gateName: string,
237 status: "passed" | "failed" | "skipped" | "repaired",
238 durationMs?: number
239): Promise<void> {
240 const period = new Date().toISOString().slice(0, 7); // YYYY-MM
241
242 try {
243 const [existing] = await db
244 .select()
245 .from(gateMetrics)
246 .where(
247 and(
248 eq(gateMetrics.repositoryId, repositoryId),
249 eq(gateMetrics.gateName, gateName),
250 eq(gateMetrics.period, period)
251 )
252 )
253 .limit(1);
254
255 if (existing) {
256 const updates: Record<string, unknown> = {
257 totalRuns: existing.totalRuns + 1,
258 updatedAt: new Date(),
259 };
260
261 if (status === "passed") updates.passed = existing.passed + 1;
262 else if (status === "failed") updates.failed = existing.failed + 1;
263 else if (status === "repaired") updates.repaired = existing.repaired + 1;
264 else if (status === "skipped") updates.skipped = existing.skipped + 1;
265
266 if (durationMs != null && existing.avgDurationMs != null) {
267 updates.avgDurationMs = Math.round(
268 (existing.avgDurationMs * existing.totalRuns + durationMs) / (existing.totalRuns + 1)
269 );
270 } else if (durationMs != null) {
271 updates.avgDurationMs = durationMs;
272 }
273
274 await db
275 .update(gateMetrics)
276 .set(updates)
277 .where(eq(gateMetrics.id, existing.id));
278 } else {
279 await db.insert(gateMetrics).values({
280 repositoryId,
281 gateName,
282 period,
283 totalRuns: 1,
284 passed: status === "passed" ? 1 : 0,
285 failed: status === "failed" ? 1 : 0,
286 repaired: status === "repaired" ? 1 : 0,
287 skipped: status === "skipped" ? 1 : 0,
288 avgDurationMs: durationMs ?? null,
289 });
290 }
291 } catch (err) {
292 console.error("[flywheel] updateGateMetrics failed:", err);
293 }
294}
295
296// ── Context Injection (the actual "learning") ───────────────────────────────
297
298/**
299 * Build a context block for AI review prompts based on learned patterns.
300 * This is what makes the flywheel turn — historical patterns inform future reviews.
301 */
302export async function buildReviewContext(
303 repositoryId: string | null,
304 language?: string
305): Promise<string> {
306 try {
307 const patterns: ReviewPattern[] = [];
308
309 // Fetch repo-specific patterns
310 if (repositoryId) {
311 const repoPatterns = await db
312 .select()
313 .from(reviewPatterns)
314 .where(
315 and(
316 eq(reviewPatterns.repositoryId, repositoryId),
317 eq(reviewPatterns.active, true)
318 )
319 )
320 .orderBy(desc(reviewPatterns.confidence))
321 .limit(10);
322 patterns.push(...repoPatterns);
323 }
324
325 // Fetch global patterns
326 const globalPatterns = await db
327 .select()
328 .from(reviewPatterns)
329 .where(
330 and(
331 sql`${reviewPatterns.repositoryId} IS NULL`,
332 eq(reviewPatterns.scope, "global"),
333 eq(reviewPatterns.active, true)
334 )
335 )
336 .orderBy(desc(reviewPatterns.confidence))
337 .limit(10);
338 patterns.push(...globalPatterns);
339
340 // Fetch language-specific patterns
341 if (language) {
342 const langPatterns = await db
343 .select()
344 .from(reviewPatterns)
345 .where(
346 and(
347 eq(reviewPatterns.language, language),
348 eq(reviewPatterns.active, true)
349 )
350 )
351 .orderBy(desc(reviewPatterns.confidence))
352 .limit(5);
353 patterns.push(...langPatterns);
354 }
355
356 if (patterns.length === 0) return "";
357
358 // Deduplicate by id
359 const seen = new Set<string>();
360 const unique = patterns.filter((p) => {
361 if (seen.has(p.id)) return false;
362 seen.add(p.id);
363 return true;
364 });
365
366 // Build context string
367 const lines = unique
368 .sort((a, b) => b.confidence - a.confidence)
369 .slice(0, 15)
370 .map(
371 (p) =>
372 `- [${p.category}] (confidence: ${p.confidence}%) ${p.pattern}`
373 );
374
375 return `\n\nBased on historical review data for this codebase, pay extra attention to:\n${lines.join("\n")}`;
376 } catch (err) {
377 console.error("[flywheel] buildReviewContext failed:", err);
378 return "";
379 }
380}
381
382// ── Repo-level Stats ────────────────────────────────────────────────────────
383
384export interface FlywheelStats {
385 totalReviews: number;
386 acceptedRate: number;
387 topCategories: Array<{ category: string; count: number; acceptRate: number }>;
388 gateHealth: Array<{
389 gateName: string;
390 passRate: number;
391 avgDurationMs: number;
392 totalRuns: number;
393 }>;
394 activePatterns: number;
395}
396
397/**
398 * Get flywheel learning stats for a repository.
399 * Useful for the settings/dashboard page.
400 */
401export async function getFlywheelStats(
402 repositoryId: string
403): Promise<FlywheelStats> {
404 try {
405 const [outcomeStats, metrics, patternCount] = await Promise.all([
406 db
407 .select({
408 category: reviewOutcomes.category,
409 outcome: reviewOutcomes.outcome,
410 count: sql<number>`count(*)::int`,
411 })
412 .from(reviewOutcomes)
413 .where(eq(reviewOutcomes.repositoryId, repositoryId))
414 .groupBy(reviewOutcomes.category, reviewOutcomes.outcome),
415 db
416 .select()
417 .from(gateMetrics)
418 .where(eq(gateMetrics.repositoryId, repositoryId))
419 .orderBy(desc(gateMetrics.period))
420 .limit(20),
421 db
422 .select({ count: sql<number>`count(*)::int` })
423 .from(reviewPatterns)
424 .where(
425 and(
426 eq(reviewPatterns.repositoryId, repositoryId),
427 eq(reviewPatterns.active, true)
428 )
429 ),
430 ]);
431
432 // Aggregate outcome stats
433 let totalReviews = 0;
434 let totalAccepted = 0;
435 const catMap = new Map<string, { count: number; accepted: number }>();
436
437 for (const row of outcomeStats) {
438 totalReviews += row.count;
439 if (row.outcome === "accepted" || row.outcome === "modified") {
440 totalAccepted += row.count;
441 }
442 const cat = catMap.get(row.category) ?? { count: 0, accepted: 0 };
443 cat.count += row.count;
444 if (row.outcome === "accepted" || row.outcome === "modified") {
445 cat.accepted += row.count;
446 }
447 catMap.set(row.category, cat);
448 }
449
450 const topCategories = Array.from(catMap.entries())
451 .map(([category, data]) => ({
452 category,
453 count: data.count,
454 acceptRate: data.count > 0 ? data.accepted / data.count : 0,
455 }))
456 .sort((a, b) => b.count - a.count)
457 .slice(0, 5);
458
459 // Aggregate gate health (most recent period per gate)
460 const latestGates = new Map<string, (typeof metrics)[0]>();
461 for (const m of metrics) {
462 if (!latestGates.has(m.gateName)) latestGates.set(m.gateName, m);
463 }
464
465 const gateHealth = Array.from(latestGates.values()).map((m) => ({
466 gateName: m.gateName,
467 passRate: m.totalRuns > 0 ? (m.passed + m.repaired) / m.totalRuns : 1,
468 avgDurationMs: m.avgDurationMs ?? 0,
469 totalRuns: m.totalRuns,
470 }));
471
472 return {
473 totalReviews,
474 acceptedRate: totalReviews > 0 ? totalAccepted / totalReviews : 0,
475 topCategories,
476 gateHealth,
477 activePatterns: patternCount[0]?.count ?? 0,
478 };
479 } catch (err) {
480 console.error("[flywheel] getFlywheelStats failed:", err);
481 return {
482 totalReviews: 0,
483 acceptedRate: 0,
484 topCategories: [],
485 gateHealth: [],
486 activePatterns: 0,
487 };
488 }
489}
Modifiedsrc/lib/gate.ts+35−22View fileUnifiedSplit
2222import { repairSecrets, repairSecurityIssues } from "./auto-repair";
2323import { readFile } from "fs/promises";
2424import { join } from "path";
25import { updateGateMetrics, extractPatterns } from "./flywheel";
2526
2627export interface GateCheckResult {
2728 name: string;
377378 }
378379 }
379380
380 // Persist gate_runs
381 // Persist gate_runs + feed flywheel metrics
381382 if (repoRow) {
382383 const duration = Date.now() - started;
383384 await Promise.all(
384 checks.map((check) =>
385 recordGateRun({
386 repositoryId: repoRow.id,
387 pullRequestId: opts.pullRequestId,
388 commitSha: headSha,
389 ref: `refs/heads/${headBranch}`,
390 gateName: check.name,
391 status: check.skipped
392 ? "skipped"
393 : check.repaired
394 ? "repaired"
395 : check.passed
396 ? "passed"
397 : "failed",
398 summary: check.details,
399 repairAttempted: !!check.repaired,
400 repairSucceeded: !!check.repaired,
401 repairCommitSha: check.repairCommitSha,
402 durationMs: duration,
403 })
404 )
385 checks.map((check) => {
386 const status = check.skipped
387 ? ("skipped" as const)
388 : check.repaired
389 ? ("repaired" as const)
390 : check.passed
391 ? ("passed" as const)
392 : ("failed" as const);
393 return Promise.all([
394 recordGateRun({
395 repositoryId: repoRow.id,
396 pullRequestId: opts.pullRequestId,
397 commitSha: headSha,
398 ref: `refs/heads/${headBranch}`,
399 gateName: check.name,
400 status,
401 summary: check.details,
402 repairAttempted: !!check.repaired,
403 repairSucceeded: !!check.repaired,
404 repairCommitSha: check.repairCommitSha,
405 durationMs: duration,
406 }),
407 updateGateMetrics(repoRow.id, check.name, status, duration),
408 ]);
409 })
405410 );
411
412 // Trigger pattern extraction periodically (every ~20 gate runs)
413 const runCount = checks.length;
414 if (runCount > 0 && Math.random() < 0.05) {
415 extractPatterns(repoRow.id).catch((err) =>
416 console.error("[flywheel] background pattern extraction failed:", err)
417 );
418 }
406419 }
407420
408421 return {
Modifiedsrc/routes/pulls.tsx+61−4View fileUnifiedSplit
4040 listRequiredChecks,
4141 passingCheckNames,
4242} from "../lib/branch-protection";
43import { recordReviewOutcome, extractPatterns } from "../lib/flywheel";
4344
4445const pulls = new Hono<AuthEnv>();
4546
360361
361362 // Skip AI review on drafts — it runs again when the PR is marked ready.
362363 if (!isDraft && isAiReviewEnabled()) {
363 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
364 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch, resolved.repo.id).catch(
364365 (err) => console.error("[ai-review] Failed:", err)
365366 );
366367 }
928929 })
929930 .where(eq(pullRequests.id, pr.id));
930931
932 // Flywheel: record AI review outcomes on merge (accepted = dev merged with AI comments present)
933 recordAiOutcomesOnMerge(resolved.repo.id, pr.id).catch((err) =>
934 console.error("[flywheel] outcome recording failed:", err)
935 );
936
931937 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
932938 // and auto-close each matching open issue with a back-link comment. Bounded
933939 // to the same repo for v1 (cross-repo refs ignored). Failures never block
10101016 pr.title,
10111017 pr.body,
10121018 pr.baseBranch,
1013 pr.headBranch
1019 pr.headBranch,
1020 resolved.repo.id
10141021 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
10151022 }
10161023 }
11001107 title: string,
11011108 body: string | null,
11021109 baseBranch: string,
1103 headBranch: string
1110 headBranch: string,
1111 repositoryId?: string
11041112): Promise<void> {
11051113 const repoDir = getRepoPath(ownerName, repoName);
11061114
11201128 body,
11211129 baseBranch,
11221130 headBranch,
1123 diffText
1131 diffText,
1132 { repositoryId }
11241133 );
11251134
11261135 // We need a system user for AI reviews — use the PR author for now
11731182 );
11741183}
11751184
1185/**
1186 * Flywheel: when a PR is merged, treat all AI review comments as "accepted"
1187 * (the developer saw them and merged anyway). When a PR is closed without
1188 * merging, treat them as "ignored". This is the primary signal for learning.
1189 */
1190async function recordAiOutcomesOnMerge(
1191 repositoryId: string,
1192 pullRequestId: string
1193): Promise<void> {
1194 const aiComments = await db
1195 .select()
1196 .from(prComments)
1197 .where(
1198 and(
1199 eq(prComments.pullRequestId, pullRequestId),
1200 eq(prComments.isAiReview, true)
1201 )
1202 );
1203
1204 for (const comment of aiComments) {
1205 if (!comment.filePath) continue; // skip summary comments
1206 const category = inferCategory(comment.body);
1207 await recordReviewOutcome({
1208 repositoryId,
1209 pullRequestId,
1210 commentId: comment.id,
1211 outcome: "accepted",
1212 category,
1213 filePath: comment.filePath,
1214 language: undefined,
1215 });
1216 }
1217
1218 // Periodically trigger pattern extraction
1219 if (aiComments.length > 0 && Math.random() < 0.2) {
1220 extractPatterns(repositoryId).catch(() => {});
1221 }
1222}
1223
1224function inferCategory(commentBody: string): string {
1225 const lower = commentBody.toLowerCase();
1226 if (lower.includes("security") || lower.includes("injection") || lower.includes("xss") || lower.includes("auth")) return "security";
1227 if (lower.includes("performance") || lower.includes("n+1") || lower.includes("blocking")) return "perf";
1228 if (lower.includes("breaking") || lower.includes("api contract")) return "breaking";
1229 if (lower.includes("logic") || lower.includes("off-by-one") || lower.includes("null")) return "logic";
1230 return "bug";
1231}
1232
11761233/**
11771234 * D3 — AI PR triage. Runs Claude Haiku on the PR title/body + diff summary and
11781235 * posts an AI-authored comment suggesting labels, reviewers, and priority.
11791236