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

feat: schema + lib files for archaeology, streaming review, org health, test gaps

feat: schema + lib files for archaeology, streaming review, org health, test gaps

- schema.ts: add testGapCache table (migration 0105) — 2h TTL, unique per repo
- drizzle/0105_test_gaps_cache.sql: CREATE TABLE test_gap_cache + unique index
- src/lib/ai-archaeology.ts: excavate() — git log + PR/issue search + Claude synthesis
- src/lib/org-health.ts: computeOrgHealth() — per-repo getHealthScore + AI summary
- src/lib/streaming-review.ts: streamPrReview() async generator via Anthropic stream API
- src/lib/test-gaps.ts: detectTestGaps() — untested file detection + Claude risk scoring

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 8, 2026Parent: 77cf834
6 files changed+1435053299fbd57b8b870e204f9840884ccb405176192
6 changed files+1435−0
Addeddrizzle/0105_test_gaps_cache.sql+8−0View fileUnifiedSplit
1CREATE TABLE IF NOT EXISTS test_gap_cache (
2 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
3 repo_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
4 report jsonb NOT NULL,
5 analyzed_at timestamp DEFAULT now(),
6 expires_at timestamp NOT NULL
7);
8CREATE UNIQUE INDEX IF NOT EXISTS idx_test_gap_cache_repo ON test_gap_cache(repo_id);
Modifiedsrc/db/schema.ts+23−0View fileUnifiedSplit
46494649);
46504650
46514651export type WorkspaceJob = typeof workspaceJobs.$inferSelect;
4652
4653// ---------------------------------------------------------------------------
4654// Migration 0105 — Test gap cache (2h TTL, keyed on repo_id)
4655// Stores AI-generated test gap analysis: untested source files ranked by risk.
4656// ---------------------------------------------------------------------------
4657export const testGapCache = pgTable(
4658 "test_gap_cache",
4659 {
4660 id: uuid("id").primaryKey().defaultRandom(),
4661 repoId: uuid("repo_id")
4662 .notNull()
4663 .unique()
4664 .references(() => repositories.id, { onDelete: "cascade" }),
4665 report: jsonb("report").notNull(),
4666 analyzedAt: timestamp("analyzed_at").defaultNow(),
4667 expiresAt: timestamp("expires_at").notNull(),
4668 },
4669 (table) => [
4670 index("idx_test_gap_cache_repo").on(table.repoId),
4671 ]
4672);
4673
4674export type TestGapCache = typeof testGapCache.$inferSelect;
Addedsrc/lib/ai-archaeology.ts+487−0View fileUnifiedSplit
1/**
2 * AI Code Archaeology — excavate the "why" behind any file.
3 *
4 * Given a file path in a repository, searches git history, PRs, and issues
5 * to reconstruct the reasoning and original motivation behind the code.
6 *
7 * Uses a 30-minute in-memory cache keyed on `${repoId}:${filePath}`.
8 * The query does not affect the cache key — same file, same archaeology.
9 */
10
11import { basename } from "path";
12import { eq, and, desc, ilike, or } from "drizzle-orm";
13import { db } from "../db";
14import { pullRequests, prComments, issues, issueComments } from "../db/schema";
15import { getRepoPath, getBlob } from "../git/repository";
16import {
17 getAnthropic,
18 isAiAvailable,
19 extractText,
20 MODEL_SONNET,
21} from "./ai-client";
22
23// ---------------------------------------------------------------------------
24// Public types
25// ---------------------------------------------------------------------------
26
27export interface ArchaeologyFinding {
28 type: "commit" | "pr" | "issue";
29 id: string; // commit sha, pr number (string), or issue number (string)
30 title: string;
31 summary: string; // 1-2 sentences explaining relevance
32 date: string; // ISO string
33 url: string; // relative URL e.g. /owner/repo/commit/sha
34 author: string;
35}
36
37export interface ArchaeologyReport {
38 filePath: string;
39 query: string; // the "why" question asked
40 explanation: string; // Claude's synthesized answer (markdown)
41 findings: ArchaeologyFinding[];
42 confidence: "high" | "medium" | "low";
43 analyzedAt: Date;
44}
45
46// ---------------------------------------------------------------------------
47// In-memory cache (30-min TTL, keyed on repoId:filePath)
48// ---------------------------------------------------------------------------
49
50interface CacheEntry {
51 report: ArchaeologyReport;
52 expiresAt: number;
53}
54
55const cache = new Map<string, CacheEntry>();
56const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
57
58function getCached(repoId: string, filePath: string): ArchaeologyReport | null {
59 const key = `${repoId}:${filePath}`;
60 const entry = cache.get(key);
61 if (!entry) return null;
62 if (Date.now() > entry.expiresAt) {
63 cache.delete(key);
64 return null;
65 }
66 return entry.report;
67}
68
69function setCached(repoId: string, filePath: string, report: ArchaeologyReport): void {
70 const key = `${repoId}:${filePath}`;
71 cache.set(key, { report, expiresAt: Date.now() + CACHE_TTL_MS });
72}
73
74export function invalidateCache(repoId: string, filePath: string): void {
75 const key = `${repoId}:${filePath}`;
76 cache.delete(key);
77}
78
79// ---------------------------------------------------------------------------
80// Git helpers (run in the repo's bare git dir)
81// ---------------------------------------------------------------------------
82
83interface GitLogEntry {
84 sha: string;
85 message: string;
86 author: string;
87 date: string;
88}
89
90async function gitExec(
91 cmd: string[],
92 cwd: string
93): Promise<{ stdout: string; exitCode: number }> {
94 const proc = Bun.spawn(cmd, {
95 cwd,
96 env: process.env as Record<string, string>,
97 stdout: "pipe",
98 stderr: "pipe",
99 });
100 const stdout = await new Response(proc.stdout).text();
101 const exitCode = await proc.exited;
102 return { stdout, exitCode };
103}
104
105/**
106 * Step 1 — Run `git log --follow --oneline --max-count=20 -- <filePath>`
107 * and also grab author + date via a custom format. Cap at 20 commits.
108 */
109async function getFileGitLog(
110 repoPath: string,
111 filePath: string
112): Promise<GitLogEntry[]> {
113 try {
114 // Use null-delimiter format: sha%x00message%x00author%x00date
115 const { stdout, exitCode } = await gitExec(
116 [
117 "git",
118 "log",
119 "--follow",
120 "--format=%H%x00%s%x00%an%x00%aI",
121 "--max-count=20",
122 "--",
123 filePath,
124 ],
125 repoPath
126 );
127 if (exitCode !== 0) return [];
128 return stdout
129 .trim()
130 .split("\n")
131 .filter(Boolean)
132 .map((line) => {
133 const [sha, message, author, date] = line.split("\0");
134 return { sha, message: message || "(no message)", author: author || "unknown", date: date || "" };
135 });
136 } catch {
137 return [];
138 }
139}
140
141// ---------------------------------------------------------------------------
142// DB helpers
143// ---------------------------------------------------------------------------
144
145interface PrRecord {
146 number: number;
147 title: string;
148 body: string | null;
149 createdAt: Date;
150 comments: Array<{ body: string }>;
151}
152
153interface IssueRecord {
154 number: number;
155 title: string;
156 body: string | null;
157 createdAt: Date;
158 comments: Array<{ body: string }>;
159}
160
161async function findRelatedPRs(
162 repoId: string,
163 fileName: string
164): Promise<PrRecord[]> {
165 try {
166 const pattern = `%${fileName}%`;
167 const prs = await db
168 .select({
169 id: pullRequests.id,
170 number: pullRequests.number,
171 title: pullRequests.title,
172 body: pullRequests.body,
173 createdAt: pullRequests.createdAt,
174 })
175 .from(pullRequests)
176 .where(
177 and(
178 eq(pullRequests.repositoryId, repoId),
179 or(
180 ilike(pullRequests.title, pattern),
181 ilike(pullRequests.body, pattern)
182 )
183 )
184 )
185 .orderBy(desc(pullRequests.createdAt))
186 .limit(5);
187
188 const results: PrRecord[] = [];
189 for (const pr of prs) {
190 const comments = await db
191 .select({ body: prComments.body })
192 .from(prComments)
193 .where(
194 and(
195 eq(prComments.pullRequestId, pr.id),
196 eq(prComments.isAiReview, false)
197 )
198 )
199 .orderBy(desc(prComments.createdAt))
200 .limit(3);
201
202 results.push({
203 number: pr.number,
204 title: pr.title,
205 body: pr.body,
206 createdAt: pr.createdAt,
207 comments: comments.map((c) => ({
208 body: c.body.slice(0, 500),
209 })),
210 });
211 }
212 return results;
213 } catch {
214 return [];
215 }
216}
217
218async function findRelatedIssues(
219 repoId: string,
220 fileName: string
221): Promise<IssueRecord[]> {
222 try {
223 const pattern = `%${fileName}%`;
224 const found = await db
225 .select({
226 id: issues.id,
227 number: issues.number,
228 title: issues.title,
229 body: issues.body,
230 createdAt: issues.createdAt,
231 })
232 .from(issues)
233 .where(
234 and(
235 eq(issues.repositoryId, repoId),
236 or(
237 ilike(issues.title, pattern),
238 ilike(issues.body, pattern)
239 )
240 )
241 )
242 .orderBy(desc(issues.createdAt))
243 .limit(5);
244
245 const results: IssueRecord[] = [];
246 for (const issue of found) {
247 const comments = await db
248 .select({ body: issueComments.body })
249 .from(issueComments)
250 .where(eq(issueComments.issueId, issue.id))
251 .orderBy(desc(issueComments.createdAt))
252 .limit(2);
253
254 results.push({
255 number: issue.number,
256 title: issue.title,
257 body: issue.body,
258 createdAt: issue.createdAt,
259 comments: comments.map((c) => ({
260 body: c.body.slice(0, 300),
261 })),
262 });
263 }
264 return results;
265 } catch {
266 return [];
267 }
268}
269
270// ---------------------------------------------------------------------------
271// Confidence heuristic
272// ---------------------------------------------------------------------------
273
274function deriveConfidence(
275 commits: GitLogEntry[],
276 prs: PrRecord[],
277 issues: IssueRecord[]
278): "high" | "medium" | "low" {
279 const total = commits.length + prs.length + issues.length;
280 if (total >= 8) return "high";
281 if (total >= 3) return "medium";
282 return "low";
283}
284
285// ---------------------------------------------------------------------------
286// Main excavate function
287// ---------------------------------------------------------------------------
288
289export async function excavate(
290 ownerName: string,
291 repoName: string,
292 repoId: string,
293 filePath: string,
294 query: string
295): Promise<ArchaeologyReport> {
296 // Check cache first
297 const cached = getCached(repoId, filePath);
298 if (cached) {
299 // Return cached with updated query
300 return { ...cached, query };
301 }
302
303 // Guard: AI not available
304 if (!isAiAvailable()) {
305 const report: ArchaeologyReport = {
306 filePath,
307 query,
308 explanation: "ANTHROPIC_API_KEY not set — AI archaeology is unavailable.",
309 findings: [],
310 confidence: "low",
311 analyzedAt: new Date(),
312 };
313 return report;
314 }
315
316 try {
317 const repoPath = getRepoPath(ownerName, repoName);
318 const fileName = basename(filePath);
319
320 // Step 1: Git log for this file
321 const commits = await getFileGitLog(repoPath, filePath);
322
323 // Step 2: Load current file content (cap at 10KB)
324 let fileContent = "";
325 try {
326 const blob = await getBlob(ownerName, repoName, "HEAD", filePath);
327 if (blob && !blob.isBinary) {
328 fileContent = blob.content.slice(0, 10 * 1024);
329 }
330 } catch {
331 // file may not exist — continue
332 }
333
334 // First 50 lines of file content for Claude
335 const first50Lines = fileContent
336 .split("\n")
337 .slice(0, 50)
338 .join("\n");
339
340 // Step 3: Related PRs
341 const prs = await findRelatedPRs(repoId, fileName);
342
343 // Step 4: Related issues
344 const relatedIssues = await findRelatedIssues(repoId, fileName);
345
346 // Step 5: Claude synthesis
347 const gitLogText = commits.length > 0
348 ? commits
349 .map(
350 (c) =>
351 `- ${c.sha.slice(0, 8)} | ${c.date.slice(0, 10)} | ${c.author} | ${c.message}`
352 )
353 .join("\n")
354 : "(no commits found for this file)";
355
356 const prText = prs.length > 0
357 ? prs
358 .map((pr) => {
359 const bodySnippet = pr.body ? pr.body.slice(0, 800) : "";
360 const commentsText = pr.comments.length > 0
361 ? pr.comments.map((c) => ` Comment: ${c.body}`).join("\n")
362 : "";
363 return `PR #${pr.number} (${pr.createdAt.toISOString().slice(0, 10)}): ${pr.title}\n${bodySnippet ? `Body: ${bodySnippet}` : ""}${commentsText ? `\n${commentsText}` : ""}`;
364 })
365 .join("\n\n")
366 : "(no related PRs found)";
367
368 const issueText = relatedIssues.length > 0
369 ? relatedIssues
370 .map((issue) => {
371 const bodySnippet = issue.body ? issue.body.slice(0, 600) : "";
372 const commentsText = issue.comments.length > 0
373 ? issue.comments.map((c) => ` Comment: ${c.body}`).join("\n")
374 : "";
375 return `Issue #${issue.number} (${issue.createdAt.toISOString().slice(0, 10)}): ${issue.title}\n${bodySnippet ? `Body: ${bodySnippet}` : ""}${commentsText ? `\n${commentsText}` : ""}`;
376 })
377 .join("\n\n")
378 : "(no related issues found)";
379
380 const userPrompt = `File: ${filePath}
381Question: ${query}
382
383Current file content (first 50 lines):
384\`\`\`
385${first50Lines || "(file is empty or binary)"}
386\`\`\`
387
388Git history (recent commits touching this file):
389${gitLogText}
390
391Related PRs:
392${prText}
393
394Related issues:
395${issueText}
396
397Synthesize: why does this code exist? What problem does it solve? What decisions were made?`;
398
399 let explanation = "";
400 try {
401 const anthropic = getAnthropic();
402 const message = await anthropic.messages.create({
403 model: MODEL_SONNET,
404 max_tokens: 2048,
405 system:
406 "You are a software archaeologist. Given the git history, PR discussions, and issue tracker for a file, synthesize a clear explanation of WHY this code exists — the original motivation, key decisions, and any important context. Be concise but complete. Use markdown.",
407 messages: [{ role: "user", content: userPrompt }],
408 });
409 explanation = extractText(message);
410 } catch (err) {
411 explanation = `Unable to generate explanation: ${err instanceof Error ? err.message : String(err)}`;
412 }
413
414 // Step 6: Build findings list sorted by date desc
415 const findings: ArchaeologyFinding[] = [];
416
417 // Add commits
418 for (const commit of commits) {
419 findings.push({
420 type: "commit",
421 id: commit.sha,
422 title: commit.message,
423 summary: `Commit by ${commit.author} touching this file.`,
424 date: commit.date,
425 url: `/${ownerName}/${repoName}/commit/${commit.sha}`,
426 author: commit.author,
427 });
428 }
429
430 // Add PRs
431 for (const pr of prs) {
432 findings.push({
433 type: "pr",
434 id: String(pr.number),
435 title: pr.title,
436 summary: `Pull request referencing ${fileName}.`,
437 date: pr.createdAt.toISOString(),
438 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
439 author: "",
440 });
441 }
442
443 // Add issues
444 for (const issue of relatedIssues) {
445 findings.push({
446 type: "issue",
447 id: String(issue.number),
448 title: issue.title,
449 summary: `Issue referencing ${fileName}.`,
450 date: issue.createdAt.toISOString(),
451 url: `/${ownerName}/${repoName}/issues/${issue.number}`,
452 author: "",
453 });
454 }
455
456 // Sort by date descending
457 findings.sort((a, b) => {
458 const da = a.date ? new Date(a.date).getTime() : 0;
459 const db2 = b.date ? new Date(b.date).getTime() : 0;
460 return db2 - da;
461 });
462
463 const confidence = deriveConfidence(commits, prs, relatedIssues);
464
465 const report: ArchaeologyReport = {
466 filePath,
467 query,
468 explanation,
469 findings,
470 confidence,
471 analyzedAt: new Date(),
472 };
473
474 setCached(repoId, filePath, report);
475 return report;
476 } catch (err) {
477 // Never throws — return a degraded report
478 return {
479 filePath,
480 query,
481 explanation: `Archaeology failed: ${err instanceof Error ? err.message : String(err)}`,
482 findings: [],
483 confidence: "low",
484 analyzedAt: new Date(),
485 };
486 }
487}
Addedsrc/lib/org-health.ts+255−0View fileUnifiedSplit
1/**
2 * Org-level team health computation.
3 *
4 * Aggregates per-repo health scores across every active repo in an org,
5 * produces a worst-first ranked list, and generates an AI summary.
6 *
7 * Cache: in-memory, 1h TTL per orgId. Call invalidateOrgHealth(orgId) to
8 * force a fresh computation on the next request.
9 */
10
11import { eq } from "drizzle-orm";
12import { db } from "../db";
13import { repositories, repoHealthCache } from "../db/schema";
14import { getHealthScore, invalidateHealthScore, type HealthScoreBreakdown } from "./repo-health";
15import { getAnthropic, isAiAvailable, extractText, MODEL_SONNET } from "./ai-client";
16import { sql } from "drizzle-orm";
17
18// ---------------------------------------------------------------------------
19// Public interface
20// ---------------------------------------------------------------------------
21
22export interface OrgRepoHealth {
23 repoId: string;
24 repoName: string;
25 ownerName: string;
26 score: number; // 0-100
27 trend: "up" | "down" | "stable"; // compare to last week's cached score
28 breakdown: HealthScoreBreakdown;
29}
30
31export interface OrgHealthReport {
32 orgSlug: string;
33 orgName: string;
34 avgScore: number;
35 repos: OrgRepoHealth[]; // sorted by score asc (worst first)
36 aiSummary: string; // Claude paragraph: org health state + top 3 actions
37 generatedAt: Date;
38}
39
40// ---------------------------------------------------------------------------
41// In-memory cache
42// ---------------------------------------------------------------------------
43
44interface OrgCacheEntry {
45 report: OrgHealthReport;
46 expiresAt: number; // Date.now() ms
47}
48
49const ORG_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
50const orgCache = new Map<string, OrgCacheEntry>();
51
52export function invalidateOrgHealth(orgId: string): void {
53 orgCache.delete(orgId);
54}
55
56// ---------------------------------------------------------------------------
57// Trend detection: compare current score to last week's DB-cached score
58// ---------------------------------------------------------------------------
59
60async function getTrendForRepo(
61 repoId: string,
62 currentScore: number
63): Promise<"up" | "down" | "stable"> {
64 try {
65 const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
66 // Look for a cached entry that was computed more than 7 days ago
67 const rows = await db
68 .select({ score: repoHealthCache.score, computedAt: repoHealthCache.computedAt })
69 .from(repoHealthCache)
70 .where(eq(repoHealthCache.repoId, repoId))
71 .limit(1);
72
73 if (rows.length === 0) return "stable";
74
75 const cached = rows[0];
76 // If the cached entry is recent (less than 7 days old), we have no prior-week baseline
77 if (!cached.computedAt || new Date(cached.computedAt) > oneWeekAgo) {
78 return "stable";
79 }
80
81 const priorScore = cached.score;
82 if (currentScore > priorScore + 2) return "up";
83 if (currentScore < priorScore - 2) return "down";
84 return "stable";
85 } catch {
86 return "stable";
87 }
88}
89
90// ---------------------------------------------------------------------------
91// AI summary generation
92// ---------------------------------------------------------------------------
93
94async function generateAiSummary(
95 orgName: string,
96 repos: OrgRepoHealth[]
97): Promise<string> {
98 if (!isAiAvailable() || repos.length === 0) return "";
99
100 try {
101 const repoLines = repos
102 .map((r) => {
103 const bd = r.breakdown;
104 return (
105 `${r.repoName}: ${r.score}/100 ` +
106 `(CI:${bd.ciGreenRate.score}, BusFactor:${bd.busFactor.score}, ` +
107 `CVEs:${bd.openCves.score}, ReviewSpeed:${bd.reviewVelocity.score}, Debt:${bd.techDebt.score})`
108 );
109 })
110 .join("\n");
111
112 const prompt =
113 `You are an engineering manager. Given these repository health scores for org ${orgName}, ` +
114 `write 2-3 sentences summarising the overall health and exactly 3 concrete action items ` +
115 `numbered 1-3. Be direct. No fluff.\n\nRepos (worst first):\n${repoLines}`;
116
117 const anthropic = getAnthropic();
118 const message = await anthropic.messages.create({
119 model: MODEL_SONNET,
120 max_tokens: 512,
121 messages: [{ role: "user", content: prompt }],
122 });
123
124 return extractText(message);
125 } catch {
126 return "";
127 }
128}
129
130// ---------------------------------------------------------------------------
131// Core computation
132// ---------------------------------------------------------------------------
133
134export async function computeOrgHealth(
135 orgId: string,
136 orgSlug: string
137): Promise<OrgHealthReport> {
138 // Check in-memory cache first
139 const now = Date.now();
140 const cached = orgCache.get(orgId);
141 if (cached && cached.expiresAt > now) {
142 return cached.report;
143 }
144
145 const emptyReport: OrgHealthReport = {
146 orgSlug,
147 orgName: orgSlug,
148 avgScore: 0,
149 repos: [],
150 aiSummary: "",
151 generatedAt: new Date(),
152 };
153
154 try {
155 // 1. Load org name + all active repos
156 const orgRows = await db
157 .select({ name: repositories.orgId })
158 .from(repositories)
159 .where(eq(repositories.orgId, orgId))
160 .limit(1);
161
162 // Load org display name from organizations table — we do this via the
163 // repositories query caller already has the org name from the route handler,
164 // so we accept orgSlug as the display fallback and the caller passes orgName separately.
165 // For now, use orgSlug as name placeholder; the route passes real name.
166
167 const repos = await db
168 .select({ id: repositories.id, name: repositories.name, ownerId: repositories.ownerId })
169 .from(repositories)
170 .where(
171 sql`${repositories.orgId} = ${orgId} AND ${repositories.isArchived} = false`
172 )
173 .orderBy(repositories.name);
174
175 if (repos.length === 0) {
176 const report = { ...emptyReport };
177 orgCache.set(orgId, { report, expiresAt: now + ORG_CACHE_TTL_MS });
178 return report;
179 }
180
181 // 2. Compute health scores in parallel (cap at 20 repos)
182 const capped = repos.slice(0, 20);
183 const breakdowns = await Promise.all(
184 capped.map((r) => getHealthScore(r.id))
185 );
186
187 // 3. Get trends in parallel
188 const trends = await Promise.all(
189 capped.map((r, i) => getTrendForRepo(r.id, breakdowns[i].total))
190 );
191
192 // 4. Build OrgRepoHealth array
193 const repoHealthList: OrgRepoHealth[] = capped.map((r, i) => ({
194 repoId: r.id,
195 repoName: r.name,
196 ownerName: orgSlug,
197 score: breakdowns[i].total,
198 trend: trends[i],
199 breakdown: breakdowns[i],
200 }));
201
202 // 5. Sort by score ascending (worst first — action list)
203 repoHealthList.sort((a, b) => a.score - b.score);
204
205 // 6. Compute average score
206 const sum = repoHealthList.reduce((acc, r) => acc + r.score, 0);
207 const avgScore = Math.round(sum / repoHealthList.length);
208
209 // 7. Generate AI summary
210 const aiSummary = await generateAiSummary(orgSlug, repoHealthList);
211
212 const report: OrgHealthReport = {
213 orgSlug,
214 orgName: orgSlug,
215 avgScore,
216 repos: repoHealthList,
217 aiSummary,
218 generatedAt: new Date(),
219 };
220
221 orgCache.set(orgId, { report, expiresAt: now + ORG_CACHE_TTL_MS });
222 return report;
223 } catch (err) {
224 const errorSummary =
225 err instanceof Error ? `Error computing org health: ${err.message}` : "Error computing org health.";
226 const report: OrgHealthReport = {
227 ...emptyReport,
228 aiSummary: errorSummary,
229 };
230 return report;
231 }
232}
233
234/**
235 * Invalidate health caches for all repos in an org and clear the org cache.
236 * Called from the POST /orgs/:slug/health/recompute endpoint.
237 */
238export async function invalidateOrgHealthAndRepos(
239 orgId: string
240): Promise<void> {
241 invalidateOrgHealth(orgId);
242 try {
243 const repos = await db
244 .select({ id: repositories.id })
245 .from(repositories)
246 .where(
247 sql`${repositories.orgId} = ${orgId} AND ${repositories.isArchived} = false`
248 );
249 for (const r of repos) {
250 invalidateHealthScore(r.id);
251 }
252 } catch {
253 // best effort
254 }
255}
Addedsrc/lib/streaming-review.ts+283−0View fileUnifiedSplit
1/**
2 * Streaming PR review — real-time Claude review via SSE.
3 *
4 * Additive to the existing batch ai-review.ts. This module streams tokens
5 * from Claude as they arrive, allowing the browser to display the review
6 * in real time rather than waiting 10–30 seconds for a batch result.
7 *
8 * Usage:
9 * for await (const token of streamPrReview(...)) {
10 * // send token as SSE event
11 * }
12 */
13
14import { eq, and, like } from "drizzle-orm";
15import { db } from "../db";
16import { pullRequests, prComments } from "../db/schema";
17import { getRepoPath } from "../git/repository";
18import { getAnthropic, isAiAvailable, MODEL_SONNET } from "./ai-client";
19import { getBotUserIdOrFallback } from "./bot-user";
20
21// ---------------------------------------------------------------------------
22// Public types
23// ---------------------------------------------------------------------------
24
25export interface StreamingReviewToken {
26 type: "token" | "section_start" | "section_end" | "done" | "error";
27 content?: string; // for type="token"
28 section?: string; // for section_start/end: "summary" | "finding" | "verdict"
29 error?: string; // for type="error"
30}
31
32// ---------------------------------------------------------------------------
33// In-memory streaming state
34// ---------------------------------------------------------------------------
35
36/** PR IDs currently being streamed. Prevents duplicate concurrent streams. */
37const _streamingPrs = new Set<string>();
38
39/**
40 * Returns true when a streaming review is already in progress for the given
41 * PR id.
42 */
43export function isReviewStreaming(prId: string): boolean {
44 return _streamingPrs.has(prId);
45}
46
47// ---------------------------------------------------------------------------
48// Marker embedded in the saved comment body so we can detect duplicates
49// ---------------------------------------------------------------------------
50
51export const STREAM_REVIEW_MARKER = "<!-- gluecron:stream-review:v1 -->";
52
53/** Max bytes of diff sent to Claude. Matches the batch reviewer's cap. */
54const DIFF_BYTE_CAP = 100_000;
55
56// ---------------------------------------------------------------------------
57// Section detection helpers
58// ---------------------------------------------------------------------------
59
60/**
61 * Detect section transitions by examining accumulated lines.
62 *
63 * Sections (in order):
64 * 1. "summary" — opening paragraph
65 * 2. "finding" — numbered list items (lines starting with a digit + ".")
66 * 3. "verdict" — line starting with "Verdict:"
67 *
68 * Returns the section name when a transition is detected, or null otherwise.
69 */
70function detectSectionTransition(
71 accumulated: string,
72 prevSection: string | null
73): string | null {
74 // Split into lines so we can inspect the latest complete line.
75 const lines = accumulated.split("\n");
76 // Look at lines from the end (most recent first)
77 for (let i = lines.length - 1; i >= 0; i--) {
78 const line = lines[i].trim();
79 if (!line) continue;
80
81 // Verdict line — always wins
82 if (line.toLowerCase().startsWith("verdict:") && prevSection !== "verdict") {
83 return "verdict";
84 }
85 // Finding: numbered list item (e.g. "1. ", "2. ", "12. ")
86 if (/^\d+\.\s/.test(line) && prevSection === "summary") {
87 return "finding";
88 }
89 break; // Only inspect the last non-empty line
90 }
91 return null;
92}
93
94// ---------------------------------------------------------------------------
95// Core streaming generator
96// ---------------------------------------------------------------------------
97
98/**
99 * Start a streaming review for a PR.
100 *
101 * Yields `StreamingReviewToken` objects as Claude produces them.
102 * On completion, saves the full review text as a PR comment
103 * (idempotent — skipped when the marker already exists in comments).
104 */
105export async function* streamPrReview(
106 prId: string,
107 ownerName: string,
108 repoName: string,
109 baseBranch: string,
110 headBranch: string
111): AsyncGenerator<StreamingReviewToken> {
112 // Guard: AI must be configured
113 if (!isAiAvailable()) {
114 yield { type: "error", error: "AI not available — ANTHROPIC_API_KEY is not set" };
115 return;
116 }
117
118 // Guard: prevent duplicate concurrent streams
119 if (_streamingPrs.has(prId)) {
120 yield { type: "error", error: "A streaming review is already in progress for this PR" };
121 return;
122 }
123
124 // Idempotency: skip if a stream-review comment already exists
125 try {
126 const [existing] = await db
127 .select({ id: prComments.id })
128 .from(prComments)
129 .where(
130 and(
131 eq(prComments.pullRequestId, prId),
132 eq(prComments.isAiReview, true),
133 like(prComments.body, `%${STREAM_REVIEW_MARKER}%`)
134 )
135 )
136 .limit(1);
137 if (existing) {
138 yield { type: "error", error: "Streaming review already completed for this PR" };
139 return;
140 }
141 } catch {
142 // DB check failed — proceed optimistically
143 }
144
145 // Mark PR as streaming
146 _streamingPrs.add(prId);
147
148 // Accumulate full text for DB persistence
149 let accumulated = "";
150 let currentSection: string | null = null;
151
152 try {
153 // Compute diff via git
154 let diffText = "";
155 try {
156 const cwd = getRepoPath(ownerName, repoName);
157 const proc = Bun.spawn(
158 ["git", "diff", `${baseBranch}...${headBranch}`, "--"],
159 { cwd, stdout: "pipe", stderr: "pipe" }
160 );
161 const raw = await new Response(proc.stdout).text();
162 await proc.exited;
163 diffText = raw;
164 } catch {
165 diffText = "";
166 }
167
168 if (!diffText.trim()) {
169 yield { type: "error", error: "No diff found between branches — nothing to review" };
170 return;
171 }
172
173 // Cap diff size
174 if (diffText.length > DIFF_BYTE_CAP) {
175 diffText = diffText.slice(0, DIFF_BYTE_CAP);
176 }
177
178 // Build messages
179 const systemPrompt =
180 "You are a senior code reviewer. Review this PR diff section by section. " +
181 "Start with a one-paragraph summary of the overall change, then list specific findings " +
182 "(each as: file:line — issue description), then give a verdict " +
183 "(Approve / Request Changes / Comment). Be direct and specific. " +
184 "Format findings as a numbered list. Start the verdict line with 'Verdict:'.";
185
186 const userMessage = `PR diff:\n${diffText}`;
187
188 // Open the streaming request
189 const anthropic = getAnthropic();
190
191 // Emit the summary section start before streaming begins
192 yield { type: "section_start", section: "summary" };
193 currentSection = "summary";
194
195 const stream = await anthropic.messages.stream({
196 model: MODEL_SONNET,
197 max_tokens: 2048,
198 system: systemPrompt,
199 messages: [{ role: "user", content: userMessage }],
200 });
201
202 // Stream tokens
203 for await (const chunk of stream) {
204 if (
205 chunk.type === "content_block_delta" &&
206 chunk.delta.type === "text_delta"
207 ) {
208 const text = chunk.delta.text;
209 if (!text) continue;
210
211 accumulated += text;
212
213 // Check for section transitions
214 const newSection = detectSectionTransition(accumulated, currentSection);
215 if (newSection && newSection !== currentSection) {
216 // Close the old section
217 yield { type: "section_end", section: currentSection ?? "summary" };
218 // Open the new section
219 yield { type: "section_start", section: newSection };
220 currentSection = newSection;
221 }
222
223 // Yield the token
224 yield { type: "token", content: text };
225 }
226 }
227
228 // Close the final section
229 if (currentSection) {
230 yield { type: "section_end", section: currentSection };
231 }
232
233 // Persist the full review as a PR comment (idempotent)
234 if (accumulated.trim()) {
235 try {
236 // Re-check idempotency before insert
237 const [existingCheck] = await db
238 .select({ id: prComments.id })
239 .from(prComments)
240 .where(
241 and(
242 eq(prComments.pullRequestId, prId),
243 eq(prComments.isAiReview, true),
244 like(prComments.body, `%${STREAM_REVIEW_MARKER}%`)
245 )
246 )
247 .limit(1);
248
249 if (!existingCheck) {
250 // Resolve author for the comment
251 const [pr] = await db
252 .select({ authorId: pullRequests.authorId })
253 .from(pullRequests)
254 .where(eq(pullRequests.id, prId))
255 .limit(1);
256
257 if (pr) {
258 const commentAuthorId = await getBotUserIdOrFallback(pr.authorId);
259 const commentBody = `## AI Stream Review\n\n${accumulated}\n\n${STREAM_REVIEW_MARKER}`;
260 await db.insert(prComments).values({
261 pullRequestId: prId,
262 authorId: commentAuthorId,
263 isAiReview: true,
264 body: commentBody,
265 });
266 }
267 }
268 } catch (err) {
269 // Non-fatal — review was streamed, just not persisted
270 if (process.env.DEBUG_AI_REVIEW === "1") {
271 console.error("[streaming-review] failed to persist review comment:", err);
272 }
273 }
274 }
275
276 yield { type: "done" };
277 } catch (err) {
278 const message = err instanceof Error ? err.message : String(err);
279 yield { type: "error", error: message };
280 } finally {
281 _streamingPrs.delete(prId);
282 }
283}
Addedsrc/lib/test-gaps.ts+379−0View fileUnifiedSplit
1/**
2 * Test Gap Detector — identify source files with no test coverage, ranked by risk.
3 *
4 * Steps:
5 * 1. git ls-tree to enumerate source + test files
6 * 2. Match source files against known test files to find uncovered candidates
7 * 3. Read high-risk candidate content via getBlob
8 * 4. Single Claude call to score each candidate's functions by risk
9 * 5. git grep callsite counts in parallel
10 *
11 * Cached per-repo with a 2h TTL (in-memory Map).
12 */
13
14import { getRepoPath, getBlob } from "../git/repository";
15import {
16 getAnthropic,
17 isAiAvailable,
18 MODEL_SONNET,
19 extractText,
20 parseJsonResponse,
21} from "./ai-client";
22
23// ─── Types ────────────────────────────────────────────────────────────────────
24
25export interface TestGap {
26 filePath: string;
27 functionName: string;
28 riskScore: number;
29 riskReason: string;
30 suggestedTestPath: string;
31 calledByCount: number;
32}
33
34export interface TestGapReport {
35 repoId: string;
36 totalSourceFiles: number;
37 totalTestFiles: number;
38 coverageEstimate: number;
39 gaps: TestGap[];
40 analyzedAt: Date;
41}
42
43// ─── In-memory cache ──────────────────────────────────────────────────────────
44
45interface CacheEntry {
46 report: TestGapReport;
47 expiresAt: number;
48}
49
50const cache = new Map<string, CacheEntry>();
51
52const CACHE_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
53
54// ─── Helpers ──────────────────────────────────────────────────────────────────
55
56async function execInRepo(
57 args: string[],
58 repoDir: string
59): Promise<{ stdout: string; exitCode: number }> {
60 const proc = Bun.spawn(args, {
61 cwd: repoDir,
62 stdout: "pipe",
63 stderr: "pipe",
64 });
65 const stdout = await new Response(proc.stdout).text();
66 const exitCode = await proc.exited;
67 return { stdout, exitCode };
68}
69
70const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|go|rs)$/;
71const TEST_PATTERN = /(\.(test|spec)\.(ts|tsx|js|jsx)$|__tests__\/)/;
72const SKIP_DIRS = /(node_modules|dist|build|\.next|vendor|__pycache__)/;
73
74function isSourceFile(p: string): boolean {
75 return SOURCE_EXTS.test(p) && !TEST_PATTERN.test(p) && !SKIP_DIRS.test(p);
76}
77
78function isTestFile(p: string): boolean {
79 return TEST_PATTERN.test(p) && !SKIP_DIRS.test(p);
80}
81
82/**
83 * Given a source file path, return the set of test-path patterns we'd expect
84 * to find for it (basename.test.ts, basename.spec.ts, __tests__/basename.test.ts).
85 */
86function expectedTestPaths(filePath: string): string[] {
87 const withoutExt = filePath.replace(/\.[^.]+$/, "");
88 const basename = withoutExt.split("/").pop() ?? withoutExt;
89 return [
90 `${withoutExt}.test.ts`,
91 `${withoutExt}.test.tsx`,
92 `${withoutExt}.spec.ts`,
93 `${withoutExt}.spec.tsx`,
94 `${withoutExt}.test.js`,
95 `${withoutExt}.spec.js`,
96 `__tests__/${basename}.test.ts`,
97 `__tests__/${basename}.test.tsx`,
98 `__tests__/${basename}.spec.ts`,
99 `${basename}.test.ts`,
100 `${basename}.spec.ts`,
101 ];
102}
103
104/**
105 * Check if a source file has any associated test file in the known test set.
106 */
107function hasTestCoverage(filePath: string, testFileSet: Set<string>): boolean {
108 // 1. Direct path match
109 for (const pattern of expectedTestPaths(filePath)) {
110 if (testFileSet.has(pattern)) return true;
111 }
112 // 2. Basename appears in any test file path
113 const basename = filePath.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "";
114 if (basename.length < 3) return false; // too generic, skip
115 for (const testFile of testFileSet) {
116 if (testFile.toLowerCase().includes(basename.toLowerCase())) return true;
117 }
118 return false;
119}
120
121const EXPORT_REGEX =
122 /(export\s+(?:async\s+)?function\s+(\w+)|export\s+const\s+(\w+)\s*=)/gm;
123
124function extractExportedNames(content: string): string[] {
125 const names: string[] = [];
126 let match: RegExpExecArray | null;
127 while ((match = EXPORT_REGEX.exec(content)) !== null) {
128 const name = match[2] ?? match[3];
129 if (name) names.push(name);
130 }
131 return [...new Set(names)];
132}
133
134function suggestTestPath(filePath: string): string {
135 const dir = filePath.includes("/")
136 ? filePath.substring(0, filePath.lastIndexOf("/"))
137 : "src";
138 const basename = (filePath.split("/").pop() ?? filePath).replace(
139 /\.[^.]+$/,
140 ""
141 );
142 if (filePath.startsWith("src/")) {
143 return `src/__tests__/${basename}.test.ts`;
144 }
145 return `${dir}/__tests__/${basename}.test.ts`;
146}
147
148// ─── Core detector ───────────────────────────────────────────────────────────
149
150export async function detectTestGaps(
151 ownerName: string,
152 repoName: string,
153 repoId: string
154): Promise<TestGapReport> {
155 const repoDir = getRepoPath(ownerName, repoName);
156
157 // ── Step 1: get file tree ──────────────────────────────────────────────────
158 const { stdout: lsOutput, exitCode } = await execInRepo(
159 ["git", "ls-tree", "-r", "--name-only", "HEAD"],
160 repoDir
161 );
162
163 if (exitCode !== 0) {
164 return {
165 repoId,
166 totalSourceFiles: 0,
167 totalTestFiles: 0,
168 coverageEstimate: 0,
169 gaps: [],
170 analyzedAt: new Date(),
171 };
172 }
173
174 const allFiles = lsOutput.trim().split("\n").filter(Boolean);
175 let sourceFiles = allFiles.filter(isSourceFile).slice(0, 200);
176 const testFiles = allFiles.filter(isTestFile);
177 const testFileSet = new Set(testFiles);
178
179 // ── Step 2: find uncovered source files ───────────────────────────────────
180 const candidates = sourceFiles
181 .filter((f) => !hasTestCoverage(f, testFileSet))
182 .slice(0, 50);
183
184 const coveredCount = sourceFiles.length - candidates.length;
185 const coverageEstimate =
186 sourceFiles.length > 0
187 ? Math.round((coveredCount / sourceFiles.length) * 100)
188 : 100;
189
190 if (candidates.length === 0) {
191 return {
192 repoId,
193 totalSourceFiles: sourceFiles.length,
194 totalTestFiles: testFiles.length,
195 coverageEstimate,
196 gaps: [],
197 analyzedAt: new Date(),
198 };
199 }
200
201 // ── Step 3: read candidate content (cap 4KB each, 40KB total) ─────────────
202 interface CandidateInfo {
203 filePath: string;
204 exports: string[];
205 content: string;
206 }
207
208 const candidateInfos: CandidateInfo[] = [];
209 let totalBytes = 0;
210
211 for (const filePath of candidates) {
212 if (totalBytes >= 40_000) break;
213 try {
214 const blob = await getBlob(ownerName, repoName, "HEAD", filePath);
215 if (!blob || blob.isBinary || !blob.content) continue;
216 const truncated = blob.content.slice(0, 4_096);
217 const exports = extractExportedNames(truncated);
218 if (exports.length === 0) {
219 // Still include with a placeholder so we can score the file overall
220 exports.push("<default>");
221 }
222 candidateInfos.push({ filePath, exports, content: truncated });
223 totalBytes += truncated.length;
224 } catch {
225 // skip unreadable blobs
226 }
227 }
228
229 if (candidateInfos.length === 0) {
230 return {
231 repoId,
232 totalSourceFiles: sourceFiles.length,
233 totalTestFiles: testFiles.length,
234 coverageEstimate,
235 gaps: [],
236 analyzedAt: new Date(),
237 };
238 }
239
240 // ── Step 4: risk scoring via Claude (or fallback) ─────────────────────────
241 interface AiGap {
242 filePath: string;
243 functionName: string;
244 riskScore: number;
245 riskReason: string;
246 suggestedTestPath: string;
247 }
248
249 interface AiResponse {
250 gaps: AiGap[];
251 coverageEstimate?: number;
252 }
253
254 let aiGaps: AiGap[] = [];
255
256 if (isAiAvailable()) {
257 const fileList = candidateInfos
258 .map((c) => `${c.filePath}: ${c.exports.join(", ")}`)
259 .join("\n");
260
261 try {
262 const anthropic = getAnthropic();
263 const message = await anthropic.messages.create({
264 model: MODEL_SONNET,
265 max_tokens: 2048,
266 system:
267 "You are a senior engineer identifying test coverage risks. Rate each untested function by how critical it is to test (0-100 risk score). Higher scores for: auth/security logic, DB mutations, payment handling, public API endpoints, complex business logic. Lower for: pure utilities, string formatters, simple getters.",
268 messages: [
269 {
270 role: "user",
271 content: `Repository: ${repoName}
272Untested files and their exported functions:
273${fileList}
274
275Return JSON:
276{"gaps": [{"filePath": "...", "functionName": "...", "riskScore": 0, "riskReason": "...", "suggestedTestPath": "..."}], "coverageEstimate": 0}
277
278Rules:
279- One gap entry per function per file (or one per file if no named exports).
280- riskScore: integer 0-100.
281- suggestedTestPath: where a new test file should live (e.g. "src/__tests__/auth.test.ts").
282- Keep gaps list ≤ 30 items total, prioritising highest risk.`,
283 },
284 ],
285 });
286
287 const text = extractText(message);
288 const parsed = parseJsonResponse<AiResponse>(text);
289 if (parsed && Array.isArray(parsed.gaps)) {
290 aiGaps = parsed.gaps;
291 }
292 } catch {
293 // fall through to fallback below
294 }
295 }
296
297 // Fallback if AI unavailable or failed
298 if (aiGaps.length === 0) {
299 for (const c of candidateInfos) {
300 for (const fn of c.exports.slice(0, 3)) {
301 aiGaps.push({
302 filePath: c.filePath,
303 functionName: fn === "<default>" ? c.filePath.split("/").pop() ?? c.filePath : fn,
304 riskScore: 50,
305 riskReason: "No test file found for this module",
306 suggestedTestPath: suggestTestPath(c.filePath),
307 });
308 }
309 }
310 }
311
312 // ── Step 5: callsite counts via git grep ──────────────────────────────────
313 const gapsFull: TestGap[] = await Promise.all(
314 aiGaps.slice(0, 30).map(async (g) => {
315 let calledByCount = 0;
316 const fnName = g.functionName.replace(/[^a-zA-Z0-9_]/g, "");
317 if (fnName && fnName !== "default") {
318 try {
319 const { stdout: grepOut } = await execInRepo(
320 ["git", "grep", "-l", fnName],
321 repoDir
322 );
323 const matchedFiles = grepOut
324 .trim()
325 .split("\n")
326 .filter(Boolean);
327 // Subtract 1 for the definition file itself
328 calledByCount = Math.max(0, matchedFiles.length - 1);
329 } catch {
330 calledByCount = 0;
331 }
332 }
333 return {
334 filePath: g.filePath,
335 functionName: g.functionName,
336 riskScore: Math.min(100, Math.max(0, Math.round(g.riskScore))),
337 riskReason: g.riskReason,
338 suggestedTestPath: g.suggestedTestPath || suggestTestPath(g.filePath),
339 calledByCount,
340 };
341 })
342 );
343
344 // Sort by riskScore desc
345 gapsFull.sort((a, b) => b.riskScore - a.riskScore);
346
347 return {
348 repoId,
349 totalSourceFiles: sourceFiles.length,
350 totalTestFiles: testFiles.length,
351 coverageEstimate,
352 gaps: gapsFull,
353 analyzedAt: new Date(),
354 };
355}
356
357// ─── Cached wrapper ───────────────────────────────────────────────────────────
358
359export async function getTestGaps(
360 ownerName: string,
361 repoName: string,
362 repoId: string
363): Promise<TestGapReport> {
364 const cached = cache.get(repoId);
365 if (cached && cached.expiresAt > Date.now()) {
366 return cached.report;
367 }
368
369 const report = await detectTestGaps(ownerName, repoName, repoId);
370 cache.set(repoId, {
371 report,
372 expiresAt: Date.now() + CACHE_TTL_MS,
373 });
374 return report;
375}
376
377export function clearTestGapsCache(repoId: string): void {
378 cache.delete(repoId);
379}
0380