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

Merge branch 'main' into claude/confident-faraday-tikcwb

CC LABS App committed on June 17, 2026Parents: 6c1fdc4 616eec8
15 files changed+37332657cd4d2daba95118acac04be72b69e21696bd855
15 changed files+3733−26
ModifiedDockerfile+8−5View fileUnifiedSplit
22WORKDIR /app
33
44# Install git (required for ALL git operations — clone, push, branch/tree
5# listing, diffs) and zip (used to package the Claude Desktop .dxt bundle at
6# build time). Verify git landed: a missing binary here must fail the build
7# loudly rather than ship an image that 500s on every repo page.
5# listing, diffs), zip (used to package the Claude Desktop .dxt bundle at
6# build time), and wget (the compose healthcheck calls it — without it the
7# container is permanently "unhealthy" and autoheal restart-loops it).
8# Verify git landed: a missing binary here must fail the build loudly rather
9# than ship an image that 500s on every repo page.
810RUN apt-get update \
9 && apt-get install -y --no-install-recommends git ca-certificates zip \
11 && apt-get install -y --no-install-recommends git ca-certificates zip wget \
1012 && rm -rf /var/lib/apt/lists/* \
11 && git --version
13 && git --version \
14 && wget --version | head -1
1215
1316# Install dependencies
1417COPY package.json bun.lock ./
Modifieddocker-compose.standalone.yml+8−0View fileUnifiedSplit
4444 - APP_BASE_URL=https://gluecron.com
4545 - SSH_PORT=0
4646 - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
47 # "Sign in with Google" — values come from .env (gitignored). Wiring them
48 # here is what makes the env bootstrap reach the container and survive
49 # redeploys; a config saved at /admin/google-oauth (DB) still takes
50 # precedence over these.
51 - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
52 - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
53 - GOOGLE_OAUTH_AUTO_CREATE=${GOOGLE_OAUTH_AUTO_CREATE:-}
54 - GOOGLE_OAUTH_ALLOWED_DOMAINS=${GOOGLE_OAUTH_ALLOWED_DOMAINS:-}
4755 expose:
4856 - "3000"
4957 volumes:
Modifieddocker-compose.yml+4−0View fileUnifiedSplit
1717 - GLUECRON_WEBHOOK_SECRET=${GLUECRON_WEBHOOK_SECRET}
1818 - CRONTECH_EVENT_TOKEN=${CRONTECH_EVENT_TOKEN}
1919 - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
20 - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
21 - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
22 - GOOGLE_OAUTH_AUTO_CREATE=${GOOGLE_OAUTH_AUTO_CREATE:-}
23 - GOOGLE_OAUTH_ALLOWED_DOMAINS=${GOOGLE_OAUTH_ALLOWED_DOMAINS:-}
2024 - EMAIL_PROVIDER=${EMAIL_PROVIDER:-log}
2125 - EMAIL_FROM=${EMAIL_FROM}
2226 - RESEND_API_KEY=${RESEND_API_KEY}
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/app.tsx+13−0View fileUnifiedSplit
119119import aiChangelogRoutes from "./routes/ai-changelog";
120120import explainRoutes from "./routes/explain";
121121import aiExplainRoutes from "./routes/ai-explain";
122import archaeologyRoutes from "./routes/ai-archaeology";
122123import aiTestsRoutes from "./routes/ai-tests";
123124import askRoutes from "./routes/ask";
124125import repoChatRoutes from "./routes/repo-chat";
147148import mergeQueueRoutes from "./routes/merge-queue";
148149import mirrorsRoutes from "./routes/mirrors";
149150import orgInsightsRoutes from "./routes/org-insights";
151import orgHealthRoutes from "./routes/org-health";
150152import packagesRoutes from "./routes/packages";
151153import packagesApiRoutes from "./routes/packages-api";
152154import ociRegistryRoutes from "./routes/oci-registry";
199201import hotFilesRoutes from "./routes/hot-files";
200202import debtMapRoutes from "./routes/debt-map";
201203import busFactorRoutes from "./routes/bus-factor";
204import testGapsRoutes from "./routes/test-gaps";
202205import crossRepoImpactRoutes from "./routes/cross-repo-impact";
203206import developerProgramRoutes from "./routes/developer-program";
204207import shareRoutes from "./routes/share";
541544// Pull requests
542545app.route("/", pullRoutes);
543546
547// Streaming PR review — SSE stream + embeddable widget
548// Mounted immediately after pullRoutes so /:owner/:repo/pulls/:number/review/*
549// resolves before the webRoutes catch-all takes over.
550import streamingReviewRoutes from "./routes/streaming-review";
551app.route("/", streamingReviewRoutes);
552
544553// /stage PR preview — serve built static files from .stage-previews/{jobId}/
545554// Registered immediately after pullRoutes so the path `/preview/:id/*` is
546555// specific enough to never clash with repo browse routes (`/:owner/:repo/*`).
753762// the simpler aiExplainRoutes so the new routes win on /:owner/:repo/explain).
754763app.route("/", explainRoutes);
755764app.route("/", aiExplainRoutes);
765app.route("/", archaeologyRoutes);
756766app.route("/", aiTestsRoutes);
757767app.route("/", askRoutes);
758768app.route("/", repoChatRoutes);
784794app.route("/", mergeQueueRoutes);
785795app.route("/", mirrorsRoutes);
786796app.route("/", orgInsightsRoutes);
797app.route("/", orgHealthRoutes);
787798app.route("/", packagesRoutes);
788799app.route("/", packagesApiRoutes);
789800// OCI / Docker container registry — /v2/* (OCI Distribution Spec v1.0)
826837app.route("/", debtMapRoutes);
827838// Bus Factor Analysis — /:owner/:repo/insights/bus-factor
828839app.route("/", busFactorRoutes);
840// Test Gap Detector — /:owner/:repo/insights/test-gaps
841app.route("/", testGapsRoutes);
829842// Cross-Repo Dependency Impact Detection — /:owner/:repo/pulls/:number/cross-repo-impact
830843app.route("/", crossRepoImpactRoutes);
831844// Hosted Claude tool-use loops — paste loop, get endpoint, billing meter.
Modifiedsrc/db/schema.ts+10−21View fileUnifiedSplit
46514651export type WorkspaceJob = typeof workspaceJobs.$inferSelect;
46524652
46534653// ---------------------------------------------------------------------------
4654// Migration 0106 — Per-repo automation settings (off / suggest / auto).
4655// One row per repository; absence of a row means the defaults in
4656// src/lib/automation-settings.ts, which match pre-0106 behavior exactly.
4657// Env kill-switches stay supreme over anything stored here.
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.
46584656// ---------------------------------------------------------------------------
4659export const repoAutomationSettings = pgTable(
4660 "repo_automation_settings",
4657export const testGapCache = pgTable(
4658 "test_gap_cache",
46614659 {
46624660 id: uuid("id").primaryKey().defaultRandom(),
4663 repositoryId: uuid("repository_id")
4661 repoId: uuid("repo_id")
46644662 .notNull()
46654663 .unique()
46664664 .references(() => repositories.id, { onDelete: "cascade" }),
4667 /** 'off' | 'suggest' — AI code review on PR open ('auto' = 'suggest'). */
4668 aiReviewMode: text("ai_review_mode").notNull().default("suggest"),
4669 /** 'off' | 'suggest' — AI triage comment on PR open. */
4670 prTriageMode: text("pr_triage_mode").notNull().default("suggest"),
4671 /** 'off' | 'suggest' — AI triage comment on issue create. */
4672 issueTriageMode: text("issue_triage_mode").notNull().default("suggest"),
4673 /** 'off' | 'suggest' | 'auto' — AI-gated auto-merge evaluation. */
4674 autoMergeMode: text("auto_merge_mode").notNull().default("auto"),
4675 /** 'off' | 'suggest' | 'auto' — CI auto-fix on failed gate runs. */
4676 ciAutofixMode: text("ci_autofix_mode").notNull().default("suggest"),
4677 createdAt: timestamp("created_at").defaultNow(),
4678 updatedAt: timestamp("updated_at").defaultNow(),
4665 report: jsonb("report").notNull(),
4666 analyzedAt: timestamp("analyzed_at").defaultNow(),
4667 expiresAt: timestamp("expires_at").notNull(),
46794668 },
46804669 (table) => [
4681 index("idx_repo_automation_settings_repo").on(table.repositoryId),
4670 index("idx_test_gap_cache_repo").on(table.repoId),
46824671 ]
46834672);
46844673
4685export type RepoAutomationSettings = typeof repoAutomationSettings.$inferSelect;
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+248−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, and, lt, sql } 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";
16
17// ---------------------------------------------------------------------------
18// Public interface
19// ---------------------------------------------------------------------------
20
21export interface OrgRepoHealth {
22 repoId: string;
23 repoName: string;
24 ownerName: string;
25 score: number; // 0-100
26 trend: "up" | "down" | "stable"; // compare to last week's cached score
27 breakdown: HealthScoreBreakdown;
28}
29
30export interface OrgHealthReport {
31 orgSlug: string;
32 orgName: string;
33 avgScore: number;
34 repos: OrgRepoHealth[]; // sorted by score asc (worst first)
35 aiSummary: string; // Claude paragraph: org health state + top 3 actions
36 generatedAt: Date;
37}
38
39// ---------------------------------------------------------------------------
40// In-memory cache
41// ---------------------------------------------------------------------------
42
43interface OrgCacheEntry {
44 report: OrgHealthReport;
45 expiresAt: number; // Date.now() ms
46}
47
48const ORG_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
49const orgCache = new Map<string, OrgCacheEntry>();
50
51export function invalidateOrgHealth(orgId: string): void {
52 orgCache.delete(orgId);
53}
54
55// ---------------------------------------------------------------------------
56// Trend detection: compare current score to last week's DB-cached score
57// ---------------------------------------------------------------------------
58
59async function getTrendForRepo(
60 repoId: string,
61 currentScore: number
62): Promise<"up" | "down" | "stable"> {
63 try {
64 const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
65 // Look for a cached entry computed more than 7 days ago as the prior-week baseline
66 const rows = await db
67 .select({ score: repoHealthCache.score, computedAt: repoHealthCache.computedAt })
68 .from(repoHealthCache)
69 .where(
70 and(
71 eq(repoHealthCache.repoId, repoId),
72 lt(repoHealthCache.computedAt, oneWeekAgo)
73 )
74 )
75 .limit(1);
76
77 if (rows.length === 0) return "stable";
78
79 const priorScore = rows[0].score;
80 if (currentScore > priorScore + 2) return "up";
81 if (currentScore < priorScore - 2) return "down";
82 return "stable";
83 } catch {
84 return "stable";
85 }
86}
87
88// ---------------------------------------------------------------------------
89// AI summary generation
90// ---------------------------------------------------------------------------
91
92async function generateAiSummary(
93 orgName: string,
94 repos: OrgRepoHealth[]
95): Promise<string> {
96 if (!isAiAvailable() || repos.length === 0) return "";
97
98 try {
99 const repoLines = repos
100 .map((r) => {
101 const bd = r.breakdown;
102 return (
103 `${r.repoName}: ${r.score}/100 ` +
104 `(CI:${bd.ciGreenRate.score}, BusFactor:${bd.busFactor.score}, ` +
105 `CVEs:${bd.openCves.score}, ReviewSpeed:${bd.reviewVelocity.score}, Debt:${bd.techDebt.score})`
106 );
107 })
108 .join("\n");
109
110 const prompt =
111 `You are an engineering manager. Given these repository health scores for org ${orgName}, ` +
112 `write 2-3 sentences summarising the overall health and exactly 3 concrete action items ` +
113 `numbered 1-3. Be direct. No fluff.\n\nRepos (worst first):\n${repoLines}`;
114
115 const anthropic = getAnthropic();
116 const message = await anthropic.messages.create({
117 model: MODEL_SONNET,
118 max_tokens: 512,
119 messages: [{ role: "user", content: prompt }],
120 });
121
122 return extractText(message);
123 } catch {
124 return "";
125 }
126}
127
128// ---------------------------------------------------------------------------
129// Core computation
130// ---------------------------------------------------------------------------
131
132export async function computeOrgHealth(
133 orgId: string,
134 orgSlug: string
135): Promise<OrgHealthReport> {
136 // Check in-memory cache first
137 const now = Date.now();
138 const cached = orgCache.get(orgId);
139 if (cached && cached.expiresAt > now) {
140 return cached.report;
141 }
142
143 const emptyReport: OrgHealthReport = {
144 orgSlug,
145 orgName: orgSlug,
146 avgScore: 0,
147 repos: [],
148 aiSummary: "",
149 generatedAt: new Date(),
150 };
151
152 try {
153 // 1. Load all non-archived repos in the org
154 const repos = await db
155 .select({ id: repositories.id, name: repositories.name })
156 .from(repositories)
157 .where(
158 and(
159 eq(repositories.orgId, orgId),
160 eq(repositories.isArchived, false)
161 )
162 )
163 .orderBy(repositories.name);
164
165 if (repos.length === 0) {
166 const report = { ...emptyReport };
167 orgCache.set(orgId, { report, expiresAt: now + ORG_CACHE_TTL_MS });
168 return report;
169 }
170
171 // 2. Compute health scores in parallel (cap at 20 repos)
172 const capped = repos.slice(0, 20);
173 const breakdowns = await Promise.all(
174 capped.map((r) => getHealthScore(r.id))
175 );
176
177 // 3. Get trends in parallel
178 const trends = await Promise.all(
179 capped.map((r, i) => getTrendForRepo(r.id, breakdowns[i].total))
180 );
181
182 // 4. Build OrgRepoHealth array
183 const repoHealthList: OrgRepoHealth[] = capped.map((r, i) => ({
184 repoId: r.id,
185 repoName: r.name,
186 ownerName: orgSlug,
187 score: breakdowns[i].total,
188 trend: trends[i],
189 breakdown: breakdowns[i],
190 }));
191
192 // 5. Sort by score ascending (worst first — action list)
193 repoHealthList.sort((a, b) => a.score - b.score);
194
195 // 6. Compute average score
196 const sum = repoHealthList.reduce((acc, r) => acc + r.score, 0);
197 const avgScore = Math.round(sum / repoHealthList.length);
198
199 // 7. Generate AI summary
200 const aiSummary = await generateAiSummary(orgSlug, repoHealthList);
201
202 const report: OrgHealthReport = {
203 orgSlug,
204 orgName: orgSlug,
205 avgScore,
206 repos: repoHealthList,
207 aiSummary,
208 generatedAt: new Date(),
209 };
210
211 orgCache.set(orgId, { report, expiresAt: now + ORG_CACHE_TTL_MS });
212 return report;
213 } catch (err) {
214 const errorSummary =
215 err instanceof Error ? `Error computing org health: ${err.message}` : "Error computing org health.";
216 const report: OrgHealthReport = {
217 ...emptyReport,
218 aiSummary: errorSummary,
219 };
220 return report;
221 }
222}
223
224/**
225 * Invalidate health caches for all repos in an org and clear the org cache.
226 * Called from the POST /orgs/:slug/health/recompute endpoint.
227 */
228export async function invalidateOrgHealthAndRepos(
229 orgId: string
230): Promise<void> {
231 invalidateOrgHealth(orgId);
232 try {
233 const repos = await db
234 .select({ id: repositories.id })
235 .from(repositories)
236 .where(
237 and(
238 eq(repositories.orgId, orgId),
239 eq(repositories.isArchived, false)
240 )
241 );
242 for (const r of repos) {
243 invalidateHealthScore(r.id);
244 }
245 } catch {
246 // best effort
247 }
248}
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 = 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}
Addedsrc/routes/ai-archaeology.tsx+817−0View fileUnifiedSplit
1/**
2 * AI Code Archaeology — /:owner/:repo/archaeology
3 *
4 * A developer can ask "why does this file exist?" and get a synthesized
5 * answer from git history, PR discussions, and the issue tracker.
6 *
7 * GET /:owner/:repo/archaeology — search form
8 * GET /:owner/:repo/archaeology?file=&q= — results page
9 * GET /:owner/:repo/archaeology?file=&q=&deep=1 — force-refresh results
10 */
11
12import { Hono } from "hono";
13import { html } from "hono/html";
14import { and, eq } from "drizzle-orm";
15import { db } from "../db";
16import { repositories, users } from "../db/schema";
17import { Layout } from "../views/layout";
18import { RepoHeader, RepoNav } from "../views/components";
19import { renderMarkdown } from "../lib/markdown";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { excavate, invalidateCache } from "../lib/ai-archaeology";
23import type { ArchaeologyFinding } from "../lib/ai-archaeology";
24
25export const archaeologyRoutes = new Hono<AuthEnv>();
26
27/* ─────────────────────────────────────────────────────────────────────────
28 * Scoped CSS
29 * ───────────────────────────────────────────────────────────────────────── */
30const styles = `
31 .arch-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
32
33 /* Hero */
34 .arch-hero {
35 position: relative;
36 margin-bottom: var(--space-5);
37 padding: var(--space-5) var(--space-6);
38 background: var(--bg-elevated);
39 border: 1px solid var(--border);
40 border-radius: 16px;
41 overflow: hidden;
42 }
43 .arch-hero::before {
44 content: '';
45 position: absolute;
46 top: 0; left: 0; right: 0;
47 height: 2px;
48 background: linear-gradient(90deg, transparent 0%, #c97b2e 30%, #e8a45a 70%, transparent 100%);
49 opacity: 0.8;
50 pointer-events: none;
51 }
52 .arch-hero-orb {
53 position: absolute;
54 inset: -20% -10% auto auto;
55 width: 380px; height: 380px;
56 background: radial-gradient(circle, rgba(201,123,46,0.18), rgba(232,164,90,0.08) 45%, transparent 70%);
57 filter: blur(70px);
58 pointer-events: none;
59 z-index: 0;
60 }
61 .arch-hero-inner { position: relative; z-index: 1; max-width: 700px; }
62
63 .arch-eyebrow {
64 font-size: 12px;
65 color: var(--text-muted);
66 margin-bottom: var(--space-2);
67 letter-spacing: 0.02em;
68 display: inline-flex;
69 align-items: center;
70 gap: 8px;
71 }
72 .arch-eyebrow .pill {
73 display: inline-flex;
74 align-items: center;
75 justify-content: center;
76 width: 18px; height: 18px;
77 border-radius: 6px;
78 background: rgba(201,123,46,0.14);
79 color: #e8a45a;
80 box-shadow: inset 0 0 0 1px rgba(201,123,46,0.35);
81 font-size: 11px;
82 }
83 .arch-title {
84 font-size: clamp(26px, 4vw, 38px);
85 font-family: var(--font-display);
86 font-weight: 800;
87 letter-spacing: -0.025em;
88 line-height: 1.05;
89 margin: 0 0 var(--space-2);
90 color: var(--text-strong);
91 }
92 .arch-title-grad {
93 background-image: linear-gradient(135deg, #e8a45a 0%, #c97b2e 50%, #e8a45a 100%);
94 -webkit-background-clip: text;
95 background-clip: text;
96 -webkit-text-fill-color: transparent;
97 color: transparent;
98 }
99 .arch-sub {
100 font-size: 14px;
101 color: var(--text-muted);
102 margin: 0;
103 line-height: 1.55;
104 max-width: 580px;
105 }
106
107 /* Search form */
108 .arch-form {
109 margin-bottom: var(--space-5);
110 background: var(--bg-elevated);
111 border: 1px solid var(--border);
112 border-radius: 14px;
113 padding: var(--space-4) var(--space-5);
114 }
115 .arch-form-title {
116 font-family: var(--font-display);
117 font-size: 15px;
118 font-weight: 700;
119 color: var(--text-strong);
120 margin: 0 0 var(--space-3);
121 }
122 .arch-form-row {
123 display: grid;
124 grid-template-columns: 1fr 1fr auto;
125 gap: var(--space-2);
126 align-items: end;
127 }
128 @media (max-width: 640px) {
129 .arch-form-row { grid-template-columns: 1fr; }
130 }
131 .arch-form label {
132 display: block;
133 font-size: 12px;
134 font-weight: 600;
135 color: var(--text-muted);
136 margin-bottom: 4px;
137 letter-spacing: 0.03em;
138 }
139 .arch-form input[type="text"] {
140 width: 100%;
141 padding: 9px 12px;
142 background: var(--bg-tertiary);
143 border: 1px solid var(--border);
144 border-radius: 8px;
145 color: var(--text);
146 font-size: 14px;
147 font-family: inherit;
148 outline: none;
149 box-sizing: border-box;
150 transition: border-color 120ms ease;
151 }
152 .arch-form input[type="text"]:focus {
153 border-color: #c97b2e;
154 box-shadow: 0 0 0 3px rgba(201,123,46,0.12);
155 }
156 .arch-submit {
157 display: inline-flex;
158 align-items: center;
159 gap: 6px;
160 padding: 9px 18px;
161 background: linear-gradient(135deg, #c97b2e 0%, #e8a45a 100%);
162 color: #fff;
163 border: 1px solid transparent;
164 border-radius: 9px;
165 font-size: 13.5px;
166 font-weight: 600;
167 cursor: pointer;
168 font-family: inherit;
169 box-shadow: 0 4px 12px -3px rgba(201,123,46,0.45);
170 transition: transform 120ms ease, box-shadow 120ms ease;
171 white-space: nowrap;
172 }
173 .arch-submit:hover {
174 transform: translateY(-1px);
175 box-shadow: 0 8px 18px -4px rgba(201,123,46,0.55);
176 }
177
178 /* Explanation panel */
179 .arch-panel {
180 position: relative;
181 margin-bottom: var(--space-5);
182 background: #ffffff;
183 color: #0a0a0a;
184 border: 1px solid #e5e7eb;
185 border-radius: 14px;
186 overflow: hidden;
187 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 8px 32px rgba(0,0,0,0.16);
188 }
189 .arch-panel-head {
190 display: flex;
191 align-items: center;
192 justify-content: space-between;
193 gap: 12px;
194 padding: 12px 18px;
195 background: #f9fafb;
196 border-bottom: 1px solid #e5e7eb;
197 flex-wrap: wrap;
198 }
199 .arch-panel-title {
200 display: flex;
201 align-items: center;
202 gap: 10px;
203 font-size: 14px;
204 font-weight: 700;
205 color: #111827;
206 margin: 0;
207 font-family: var(--font-display, system-ui, sans-serif);
208 }
209 .arch-panel-dot {
210 width: 8px; height: 8px;
211 border-radius: 9999px;
212 background: linear-gradient(135deg, #c97b2e, #e8a45a);
213 box-shadow: 0 0 0 3px rgba(201,123,46,0.18);
214 }
215 .arch-panel-meta {
216 display: flex;
217 align-items: center;
218 gap: 10px;
219 flex-wrap: wrap;
220 }
221
222 /* Confidence pill */
223 .arch-confidence {
224 display: inline-flex;
225 align-items: center;
226 gap: 5px;
227 padding: 2px 9px;
228 border-radius: 9999px;
229 font-size: 10.5px;
230 font-weight: 700;
231 letter-spacing: 0.05em;
232 text-transform: uppercase;
233 }
234 .arch-confidence-high {
235 background: rgba(52,211,153,0.12);
236 color: #047857;
237 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.4);
238 }
239 .arch-confidence-medium {
240 background: rgba(251,191,36,0.12);
241 color: #92400e;
242 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.4);
243 }
244 .arch-confidence-low {
245 background: rgba(248,113,113,0.12);
246 color: #b91c1c;
247 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.4);
248 }
249 .arch-confidence .dot {
250 width: 5px; height: 5px;
251 border-radius: 9999px;
252 background: currentColor;
253 }
254
255 .arch-panel-body {
256 padding: 22px 24px;
257 }
258 .arch-panel-body .markdown-body {
259 color: #0a0a0a;
260 background: #ffffff;
261 font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif);
262 font-size: 14.5px;
263 line-height: 1.65;
264 }
265 .arch-panel-body .markdown-body h1,
266 .arch-panel-body .markdown-body h2,
267 .arch-panel-body .markdown-body h3 {
268 color: #0a0a0a;
269 border-bottom-color: #e5e7eb;
270 }
271 .arch-panel-body .markdown-body a { color: #92400e; }
272 .arch-panel-body .markdown-body code {
273 background: #fef3c7;
274 color: #92400e;
275 padding: 1px 5px;
276 border-radius: 4px;
277 font-family: var(--font-mono);
278 font-size: 12.5px;
279 }
280 .arch-panel-body .markdown-body pre {
281 background: #0f111a;
282 color: #e6edf3;
283 border: 1px solid #1f2330;
284 border-radius: 8px;
285 padding: 12px 14px;
286 overflow-x: auto;
287 }
288 .arch-panel-body .markdown-body pre code {
289 background: transparent;
290 color: inherit;
291 padding: 0;
292 }
293 .arch-panel-body .markdown-body blockquote {
294 border-left: 3px solid #fcd34d;
295 background: #fffbeb;
296 color: #78350f;
297 padding: 8px 14px;
298 margin: 12px 0;
299 border-radius: 6px;
300 }
301
302 /* Actions row below panel */
303 .arch-actions {
304 display: flex;
305 align-items: center;
306 gap: 12px;
307 margin-bottom: var(--space-5);
308 flex-wrap: wrap;
309 }
310 .arch-dig-deeper {
311 display: inline-flex;
312 align-items: center;
313 gap: 6px;
314 padding: 8px 16px;
315 background: var(--bg-elevated);
316 border: 1px solid var(--border);
317 border-radius: 9px;
318 font-size: 13px;
319 font-weight: 600;
320 color: var(--text);
321 text-decoration: none;
322 transition: background 120ms ease, border-color 120ms ease;
323 }
324 .arch-dig-deeper:hover {
325 background: var(--bg-tertiary);
326 border-color: #c97b2e;
327 color: #e8a45a;
328 }
329 .arch-file-link {
330 font-size: 12.5px;
331 color: var(--text-muted);
332 text-decoration: none;
333 font-family: var(--font-mono);
334 padding: 4px 8px;
335 background: var(--bg-tertiary);
336 border-radius: 6px;
337 border: 1px solid var(--border);
338 }
339 .arch-file-link:hover { color: var(--text); border-color: var(--border-strong, var(--border)); }
340 .arch-analyzed-at {
341 font-size: 11.5px;
342 color: var(--text-muted);
343 margin-left: auto;
344 }
345
346 /* Findings timeline */
347 .arch-timeline-title {
348 font-family: var(--font-display);
349 font-size: 16px;
350 font-weight: 700;
351 color: var(--text-strong);
352 margin: 0 0 var(--space-3);
353 letter-spacing: -0.01em;
354 }
355 .arch-timeline {
356 display: flex;
357 flex-direction: column;
358 gap: 0;
359 border: 1px solid var(--border);
360 border-radius: 12px;
361 overflow: hidden;
362 margin-bottom: var(--space-5);
363 }
364 .arch-finding {
365 display: grid;
366 grid-template-columns: 36px 1fr auto;
367 align-items: start;
368 gap: var(--space-3);
369 padding: 14px 16px;
370 border-bottom: 1px solid var(--border);
371 background: var(--bg-elevated);
372 text-decoration: none;
373 color: inherit;
374 transition: background 100ms ease;
375 }
376 .arch-finding:last-child { border-bottom: none; }
377 .arch-finding:hover { background: var(--bg-tertiary); }
378
379 .arch-finding-icon {
380 display: flex;
381 align-items: center;
382 justify-content: center;
383 width: 28px; height: 28px;
384 border-radius: 8px;
385 font-size: 13px;
386 flex-shrink: 0;
387 margin-top: 1px;
388 }
389 .arch-icon-commit {
390 background: rgba(140,109,255,0.12);
391 color: #a78bfa;
392 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
393 }
394 .arch-icon-pr {
395 background: rgba(52,211,153,0.12);
396 color: #10b981;
397 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.28);
398 }
399 .arch-icon-issue {
400 background: rgba(248,113,113,0.12);
401 color: #f87171;
402 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.28);
403 }
404
405 .arch-finding-body {}
406 .arch-finding-title {
407 font-size: 13.5px;
408 font-weight: 600;
409 color: var(--text-strong);
410 margin: 0 0 3px;
411 line-height: 1.35;
412 word-break: break-word;
413 }
414 .arch-finding-summary {
415 font-size: 12.5px;
416 color: var(--text-muted);
417 margin: 0;
418 line-height: 1.45;
419 }
420
421 .arch-finding-meta {
422 display: flex;
423 flex-direction: column;
424 align-items: flex-end;
425 gap: 4px;
426 font-size: 11.5px;
427 color: var(--text-muted);
428 white-space: nowrap;
429 flex-shrink: 0;
430 }
431 .arch-finding-type {
432 font-size: 10px;
433 font-weight: 700;
434 letter-spacing: 0.06em;
435 text-transform: uppercase;
436 padding: 1px 6px;
437 border-radius: 4px;
438 }
439 .arch-type-commit { background: rgba(140,109,255,0.12); color: #a78bfa; }
440 .arch-type-pr { background: rgba(52,211,153,0.12); color: #10b981; }
441 .arch-type-issue { background: rgba(248,113,113,0.12); color: #f87171; }
442
443 /* Empty states */
444 .arch-empty {
445 position: relative;
446 margin: var(--space-4) 0;
447 padding: var(--space-6);
448 border: 1px dashed var(--border);
449 border-radius: 14px;
450 background: var(--bg-elevated);
451 text-align: center;
452 overflow: hidden;
453 }
454 .arch-empty-orb {
455 position: absolute;
456 inset: -40% 35% auto 35%;
457 width: 260px; height: 260px;
458 background: radial-gradient(circle, rgba(201,123,46,0.16), rgba(232,164,90,0.06) 45%, transparent 70%);
459 filter: blur(55px);
460 pointer-events: none;
461 z-index: 0;
462 }
463 .arch-empty > * { position: relative; z-index: 1; }
464 .arch-empty h2 {
465 margin: 0 0 6px;
466 font-family: var(--font-display);
467 font-size: 18px;
468 color: var(--text-strong);
469 }
470 .arch-empty p {
471 margin: 0 auto 12px;
472 color: var(--text-muted);
473 font-size: 14px;
474 max-width: 440px;
475 line-height: 1.55;
476 }
477
478 /* Powered-by pill */
479 .arch-poweredby {
480 margin-top: var(--space-5);
481 text-align: center;
482 color: var(--text-muted);
483 font-size: 11.5px;
484 }
485 .arch-poweredby-pill {
486 display: inline-flex;
487 align-items: center;
488 gap: 6px;
489 padding: 4px 10px;
490 border-radius: 9999px;
491 background: rgba(201,123,46,0.08);
492 border: 1px solid rgba(201,123,46,0.22);
493 color: var(--text-muted);
494 font-size: 11px;
495 letter-spacing: 0.04em;
496 text-transform: uppercase;
497 font-weight: 600;
498 }
499 .arch-poweredby-pill .dot {
500 width: 6px; height: 6px;
501 border-radius: 9999px;
502 background: linear-gradient(135deg, #c97b2e, #e8a45a);
503 }
504
505 :root[data-theme='light'] .arch-panel {
506 box-shadow: 0 1px 0 rgba(0,0,0,0.02), 0 8px 28px rgba(15,16,28,0.08);
507 }
508`;
509
510// ---------------------------------------------------------------------------
511// Helpers
512// ---------------------------------------------------------------------------
513
514interface ResolvedRepo {
515 ownerId: string;
516 repoId: string;
517}
518
519async function resolveRepo(
520 ownerName: string,
521 repoName: string
522): Promise<ResolvedRepo | null> {
523 try {
524 const [ownerRow] = await db
525 .select()
526 .from(users)
527 .where(eq(users.username, ownerName))
528 .limit(1);
529 if (!ownerRow) return null;
530
531 const [repoRow] = await db
532 .select()
533 .from(repositories)
534 .where(
535 and(
536 eq(repositories.ownerId, ownerRow.id),
537 eq(repositories.name, repoName)
538 )
539 )
540 .limit(1);
541 if (!repoRow) return null;
542
543 return { ownerId: ownerRow.id, repoId: repoRow.id };
544 } catch {
545 return null;
546 }
547}
548
549function findingIcon(type: ArchaeologyFinding["type"]): string {
550 if (type === "commit") return "◆"; // diamond
551 if (type === "pr") return "↪"; // right arrow hook
552 return "!"; // issue
553}
554
555function confidenceLabel(conf: "high" | "medium" | "low"): string {
556 if (conf === "high") return "High confidence";
557 if (conf === "medium") return "Medium confidence";
558 return "Low confidence";
559}
560
561// ---------------------------------------------------------------------------
562// Route
563// ---------------------------------------------------------------------------
564
565archaeologyRoutes.get("/:owner/:repo/archaeology", softAuth, async (c) => {
566 const { owner, repo } = c.req.param();
567 const user = c.get("user");
568 const filePath = c.req.query("file") ?? "";
569 const query = c.req.query("q") ?? "Why does this code exist?";
570 const deep = c.req.query("deep") === "1";
571
572 const resolved = await resolveRepo(owner, repo);
573 if (!resolved) {
574 return c.html(
575 <Layout title="Not Found" user={user}>
576 <div class="empty-state">
577 <h2>Repository not found</h2>
578 </div>
579 </Layout>,
580 404
581 );
582 }
583
584 // Search form view (no file param)
585 if (!filePath) {
586 return c.html(
587 <Layout title={`Archaeology — ${owner}/${repo}`} user={user}>
588 <RepoHeader owner={owner} repo={repo} />
589 <RepoNav owner={owner} repo={repo} active="archaeology" />
590 <div class="arch-wrap">
591 <section class="arch-hero">
592 <div class="arch-hero-orb" aria-hidden="true" />
593 <div class="arch-hero-inner">
594 <div class="arch-eyebrow">
595 <span class="pill" aria-hidden="true">{"🏛"}</span>
596 AI · gluecron · archaeology
597 </div>
598 <h1 class="arch-title">
599 <span class="arch-title-grad">Archaeology.</span>
600 </h1>
601 <p class="arch-sub">
602 Ask why any file exists. Claude searches git history, pull
603 requests, and issues to reconstruct the original motivation
604 and key decisions.
605 </p>
606 </div>
607 </section>
608
609 <div class="arch-form">
610 <p class="arch-form-title">Excavate a file</p>
611 <form method="get" action={`/${owner}/${repo}/archaeology`}>
612 <div class="arch-form-row">
613 <div>
614 <label for="arch-file">File path</label>
615 <input
616 id="arch-file"
617 type="text"
618 name="file"
619 placeholder="src/lib/auth.ts"
620 required
621 autocomplete="off"
622 spellcheck={false as any}
623 />
624 </div>
625 <div>
626 <label for="arch-q">Question (optional)</label>
627 <input
628 id="arch-q"
629 type="text"
630 name="q"
631 placeholder="Why does this exist?"
632 autocomplete="off"
633 />
634 </div>
635 <div>
636 <button type="submit" class="arch-submit">
637 {"🏛"} Dig
638 </button>
639 </div>
640 </div>
641 </form>
642 </div>
643
644 <div class="arch-poweredby">
645 <span class="arch-poweredby-pill">
646 <span class="dot" aria-hidden="true" />
647 Powered by Claude
648 </span>
649 </div>
650 </div>
651 <style dangerouslySetInnerHTML={{ __html: styles }} />
652 </Layout>
653 );
654 }
655
656 // Results view — excavate
657 if (deep) {
658 invalidateCache(resolved.repoId, filePath);
659 }
660
661 const report = await excavate(owner, repo, resolved.repoId, filePath, query);
662
663 const deepUrl = `/${owner}/${repo}/archaeology?file=${encodeURIComponent(filePath)}&q=${encodeURIComponent(query)}&deep=1`;
664 const fileUrl = `/${owner}/${repo}/blob/HEAD/${filePath}`;
665 const analyzedAt = report.analyzedAt.toUTCString().replace(" GMT", " UTC");
666
667 return c.html(
668 <Layout title={`Archaeology: ${filePath} — ${owner}/${repo}`} user={user}>
669 <RepoHeader owner={owner} repo={repo} />
670 <RepoNav owner={owner} repo={repo} active="archaeology" />
671 <div class="arch-wrap">
672 <section class="arch-hero">
673 <div class="arch-hero-orb" aria-hidden="true" />
674 <div class="arch-hero-inner">
675 <div class="arch-eyebrow">
676 <span class="pill" aria-hidden="true">{"🏛"}</span>
677 AI · gluecron · archaeology
678 </div>
679 <h1 class="arch-title">
680 <span class="arch-title-grad">Archaeology.</span>
681 </h1>
682 <p class="arch-sub">
683 Why does <code style="font-family:var(--font-mono);font-size:13px;background:var(--bg-tertiary);padding:1px 6px;border-radius:4px;color:var(--text)">{filePath}</code> exist?
684 </p>
685 </div>
686 </section>
687
688 {/* Search form (pre-filled) */}
689 <div class="arch-form">
690 <p class="arch-form-title">Refine your question</p>
691 <form method="get" action={`/${owner}/${repo}/archaeology`}>
692 <div class="arch-form-row">
693 <div>
694 <label for="arch-file2">File path</label>
695 <input
696 id="arch-file2"
697 type="text"
698 name="file"
699 value={filePath}
700 required
701 autocomplete="off"
702 spellcheck={false as any}
703 />
704 </div>
705 <div>
706 <label for="arch-q2">Question</label>
707 <input
708 id="arch-q2"
709 type="text"
710 name="q"
711 value={query}
712 autocomplete="off"
713 />
714 </div>
715 <div>
716 <button type="submit" class="arch-submit">
717 {"🏛"} Dig
718 </button>
719 </div>
720 </div>
721 </form>
722 </div>
723
724 {/* Explanation panel */}
725 <section class="arch-panel" aria-label="AI explanation">
726 <header class="arch-panel-head">
727 <p class="arch-panel-title">
728 <span class="arch-panel-dot" aria-hidden="true" />
729 Explanation
730 </p>
731 <div class="arch-panel-meta">
732 <span
733 class={`arch-confidence arch-confidence-${report.confidence}`}
734 >
735 <span class="dot" aria-hidden="true" />
736 {confidenceLabel(report.confidence)}
737 </span>
738 </div>
739 </header>
740 <div class="arch-panel-body">
741 <div class="markdown-body">
742 {html(
743 [renderMarkdown(report.explanation)] as unknown as TemplateStringsArray
744 )}
745 </div>
746 </div>
747 </section>
748
749 {/* Actions */}
750 <div class="arch-actions">
751 <a href={deepUrl} class="arch-dig-deeper">
752 {"🔍"} Dig deeper
753 </a>
754 <a href={fileUrl} class="arch-file-link">
755 {filePath}
756 </a>
757 <span class="arch-analyzed-at">Analyzed {analyzedAt}</span>
758 </div>
759
760 {/* Findings timeline */}
761 {report.findings.length > 0 ? (
762 <>
763 <h2 class="arch-timeline-title">Evidence timeline</h2>
764 <div class="arch-timeline">
765 {report.findings.map((f) => (
766 <a
767 href={f.url}
768 class="arch-finding"
769 key={`${f.type}-${f.id}`}
770 >
771 <span
772 class={`arch-finding-icon arch-icon-${f.type}`}
773 aria-label={f.type}
774 >
775 {findingIcon(f.type)}
776 </span>
777 <div class="arch-finding-body">
778 <p class="arch-finding-title">{f.title}</p>
779 <p class="arch-finding-summary">{f.summary}</p>
780 </div>
781 <div class="arch-finding-meta">
782 <span class={`arch-finding-type arch-type-${f.type}`}>
783 {f.type}
784 </span>
785 <span>{f.date ? f.date.slice(0, 10) : ""}</span>
786 {f.author ? <span>{f.author}</span> : null}
787 </div>
788 </a>
789 ))}
790 </div>
791 </>
792 ) : (
793 <div class="arch-empty">
794 <div class="arch-empty-orb" aria-hidden="true" />
795 <h2>No supporting evidence found</h2>
796 <p>
797 No commits, pull requests, or issues mentioning{" "}
798 <strong>{filePath.split("/").pop()}</strong> were found in this
799 repository. The explanation above was generated from file content
800 alone.
801 </p>
802 </div>
803 )}
804
805 <div class="arch-poweredby">
806 <span class="arch-poweredby-pill">
807 <span class="dot" aria-hidden="true" />
808 Powered by Claude
809 </span>
810 </div>
811 </div>
812 <style dangerouslySetInnerHTML={{ __html: styles }} />
813 </Layout>
814 );
815});
816
817export default archaeologyRoutes;
Addedsrc/routes/org-health.tsx+548−0View fileUnifiedSplit
1/**
2 * Org-level team health dashboard.
3 *
4 * GET /orgs/:slug/health — ranked health page (worst-first)
5 * POST /orgs/:slug/health/recompute — invalidate caches + redirect
6 *
7 * Reuses `computeOrgHealth` from src/lib/org-health.ts and the
8 * `getHealthScore` signal breakdown already built in src/lib/repo-health.ts.
9 * No new tables — leverages repo_health_cache + bus_factor_cache etc.
10 */
11
12import { Hono } from "hono";
13import { eq, and } from "drizzle-orm";
14import { db } from "../db";
15import { organizations, orgMembers } from "../db/schema";
16import { Layout } from "../views/layout";
17import { softAuth, requireAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import {
20 computeOrgHealth,
21 invalidateOrgHealthAndRepos,
22 type OrgRepoHealth,
23} from "../lib/org-health";
24import { loadOrgForUser, orgRoleAtLeast } from "../lib/orgs";
25
26const orgHealthRoutes = new Hono<AuthEnv>();
27orgHealthRoutes.use("*", softAuth);
28
29// ---------------------------------------------------------------------------
30// Helpers
31// ---------------------------------------------------------------------------
32
33function scoreColor(score: number): string {
34 if (score >= 80) return "#34d399";
35 if (score >= 50) return "#fbbf24";
36 return "#f87171";
37}
38
39function scorePillClass(score: number): string {
40 if (score >= 80) return "oh-pill--good";
41 if (score >= 50) return "oh-pill--warn";
42 return "oh-pill--bad";
43}
44
45function trendArrow(trend: "up" | "down" | "stable"): string {
46 if (trend === "up") return "↑";
47 if (trend === "down") return "↓";
48 return "→";
49}
50
51function trendClass(trend: "up" | "down" | "stable"): string {
52 if (trend === "up") return "oh-trend--up";
53 if (trend === "down") return "oh-trend--down";
54 return "oh-trend--stable";
55}
56
57function badgeStyle(score: number): string {
58 const color = scoreColor(score);
59 return `background:rgba(0,0,0,0.25);color:${color};border:1px solid ${color}40;padding:2px 6px;border-radius:4px;font-size:11px;font-family:var(--font-mono);font-weight:600`;
60}
61
62// ---------------------------------------------------------------------------
63// Scoped CSS — every class prefixed `.oh-`
64// ---------------------------------------------------------------------------
65
66const styles = `
67 .oh-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
68
69 /* Hero */
70 .oh-hero {
71 position: relative;
72 margin-bottom: var(--space-5);
73 padding: var(--space-5) var(--space-6);
74 background: var(--bg-elevated);
75 border: 1px solid var(--border);
76 border-radius: 16px;
77 overflow: hidden;
78 }
79 .oh-hero::before {
80 content: '';
81 position: absolute;
82 top: 0; left: 0; right: 0;
83 height: 2px;
84 background: linear-gradient(90deg, transparent 0%, #34d399 30%, #fbbf24 70%, transparent 100%);
85 opacity: 0.75;
86 pointer-events: none;
87 }
88 .oh-hero-orb {
89 position: absolute;
90 inset: -30% -15% auto auto;
91 width: 420px; height: 420px;
92 background: radial-gradient(circle, rgba(52,211,153,0.18), rgba(251,191,36,0.08) 45%, transparent 70%);
93 filter: blur(80px);
94 opacity: 0.6;
95 pointer-events: none;
96 z-index: 0;
97 }
98 .oh-hero-inner {
99 position: relative;
100 z-index: 1;
101 display: flex;
102 align-items: flex-start;
103 justify-content: space-between;
104 gap: var(--space-4);
105 flex-wrap: wrap;
106 }
107 .oh-hero-text { max-width: 720px; }
108 .oh-eyebrow {
109 display: inline-flex;
110 align-items: center;
111 gap: 8px;
112 text-transform: uppercase;
113 font-family: var(--font-mono);
114 font-size: 11px;
115 letter-spacing: 0.18em;
116 color: var(--text-muted);
117 font-weight: 600;
118 margin-bottom: 14px;
119 }
120 .oh-eyebrow-dot {
121 width: 8px; height: 8px;
122 border-radius: 9999px;
123 background: linear-gradient(135deg, #34d399, #fbbf24);
124 box-shadow: 0 0 0 3px rgba(52,211,153,0.18);
125 }
126 .oh-title {
127 font-family: var(--font-display);
128 font-size: clamp(26px, 4vw, 38px);
129 font-weight: 800;
130 letter-spacing: -0.025em;
131 line-height: 1.05;
132 margin: 0 0 var(--space-2);
133 color: var(--text-strong);
134 }
135 .oh-title-grad {
136 background-image: linear-gradient(135deg, #6ee7b7 0%, #34d399 50%, #fbbf24 100%);
137 -webkit-background-clip: text;
138 background-clip: text;
139 -webkit-text-fill-color: transparent;
140 color: transparent;
141 }
142 .oh-sub {
143 font-size: 15px;
144 color: var(--text-muted);
145 margin: 0;
146 line-height: 1.55;
147 }
148 .oh-back {
149 display: inline-flex;
150 align-items: center;
151 gap: 6px;
152 padding: 8px 14px;
153 border-radius: 10px;
154 border: 1px solid var(--border-strong, var(--border));
155 background: transparent;
156 color: var(--text);
157 font-size: 13px;
158 font-weight: 600;
159 text-decoration: none;
160 transition: background 120ms ease, border-color 120ms ease;
161 white-space: nowrap;
162 }
163 .oh-back:hover {
164 background: rgba(52,211,153,0.06);
165 border-color: rgba(52,211,153,0.45);
166 text-decoration: none;
167 }
168
169 /* Avg score pill */
170 .oh-pill {
171 display: inline-flex;
172 align-items: center;
173 justify-content: center;
174 width: 64px; height: 64px;
175 border-radius: 9999px;
176 font-family: var(--font-display);
177 font-size: 22px;
178 font-weight: 800;
179 border: 3px solid;
180 margin-top: 14px;
181 font-variant-numeric: tabular-nums;
182 }
183 .oh-pill--good { color: #34d399; border-color: rgba(52,211,153,0.5); background: rgba(52,211,153,0.08); }
184 .oh-pill--warn { color: #fbbf24; border-color: rgba(251,191,36,0.5); background: rgba(251,191,36,0.08); }
185 .oh-pill--bad { color: #f87171; border-color: rgba(248,113,113,0.5); background: rgba(248,113,113,0.08); }
186
187 /* AI summary card */
188 .oh-ai-card {
189 margin-bottom: var(--space-5);
190 padding: var(--space-4) var(--space-5);
191 background: rgba(59,130,246,0.05);
192 border: 1px solid rgba(59,130,246,0.3);
193 border-radius: 14px;
194 font-size: 14px;
195 line-height: 1.65;
196 color: var(--text);
197 white-space: pre-wrap;
198 }
199 .oh-ai-label {
200 display: flex;
201 align-items: center;
202 gap: 7px;
203 font-size: 10.5px;
204 font-weight: 700;
205 letter-spacing: 0.14em;
206 text-transform: uppercase;
207 color: #93c5fd;
208 margin-bottom: var(--space-2);
209 }
210 .oh-ai-dot {
211 width: 6px; height: 6px;
212 border-radius: 9999px;
213 background: #3b82f6;
214 box-shadow: 0 0 0 3px rgba(59,130,246,0.2);
215 }
216
217 /* Repo table */
218 .oh-table-wrap {
219 background: var(--bg-elevated);
220 border: 1px solid var(--border);
221 border-radius: 14px;
222 overflow: hidden;
223 margin-bottom: var(--space-5);
224 }
225 .oh-table-head {
226 padding: var(--space-3) var(--space-4);
227 display: flex;
228 align-items: center;
229 justify-content: space-between;
230 border-bottom: 1px solid var(--border);
231 flex-wrap: wrap;
232 gap: var(--space-2);
233 }
234 .oh-table-title {
235 margin: 0;
236 font-family: var(--font-display);
237 font-size: 16px;
238 font-weight: 700;
239 letter-spacing: -0.015em;
240 color: var(--text-strong);
241 }
242 .oh-table-sub { font-size: 12px; color: var(--text-muted); }
243 table.oh-table {
244 width: 100%;
245 border-collapse: collapse;
246 font-size: 13px;
247 }
248 .oh-table th {
249 padding: 10px 14px;
250 text-align: left;
251 font-size: 10.5px;
252 font-weight: 700;
253 letter-spacing: 0.12em;
254 text-transform: uppercase;
255 color: var(--text-muted);
256 border-bottom: 1px solid var(--border);
257 white-space: nowrap;
258 }
259 .oh-table td {
260 padding: 10px 14px;
261 border-bottom: 1px solid var(--border);
262 vertical-align: middle;
263 }
264 .oh-table tr:last-child td { border-bottom: none; }
265 .oh-table tr:hover td { background: rgba(255,255,255,0.02); }
266 .oh-rank {
267 width: 28px; height: 28px;
268 border-radius: 8px;
269 background: rgba(140,109,255,0.10);
270 color: #b69dff;
271 font-family: var(--font-mono);
272 font-size: 12px;
273 font-weight: 700;
274 display: inline-flex;
275 align-items: center;
276 justify-content: center;
277 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
278 }
279 .oh-repo-link {
280 color: var(--text-strong);
281 font-weight: 600;
282 text-decoration: none;
283 font-size: 13.5px;
284 }
285 .oh-repo-link:hover { color: var(--accent); }
286
287 /* Score bar */
288 .oh-bar-track {
289 width: 120px;
290 height: 6px;
291 background: rgba(255,255,255,0.08);
292 border-radius: 3px;
293 overflow: hidden;
294 display: inline-block;
295 vertical-align: middle;
296 margin-right: 8px;
297 }
298
299 /* Trend arrows */
300 .oh-trend--up { color: #34d399; font-weight: 700; }
301 .oh-trend--down { color: #f87171; font-weight: 700; }
302 .oh-trend--stable { color: var(--text-muted); }
303
304 /* Recompute button */
305 .oh-recompute-form { margin-top: var(--space-2); }
306 .oh-recompute-btn {
307 display: inline-flex;
308 align-items: center;
309 gap: 6px;
310 padding: 8px 16px;
311 border-radius: 10px;
312 border: 1px solid var(--border-strong, var(--border));
313 background: transparent;
314 color: var(--text);
315 font-size: 13px;
316 font-weight: 600;
317 cursor: pointer;
318 transition: background 120ms ease, border-color 120ms ease;
319 }
320 .oh-recompute-btn:hover {
321 background: rgba(52,211,153,0.07);
322 border-color: rgba(52,211,153,0.4);
323 }
324
325 /* Empty state */
326 .oh-empty {
327 padding: var(--space-6) var(--space-4);
328 text-align: center;
329 color: var(--text-muted);
330 border: 1px dashed var(--border-strong, var(--border));
331 border-radius: 14px;
332 }
333 .oh-empty strong {
334 display: block;
335 font-size: 16px;
336 font-weight: 700;
337 color: var(--text-strong);
338 margin-bottom: 8px;
339 }
340
341 /* Generated-at footer */
342 .oh-footer {
343 font-size: 11.5px;
344 color: var(--text-muted);
345 margin-top: var(--space-3);
346 font-family: var(--font-mono);
347 }
348`;
349
350// ---------------------------------------------------------------------------
351// GET /orgs/:slug/health
352// ---------------------------------------------------------------------------
353
354orgHealthRoutes.get("/orgs/:slug/health", async (c) => {
355 const slug = c.req.param("slug");
356 const user = c.get("user");
357
358 const { org, role } = await loadOrgForUser(slug, user?.id);
359 if (!org) return c.notFound();
360
361 const report = await computeOrgHealth(org.id, org.slug);
362 // Patch org name in case the lib used slug as placeholder
363 const orgName = org.name;
364
365 const isAdmin = !!role && orgRoleAtLeast(role, "admin");
366 const avgScore = report.avgScore;
367
368 return c.html(
369 <Layout title={`${orgName} — Engineering Health`} user={user ?? null}>
370 <div class="oh-wrap">
371 {/* Hero */}
372 <section class="oh-hero">
373 <div class="oh-hero-orb" aria-hidden="true" />
374 <div class="oh-hero-inner">
375 <div class="oh-hero-text">
376 <div class="oh-eyebrow">
377 <span class="oh-eyebrow-dot" aria-hidden="true" />
378 Team health · {slug}
379 </div>
380 <h2 class="oh-title">
381 <span class="oh-title-grad">{orgName}</span>{" "}
382 Engineering Health
383 </h2>
384 <p class="oh-sub">
385 All repositories ranked worst-first by composite health score.
386 Fix the bottom of the list to move the org average.
387 </p>
388 <div
389 class={"oh-pill " + scorePillClass(avgScore)}
390 title={`Org average: ${avgScore}/100`}
391 >
392 {avgScore}
393 </div>
394 </div>
395 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-3)">
396 <a href={`/orgs/${slug}`} class="oh-back">
397 ← Back to {slug}
398 </a>
399 {isAdmin && (
400 <form
401 method="post"
402 action={`/orgs/${slug}/health/recompute`}
403 class="oh-recompute-form"
404 >
405 <button type="submit" class="oh-recompute-btn">
406 ↺ Recompute
407 </button>
408 </form>
409 )}
410 </div>
411 </div>
412 </section>
413
414 {/* AI Summary */}
415 {report.aiSummary && (
416 <div class="oh-ai-card">
417 <div class="oh-ai-label">
418 <span class="oh-ai-dot" aria-hidden="true" />
419 AI Engineering Summary
420 </div>
421 {report.aiSummary}
422 </div>
423 )}
424
425 {/* Repo table */}
426 <div class="oh-table-wrap">
427 <div class="oh-table-head">
428 <h3 class="oh-table-title">Repository Health — Worst First</h3>
429 <span class="oh-table-sub">{report.repos.length} repo{report.repos.length === 1 ? "" : "s"}</span>
430 </div>
431
432 {report.repos.length === 0 ? (
433 <div class="oh-empty">
434 <strong>No repositories found</strong>
435 <p>This org has no active repositories yet, or health data is still loading.</p>
436 </div>
437 ) : (
438 <table class="oh-table">
439 <thead>
440 <tr>
441 <th style="width:40px">#</th>
442 <th>Repository</th>
443 <th style="width:200px">Score</th>
444 <th style="width:60px">Trend</th>
445 <th style="width:55px">CI</th>
446 <th style="width:80px">BusFactor</th>
447 <th style="width:55px">CVEs</th>
448 <th style="width:60px">Review</th>
449 <th style="width:55px">Debt</th>
450 </tr>
451 </thead>
452 <tbody>
453 {report.repos.map((r: OrgRepoHealth, i: number) => {
454 const b = r.breakdown;
455 const barColor = scoreColor(r.score);
456 return (
457 <tr key={r.repoId}>
458 <td>
459 <span class="oh-rank">{i + 1}</span>
460 </td>
461 <td>
462 <a
463 href={`/${slug}/${r.repoName}`}
464 class="oh-repo-link"
465 >
466 {slug}/{r.repoName}
467 </a>
468 </td>
469 <td>
470 <span class="oh-bar-track">
471 <div
472 style={`width:${r.score}%;background:${barColor};height:6px;border-radius:3px`}
473 />
474 </span>
475 <span
476 style={`color:${barColor};font-family:var(--font-mono);font-size:13px;font-weight:700`}
477 >
478 {r.score}
479 </span>
480 <span style="color:var(--text-muted);font-size:11px">/100</span>
481 </td>
482 <td>
483 <span class={trendClass(r.trend)} title={`Trend: ${r.trend}`}>
484 {trendArrow(r.trend)}
485 </span>
486 </td>
487 <td>
488 <span style={badgeStyle(b.ciGreenRate.score)}>
489 {b.ciGreenRate.score}
490 </span>
491 </td>
492 <td>
493 <span style={badgeStyle(b.busFactor.score)}>
494 {b.busFactor.score}
495 </span>
496 </td>
497 <td>
498 <span style={badgeStyle(b.openCves.score)}>
499 {b.openCves.score}
500 </span>
501 </td>
502 <td>
503 <span style={badgeStyle(b.reviewVelocity.score)}>
504 {b.reviewVelocity.score}
505 </span>
506 </td>
507 <td>
508 <span style={badgeStyle(b.techDebt.score)}>
509 {b.techDebt.score}
510 </span>
511 </td>
512 </tr>
513 );
514 })}
515 </tbody>
516 </table>
517 )}
518 </div>
519
520 <div class="oh-footer">
521 Generated {report.generatedAt.toISOString().replace("T", " ").slice(0, 19)} UTC · cached 1h
522 </div>
523 </div>
524 <style dangerouslySetInnerHTML={{ __html: styles }} />
525 </Layout>
526 );
527});
528
529// ---------------------------------------------------------------------------
530// POST /orgs/:slug/health/recompute — admin only, invalidate + redirect
531// ---------------------------------------------------------------------------
532
533orgHealthRoutes.post("/orgs/:slug/health/recompute", requireAuth, async (c) => {
534 const slug = c.req.param("slug");
535 const user = c.get("user")!;
536
537 const { org, role } = await loadOrgForUser(slug, user.id);
538 if (!org) return c.notFound();
539 if (!role || !orgRoleAtLeast(role, "admin")) {
540 return c.text("Forbidden", 403);
541 }
542
543 await invalidateOrgHealthAndRepos(org.id);
544 return c.redirect(`/orgs/${slug}/health`);
545});
546
547export { orgHealthRoutes };
548export default orgHealthRoutes;
Addedsrc/routes/streaming-review.ts+402−0View fileUnifiedSplit
1/**
2 * Streaming PR review routes.
3 *
4 * Two endpoints:
5 *
6 * GET /:owner/:repo/pulls/:number/review/stream
7 * SSE endpoint — streams a real-time AI review of the PR diff.
8 * If a review is already in progress, sends a waiting message and
9 * polls every 2s until it finishes (max 60s), then streams the result.
10 * On completion, saves the full review as a PR comment (idempotent).
11 *
12 * GET /:owner/:repo/pulls/:number/review/stream-ui
13 * Returns an HTML fragment (<div id="stream-review">) with inline JS
14 * that opens an EventSource to ./review/stream and renders tokens in
15 * real-time. Can be embedded in any page via an iframe or fetch-insert.
16 *
17 * Neither endpoint modifies src/routes/pulls.tsx.
18 */
19
20import { Hono } from "hono";
21import { eq, and } from "drizzle-orm";
22import { db } from "../db";
23import { repositories, users, pullRequests } from "../db/schema";
24import { softAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import {
27 streamPrReview,
28 isReviewStreaming,
29 type StreamingReviewToken,
30} from "../lib/streaming-review";
31
32const app = new Hono<AuthEnv>();
33
34// ---------------------------------------------------------------------------
35// Helpers
36// ---------------------------------------------------------------------------
37
38/** Resolve owner + repo from URL params. Returns null if not found. */
39async function resolveRepo(ownerName: string, repoName: string) {
40 const [owner] = await db
41 .select()
42 .from(users)
43 .where(eq(users.username, ownerName))
44 .limit(1);
45 if (!owner) return null;
46
47 const [repo] = await db
48 .select()
49 .from(repositories)
50 .where(
51 and(
52 eq(repositories.ownerId, owner.id),
53 eq(repositories.name, repoName)
54 )
55 )
56 .limit(1);
57 if (!repo) return null;
58
59 return { owner, repo };
60}
61
62/** Resolve a PR by repo id + PR number. Returns null if not found. */
63async function resolvePr(repositoryId: string, prNumber: number) {
64 const [pr] = await db
65 .select()
66 .from(pullRequests)
67 .where(
68 and(
69 eq(pullRequests.repositoryId, repositoryId),
70 eq(pullRequests.number, prNumber)
71 )
72 )
73 .limit(1);
74 return pr ?? null;
75}
76
77/** Encode a SSE data line from a StreamingReviewToken. */
78function encodeToken(token: StreamingReviewToken): string {
79 return `data: ${JSON.stringify(token)}\n\n`;
80}
81
82// ---------------------------------------------------------------------------
83// SSE stream endpoint
84// ---------------------------------------------------------------------------
85
86app.get("/:owner/:repo/pulls/:number/review/stream", softAuth, async (c) => {
87 const { owner: ownerName, repo: repoName, number: numberStr } = c.req.param();
88 const prNumber = parseInt(numberStr, 10);
89 if (isNaN(prNumber) || prNumber < 1) {
90 return c.json({ error: "Invalid PR number" }, 400);
91 }
92
93 // Resolve repo
94 const resolved = await resolveRepo(ownerName, repoName);
95 if (!resolved) {
96 return c.json({ error: "Repository not found" }, 404);
97 }
98 const { repo } = resolved;
99
100 // Resolve PR
101 const pr = await resolvePr(repo.id, prNumber);
102 if (!pr) {
103 return c.json({ error: "Pull request not found" }, 404);
104 }
105
106 const prId = pr.id;
107 const encoder = new TextEncoder();
108
109 const stream = new ReadableStream<Uint8Array>({
110 async start(controller) {
111 let closed = false;
112
113 const safeEnqueue = (chunk: string) => {
114 if (closed) return;
115 try {
116 controller.enqueue(encoder.encode(chunk));
117 } catch {
118 closed = true;
119 }
120 };
121
122 const close = () => {
123 if (closed) return;
124 closed = true;
125 try {
126 controller.close();
127 } catch {
128 // already closed
129 }
130 };
131
132 // Flush headers on proxy buffering
133 safeEnqueue(": open\n\n");
134
135 // If a review is already streaming, wait for it (max 60s, poll 2s)
136 if (isReviewStreaming(prId)) {
137 safeEnqueue(
138 encodeToken({
139 type: "token",
140 content: "A review is already in progress. Waiting for it to complete...\n",
141 })
142 );
143
144 const MAX_WAIT_MS = 60_000;
145 const POLL_MS = 2_000;
146 const deadline = Date.now() + MAX_WAIT_MS;
147
148 while (isReviewStreaming(prId) && Date.now() < deadline && !closed) {
149 await new Promise((r) => setTimeout(r, POLL_MS));
150 }
151
152 if (isReviewStreaming(prId)) {
153 safeEnqueue(
154 encodeToken({
155 type: "error",
156 error: "Timed out waiting for in-progress review to complete.",
157 })
158 );
159 close();
160 return;
161 }
162
163 // Review finished while we were waiting — inform the client
164 safeEnqueue(
165 encodeToken({
166 type: "done",
167 })
168 );
169 close();
170 return;
171 }
172
173 // Start the streaming review
174 try {
175 for await (const token of streamPrReview(
176 prId,
177 ownerName,
178 repoName,
179 pr.baseBranch,
180 pr.headBranch
181 )) {
182 if (closed) break;
183 safeEnqueue(encodeToken(token));
184 if (token.type === "done" || token.type === "error") {
185 break;
186 }
187 }
188 } catch (err) {
189 const message = err instanceof Error ? err.message : String(err);
190 safeEnqueue(encodeToken({ type: "error", error: message }));
191 }
192
193 close();
194 },
195 });
196
197 // Handle client disconnect via AbortSignal — the ReadableStream cancel
198 // is not directly wired, but closing the outer Response suffices for
199 // Bun's HTTP layer to GC the stream controller.
200 return new Response(stream, {
201 status: 200,
202 headers: {
203 "Content-Type": "text/event-stream; charset=utf-8",
204 "Cache-Control": "no-cache, no-transform",
205 "Connection": "keep-alive",
206 "X-Accel-Buffering": "no",
207 },
208 });
209});
210
211// ---------------------------------------------------------------------------
212// Embeddable widget endpoint
213// ---------------------------------------------------------------------------
214
215app.get("/:owner/:repo/pulls/:number/review/stream-ui", softAuth, async (c) => {
216 const { owner: ownerName, repo: repoName, number: numberStr } = c.req.param();
217 const prNumber = parseInt(numberStr, 10);
218 if (isNaN(prNumber) || prNumber < 1) {
219 return c.html("<p>Invalid PR number</p>", 400);
220 }
221
222 const streamUrl = `/${ownerName}/${repoName}/pulls/${prNumber}/review/stream`;
223
224 const html = `<!DOCTYPE html>
225<html lang="en">
226<head>
227<meta charset="utf-8">
228<meta name="viewport" content="width=device-width, initial-scale=1">
229<title>AI Stream Review — ${ownerName}/${repoName} #${prNumber}</title>
230<style>
231 * { box-sizing: border-box; margin: 0; padding: 0; }
232 body {
233 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
234 background: #0d1117;
235 color: #e6edf3;
236 padding: 16px;
237 }
238 #stream-review {
239 border: 1px solid #30363d;
240 border-radius: 8px;
241 background: #161b22;
242 padding: 16px;
243 position: relative;
244 }
245 .sr-header {
246 display: flex;
247 align-items: center;
248 gap: 10px;
249 margin-bottom: 14px;
250 font-size: 14px;
251 font-weight: 600;
252 color: #8b949e;
253 text-transform: uppercase;
254 letter-spacing: 0.04em;
255 }
256 .sr-spinner {
257 display: inline-block;
258 width: 14px; height: 14px;
259 border: 2px solid #30363d;
260 border-top-color: #58a6ff;
261 border-radius: 50%;
262 animation: spin 0.8s linear infinite;
263 }
264 @keyframes spin { to { transform: rotate(360deg); } }
265 .sr-section {
266 margin-bottom: 14px;
267 }
268 .sr-section-label {
269 font-size: 11px;
270 font-weight: 600;
271 text-transform: uppercase;
272 letter-spacing: 0.06em;
273 color: #58a6ff;
274 margin-bottom: 6px;
275 padding: 2px 6px;
276 border-left: 2px solid #58a6ff;
277 }
278 .sr-section-label.finding { color: #f0883e; border-left-color: #f0883e; }
279 .sr-section-label.verdict { color: #3fb950; border-left-color: #3fb950; }
280 pre.sr-content {
281 font-family: "SFMono-Regular", Consolas, monospace;
282 font-size: 13px;
283 line-height: 1.6;
284 white-space: pre-wrap;
285 word-break: break-word;
286 color: #e6edf3;
287 background: none;
288 border: none;
289 padding: 0;
290 }
291 .sr-done-msg {
292 font-size: 12px;
293 color: #3fb950;
294 margin-top: 12px;
295 padding: 6px 10px;
296 border: 1px solid #3fb950;
297 border-radius: 4px;
298 display: none;
299 }
300 .sr-error-msg {
301 font-size: 12px;
302 color: #f85149;
303 margin-top: 12px;
304 padding: 6px 10px;
305 border: 1px solid #f85149;
306 border-radius: 4px;
307 display: none;
308 }
309</style>
310</head>
311<body>
312<div id="stream-review">
313 <div class="sr-header">
314 <span class="sr-spinner" id="sr-spinner"></span>
315 <span>AI Stream Review</span>
316 </div>
317 <div id="sr-body">
318 <div class="sr-section" id="sr-section-summary">
319 <div class="sr-section-label summary">Summary</div>
320 <pre class="sr-content" id="sr-pre-summary"></pre>
321 </div>
322 <div class="sr-section" id="sr-section-finding" style="display:none">
323 <div class="sr-section-label finding">Findings</div>
324 <pre class="sr-content" id="sr-pre-finding"></pre>
325 </div>
326 <div class="sr-section" id="sr-section-verdict" style="display:none">
327 <div class="sr-section-label verdict">Verdict</div>
328 <pre class="sr-content" id="sr-pre-verdict"></pre>
329 </div>
330 </div>
331 <div class="sr-done-msg" id="sr-done">Review complete. Comment saved to PR.</div>
332 <div class="sr-error-msg" id="sr-error"></div>
333</div>
334
335<script>
336(function() {
337 var streamUrl = ${JSON.stringify(streamUrl)};
338 var currentSection = "summary";
339 var sectionPres = {
340 summary: document.getElementById("sr-pre-summary"),
341 finding: document.getElementById("sr-pre-finding"),
342 verdict: document.getElementById("sr-pre-verdict")
343 };
344 var sectionDivs = {
345 summary: document.getElementById("sr-section-summary"),
346 finding: document.getElementById("sr-section-finding"),
347 verdict: document.getElementById("sr-section-verdict")
348 };
349 var spinner = document.getElementById("sr-spinner");
350 var doneMsg = document.getElementById("sr-done");
351 var errorMsg = document.getElementById("sr-error");
352
353 function showSection(name) {
354 if (sectionDivs[name]) {
355 sectionDivs[name].style.display = "";
356 }
357 currentSection = name;
358 }
359
360 var es = new EventSource(streamUrl);
361
362 es.onmessage = function(e) {
363 var token;
364 try { token = JSON.parse(e.data); } catch { return; }
365
366 if (token.type === "token" && token.content) {
367 var pre = sectionPres[currentSection];
368 if (pre) pre.textContent += token.content;
369 } else if (token.type === "section_start" && token.section) {
370 showSection(token.section);
371 } else if (token.type === "done") {
372 es.close();
373 if (spinner) spinner.style.display = "none";
374 if (doneMsg) doneMsg.style.display = "";
375 } else if (token.type === "error") {
376 es.close();
377 if (spinner) spinner.style.display = "none";
378 if (errorMsg) {
379 errorMsg.textContent = "Error: " + (token.error || "Unknown error");
380 errorMsg.style.display = "";
381 }
382 }
383 };
384
385 es.onerror = function() {
386 es.close();
387 if (spinner) spinner.style.display = "none";
388 if (errorMsg) {
389 errorMsg.textContent = "Connection error. The review stream was interrupted.";
390 errorMsg.style.display = "";
391 }
392 };
393})();
394</script>
395</body>
396</html>`;
397
398 return c.html(html);
399});
400
401export { app as streamingReviewRoutes };
402export default app;
Addedsrc/routes/test-gaps.tsx+511−0View fileUnifiedSplit
1/**
2 * Test Gap Report — /:owner/:repo/insights/test-gaps
3 *
4 * Surfaces untested functions ranked by risk. Developers can see at a glance
5 * which parts of their codebase have zero test coverage and why each matters.
6 *
7 * GET /:owner/:repo/insights/test-gaps — report page
8 * POST /:owner/:repo/insights/test-gaps/refresh — owner-only: clear cache + reanalyse
9 */
10
11import { Hono } from "hono";
12import { db } from "../db";
13import { repositories, users } from "../db/schema";
14import { and, eq } from "drizzle-orm";
15import type { AuthEnv } from "../middleware/auth";
16import { softAuth, requireAuth } from "../middleware/auth";
17import { requireRepoAccess } from "../middleware/repo-access";
18import { Layout } from "../views/layout";
19import { RepoHeader, RepoNav } from "../views/components";
20import { getUnreadCount } from "../lib/unread";
21import { getTestGaps, clearTestGapsCache, type TestGap, type TestGapReport } from "../lib/test-gaps";
22
23const testGapsRoutes = new Hono<AuthEnv>();
24
25// ─── CSS ──────────────────────────────────────────────────────────────────────
26
27const styles = `
28 .tg-wrap {
29 max-width: 1080px;
30 margin: 0 auto;
31 padding: var(--space-5) var(--space-4);
32 }
33
34 /* Insights sub-navigation */
35 .tg-subnav {
36 display: flex;
37 gap: 4px;
38 margin-bottom: var(--space-5);
39 border-bottom: 1px solid var(--border);
40 padding-bottom: 0;
41 }
42 .tg-subnav-link {
43 padding: 8px 14px;
44 font-size: 13px;
45 font-weight: 500;
46 color: var(--text-muted);
47 text-decoration: none;
48 border-bottom: 2px solid transparent;
49 margin-bottom: -1px;
50 transition: color 120ms ease, border-color 120ms ease;
51 border-radius: 4px 4px 0 0;
52 }
53 .tg-subnav-link:hover { color: var(--text); }
54 .tg-subnav-link.active {
55 color: var(--accent, #5865f2);
56 border-bottom-color: var(--accent, #5865f2);
57 }
58
59 /* Hero */
60 .tg-hero {
61 position: relative;
62 margin-bottom: var(--space-5);
63 padding: var(--space-5) var(--space-6);
64 background: var(--bg-elevated);
65 border: 1px solid var(--border);
66 border-radius: 16px;
67 overflow: hidden;
68 }
69 .tg-hero::before {
70 content: '';
71 position: absolute;
72 top: 0; left: 0; right: 0;
73 height: 2px;
74 background: linear-gradient(90deg, transparent 0%, #6366f1 30%, #8b5cf6 70%, transparent 100%);
75 opacity: 0.75;
76 pointer-events: none;
77 }
78 .tg-hero-orb {
79 position: absolute;
80 inset: -30% -15% auto auto;
81 width: 460px; height: 460px;
82 background: radial-gradient(circle, rgba(99,102,241,0.16), rgba(139,92,246,0.08) 45%, transparent 70%);
83 filter: blur(80px);
84 opacity: 0.75;
85 pointer-events: none;
86 z-index: 0;
87 }
88 .tg-hero-inner { position: relative; z-index: 1; max-width: 760px; }
89 .tg-hero-eyebrow {
90 display: inline-flex; align-items: center; gap: 6px;
91 font-size: 11px; font-weight: 700; letter-spacing: 0.07em;
92 text-transform: uppercase;
93 color: #8b5cf6;
94 margin-bottom: 10px;
95 }
96 .tg-hero-title {
97 font-family: var(--font-display);
98 font-size: clamp(22px, 3vw, 30px);
99 font-weight: 800;
100 letter-spacing: -0.025em;
101 line-height: 1.1;
102 margin: 0 0 8px;
103 color: var(--text-strong);
104 }
105 .tg-hero-sub {
106 font-size: 14px;
107 color: var(--text-muted);
108 margin: 0;
109 line-height: 1.5;
110 }
111 .tg-hero-actions {
112 display: flex;
113 gap: 10px;
114 align-items: center;
115 margin-top: 16px;
116 flex-wrap: wrap;
117 }
118
119 /* Stats bar */
120 .tg-stats {
121 display: flex;
122 gap: 16px;
123 margin-bottom: var(--space-5);
124 flex-wrap: wrap;
125 }
126 .tg-stat-card {
127 flex: 1 1 160px;
128 padding: 14px 18px;
129 background: var(--bg-elevated);
130 border: 1px solid var(--border);
131 border-radius: 12px;
132 min-width: 120px;
133 }
134 .tg-stat-value {
135 font-family: var(--font-display);
136 font-size: 26px;
137 font-weight: 800;
138 line-height: 1;
139 margin-bottom: 4px;
140 color: var(--text-strong);
141 font-variant-numeric: tabular-nums;
142 }
143 .tg-stat-label {
144 font-size: 12px;
145 color: var(--text-muted);
146 font-weight: 500;
147 }
148 .tg-stat-card.is-good .tg-stat-value { color: #22c55e; }
149 .tg-stat-card.is-warn .tg-stat-value { color: #f59e0b; }
150 .tg-stat-card.is-danger .tg-stat-value { color: #ef4444; }
151
152 /* Gap list */
153 .tg-list {
154 display: flex;
155 flex-direction: column;
156 gap: 10px;
157 }
158
159 /* Individual gap card */
160 .tg-gap-card {
161 padding: 14px 18px;
162 background: var(--bg-elevated);
163 border: 1px solid var(--border);
164 border-radius: 12px;
165 transition: border-color 120ms ease;
166 }
167 .tg-gap-card:hover { border-color: rgba(99,102,241,0.4); }
168 .tg-gap-card.risk-high { border-left: 3px solid #ef4444; }
169 .tg-gap-card.risk-medium { border-left: 3px solid #f59e0b; }
170 .tg-gap-card.risk-low { border-left: 3px solid #22c55e; }
171
172 .tg-gap-header {
173 display: flex;
174 align-items: center;
175 gap: 10px;
176 margin-bottom: 8px;
177 flex-wrap: wrap;
178 }
179 .tg-gap-file {
180 flex: 1 1 auto;
181 font-family: var(--font-mono);
182 font-size: 12px;
183 font-weight: 600;
184 color: var(--text-muted);
185 word-break: break-all;
186 }
187 .tg-gap-fn {
188 font-family: var(--font-mono);
189 font-size: 13px;
190 font-weight: 700;
191 color: var(--text-strong);
192 }
193
194 /* Risk score badge */
195 .tg-risk-badge {
196 display: inline-flex;
197 align-items: center;
198 padding: 2px 9px;
199 border-radius: 9999px;
200 font-size: 10.5px;
201 font-weight: 700;
202 letter-spacing: 0.04em;
203 text-transform: uppercase;
204 flex-shrink: 0;
205 }
206 .tg-risk-badge.is-high { color: #ef4444; background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); }
207 .tg-risk-badge.is-medium { color: #f59e0b; background: rgba(245,158,11,0.12); border: 1px solid rgba(245,158,11,0.3); }
208 .tg-risk-badge.is-low { color: #22c55e; background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.3); }
209
210 .tg-gap-meta {
211 display: flex;
212 gap: 16px;
213 font-size: 12px;
214 color: var(--text-muted);
215 margin-bottom: 10px;
216 flex-wrap: wrap;
217 align-items: center;
218 }
219 .tg-gap-reason {
220 font-size: 13px;
221 color: var(--text);
222 margin-bottom: 10px;
223 line-height: 1.5;
224 }
225
226 .tg-gap-footer {
227 display: flex;
228 align-items: center;
229 gap: 12px;
230 flex-wrap: wrap;
231 }
232 .tg-test-path {
233 font-family: var(--font-mono);
234 font-size: 11px;
235 color: var(--text-muted);
236 background: var(--bg-tertiary, rgba(255,255,255,0.04));
237 padding: 3px 8px;
238 border-radius: 4px;
239 flex: 1 1 auto;
240 }
241 .tg-stub-btn {
242 display: inline-flex; align-items: center; gap: 6px;
243 padding: 5px 12px;
244 border-radius: 6px;
245 font-size: 12px; font-weight: 600;
246 color: var(--text-strong);
247 background: var(--bg-tertiary, rgba(255,255,255,0.07));
248 border: 1px solid var(--border);
249 text-decoration: none;
250 transition: background 120ms ease, border-color 120ms ease;
251 white-space: nowrap;
252 flex-shrink: 0;
253 }
254 .tg-stub-btn:hover {
255 background: var(--bg-secondary);
256 border-color: rgba(99,102,241,0.5);
257 }
258
259 /* Reanalyse button */
260 .tg-reanalyze-btn {
261 display: inline-flex; align-items: center; gap: 6px;
262 padding: 8px 16px;
263 border-radius: 8px;
264 font-size: 13px; font-weight: 600;
265 color: #fff;
266 background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 130%);
267 border: none;
268 cursor: pointer;
269 transition: opacity 120ms ease;
270 }
271 .tg-reanalyze-btn:hover { opacity: 0.85; }
272
273 .tg-analyzed-at {
274 font-size: 12px;
275 color: var(--text-muted);
276 margin-top: 4px;
277 }
278
279 /* Empty / no-gaps state */
280 .tg-empty {
281 text-align: center;
282 padding: 64px 24px;
283 color: var(--text-muted);
284 }
285 .tg-empty-icon { font-size: 40px; margin-bottom: 12px; }
286 .tg-empty-title { font-size: 18px; font-weight: 700; color: var(--text-strong); margin-bottom: 6px; }
287 .tg-empty-sub { font-size: 14px; }
288`;
289
290// ─── Helper: determine risk tier from score ──────────────────────────────────
291
292function riskTier(score: number): "high" | "medium" | "low" {
293 if (score > 70) return "high";
294 if (score >= 40) return "medium";
295 return "low";
296}
297
298// ─── Route: GET /:owner/:repo/insights/test-gaps ─────────────────────────────
299
300testGapsRoutes.use("/:owner/:repo/insights/test-gaps", softAuth);
301
302testGapsRoutes.get(
303 "/:owner/:repo/insights/test-gaps",
304 requireRepoAccess("read"),
305 async (c) => {
306 const user = c.get("user") ?? null;
307 const params = c.req.param() as { owner: string; repo: string };
308 const ownerName = params.owner;
309 const repoName = params.repo;
310
311 // Load repo
312 const repoRows = await db
313 .select({ id: repositories.id, isPrivate: repositories.isPrivate, ownerId: repositories.ownerId })
314 .from(repositories)
315 .innerJoin(users, eq(repositories.ownerId, users.id))
316 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
317 .limit(1);
318
319 if (!repoRows.length) return c.notFound();
320 const repo = repoRows[0];
321 const isOwner = !!user && user.id === repo.ownerId;
322
323 const unreadCount = user ? await getUnreadCount(user.id) : 0;
324
325 // Load / compute report
326 let report: TestGapReport | null = null;
327 let analysisError: string | null = null;
328 try {
329 report = await getTestGaps(ownerName, repoName, repo.id);
330 } catch (err) {
331 analysisError = err instanceof Error ? err.message : "Analysis failed";
332 }
333
334 const highCount = report?.gaps.filter((g) => riskTier(g.riskScore) === "high").length ?? 0;
335 const mediumCount = report?.gaps.filter((g) => riskTier(g.riskScore) === "medium").length ?? 0;
336 const lowCount = report?.gaps.filter((g) => riskTier(g.riskScore) === "low").length ?? 0;
337
338 return c.html(
339 <Layout
340 title={`Test Gaps — ${ownerName}/${repoName}`}
341 user={user}
342 notificationCount={unreadCount}
343 >
344 <style dangerouslySetInnerHTML={{ __html: styles }} />
345 <div class="tg-wrap">
346 <RepoHeader owner={ownerName} repo={repoName} />
347 <RepoNav owner={ownerName} repo={repoName} active="insights" />
348
349 {/* Sub-navigation */}
350 <nav class="tg-subnav">
351 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights`}>Overview</a>
352 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights/health`}>Health</a>
353 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights/velocity`}>Velocity</a>
354 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights/hotfiles`}>Hot Files</a>
355 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights/bus-factor`}>Bus Factor</a>
356 <a class="tg-subnav-link active" href={`/${ownerName}/${repoName}/insights/test-gaps`}>Test Gaps</a>
357 </nav>
358
359 {/* Hero */}
360 <div class="tg-hero">
361 <div class="tg-hero-orb" aria-hidden="true" />
362 <div class="tg-hero-inner">
363 <div class="tg-hero-eyebrow">&#x2717; Test Coverage</div>
364 <h1 class="tg-hero-title">Test Gap Detector</h1>
365 <p class="tg-hero-sub">
366 Functions and modules with zero test coverage, ranked by risk.
367 Write tests where they matter most.
368 </p>
369 <div class="tg-hero-actions">
370 {isOwner && (
371 <form method="post" action={`/${ownerName}/${repoName}/insights/test-gaps/refresh`}>
372 <button type="submit" class="tg-reanalyze-btn">
373 &#8635; Re-analyse
374 </button>
375 </form>
376 )}
377 {report && (
378 <span class="tg-analyzed-at">
379 {report.totalSourceFiles} source files &middot;{" "}
380 {report.totalTestFiles} test files &middot;{" "}
381 ~{report.coverageEstimate}% coverage estimate
382 </span>
383 )}
384 </div>
385 </div>
386 </div>
387
388 {analysisError && (
389 <div style="padding:12px 16px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:8px;margin-bottom:16px;font-size:13px;color:#ef4444;">
390 Analysis error: {analysisError}
391 </div>
392 )}
393
394 {report ? (
395 <>
396 {/* Stats bar */}
397 <div class="tg-stats">
398 <div class={`tg-stat-card ${highCount > 0 ? "is-danger" : "is-good"}`}>
399 <div class="tg-stat-value">{highCount}</div>
400 <div class="tg-stat-label">High-risk gaps</div>
401 </div>
402 <div class={`tg-stat-card ${mediumCount > 0 ? "is-warn" : "is-good"}`}>
403 <div class="tg-stat-value">{mediumCount}</div>
404 <div class="tg-stat-label">Medium-risk gaps</div>
405 </div>
406 <div class="tg-stat-card is-good">
407 <div class="tg-stat-value">{lowCount}</div>
408 <div class="tg-stat-label">Low-risk gaps</div>
409 </div>
410 <div class={`tg-stat-card ${report.coverageEstimate < 50 ? "is-danger" : report.coverageEstimate < 80 ? "is-warn" : "is-good"}`}>
411 <div class="tg-stat-value">{report.coverageEstimate}%</div>
412 <div class="tg-stat-label">Coverage estimate</div>
413 </div>
414 </div>
415
416 {/* Gap list or empty state */}
417 {report.gaps.length === 0 ? (
418 <div class="tg-empty">
419 <div class="tg-empty-icon">&#x1F389;</div>
420 <div class="tg-empty-title">No test gaps detected</div>
421 <p class="tg-empty-sub">
422 Every source file appears to have an associated test file. Great work!
423 </p>
424 </div>
425 ) : (
426 <div class="tg-list">
427 {report.gaps.map((gap: TestGap) => {
428 const tier = riskTier(gap.riskScore);
429 const stubUrl = `/${ownerName}/${repoName}/ai/tests?file=${encodeURIComponent(gap.filePath)}`;
430 return (
431 <div class={`tg-gap-card risk-${tier}`}>
432 <div class="tg-gap-header">
433 <div style="flex:1 1 auto;">
434 <div class="tg-gap-file">{gap.filePath}</div>
435 <div class="tg-gap-fn">{gap.functionName}</div>
436 </div>
437 <span class={`tg-risk-badge is-${tier}`}>
438 {gap.riskScore} / 100
439 </span>
440 </div>
441 <div class="tg-gap-meta">
442 <span>Risk: {tier}</span>
443 {gap.calledByCount > 0 && (
444 <span>Called from {gap.calledByCount} file{gap.calledByCount !== 1 ? "s" : ""}</span>
445 )}
446 </div>
447 <div class="tg-gap-reason">{gap.riskReason}</div>
448 <div class="tg-gap-footer">
449 <code class="tg-test-path">Suggested: {gap.suggestedTestPath}</code>
450 <a href={stubUrl} class="tg-stub-btn">
451 &#x2728; Generate test stub
452 </a>
453 </div>
454 </div>
455 );
456 })}
457 </div>
458 )}
459 </>
460 ) : !analysisError ? (
461 <div class="tg-empty">
462 <div class="tg-empty-icon">&#x1F9EA;</div>
463 <div class="tg-empty-title">No analysis yet</div>
464 <p class="tg-empty-sub">
465 {isOwner
466 ? "Click Re-analyse to scan this repository for test gaps."
467 : "The repository owner hasn't run a test gap scan yet."}
468 </p>
469 </div>
470 ) : null}
471 </div>
472 </Layout>
473 );
474 }
475);
476
477// ─── Route: POST /:owner/:repo/insights/test-gaps/refresh ────────────────────
478
479testGapsRoutes.use("/:owner/:repo/insights/test-gaps/refresh", requireAuth);
480
481testGapsRoutes.post(
482 "/:owner/:repo/insights/test-gaps/refresh",
483 requireRepoAccess("write"),
484 async (c) => {
485 const user = c.get("user")!;
486 const params = c.req.param() as { owner: string; repo: string };
487 const ownerName = params.owner;
488 const repoName = params.repo;
489
490 const repoRows = await db
491 .select({ id: repositories.id, ownerId: repositories.ownerId })
492 .from(repositories)
493 .innerJoin(users, eq(repositories.ownerId, users.id))
494 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
495 .limit(1);
496
497 if (!repoRows.length) return c.notFound();
498 const repo = repoRows[0];
499
500 if (user.id !== repo.ownerId) {
501 return c.text("Forbidden", 403);
502 }
503
504 // Clear cache so the GET picks up a fresh analysis
505 clearTestGapsCache(repo.id);
506
507 return c.redirect(`/${ownerName}/${repoName}/insights/test-gaps`);
508 }
509);
510
511export default testGapsRoutes;
Modifiedsrc/views/components.tsx+7−0View fileUnifiedSplit
321321 >
322322 {"\u2728"} NL Search
323323 </a>
324 <a
325 href={`/${owner}/${repo}/archaeology`}
326 class={`repo-nav-ai${active === "archaeology" ? " active" : ""}`}
327 title="AI Archaeology \u2014 excavate why any file exists using git history, PRs, and issues"
328 >
329 {"\ud83c\udfdb"} Archaeology
330 </a>
324331 </div>
325332);
326333
327334