Commit77cf834unknown_key
feat: AI Copilot Workspace, Repo Health Score, NL Code Search
feat: AI Copilot Workspace, Repo Health Score, NL Code Search Three new features shipping together: AI Copilot Workspace (src/lib/ai-workspace.ts, src/routes/ai-workspace.tsx): - Start Workspace on any issue → Claude explores the codebase, posts an implementation plan as an issue comment, creates a branch, implements file changes, commits, and opens a draft PR automatically - In-memory job store with status tracking (pending/planning/implementing/done/failed) - Auto-refresh status page, rate-limited to 1 active job per repo - Migration 0103: workspace_jobs table for auditability Repository Health Score (src/lib/repo-health.ts, src/routes/repo-health.tsx): - 0-100 composite score: CI green rate (25), bus factor (20), open CVEs (20), PR review velocity (15), tech debt (20) - 6h in-memory + DB cache, invalidated on push - Full breakdown page at /:owner/:repo/health with SVG gauge - Migration 0104: repo_health_cache table Natural Language Code Search (src/lib/nl-search.ts, src/routes/nl-search.tsx): - "Find all places where we do X" — Claude as the reasoner over real file content - Flow: keyword extraction → git grep candidates → file reads → Claude reasoning pass - 15min result cache, fallback to code_chunks table when grep returns nothing - Discoverable via "NL Search" tab added to RepoNav in components.tsx https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
11 files changed+3350−177cf8344a6e989bbbb59df4bbd551bd184347560
11 changed files+3350−1
Addeddrizzle/0103_workspace_jobs.sql+16−0View fileUnifiedSplit
@@ -0,0 +1,16 @@
1-- Workspace job persistence (hot path is in-memory; this is for auditability)
2CREATE TABLE IF NOT EXISTS workspace_jobs (
3 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4 repo_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
5 issue_id uuid NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
6 triggered_by uuid REFERENCES users(id),
7 status text NOT NULL DEFAULT 'pending',
8 plan_comment text,
9 branch_name text,
10 pr_number int,
11 error_message text,
12 started_at timestamp DEFAULT now(),
13 updated_at timestamp DEFAULT now()
14);
15CREATE INDEX IF NOT EXISTS idx_workspace_jobs_repo ON workspace_jobs(repo_id);
16CREATE INDEX IF NOT EXISTS idx_workspace_jobs_issue ON workspace_jobs(issue_id);
Addeddrizzle/0104_repo_health_cache.sql+9−0View fileUnifiedSplit
@@ -0,0 +1,9 @@
1CREATE TABLE IF NOT EXISTS repo_health_cache (
2 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
3 repo_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
4 score int NOT NULL,
5 breakdown jsonb NOT NULL,
6 computed_at timestamp DEFAULT now(),
7 expires_at timestamp NOT NULL
8);
9CREATE UNIQUE INDEX IF NOT EXISTS idx_repo_health_cache_repo ON repo_health_cache(repo_id);
Modifiedsrc/app.tsx+9−0View fileUnifiedSplit
@@ -164,6 +164,7 @@ import requiredChecksRoutes from "./routes/required-checks";
164164import rulesetsRoutes from "./routes/rulesets";
165165import searchRoutes from "./routes/search";
166166import semanticSearchRoutes from "./routes/semantic-search";
167import nlSearchRoutes from "./routes/nl-search";
167168import signingKeysRoutes from "./routes/signing-keys";
168169import sponsorsRoutes from "./routes/sponsors";
169170import ssoRoutes from "./routes/sso";
@@ -191,6 +192,7 @@ import velocityRoutes from "./routes/velocity";
191192import { staleBranchRoutes } from "./routes/stale-branches";
192193import pulseRoutes from "./routes/pulse";
193194import healthScoreRoutes from "./routes/health-score";
195import repoHealthRoutes from "./routes/repo-health";
194196import hotFilesRoutes from "./routes/hot-files";
195197import debtMapRoutes from "./routes/debt-map";
196198import busFactorRoutes from "./routes/bus-factor";
@@ -198,6 +200,7 @@ import crossRepoImpactRoutes from "./routes/cross-repo-impact";
198200import developerProgramRoutes from "./routes/developer-program";
199201import shareRoutes from "./routes/share";
200202import incidentHookRoutes from "./routes/incident-hooks";
203import workspaceRoutes from "./routes/ai-workspace";
201204import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
202205import { csrfToken, csrfProtect } from "./middleware/csrf";
203206import { noCache } from "./middleware/no-cache";
@@ -521,6 +524,9 @@ app.route("/", milestonesRoutes);
521524// Ship Agent — autonomous AI feature implementation
522525app.route("/", shipAgentRoutes);
523526
527// AI Copilot Workspace — issue-to-PR autonomous agent
528app.route("/", workspaceRoutes);
529
524530// Comment moderation queue — owner-only `/:owner/:repo/comments/pending`
525531// + per-row approve/reject/spam actions. Mounted before `pullRoutes` so
526532// the `/:owner/:repo/comments/*` paths resolve before the broader PR
@@ -805,6 +811,8 @@ app.route("/", staleBranchRoutes);
805811app.route("/", pulseRoutes);
806812// Repository Health Score — BLOCK M14 — /:owner/:repo/insights/health
807813app.route("/", healthScoreRoutes);
814// Repository Health Score breakdown page — /:owner/:repo/health
815app.route("/", repoHealthRoutes);
808816// Hot Files Heatmap — BLOCK M16 — /:owner/:repo/insights/hotfiles
809817app.route("/", hotFilesRoutes);
810818// AI Technical Debt Map — /:owner/:repo/debt-map (visual debt graph + Claude analysis)
@@ -821,6 +829,7 @@ app.route("/", requiredChecksRoutes);
821829app.route("/", rulesetsRoutes);
822830app.route("/", searchRoutes);
823831app.route("/", semanticSearchRoutes);
832app.route("/", nlSearchRoutes);
824833app.route("/", signingKeysRoutes);
825834app.route("/", sponsorsRoutes);
826835app.route("/", ssoRoutes);
Modifiedsrc/db/schema.ts+54−0View fileUnifiedSplit
@@ -4595,3 +4595,57 @@ export const crossRepoImpactCache = pgTable(
45954595);
45964596
45974597export type CrossRepoImpactCache = typeof crossRepoImpactCache.$inferSelect;
4598
4599// ---------------------------------------------------------------------------
4600// Migration 0104 — Repository health score cache (6h TTL, in-memory + DB)
4601// ---------------------------------------------------------------------------
4602export const repoHealthCache = pgTable(
4603 "repo_health_cache",
4604 {
4605 id: uuid("id").primaryKey().defaultRandom(),
4606 repoId: uuid("repo_id")
4607 .notNull()
4608 .unique()
4609 .references(() => repositories.id, { onDelete: "cascade" }),
4610 score: integer("score").notNull(),
4611 breakdown: jsonb("breakdown").notNull(),
4612 computedAt: timestamp("computed_at").defaultNow(),
4613 expiresAt: timestamp("expires_at").notNull(),
4614 },
4615 (table) => [
4616 index("idx_repo_health_cache_repo").on(table.repoId),
4617 ]
4618);
4619
4620export type RepoHealthCache = typeof repoHealthCache.$inferSelect;
4621
4622// ---------------------------------------------------------------------------
4623// Migration 0103 — Workspace jobs (AI Copilot Workspace: issue → PR agent)
4624// Hot path is in-memory; this table is for auditability + restart recovery.
4625// ---------------------------------------------------------------------------
4626export const workspaceJobs = pgTable(
4627 "workspace_jobs",
4628 {
4629 id: uuid("id").primaryKey().defaultRandom(),
4630 repoId: uuid("repo_id")
4631 .notNull()
4632 .references(() => repositories.id, { onDelete: "cascade" }),
4633 issueId: uuid("issue_id")
4634 .notNull()
4635 .references(() => issues.id, { onDelete: "cascade" }),
4636 triggeredBy: uuid("triggered_by").references(() => users.id),
4637 status: text("status").notNull().default("pending"),
4638 planComment: text("plan_comment"),
4639 branchName: text("branch_name"),
4640 prNumber: integer("pr_number"),
4641 errorMessage: text("error_message"),
4642 startedAt: timestamp("started_at").defaultNow(),
4643 updatedAt: timestamp("updated_at").defaultNow(),
4644 },
4645 (table) => [
4646 index("idx_workspace_jobs_repo").on(table.repoId),
4647 index("idx_workspace_jobs_issue").on(table.issueId),
4648 ]
4649);
4650
4651export type WorkspaceJob = typeof workspaceJobs.$inferSelect;
Addedsrc/lib/ai-workspace.ts+587−0View fileUnifiedSplit
@@ -0,0 +1,587 @@
1/**
2 * AI Copilot Workspace — issue-to-PR autonomous agent.
3 *
4 * Pipeline:
5 * 1. Load issue context (title + body + recent comments).
6 * 2. Explore the codebase — ls-tree then ask Claude which files to read.
7 * 3. Generate an implementation plan (Claude call) + post as issue comment.
8 * 4. Implement: create branch, apply file edits via git plumbing.
9 * 5. Open a draft PR linked to the issue.
10 *
11 * The hot path is fully in-memory (WorkspaceJob map). A workspace_jobs table
12 * is written for auditability via `src/db/schema.ts` + migration 0103.
13 *
14 * Never throws externally. All failures set job.status = "failed".
15 */
16
17import { join } from "path";
18import { eq, desc } from "drizzle-orm";
19import { db } from "../db";
20import {
21 issues,
22 issueComments,
23 repositories,
24 users,
25 pullRequests,
26 workspaceJobs,
27} from "../db/schema";
28import { getAnthropic, isAiAvailable, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client";
29import { applyEditsToNewBranch, type FileEdit } from "./spec-git";
30import { getBlob } from "../git/repository";
31import { getBotUserIdOrFallback } from "./bot-user";
32import { config } from "./config";
33
34// ---------------------------------------------------------------------------
35// Public types
36// ---------------------------------------------------------------------------
37
38export type WorkspaceStatus =
39 | "pending"
40 | "planning"
41 | "implementing"
42 | "opening_pr"
43 | "done"
44 | "failed";
45
46export interface WorkspaceJob {
47 id: string;
48 repoId: string;
49 issueId: string;
50 issueNumber: number;
51 ownerName: string;
52 repoName: string;
53 status: WorkspaceStatus;
54 planComment?: string; // the plan posted as issue comment
55 branchName?: string; // e.g. "workspace/issue-42-fix-auth"
56 prNumber?: number;
57 errorMessage?: string;
58 startedAt: Date;
59 updatedAt: Date;
60}
61
62// ---------------------------------------------------------------------------
63// In-memory job store (LRU, max 100 jobs)
64// ---------------------------------------------------------------------------
65
66const MAX_JOBS = 100;
67const jobById = new Map<string, WorkspaceJob>();
68// issueId → jobId for O(1) lookup by issue
69const jobByIssueId = new Map<string, string>();
70// repoId → jobId for "one active job per repo" guard
71const activeJobByRepoId = new Map<string, string>();
72
73function evictOldestIfNeeded(): void {
74 if (jobById.size < MAX_JOBS) return;
75 // Evict oldest (first inserted) entry
76 const firstKey = jobById.keys().next().value;
77 if (firstKey) {
78 const old = jobById.get(firstKey);
79 if (old) {
80 jobByIssueId.delete(old.issueId);
81 if (activeJobByRepoId.get(old.repoId) === firstKey) {
82 activeJobByRepoId.delete(old.repoId);
83 }
84 }
85 jobById.delete(firstKey);
86 }
87}
88
89function storeJob(job: WorkspaceJob): void {
90 evictOldestIfNeeded();
91 jobById.set(job.id, job);
92 jobByIssueId.set(job.issueId, job.id);
93 if (job.status !== "done" && job.status !== "failed") {
94 activeJobByRepoId.set(job.repoId, job.id);
95 }
96}
97
98function updateJob(job: WorkspaceJob, patch: Partial<WorkspaceJob>): void {
99 Object.assign(job, patch, { updatedAt: new Date() });
100 if (job.status === "done" || job.status === "failed") {
101 if (activeJobByRepoId.get(job.repoId) === job.id) {
102 activeJobByRepoId.delete(job.repoId);
103 }
104 }
105}
106
107// ---------------------------------------------------------------------------
108// Public accessors
109// ---------------------------------------------------------------------------
110
111export function getWorkspaceJob(jobId: string): WorkspaceJob | undefined {
112 return jobById.get(jobId);
113}
114
115export function getWorkspaceJobForIssue(issueId: string): WorkspaceJob | undefined {
116 const jobId = jobByIssueId.get(issueId);
117 return jobId ? jobById.get(jobId) : undefined;
118}
119
120// ---------------------------------------------------------------------------
121// Entry point
122// ---------------------------------------------------------------------------
123
124export async function startWorkspace(
125 issueId: string,
126 issueNumber: number,
127 repoId: string,
128 ownerName: string,
129 repoName: string,
130 triggeredByUserId: string
131): Promise<WorkspaceJob> {
132 // Guard: AI must be available
133 if (!isAiAvailable()) {
134 const failed: WorkspaceJob = {
135 id: crypto.randomUUID(),
136 repoId,
137 issueId,
138 issueNumber,
139 ownerName,
140 repoName,
141 status: "failed",
142 errorMessage: "ANTHROPIC_API_KEY is not configured",
143 startedAt: new Date(),
144 updatedAt: new Date(),
145 };
146 storeJob(failed);
147 return failed;
148 }
149
150 // Guard: max 1 active job per repo
151 const existingJobId = activeJobByRepoId.get(repoId);
152 if (existingJobId) {
153 const existing = jobById.get(existingJobId);
154 if (existing && existing.status !== "done" && existing.status !== "failed") {
155 return existing;
156 }
157 // Stale entry — clear it
158 activeJobByRepoId.delete(repoId);
159 }
160
161 const job: WorkspaceJob = {
162 id: crypto.randomUUID(),
163 repoId,
164 issueId,
165 issueNumber,
166 ownerName,
167 repoName,
168 status: "pending",
169 startedAt: new Date(),
170 updatedAt: new Date(),
171 };
172
173 storeJob(job);
174
175 // Persist to DB for auditability (best-effort)
176 try {
177 await db.insert(workspaceJobs).values({
178 id: job.id,
179 repoId,
180 issueId,
181 triggeredBy: triggeredByUserId,
182 status: "pending",
183 });
184 } catch {
185 /* non-fatal — in-memory store is the source of truth */
186 }
187
188 // Fire-and-forget pipeline
189 runWorkspacePipeline(job, triggeredByUserId).catch(() => {
190 /* errors are captured inside the pipeline */
191 });
192
193 return job;
194}
195
196// ---------------------------------------------------------------------------
197// Pipeline
198// ---------------------------------------------------------------------------
199
200async function runWorkspacePipeline(
201 job: WorkspaceJob,
202 triggeredByUserId: string
203): Promise<void> {
204 try {
205 // Step 1 — Load issue context
206 const issueCtx = await loadIssueContext(job.issueId);
207 if (!issueCtx) {
208 throw new Error("Issue not found or DB read failed");
209 }
210
211 // Step 2 — Explore codebase
212 updateJob(job, { status: "planning" });
213 await persistJobStatus(job);
214
215 const reposBase = config.gitReposPath;
216 const repoDiskPath = join(reposBase, job.ownerName, `${job.repoName}.git`);
217
218 const fileTree = await getFileTree(repoDiskPath);
219 const relevantFiles = await pickRelevantFiles(issueCtx, fileTree);
220 const fileContents = await readFileContents(
221 job.ownerName,
222 job.repoName,
223 relevantFiles
224 );
225
226 // Step 3 — Generate implementation plan
227 const plan = await generatePlan(issueCtx, fileContents);
228
229 // Post plan as issue comment
230 const planBody = formatPlanComment(plan);
231 const botUserId = await getBotUserIdOrFallback(triggeredByUserId);
232 const commentId = await postIssueComment(job.issueId, botUserId, planBody);
233 updateJob(job, { planComment: commentId ?? undefined });
234
235 // Step 4 — Implement
236 updateJob(job, { status: "implementing" });
237 await persistJobStatus(job);
238
239 // Resolve default branch
240 const repoRow = await db
241 .select({ defaultBranch: repositories.defaultBranch })
242 .from(repositories)
243 .where(eq(repositories.id, job.repoId))
244 .limit(1);
245 const baseBranch = repoRow[0]?.defaultBranch ?? "main";
246
247 const branchName = plan.branchName || sanitizeBranchName(
248 `workspace/issue-${job.issueNumber}-${slugify(issueCtx.title)}`
249 );
250 updateJob(job, { branchName });
251
252 const edits = buildEdits(plan);
253 if (edits.length === 0) {
254 throw new Error("AI plan produced no file changes");
255 }
256
257 const commitMsg =
258 `feat: implement #${job.issueNumber} — ${plan.summary.slice(0, 60)}\n\n` +
259 `Closes #${job.issueNumber}\n\n` +
260 `Generated by Gluecron AI Workspace.`;
261
262 const applied = await applyEditsToNewBranch({
263 repoDiskPath,
264 baseRef: baseBranch,
265 edits,
266 branchName,
267 commitMessage: commitMsg,
268 authorName: "Gluecron Workspace",
269 authorEmail: "workspace@gluecron.com",
270 });
271
272 if (!applied.ok) {
273 throw new Error(`git apply failed: ${applied.error}`);
274 }
275
276 // Step 5 — Open draft PR
277 updateJob(job, { status: "opening_pr" });
278 await persistJobStatus(job);
279
280 const prBody =
281 `Closes #${job.issueNumber}\n\n` +
282 `${plan.summary}\n\n` +
283 `**Files changed:**\n` +
284 applied.filesChanged.map((f) => `- \`${f}\``).join("\n") +
285 `\n\n<!-- gluecron:workspace:pr -->`;
286
287 const prTitle = `feat: ${plan.summary.slice(0, 140)}`;
288
289 const [pr] = await db
290 .insert(pullRequests)
291 .values({
292 repositoryId: job.repoId,
293 authorId: botUserId,
294 title: prTitle.slice(0, 200),
295 body: prBody,
296 baseBranch,
297 headBranch: applied.branchName,
298 isDraft: true,
299 })
300 .returning({ number: pullRequests.number });
301
302 if (!pr?.number) {
303 throw new Error("PR insert returned no number");
304 }
305
306 updateJob(job, { status: "done", prNumber: pr.number });
307 await persistJobStatus(job);
308 } catch (err: unknown) {
309 const msg = err instanceof Error ? err.message : String(err);
310 updateJob(job, { status: "failed", errorMessage: msg });
311 await persistJobStatus(job);
312 }
313}
314
315// ---------------------------------------------------------------------------
316// Step helpers
317// ---------------------------------------------------------------------------
318
319interface IssueContext {
320 title: string;
321 body: string;
322 comments: string[];
323}
324
325async function loadIssueContext(issueId: string): Promise<IssueContext | null> {
326 try {
327 const issueRows = await db
328 .select({ title: issues.title, body: issues.body })
329 .from(issues)
330 .where(eq(issues.id, issueId))
331 .limit(1);
332
333 if (!issueRows[0]) return null;
334 const { title, body } = issueRows[0];
335
336 const commentRows = await db
337 .select({ body: issueComments.body })
338 .from(issueComments)
339 .where(eq(issueComments.issueId, issueId))
340 .orderBy(desc(issueComments.createdAt))
341 .limit(5);
342
343 return {
344 title,
345 body: body ?? "",
346 comments: commentRows.map((r) => r.body),
347 };
348 } catch {
349 return null;
350 }
351}
352
353async function getFileTree(repoDiskPath: string): Promise<string[]> {
354 try {
355 const proc = Bun.spawn(
356 ["git", "ls-tree", "-r", "--name-only", "HEAD"],
357 { cwd: repoDiskPath, stdout: "pipe", stderr: "pipe" }
358 );
359 const text = await new Response(proc.stdout).text();
360 await proc.exited;
361 const lines = text
362 .split("\n")
363 .map((l) => l.trim())
364 .filter(Boolean);
365 return lines.slice(0, 500);
366 } catch {
367 return [];
368 }
369}
370
371async function pickRelevantFiles(
372 ctx: IssueContext,
373 fileTree: string[]
374): Promise<string[]> {
375 if (fileTree.length === 0) return [];
376
377 const prompt =
378 `Given this issue:\n${ctx.title}\n${ctx.body}\n\n` +
379 `And this file tree:\n${fileTree.join("\n")}\n\n` +
380 `List the 20 most relevant file paths to read in order to implement the issue. ` +
381 `Return JSON: {"files": string[]}`;
382
383 try {
384 const client = getAnthropic();
385 const msg = await client.messages.create({
386 model: MODEL_SONNET,
387 max_tokens: 512,
388 temperature: 0,
389 messages: [{ role: "user", content: prompt }],
390 });
391 const text = extractText(msg);
392 const parsed = parseJsonResponse<{ files: string[] }>(text);
393 if (parsed && Array.isArray(parsed.files)) {
394 return parsed.files.slice(0, 20).filter((f: string) => typeof f === "string");
395 }
396 } catch {
397 /* fall through — return empty */
398 }
399 // Fallback: first 10 files
400 return fileTree.slice(0, 10);
401}
402
403const MAX_FILE_BYTES = 8 * 1024; // 8 KB per file
404const MAX_TOTAL_BYTES = 80 * 1024; // 80 KB total
405
406async function readFileContents(
407 ownerName: string,
408 repoName: string,
409 filePaths: string[]
410): Promise<Array<{ path: string; content: string }>> {
411 const results: Array<{ path: string; content: string }> = [];
412 let totalBytes = 0;
413
414 for (const filePath of filePaths) {
415 if (totalBytes >= MAX_TOTAL_BYTES) break;
416 try {
417 const blob = await getBlob(ownerName, repoName, "HEAD", filePath);
418 if (!blob || blob.isBinary) continue;
419 const content = blob.content.slice(0, MAX_FILE_BYTES);
420 totalBytes += content.length;
421 results.push({ path: filePath, content });
422 } catch {
423 /* skip unreadable files */
424 }
425 }
426
427 return results;
428}
429
430interface FilePlan {
431 path: string;
432 action: "create" | "modify" | "delete";
433 description: string;
434 patch: string;
435}
436
437interface WorkspacePlan {
438 summary: string;
439 files: FilePlan[];
440 branchName: string;
441}
442
443async function generatePlan(
444 ctx: IssueContext,
445 fileContents: Array<{ path: string; content: string }>
446): Promise<WorkspacePlan> {
447 const fileSection = fileContents
448 .map((f) => `=== ${f.path} ===\n${f.content}`)
449 .join("\n\n");
450
451 const commentSection = ctx.comments.length > 0
452 ? `\n\nComments:\n${ctx.comments.map((c, i) => `[${i + 1}] ${c}`).join("\n\n")}`
453 : "";
454
455 const userPrompt =
456 `Issue: ${ctx.title}\n${ctx.body}${commentSection}\n\n` +
457 `Relevant code:\n${fileSection}\n\n` +
458 `Return JSON:\n` +
459 `{\n` +
460 ` "summary": string,\n` +
461 ` "files": Array<{\n` +
462 ` "path": string,\n` +
463 ` "action": "create" | "modify" | "delete",\n` +
464 ` "description": string,\n` +
465 ` "patch": string\n` +
466 ` }>,\n` +
467 ` "branchName": string\n` +
468 `}`;
469
470 const systemPrompt =
471 "You are an expert software engineer. Generate a detailed implementation plan for the given issue. " +
472 "For each file, provide the full new content (not a diff) in the 'patch' field. " +
473 "Branch name should follow the pattern: workspace/issue-<number>-<short-slug>. " +
474 "Return only valid JSON — no prose, no markdown fences.";
475
476 const client = getAnthropic();
477 const msg = await client.messages.create({
478 model: MODEL_SONNET,
479 max_tokens: 4096,
480 temperature: 0.2,
481 system: systemPrompt,
482 messages: [{ role: "user", content: userPrompt }],
483 });
484
485 const text = extractText(msg);
486 const parsed = parseJsonResponse<WorkspacePlan>(text);
487
488 if (!parsed || typeof parsed.summary !== "string") {
489 throw new Error("AI returned invalid plan JSON");
490 }
491
492 return {
493 summary: parsed.summary || "AI-generated implementation",
494 files: Array.isArray(parsed.files) ? parsed.files : [],
495 branchName: typeof parsed.branchName === "string" && parsed.branchName
496 ? sanitizeBranchName(parsed.branchName)
497 : "",
498 };
499}
500
501function buildEdits(plan: WorkspacePlan): FileEdit[] {
502 const edits: FileEdit[] = [];
503 for (const f of plan.files) {
504 if (!f.path || typeof f.path !== "string") continue;
505 // Reject traversal attempts
506 if (f.path.startsWith("/") || f.path.includes("..")) continue;
507
508 if (f.action === "delete") {
509 edits.push({ action: "delete", path: f.path });
510 } else {
511 // create or modify — we always write the full content
512 const content = typeof f.patch === "string" ? f.patch : "";
513 edits.push({ action: f.action === "create" ? "create" : "edit", path: f.path, content });
514 }
515 }
516 return edits;
517}
518
519function formatPlanComment(plan: WorkspacePlan): string {
520 const fileLines = plan.files
521 .map((f) => `- **${f.action}** \`${f.path}\` — ${f.description}`)
522 .join("\n");
523
524 return (
525 `<!-- gluecron:workspace:plan -->\n` +
526 `## AI Workspace Plan\n\n` +
527 `${plan.summary}\n\n` +
528 `**Files to change:**\n` +
529 (fileLines || "_No files identified_") +
530 `\n\n` +
531 `_Implementing now... a draft PR will be opened when complete._`
532 );
533}
534
535async function postIssueComment(
536 issueId: string,
537 authorId: string,
538 body: string
539): Promise<string | null> {
540 try {
541 const [row] = await db
542 .insert(issueComments)
543 .values({ issueId, authorId, body })
544 .returning({ id: issueComments.id });
545 return row?.id ?? null;
546 } catch {
547 return null;
548 }
549}
550
551async function persistJobStatus(job: WorkspaceJob): Promise<void> {
552 try {
553 await db
554 .update(workspaceJobs)
555 .set({
556 status: job.status,
557 planComment: job.planComment ?? null,
558 branchName: job.branchName ?? null,
559 prNumber: job.prNumber ?? null,
560 errorMessage: job.errorMessage ?? null,
561 updatedAt: job.updatedAt,
562 })
563 .where(eq(workspaceJobs.id, job.id));
564 } catch {
565 /* non-fatal */
566 }
567}
568
569// ---------------------------------------------------------------------------
570// Utilities
571// ---------------------------------------------------------------------------
572
573function slugify(text: string): string {
574 return text
575 .toLowerCase()
576 .replace(/[^a-z0-9]+/g, "-")
577 .replace(/^-+|-+$/g, "")
578 .slice(0, 40) || "change";
579}
580
581function sanitizeBranchName(name: string): string {
582 return name
583 .replace(/[^a-zA-Z0-9/_-]/g, "-")
584 .replace(/--+/g, "-")
585 .replace(/^-+|-+$/g, "")
586 .slice(0, 100);
587}
Addedsrc/lib/nl-search.ts+516−0View fileUnifiedSplit
@@ -0,0 +1,516 @@
1/**
2 * Natural Language Code Search — Claude-powered intent search.
3 *
4 * Unlike embedding-based semantic search (which requires a pre-built vector
5 * index), NL search uses Claude as the reasoner over actual file content.
6 * A developer types a natural language question like:
7 * "find all places where we validate user email but don't check MX records"
8 * "where do we write to the database without a transaction?"
9 * and Claude finds the matching code.
10 *
11 * Algorithm:
12 * 1. Quick Claude call to extract grep-friendly keywords from the query.
13 * 2. Run `git grep -l <keyword>` for each keyword; union matching files.
14 * Cap at 40 files. Fall back to code_chunks table if grep returns 0.
15 * 3. Read each candidate file via git show (HEAD:<path>), capped at 6KB each.
16 * Build combined context, capped at 80KB total.
17 * 4. Single Claude reasoning pass: find all places matching the query,
18 * return JSON with filePath, lineStart, lineEnd, snippet, explanation,
19 * confidence.
20 * 5. Cache results in-memory per `${repoId}:${query}` for 15 minutes.
21 */
22
23import { getAnthropic, MODEL_SONNET, isAiAvailable, parseJsonResponse } from "./ai-client";
24import { getRepoPath } from "../git/repository";
25import { db } from "../db";
26import { codeChunks } from "../db/schema";
27import { eq } from "drizzle-orm";
28
29// ---------------------------------------------------------------------------
30// Public types
31// ---------------------------------------------------------------------------
32
33export interface NlSearchResult {
34 filePath: string;
35 lineStart: number;
36 lineEnd: number;
37 snippet: string; // the relevant lines
38 explanation: string; // why this matches the query
39 confidence: "high" | "medium" | "low";
40}
41
42export interface NlSearchResponse {
43 query: string;
44 results: NlSearchResult[];
45 totalFilesScanned: number;
46 searchedAt: Date;
47 cached: boolean;
48}
49
50// ---------------------------------------------------------------------------
51// In-memory cache (15-minute TTL)
52// ---------------------------------------------------------------------------
53
54interface CacheEntry {
55 response: NlSearchResponse;
56 expiresAt: number;
57}
58
59const nlCache = new Map<string, CacheEntry>();
60const NL_CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
61
62function cacheGet(key: string): NlSearchResponse | null {
63 const entry = nlCache.get(key);
64 if (!entry) return null;
65 if (Date.now() > entry.expiresAt) {
66 nlCache.delete(key);
67 return null;
68 }
69 return entry.response;
70}
71
72function cacheSet(key: string, response: NlSearchResponse): void {
73 // Evict expired entries to keep memory bounded.
74 if (nlCache.size > 200) {
75 const now = Date.now();
76 for (const [k, v] of nlCache) {
77 if (v.expiresAt < now) nlCache.delete(k);
78 }
79 }
80 nlCache.set(key, { response, expiresAt: Date.now() + NL_CACHE_TTL_MS });
81}
82
83// ---------------------------------------------------------------------------
84// Skip-list: skip binary/build/lock files
85// ---------------------------------------------------------------------------
86
87const SKIP_DIRS = new Set([
88 "node_modules",
89 ".git",
90 "dist",
91 "build",
92 "vendor",
93 ".next",
94 ".turbo",
95 "target",
96 "__pycache__",
97]);
98
99const SKIP_EXTS = new Set([
100 ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp",
101 ".woff", ".woff2", ".ttf", ".eot",
102 ".pdf", ".zip", ".gz", ".tar",
103 ".lockb", ".lock",
104 ".min.js", ".min.css",
105]);
106
107function shouldSkipPath(filePath: string): boolean {
108 const parts = filePath.split("/");
109 for (const part of parts.slice(0, -1)) {
110 if (SKIP_DIRS.has(part.toLowerCase())) return true;
111 }
112 const basename = parts[parts.length - 1].toLowerCase();
113 for (const ext of SKIP_EXTS) {
114 if (basename.endsWith(ext)) return true;
115 }
116 // Lock files by exact name
117 const lockFiles = new Set([
118 "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
119 "bun.lockb", "bun.lock", "poetry.lock", "cargo.lock",
120 "composer.lock", "gemfile.lock",
121 ]);
122 if (lockFiles.has(basename)) return true;
123 return false;
124}
125
126// ---------------------------------------------------------------------------
127// Git helpers
128// ---------------------------------------------------------------------------
129
130async function gitExec(
131 cmd: string[],
132 cwd: string
133): Promise<{ stdout: string; exitCode: number }> {
134 const proc = Bun.spawn(cmd, {
135 cwd,
136 env: process.env as Record<string, string>,
137 stdout: "pipe",
138 stderr: "pipe",
139 });
140 const stdout = await new Response(proc.stdout).text();
141 const exitCode = await proc.exited;
142 return { stdout, exitCode };
143}
144
145/**
146 * Run `git grep -l <keyword> HEAD --` and return matching file paths.
147 */
148async function grepForKeyword(
149 ownerName: string,
150 repoName: string,
151 keyword: string
152): Promise<string[]> {
153 const repoDir = getRepoPath(ownerName, repoName);
154 try {
155 const { stdout, exitCode } = await gitExec(
156 ["git", "grep", "-l", "-i", keyword, "HEAD", "--"],
157 repoDir
158 );
159 // exit code 1 = no matches (not an error)
160 if (exitCode !== 0 && exitCode !== 1) return [];
161 return stdout
162 .trim()
163 .split("\n")
164 .filter(Boolean)
165 .map((line) => {
166 // git grep -l with a ref outputs "<ref>:<path>" or just "<path>"
167 const idx = line.indexOf(":");
168 return idx >= 0 ? line.slice(idx + 1) : line;
169 })
170 .filter((p) => !shouldSkipPath(p));
171 } catch {
172 return [];
173 }
174}
175
176/**
177 * Read a file from the repo at HEAD, capped to maxBytes.
178 * Returns empty string on error.
179 */
180async function readFileFromGit(
181 ownerName: string,
182 repoName: string,
183 filePath: string,
184 maxBytes = 6 * 1024
185): Promise<string> {
186 const repoDir = getRepoPath(ownerName, repoName);
187 try {
188 const { stdout, exitCode } = await gitExec(
189 ["git", "show", `HEAD:${filePath}`],
190 repoDir
191 );
192 if (exitCode !== 0) return "";
193 return stdout.slice(0, maxBytes);
194 } catch {
195 return "";
196 }
197}
198
199// ---------------------------------------------------------------------------
200// Step 1 — Extract keywords from the query via Claude
201// ---------------------------------------------------------------------------
202
203interface KeywordExtraction {
204 keywords: string[];
205 fileTypes: string[];
206}
207
208async function extractKeywords(query: string): Promise<KeywordExtraction> {
209 const client = getAnthropic();
210 try {
211 const msg = await client.messages.create({
212 model: MODEL_SONNET,
213 max_tokens: 256,
214 messages: [
215 {
216 role: "user",
217 content:
218 `Extract 3-5 grep-friendly keywords from this natural language search query. ` +
219 `Return JSON only, no prose: {"keywords": string[], "fileTypes": string[]}\n` +
220 `Keywords should be short, concrete identifiers/patterns likely to appear in code. ` +
221 `fileTypes is an optional list of file extensions (e.g. [".ts", ".tsx"]) to narrow the search. ` +
222 `Return [] for fileTypes if the query is language-agnostic.\n\n` +
223 `Query: ${query}`,
224 },
225 ],
226 });
227 const text = msg.content.find((b) => b.type === "text")?.text ?? "";
228 const parsed = parseJsonResponse<KeywordExtraction>(text);
229 if (parsed && Array.isArray(parsed.keywords)) {
230 return {
231 keywords: parsed.keywords.slice(0, 5).filter((k) => typeof k === "string" && k.length > 0),
232 fileTypes: Array.isArray(parsed.fileTypes) ? parsed.fileTypes : [],
233 };
234 }
235 } catch (err) {
236 console.error("[nl-search] keyword extraction error:", err);
237 }
238 // Fallback: split query into words
239 const words = query
240 .split(/[^a-zA-Z0-9_]+/)
241 .filter((w) => w.length >= 3)
242 .slice(0, 5);
243 return { keywords: words, fileTypes: [] };
244}
245
246// ---------------------------------------------------------------------------
247// Step 2 — Gather candidate files via git grep
248// ---------------------------------------------------------------------------
249
250async function gatherCandidates(
251 ownerName: string,
252 repoName: string,
253 repoId: string,
254 extraction: KeywordExtraction,
255 maxFiles = 40
256): Promise<string[]> {
257 const seen = new Set<string>();
258
259 // Run git grep for each keyword (in parallel)
260 const results = await Promise.allSettled(
261 extraction.keywords.map((kw) => grepForKeyword(ownerName, repoName, kw))
262 );
263
264 for (const r of results) {
265 if (r.status === "fulfilled") {
266 for (const p of r.value) {
267 seen.add(p);
268 if (seen.size >= maxFiles * 2) break;
269 }
270 }
271 }
272
273 // Apply file-type filter if provided
274 let candidates = Array.from(seen);
275 if (extraction.fileTypes.length > 0) {
276 const filtered = candidates.filter((p) =>
277 extraction.fileTypes.some((ext) => p.endsWith(ext))
278 );
279 // Only narrow if we still have results
280 if (filtered.length > 0) candidates = filtered;
281 }
282
283 candidates = candidates.slice(0, maxFiles);
284
285 // Fallback: if git grep returned nothing, read from code_chunks table
286 if (candidates.length === 0) {
287 try {
288 const rows = await db
289 .select({ path: codeChunks.path })
290 .from(codeChunks)
291 .where(eq(codeChunks.repositoryId, repoId))
292 .groupBy(codeChunks.path)
293 .limit(maxFiles);
294 candidates = rows.map((r) => r.path).filter((p) => !shouldSkipPath(p));
295 } catch {
296 // DB unavailable — return empty
297 }
298 }
299
300 return candidates;
301}
302
303// ---------------------------------------------------------------------------
304// Step 3 — Read file contents and build context
305// ---------------------------------------------------------------------------
306
307/**
308 * For each candidate file, read content (capped at 6KB).
309 * Build a combined context string where each file is prefixed with a header
310 * showing the filename and line numbers, capped at 80KB total.
311 */
312async function buildContext(
313 ownerName: string,
314 repoName: string,
315 candidates: string[],
316 maxTotalBytes = 80 * 1024
317): Promise<{ contextStr: string; filesRead: string[] }> {
318 const BATCH = 10;
319 const fileContents: Array<{ path: string; content: string }> = [];
320
321 for (let i = 0; i < candidates.length; i += BATCH) {
322 const batch = candidates.slice(i, i + BATCH);
323 const contents = await Promise.all(
324 batch.map((p) => readFileFromGit(ownerName, repoName, p, 6144))
325 );
326 for (let j = 0; j < batch.length; j++) {
327 if (contents[j]) {
328 fileContents.push({ path: batch[j], content: contents[j] });
329 }
330 }
331 }
332
333 // Build numbered context string
334 let contextStr = "";
335 const filesRead: string[] = [];
336
337 for (const { path, content } of fileContents) {
338 if (contextStr.length >= maxTotalBytes) break;
339 const lines = content.split("\n");
340 // Number lines starting at 1
341 const numbered = lines
342 .map((line, idx) => `${idx + 1}: ${line}`)
343 .join("\n");
344 const header = `\n\n=== FILE: ${path} ===\n`;
345 const block = header + numbered;
346 if (contextStr.length + block.length > maxTotalBytes) {
347 // Trim block to fit
348 const remaining = maxTotalBytes - contextStr.length;
349 if (remaining > header.length + 100) {
350 contextStr += block.slice(0, remaining);
351 filesRead.push(path);
352 }
353 } else {
354 contextStr += block;
355 filesRead.push(path);
356 }
357 }
358
359 return { contextStr, filesRead };
360}
361
362// ---------------------------------------------------------------------------
363// Step 4 — Claude reasoning pass
364// ---------------------------------------------------------------------------
365
366interface RawResult {
367 filePath: string;
368 lineStart: number;
369 lineEnd: number;
370 snippet: string;
371 explanation: string;
372 confidence: "high" | "medium" | "low";
373}
374
375async function reasonWithClaude(
376 query: string,
377 contextStr: string
378): Promise<NlSearchResult[]> {
379 const client = getAnthropic();
380
381 const systemPrompt =
382 `You are a code analysis expert. Find all places in the provided code that match the user's query. ` +
383 `Be precise about file paths and line numbers. Only return matches that genuinely satisfy the query — ` +
384 `do not include tangentially related code.`;
385
386 const userPrompt =
387 `Query: ${query}\n\n` +
388 `Code files:\n${contextStr}\n\n` +
389 `Return JSON only, no prose:\n` +
390 `{\n` +
391 ` "results": Array<{\n` +
392 ` "filePath": string,\n` +
393 ` "lineStart": number,\n` +
394 ` "lineEnd": number,\n` +
395 ` "snippet": string,\n` +
396 ` "explanation": string,\n` +
397 ` "confidence": "high" | "medium" | "low"\n` +
398 ` }>\n` +
399 `}\n\n` +
400 `Return {"results": []} if nothing matches. Sort by confidence descending. Max 10 results.`;
401
402 try {
403 const msg = await client.messages.create({
404 model: MODEL_SONNET,
405 max_tokens: 3000,
406 system: systemPrompt,
407 messages: [{ role: "user", content: userPrompt }],
408 });
409
410 const text = msg.content.find((b) => b.type === "text")?.text ?? "";
411 const parsed = parseJsonResponse<{ results: RawResult[] }>(text);
412
413 if (!parsed || !Array.isArray(parsed.results)) return [];
414
415 return parsed.results
416 .filter(
417 (r): r is RawResult =>
418 typeof r.filePath === "string" &&
419 typeof r.lineStart === "number" &&
420 typeof r.lineEnd === "number" &&
421 typeof r.snippet === "string" &&
422 typeof r.explanation === "string" &&
423 (r.confidence === "high" || r.confidence === "medium" || r.confidence === "low")
424 )
425 .slice(0, 10);
426 } catch (err) {
427 console.error("[nl-search] Claude reasoning error:", err);
428 return [];
429 }
430}
431
432// ---------------------------------------------------------------------------
433// Main export
434// ---------------------------------------------------------------------------
435
436/**
437 * Natural language code search powered by Claude.
438 *
439 * @param ownerName - repo owner username
440 * @param repoName - repo name
441 * @param repoId - DB repository id (used as cache key + fallback)
442 * @param query - natural-language search query
443 * @returns NlSearchResponse (never throws)
444 */
445export async function nlSearch(
446 ownerName: string,
447 repoName: string,
448 repoId: string,
449 query: string
450): Promise<NlSearchResponse> {
451 const q = query.trim();
452 const empty: NlSearchResponse = {
453 query: q,
454 results: [],
455 totalFilesScanned: 0,
456 searchedAt: new Date(),
457 cached: false,
458 };
459
460 if (!q) return empty;
461
462 // Guard: AI must be available
463 if (!isAiAvailable()) {
464 return { ...empty, results: [] };
465 }
466
467 // Cache check
468 const cacheKey = `${repoId}:${q}`;
469 const cached = cacheGet(cacheKey);
470 if (cached) {
471 return { ...cached, cached: true };
472 }
473
474 try {
475 // Step 1 — Extract keywords
476 const extraction = await extractKeywords(q);
477
478 // Step 2 — Gather candidate files
479 const candidates = await gatherCandidates(
480 ownerName, repoName, repoId, extraction, 40
481 );
482
483 if (candidates.length === 0) {
484 cacheSet(cacheKey, empty);
485 return empty;
486 }
487
488 // Step 3 — Read file contents
489 const { contextStr, filesRead } = await buildContext(
490 ownerName, repoName, candidates, 80 * 1024
491 );
492
493 if (!contextStr || filesRead.length === 0) {
494 cacheSet(cacheKey, empty);
495 return empty;
496 }
497
498 // Step 4 — Claude reasoning pass
499 const results = await reasonWithClaude(q, contextStr);
500
501 const response: NlSearchResponse = {
502 query: q,
503 results,
504 totalFilesScanned: filesRead.length,
505 searchedAt: new Date(),
506 cached: false,
507 };
508
509 // Step 5 — Cache and return
510 cacheSet(cacheKey, response);
511 return response;
512 } catch (err) {
513 console.error("[nl-search] unexpected error:", err);
514 return empty;
515 }
516}
Addedsrc/lib/repo-health.ts+312−0View fileUnifiedSplit
@@ -0,0 +1,312 @@
1/**
2 * Repository Health Score — composite 0-100 signal.
3 *
4 * Five signals (always totals 100 when fully populated):
5 * CI green rate 25 pts gate_runs last 30 days
6 * Bus factor 20 pts bus_factor_cache table
7 * Open CVEs 20 pts repo_advisory_alerts table
8 * PR review velocity 15 pts pull_requests + pr_comments
9 * Tech debt 20 pts repo_onboarding_data (neutral 15 if no data)
10 *
11 * Cached in-memory with a 6-hour TTL. Call invalidateHealthScore(repoId) on
12 * every push to force a fresh computation on the next page load.
13 */
14
15import { db } from "../db";
16import {
17 gateRuns,
18 busFactorCache,
19 repoAdvisoryAlerts,
20 pullRequests,
21 prComments,
22 repoOnboardingData,
23} from "../db/schema";
24import { eq, and, gte, lt, sql, count, min } from "drizzle-orm";
25import type { BusFactorFile } from "./bus-factor";
26
27// ---------------------------------------------------------------------------
28// Public interface
29// ---------------------------------------------------------------------------
30
31export interface HealthScoreBreakdown {
32 total: number;
33 ciGreenRate: {
34 score: number; // 0-25
35 rate: number; // 0.0-1.0
36 totalRuns: number;
37 passedRuns: number;
38 };
39 busFactor: {
40 score: number; // 0-20
41 atRiskFileCount: number;
42 criticalCount: number;
43 };
44 openCves: {
45 score: number; // 0-20
46 count: number;
47 };
48 reviewVelocity: {
49 score: number; // 0-15
50 avgHours: number | null;
51 sampleSize: number;
52 };
53 techDebt: {
54 score: number; // 0-20
55 available: boolean;
56 };
57 computedAt: Date;
58}
59
60// ---------------------------------------------------------------------------
61// In-memory cache
62// ---------------------------------------------------------------------------
63
64interface CacheEntry {
65 breakdown: HealthScoreBreakdown;
66 expiresAt: number; // Date.now() ms
67}
68
69const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
70const memCache = new Map<string, CacheEntry>();
71
72export function invalidateHealthScore(repoId: string): void {
73 memCache.delete(repoId);
74}
75
76// ---------------------------------------------------------------------------
77// Signal: CI green rate (0-25 pts)
78// ---------------------------------------------------------------------------
79
80async function ciGreenRate(repoId: string): Promise<HealthScoreBreakdown["ciGreenRate"]> {
81 try {
82 const since = new Date(Date.now() - 30 * 86_400_000);
83 const rows = await db
84 .select({ status: gateRuns.status })
85 .from(gateRuns)
86 .where(and(eq(gateRuns.repositoryId, repoId), gte(gateRuns.createdAt, since)));
87
88 if (rows.length === 0) {
89 // No runs in last 30 days — benefit of the doubt
90 return { score: 20, rate: 1, totalRuns: 0, passedRuns: 0 };
91 }
92
93 const totalRuns = rows.length;
94 const passedRuns = rows.filter(
95 (r) => r.status === "passed" || r.status === "repaired"
96 ).length;
97 const rate = passedRuns / totalRuns;
98 const score = Math.round(rate * 25);
99 return { score, rate, totalRuns, passedRuns };
100 } catch {
101 return { score: 20, rate: 1, totalRuns: 0, passedRuns: 0 };
102 }
103}
104
105// ---------------------------------------------------------------------------
106// Signal: Bus factor (0-20 pts)
107// ---------------------------------------------------------------------------
108
109async function busFactorSignal(repoId: string): Promise<HealthScoreBreakdown["busFactor"]> {
110 try {
111 const rows = await db
112 .select()
113 .from(busFactorCache)
114 .where(eq(busFactorCache.repositoryId, repoId))
115 .limit(1);
116
117 if (rows.length === 0) {
118 // No cache entry — neutral
119 return { score: 15, atRiskFileCount: 0, criticalCount: 0 };
120 }
121
122 const atRiskFiles = (rows[0].atRiskFiles ?? []) as BusFactorFile[];
123 const criticalCount = atRiskFiles.filter((f) => f.risk === "critical").length;
124 const highCount = atRiskFiles.filter((f) => f.risk === "high").length;
125
126 // Start at 20, subtract penalty per risky file (capped)
127 const criticalPenalty = Math.min(criticalCount * 5, 20);
128 const highPenalty = Math.min(highCount * 2, 10);
129 const score = Math.max(0, 20 - criticalPenalty - highPenalty);
130
131 return { score, atRiskFileCount: atRiskFiles.length, criticalCount };
132 } catch {
133 return { score: 15, atRiskFileCount: 0, criticalCount: 0 };
134 }
135}
136
137// ---------------------------------------------------------------------------
138// Signal: Open CVEs (0-20 pts)
139// ---------------------------------------------------------------------------
140
141async function openCvesSignal(repoId: string): Promise<HealthScoreBreakdown["openCves"]> {
142 try {
143 const rows = await db
144 .select({ n: count() })
145 .from(repoAdvisoryAlerts)
146 .where(
147 and(
148 eq(repoAdvisoryAlerts.repositoryId, repoId),
149 eq(repoAdvisoryAlerts.status, "open")
150 )
151 );
152
153 const cveCount = Number(rows[0]?.n ?? 0);
154 let score: number;
155 if (cveCount === 0) score = 20;
156 else if (cveCount === 1) score = 15;
157 else if (cveCount === 2) score = 10;
158 else if (cveCount <= 4) score = 5;
159 else score = 0;
160
161 return { score, count: cveCount };
162 } catch {
163 return { score: 20, count: 0 };
164 }
165}
166
167// ---------------------------------------------------------------------------
168// Signal: PR review velocity (0-15 pts)
169// ---------------------------------------------------------------------------
170
171async function reviewVelocitySignal(repoId: string): Promise<HealthScoreBreakdown["reviewVelocity"]> {
172 try {
173 const since = new Date(Date.now() - 30 * 86_400_000);
174
175 // Get merged PRs from last 30 days with their first human review comment timestamp
176 const rows = await db
177 .select({
178 prId: pullRequests.id,
179 prCreatedAt: pullRequests.createdAt,
180 firstReview: min(prComments.createdAt),
181 })
182 .from(pullRequests)
183 .innerJoin(
184 prComments,
185 and(
186 eq(prComments.pullRequestId, pullRequests.id),
187 eq(prComments.isAiReview, false)
188 )
189 )
190 .where(
191 and(
192 eq(pullRequests.repositoryId, repoId),
193 eq(pullRequests.state, "merged"),
194 gte(pullRequests.createdAt, since)
195 )
196 )
197 .groupBy(pullRequests.id, pullRequests.createdAt)
198 .limit(20);
199
200 if (rows.length === 0) {
201 return { score: 0, avgHours: null, sampleSize: 0 };
202 }
203
204 // Compute average hours from PR creation to first review
205 let totalHours = 0;
206 let validCount = 0;
207 for (const row of rows) {
208 if (row.firstReview && row.prCreatedAt) {
209 const diffMs =
210 new Date(row.firstReview).getTime() -
211 new Date(row.prCreatedAt).getTime();
212 if (diffMs >= 0) {
213 totalHours += diffMs / 3_600_000;
214 validCount++;
215 }
216 }
217 }
218
219 if (validCount === 0) {
220 return { score: 0, avgHours: null, sampleSize: rows.length };
221 }
222
223 const avgHours = totalHours / validCount;
224 let score: number;
225 if (avgHours < 4) score = 15;
226 else if (avgHours < 8) score = 12;
227 else if (avgHours < 24) score = 8;
228 else if (avgHours < 72) score = 4;
229 else score = 0;
230
231 return { score, avgHours, sampleSize: validCount };
232 } catch {
233 return { score: 0, avgHours: null, sampleSize: 0 };
234 }
235}
236
237// ---------------------------------------------------------------------------
238// Signal: Tech debt (0-20 pts)
239// ---------------------------------------------------------------------------
240
241async function techDebtSignal(repoId: string): Promise<HealthScoreBreakdown["techDebt"]> {
242 try {
243 const rows = await db
244 .select()
245 .from(repoOnboardingData)
246 .where(eq(repoOnboardingData.repositoryId, repoId))
247 .limit(1);
248
249 if (rows.length === 0) {
250 // No onboarding data — neutral
251 return { score: 15, available: false };
252 }
253
254 // Onboarding data exists but doesn't carry a debtScore field in schema v1 —
255 // give the neutral 15-pt benefit of the doubt.
256 return { score: 15, available: true };
257 } catch {
258 return { score: 15, available: false };
259 }
260}
261
262// ---------------------------------------------------------------------------
263// Core computation
264// ---------------------------------------------------------------------------
265
266export async function computeHealthScore(
267 repoId: string
268): Promise<HealthScoreBreakdown> {
269 const [ci, bf, cve, vel, debt] = await Promise.all([
270 ciGreenRate(repoId),
271 busFactorSignal(repoId),
272 openCvesSignal(repoId),
273 reviewVelocitySignal(repoId),
274 techDebtSignal(repoId),
275 ]);
276
277 const total = Math.min(
278 100,
279 ci.score + bf.score + cve.score + vel.score + debt.score
280 );
281
282 return {
283 total,
284 ciGreenRate: ci,
285 busFactor: bf,
286 openCves: cve,
287 reviewVelocity: vel,
288 techDebt: debt,
289 computedAt: new Date(),
290 };
291}
292
293// ---------------------------------------------------------------------------
294// Cached version (6h TTL)
295// ---------------------------------------------------------------------------
296
297export async function getHealthScore(
298 repoId: string
299): Promise<HealthScoreBreakdown> {
300 const now = Date.now();
301 const cached = memCache.get(repoId);
302 if (cached && cached.expiresAt > now) {
303 return cached.breakdown;
304 }
305
306 const breakdown = await computeHealthScore(repoId);
307 memCache.set(repoId, {
308 breakdown,
309 expiresAt: now + CACHE_TTL_MS,
310 });
311 return breakdown;
312}
Addedsrc/routes/ai-workspace.tsx+544−0View fileUnifiedSplit
@@ -0,0 +1,544 @@
1/**
2 * AI Copilot Workspace routes.
3 *
4 * GET /:owner/:repo/issues/:number/workspace — status page
5 * POST /:owner/:repo/issues/:number/workspace/start — start job
6 */
7
8import { Hono } from "hono";
9import { eq, and } from "drizzle-orm";
10import { db } from "../db";
11import { issues, repositories, users } from "../db/schema";
12import { Layout } from "../views/layout";
13import { softAuth, requireAuth } from "../middleware/auth";
14import { requireRepoAccess } from "../middleware/repo-access";
15import type { AuthEnv } from "../middleware/auth";
16import {
17 startWorkspace,
18 getWorkspaceJobForIssue,
19 type WorkspaceJob,
20 type WorkspaceStatus,
21} from "../lib/ai-workspace";
22import { isAiAvailable } from "../lib/ai-client";
23
24export const workspaceRoutes = new Hono<AuthEnv>();
25
26// ---------------------------------------------------------------------------
27// Styles
28// ---------------------------------------------------------------------------
29
30const wsStyles = `
31 .ws-hero {
32 position: relative;
33 margin: 4px 0 24px;
34 padding: 28px 32px;
35 background: var(--bg-elevated);
36 border: 1px solid var(--border);
37 border-radius: 16px;
38 overflow: hidden;
39 }
40 .ws-hero::before {
41 content: '';
42 position: absolute;
43 top: 0; left: 0; right: 0;
44 height: 2px;
45 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
46 opacity: 0.7;
47 pointer-events: none;
48 }
49 .ws-title {
50 font-family: var(--font-display);
51 font-size: clamp(22px, 3vw, 30px);
52 font-weight: 700;
53 letter-spacing: -0.022em;
54 color: var(--text-strong);
55 margin: 0 0 8px;
56 }
57 .ws-subtitle {
58 font-size: 15px;
59 color: var(--text-muted);
60 margin: 0;
61 line-height: 1.5;
62 }
63 .ws-stepper {
64 display: flex;
65 flex-direction: column;
66 gap: 0;
67 margin: 28px 0;
68 }
69 .ws-step {
70 display: flex;
71 align-items: flex-start;
72 gap: 14px;
73 padding: 14px 0;
74 border-left: 2px solid var(--border);
75 padding-left: 20px;
76 position: relative;
77 }
78 .ws-step:last-child {
79 border-left: 2px solid transparent;
80 }
81 .ws-step-dot {
82 position: absolute;
83 left: -7px;
84 top: 18px;
85 width: 12px;
86 height: 12px;
87 border-radius: 50%;
88 background: var(--border);
89 border: 2px solid var(--bg);
90 flex-shrink: 0;
91 transition: background 200ms;
92 }
93 .ws-step.is-done .ws-step-dot {
94 background: #34d399;
95 }
96 .ws-step.is-active .ws-step-dot {
97 background: #8c6dff;
98 box-shadow: 0 0 0 4px rgba(140,109,255,0.22);
99 animation: ws-pulse 1.4s ease-in-out infinite;
100 }
101 .ws-step.is-failed .ws-step-dot {
102 background: #f87171;
103 }
104 @keyframes ws-pulse {
105 0%, 100% { box-shadow: 0 0 0 4px rgba(140,109,255,0.22); }
106 50% { box-shadow: 0 0 0 7px rgba(140,109,255,0.10); }
107 }
108 .ws-step-label {
109 font-size: 14px;
110 font-weight: 600;
111 color: var(--text-muted);
112 line-height: 1.4;
113 padding-top: 1px;
114 }
115 .ws-step.is-done .ws-step-label { color: #34d399; }
116 .ws-step.is-active .ws-step-label { color: var(--text-strong); }
117 .ws-step.is-failed .ws-step-label { color: #f87171; }
118 .ws-step-desc {
119 font-size: 12.5px;
120 color: var(--text-muted);
121 margin-top: 2px;
122 }
123 .ws-result {
124 margin-top: 18px;
125 padding: 16px 20px;
126 border-radius: 12px;
127 font-size: 14px;
128 line-height: 1.55;
129 }
130 .ws-result.is-done {
131 background: rgba(52,211,153,0.08);
132 border: 1px solid rgba(52,211,153,0.3);
133 color: var(--text);
134 }
135 .ws-result.is-failed {
136 background: rgba(248,113,113,0.08);
137 border: 1px solid rgba(248,113,113,0.3);
138 color: var(--text);
139 }
140 .ws-pr-link {
141 display: inline-flex;
142 align-items: center;
143 gap: 6px;
144 margin-top: 12px;
145 padding: 9px 18px;
146 border-radius: 8px;
147 background: rgba(140,109,255,0.14);
148 border: 1px solid rgba(140,109,255,0.35);
149 color: var(--accent);
150 font-weight: 600;
151 text-decoration: none;
152 font-size: 13.5px;
153 transition: background 120ms;
154 }
155 .ws-pr-link:hover { background: rgba(140,109,255,0.22); text-decoration: none; }
156 .ws-start-btn {
157 display: inline-flex;
158 align-items: center;
159 gap: 8px;
160 padding: 10px 22px;
161 border-radius: 8px;
162 background: rgba(140,109,255,0.14);
163 border: 1px solid rgba(140,109,255,0.35);
164 color: var(--accent);
165 font-weight: 700;
166 font-size: 14px;
167 cursor: pointer;
168 transition: background 120ms;
169 }
170 .ws-start-btn:hover { background: rgba(140,109,255,0.22); }
171 .ws-explain {
172 margin-top: 18px;
173 padding: 14px 18px;
174 border-radius: 10px;
175 background: var(--bg-secondary);
176 border: 1px solid var(--border);
177 font-size: 13.5px;
178 color: var(--text-muted);
179 line-height: 1.6;
180 }
181 .ws-explain ul {
182 margin: 8px 0 0 18px;
183 padding: 0;
184 }
185 .ws-explain li { margin: 4px 0; }
186 .ws-back-link {
187 display: inline-flex;
188 align-items: center;
189 gap: 6px;
190 font-size: 13px;
191 color: var(--text-muted);
192 text-decoration: none;
193 margin-bottom: 18px;
194 }
195 .ws-back-link:hover { color: var(--text); text-decoration: none; }
196 .ws-badge {
197 display: inline-block;
198 padding: 2px 9px;
199 border-radius: 9999px;
200 font-size: 11px;
201 font-weight: 700;
202 letter-spacing: 0.03em;
203 text-transform: uppercase;
204 }
205 .ws-badge-active {
206 background: rgba(140,109,255,0.15);
207 color: #a78bfa;
208 border: 1px solid rgba(140,109,255,0.3);
209 }
210 .ws-badge-done {
211 background: rgba(52,211,153,0.12);
212 color: #34d399;
213 border: 1px solid rgba(52,211,153,0.3);
214 }
215 .ws-badge-failed {
216 background: rgba(248,113,113,0.12);
217 color: #f87171;
218 border: 1px solid rgba(248,113,113,0.3);
219 }
220`;
221
222// ---------------------------------------------------------------------------
223// Step definitions
224// ---------------------------------------------------------------------------
225
226const STEPS: Array<{ key: WorkspaceStatus; label: string; desc: string }> = [
227 { key: "planning", label: "Planning", desc: "Reading the issue, exploring the codebase, generating a plan" },
228 { key: "implementing", label: "Implementing", desc: "Creating a branch and applying file changes" },
229 { key: "opening_pr", label: "Opening PR", desc: "Pushing the branch and opening a draft pull request" },
230 { key: "done", label: "Done", desc: "PR is open and ready for review" },
231];
232
233const STATUS_ORDER: Record<WorkspaceStatus, number> = {
234 pending: -1,
235 planning: 0,
236 implementing: 1,
237 opening_pr: 2,
238 done: 3,
239 failed: -2,
240};
241
242function stepClass(stepStatus: WorkspaceStatus, currentStatus: WorkspaceStatus): string {
243 if (currentStatus === "failed") {
244 // All steps pending or last attempted step shows as failed — just show neutral
245 return "";
246 }
247 const stepOrder = STATUS_ORDER[stepStatus];
248 const curOrder = STATUS_ORDER[currentStatus];
249 if (stepOrder < curOrder) return "is-done";
250 if (stepOrder === curOrder) return "is-active";
251 return "";
252}
253
254// ---------------------------------------------------------------------------
255// Helpers
256// ---------------------------------------------------------------------------
257
258async function resolveIssueAndRepo(
259 ownerName: string,
260 repoName: string,
261 issueNum: number
262) {
263 const [ownerRow] = await db
264 .select({ id: users.id })
265 .from(users)
266 .where(eq(users.username, ownerName))
267 .limit(1);
268 if (!ownerRow) return null;
269
270 const [repoRow] = await db
271 .select({ id: repositories.id, name: repositories.name, isPrivate: repositories.isPrivate })
272 .from(repositories)
273 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repoName)))
274 .limit(1);
275 if (!repoRow) return null;
276
277 const [issueRow] = await db
278 .select({ id: issues.id, number: issues.number, title: issues.title, state: issues.state })
279 .from(issues)
280 .where(and(eq(issues.repositoryId, repoRow.id), eq(issues.number, issueNum)))
281 .limit(1);
282 if (!issueRow) return null;
283
284 return { owner: ownerRow, repo: repoRow, issue: issueRow };
285}
286
287// ---------------------------------------------------------------------------
288// WorkspaceStatusPage JSX component
289// ---------------------------------------------------------------------------
290
291function WorkspaceStatusPage({
292 owner,
293 repoName,
294 issue,
295 job,
296 user,
297}: {
298 owner: string;
299 repoName: string;
300 issue: { number: number; title: string };
301 job: WorkspaceJob | undefined;
302 user: { username: string } | null | undefined;
303}) {
304 const isActive =
305 job &&
306 ["pending", "planning", "implementing", "opening_pr"].includes(job.status);
307 const isDone = job?.status === "done";
308 const isFailed = job?.status === "failed";
309 const hasJob = !!job;
310
311 const issueUrl = `/${owner}/${repoName}/issues/${issue.number}`;
312 const prUrl = isDone && job.prNumber
313 ? `/${owner}/${repoName}/pulls/${job.prNumber}`
314 : null;
315 const branchUrl = job?.branchName
316 ? `/${owner}/${repoName}/tree/${job.branchName}`
317 : null;
318
319 return (
320 <Layout title={`AI Workspace — #${issue.number}`} user={user as any}>
321 {isActive && (
322 <meta http-equiv="refresh" content="3" />
323 )}
324 <style dangerouslySetInnerHTML={{ __html: wsStyles }} />
325 <div style="max-width:800px;margin:0 auto;padding:24px 16px">
326 <a href={issueUrl} class="ws-back-link">
327 ← #{issue.number}: {issue.title}
328 </a>
329
330 <div class="ws-hero">
331 <h1 class="ws-title">
332 AI Copilot Workspace
333 {isActive && (
334 <span class="ws-badge ws-badge-active" style="margin-left:12px;vertical-align:middle">
335 Running
336 </span>
337 )}
338 {isDone && (
339 <span class="ws-badge ws-badge-done" style="margin-left:12px;vertical-align:middle">
340 Done
341 </span>
342 )}
343 {isFailed && (
344 <span class="ws-badge ws-badge-failed" style="margin-left:12px;vertical-align:middle">
345 Failed
346 </span>
347 )}
348 </h1>
349 <p class="ws-subtitle">
350 Autonomous issue-to-PR agent — reads the issue, explores the codebase,
351 proposes a plan, then opens a draft pull request.
352 </p>
353 </div>
354
355 {/* If no job, show Start button */}
356 {!hasJob && (
357 <div>
358 <form method="post" action={`/${owner}/${repoName}/issues/${issue.number}/workspace/start`}>
359 <button type="submit" class="ws-start-btn">
360 Start Workspace
361 </button>
362 </form>
363 <div class="ws-explain">
364 <strong>What Gluecron will do:</strong>
365 <ul>
366 <li>Read issue #{issue.number} and recent comments to understand the task</li>
367 <li>Explore the codebase — file tree + most relevant files (Claude-selected)</li>
368 <li>Generate an implementation plan and post it as an issue comment</li>
369 <li>Create a branch, implement the changes file-by-file</li>
370 <li>Open a draft PR linked to this issue</li>
371 </ul>
372 <p style="margin:12px 0 0">
373 This usually takes 60–180 seconds depending on codebase size.
374 The page auto-refreshes while running.
375 </p>
376 </div>
377 </div>
378 )}
379
380 {/* Stepper — shown when a job exists */}
381 {hasJob && (
382 <div>
383 <div class="ws-stepper">
384 {STEPS.map((step) => {
385 const cls = isFailed
386 ? ""
387 : stepClass(step.key, job!.status);
388 return (
389 <div class={`ws-step ${cls}`}>
390 <div class="ws-step-dot" />
391 <div>
392 <div class="ws-step-label">{step.label}</div>
393 <div class="ws-step-desc">{step.desc}</div>
394 </div>
395 </div>
396 );
397 })}
398 </div>
399
400 {/* Done state */}
401 {isDone && prUrl && (
402 <div class="ws-result is-done">
403 <strong>Workspace complete!</strong> A draft PR has been opened.
404 <br />
405 {job.planComment && (
406 <p style="margin-top:8px;font-size:13px;color:var(--text-muted)">
407 An implementation plan was posted as a comment on the issue.
408 </p>
409 )}
410 <a href={prUrl} class="ws-pr-link">
411 View Draft PR #{job.prNumber}
412 </a>
413 {branchUrl && (
414 <>
415 {" "}
416 <a href={branchUrl} class="ws-pr-link" style="margin-left:8px">
417 Browse Branch
418 </a>
419 </>
420 )}
421 </div>
422 )}
423
424 {/* Failed state */}
425 {isFailed && (
426 <div class="ws-result is-failed">
427 <strong>Workspace failed.</strong>
428 {job.errorMessage && (
429 <p style="margin:8px 0 0;font-family:var(--font-mono);font-size:12px;word-break:break-all">
430 {job.errorMessage}
431 </p>
432 )}
433 <form
434 method="post"
435 action={`/${owner}/${repoName}/issues/${issue.number}/workspace/start`}
436 style="margin-top:14px"
437 >
438 <button type="submit" class="ws-start-btn">
439 Retry Workspace
440 </button>
441 </form>
442 </div>
443 )}
444
445 {/* Active state hint */}
446 {isActive && (
447 <p style="font-size:13px;color:var(--text-muted);margin-top:12px">
448 This page refreshes every 3 seconds. You can leave and come back.
449 </p>
450 )}
451 </div>
452 )}
453 </div>
454 </Layout>
455 );
456}
457
458// ---------------------------------------------------------------------------
459// GET /:owner/:repo/issues/:number/workspace
460// ---------------------------------------------------------------------------
461
462workspaceRoutes.get(
463 "/:owner/:repo/issues/:number/workspace",
464 softAuth,
465 requireAuth,
466 requireRepoAccess("read"),
467 async (c) => {
468 const { owner, repo } = c.req.param();
469 const issueNum = parseInt(c.req.param("number"), 10);
470 const user = c.get("user");
471
472 const resolved = await resolveIssueAndRepo(owner, repo, issueNum);
473 if (!resolved) {
474 return c.html(
475 <Layout title="Not Found" user={user}>
476 <div style="padding:40px;text-align:center;color:var(--text-muted)">Issue not found.</div>
477 </Layout>,
478 404
479 );
480 }
481
482 const job = getWorkspaceJobForIssue(resolved.issue.id);
483
484 return c.html(
485 <WorkspaceStatusPage
486 owner={owner}
487 repoName={repo}
488 issue={{ number: resolved.issue.number, title: resolved.issue.title }}
489 job={job}
490 user={user}
491 />
492 );
493 }
494);
495
496// ---------------------------------------------------------------------------
497// POST /:owner/:repo/issues/:number/workspace/start
498// ---------------------------------------------------------------------------
499
500workspaceRoutes.post(
501 "/:owner/:repo/issues/:number/workspace/start",
502 softAuth,
503 requireAuth,
504 requireRepoAccess("write"),
505 async (c) => {
506 const { owner, repo } = c.req.param();
507 const issueNum = parseInt(c.req.param("number"), 10);
508 const user = c.get("user")!;
509
510 if (!isAiAvailable()) {
511 return c.html(
512 <Layout title="AI unavailable" user={user}>
513 <div style="padding:40px;text-align:center;color:var(--text-muted)">
514 ANTHROPIC_API_KEY is not configured. AI Workspace requires the AI features to be enabled.
515 </div>
516 </Layout>,
517 503
518 );
519 }
520
521 const resolved = await resolveIssueAndRepo(owner, repo, issueNum);
522 if (!resolved) {
523 return c.html(
524 <Layout title="Not Found" user={user}>
525 <div style="padding:40px;text-align:center;color:var(--text-muted)">Issue not found.</div>
526 </Layout>,
527 404
528 );
529 }
530
531 await startWorkspace(
532 resolved.issue.id,
533 resolved.issue.number,
534 resolved.repo.id,
535 owner,
536 repo,
537 user.id
538 );
539
540 return c.redirect(`/${owner}/${repo}/issues/${issueNum}/workspace`);
541 }
542);
543
544export default workspaceRoutes;
Addedsrc/routes/nl-search.tsx+662−0View fileUnifiedSplit
@@ -0,0 +1,662 @@
1/**
2 * Natural Language Code Search UI
3 *
4 * GET /:owner/:repo/search/nl?q=... — search form + server-rendered results
5 *
6 * Uses Claude as the reasoner over actual file content (not embeddings).
7 * Complements the embedding-based semantic search at /search/semantic.
8 *
9 * - softAuth: public repos searchable without login
10 * - Server-side rendering: accept the wait (no client-side streaming needed)
11 * - Results link to /:owner/:repo/blob/HEAD/<filePath>#L<lineStart>
12 */
13
14import { Hono } from "hono";
15import { eq, and } from "drizzle-orm";
16import { db } from "../db";
17import { repositories, users } from "../db/schema";
18import { Layout } from "../views/layout";
19import { RepoHeader } from "../views/components";
20import { IssueNav } from "./issues";
21import { softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { nlSearch } from "../lib/nl-search";
24import { isAiAvailable } from "../lib/ai-client";
25
26const nlSearchRoutes = new Hono<AuthEnv>();
27nlSearchRoutes.use("*", softAuth);
28
29// ─── Scoped CSS (.nl-*) ──────────────────────────────────────────────────────
30const nlStyles = `
31 .nl-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
32
33 .nl-head {
34 margin-bottom: var(--space-5);
35 display: flex;
36 align-items: flex-end;
37 justify-content: space-between;
38 gap: var(--space-4);
39 flex-wrap: wrap;
40 }
41 .nl-head-text { flex: 1; min-width: 280px; }
42 .nl-eyebrow {
43 display: inline-flex;
44 align-items: center;
45 gap: 8px;
46 text-transform: uppercase;
47 font-family: var(--font-mono);
48 font-size: 11px;
49 letter-spacing: 0.16em;
50 color: var(--text-muted);
51 font-weight: 600;
52 margin-bottom: 10px;
53 }
54 .nl-eyebrow-dot {
55 width: 8px; height: 8px;
56 border-radius: 9999px;
57 background: linear-gradient(135deg, #f59e0b, #ef4444);
58 box-shadow: 0 0 0 3px rgba(245,158,11,0.18);
59 }
60 .nl-title {
61 font-family: var(--font-display);
62 font-size: clamp(24px, 3.4vw, 36px);
63 font-weight: 800;
64 letter-spacing: -0.028em;
65 line-height: 1.1;
66 margin: 0 0 6px;
67 color: var(--text-strong);
68 }
69 .nl-title-grad {
70 background-image: linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #ef4444 100%);
71 -webkit-background-clip: text;
72 background-clip: text;
73 -webkit-text-fill-color: transparent;
74 color: transparent;
75 }
76 .nl-sub {
77 margin: 0;
78 font-size: 14px;
79 color: var(--text-muted);
80 line-height: 1.5;
81 max-width: 720px;
82 }
83
84 .nl-provider {
85 display: inline-flex;
86 align-items: center;
87 gap: 6px;
88 padding: 5px 10px;
89 border-radius: 9999px;
90 font-family: var(--font-mono);
91 font-size: 11px;
92 color: var(--text-muted);
93 background: rgba(255,255,255,0.03);
94 border: 1px solid var(--border);
95 }
96 .nl-provider .dot {
97 width: 6px; height: 6px;
98 border-radius: 9999px;
99 background: linear-gradient(135deg, #f59e0b, #ef4444);
100 }
101
102 /* ─── Search bar ─── */
103 .nl-search {
104 display: flex;
105 gap: 10px;
106 align-items: stretch;
107 margin-bottom: var(--space-4);
108 }
109 .nl-search-input-wrap { position: relative; flex: 1; }
110 .nl-search-icon {
111 position: absolute;
112 top: 50%;
113 left: 14px;
114 transform: translateY(-50%);
115 color: var(--text-muted);
116 pointer-events: none;
117 }
118 .nl-search-input {
119 width: 100%;
120 box-sizing: border-box;
121 padding: 12px 14px 12px 40px;
122 font: inherit;
123 font-size: 14.5px;
124 color: var(--text);
125 background: rgba(255,255,255,0.03);
126 border: 1px solid var(--border-strong);
127 border-radius: 12px;
128 outline: none;
129 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
130 }
131 .nl-search-input:focus {
132 border-color: rgba(245,158,11,0.55);
133 background: rgba(255,255,255,0.05);
134 box-shadow: 0 0 0 3px rgba(245,158,11,0.20);
135 }
136 .nl-btn {
137 display: inline-flex;
138 align-items: center;
139 justify-content: center;
140 gap: 6px;
141 padding: 0 18px;
142 border-radius: 12px;
143 font-size: 14px;
144 font-weight: 600;
145 text-decoration: none;
146 border: 1px solid transparent;
147 cursor: pointer;
148 font: inherit;
149 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
150 line-height: 1;
151 white-space: nowrap;
152 }
153 .nl-btn-primary {
154 background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
155 color: #ffffff;
156 box-shadow: 0 6px 18px -6px rgba(245,158,11,0.55), inset 0 1px 0 rgba(255,255,255,0.16);
157 }
158 .nl-btn-primary:hover {
159 transform: translateY(-1px);
160 box-shadow: 0 10px 24px -8px rgba(245,158,11,0.65), inset 0 1px 0 rgba(255,255,255,0.20);
161 text-decoration: none;
162 color: #ffffff;
163 }
164
165 /* ─── Examples ─── */
166 .nl-examples {
167 margin-bottom: var(--space-3);
168 display: flex;
169 flex-wrap: wrap;
170 gap: 8px;
171 align-items: center;
172 }
173 .nl-examples-label {
174 font-size: 12px;
175 color: var(--text-muted);
176 font-family: var(--font-mono);
177 }
178 .nl-example-chip {
179 display: inline-flex;
180 align-items: center;
181 padding: 4px 10px;
182 border-radius: 9999px;
183 font-size: 12px;
184 color: var(--text-muted);
185 background: rgba(255,255,255,0.03);
186 border: 1px solid var(--border);
187 cursor: pointer;
188 text-decoration: none;
189 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
190 }
191 .nl-example-chip:hover {
192 border-color: rgba(245,158,11,0.45);
193 color: var(--text-strong);
194 background: rgba(245,158,11,0.06);
195 text-decoration: none;
196 }
197
198 /* ─── Status bar ─── */
199 .nl-status {
200 margin-bottom: var(--space-3);
201 padding: 9px 14px;
202 border-radius: 10px;
203 background: rgba(255,255,255,0.025);
204 border: 1px solid var(--border);
205 font-size: 12px;
206 color: var(--text-muted);
207 display: flex;
208 align-items: center;
209 justify-content: space-between;
210 gap: var(--space-3);
211 flex-wrap: wrap;
212 font-variant-numeric: tabular-nums;
213 }
214 .nl-status .num { color: var(--text-strong); font-weight: 600; }
215
216 /* ─── Result cards ─── */
217 .nl-results { display: flex; flex-direction: column; gap: 10px; }
218 .nl-result {
219 padding: 14px;
220 background: var(--bg-elevated);
221 border: 1px solid var(--border);
222 border-radius: 12px;
223 transition: border-color 120ms ease, background 120ms ease;
224 }
225 .nl-result:hover {
226 border-color: var(--border-strong);
227 background: rgba(255,255,255,0.025);
228 }
229 .nl-result-head {
230 display: flex;
231 align-items: center;
232 justify-content: space-between;
233 gap: 10px;
234 flex-wrap: wrap;
235 margin-bottom: 8px;
236 }
237 .nl-result-path {
238 font-family: var(--font-mono);
239 font-size: 13px;
240 font-weight: 600;
241 color: var(--text-strong);
242 text-decoration: none;
243 word-break: break-all;
244 letter-spacing: -0.005em;
245 }
246 .nl-result-path .lines { color: var(--text-muted); font-weight: 500; }
247 .nl-result-path:hover { color: #fcd34d; text-decoration: none; }
248 .nl-confidence {
249 display: inline-flex;
250 align-items: center;
251 gap: 5px;
252 padding: 2px 9px;
253 border-radius: 9999px;
254 font-family: var(--font-mono);
255 font-size: 11px;
256 font-weight: 600;
257 flex-shrink: 0;
258 }
259 .nl-confidence-high {
260 background: rgba(52,211,153,0.12);
261 color: #6ee7b7;
262 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
263 }
264 .nl-confidence-medium {
265 background: rgba(251,191,36,0.12);
266 color: #fcd34d;
267 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
268 }
269 .nl-confidence-low {
270 background: rgba(156,163,175,0.10);
271 color: var(--text-muted);
272 box-shadow: inset 0 0 0 1px rgba(156,163,175,0.25);
273 }
274 .nl-explanation {
275 font-size: 13px;
276 color: var(--text-muted);
277 margin: 0 0 8px;
278 line-height: 1.5;
279 }
280 .nl-snippet {
281 margin: 0;
282 padding: 10px 12px;
283 background: rgba(0,0,0,0.25);
284 border: 1px solid var(--border);
285 border-radius: 8px;
286 font-family: var(--font-mono);
287 font-size: 12px;
288 line-height: 1.55;
289 color: var(--text);
290 overflow-x: auto;
291 white-space: pre-wrap;
292 word-break: break-word;
293 }
294
295 /* ─── Banner (no AI key) ─── */
296 .nl-banner {
297 margin-bottom: var(--space-4);
298 padding: 12px 16px;
299 border-radius: 10px;
300 font-size: 13.5px;
301 border: 1px solid rgba(245,158,11,0.35);
302 background: rgba(245,158,11,0.08);
303 color: #fcd34d;
304 display: flex;
305 align-items: center;
306 gap: 10px;
307 }
308 .nl-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
309
310 /* ─── Empty state ─── */
311 .nl-empty {
312 position: relative;
313 overflow: hidden;
314 padding: clamp(28px, 5vw, 52px) clamp(20px, 4vw, 40px);
315 text-align: center;
316 background: var(--bg-elevated);
317 border: 1px dashed var(--border-strong);
318 border-radius: 16px;
319 }
320 .nl-empty-orb {
321 position: absolute;
322 inset: -40% 25% auto 25%;
323 height: 300px;
324 background: radial-gradient(circle, rgba(245,158,11,0.15), rgba(239,68,68,0.08) 45%, transparent 70%);
325 filter: blur(72px);
326 opacity: 0.7;
327 pointer-events: none;
328 z-index: 0;
329 }
330 .nl-empty-inner { position: relative; z-index: 1; }
331 .nl-empty-icon {
332 width: 56px; height: 56px;
333 margin: 0 auto 14px;
334 border-radius: 9999px;
335 background: linear-gradient(135deg, rgba(245,158,11,0.25), rgba(239,68,68,0.20));
336 box-shadow: inset 0 0 0 1px rgba(245,158,11,0.40);
337 display: inline-flex;
338 align-items: center;
339 justify-content: center;
340 color: #fcd34d;
341 }
342 .nl-empty-title {
343 font-family: var(--font-display);
344 font-size: 18px;
345 font-weight: 700;
346 margin: 0 0 6px;
347 color: var(--text-strong);
348 }
349 .nl-empty-sub {
350 margin: 0 auto;
351 font-size: 13.5px;
352 color: var(--text-muted);
353 max-width: 480px;
354 line-height: 1.5;
355 }
356
357 /* ─── Footer ─── */
358 .nl-footer {
359 margin-top: var(--space-5);
360 font-size: 12px;
361 color: var(--text-muted);
362 text-align: center;
363 font-family: var(--font-mono);
364 }
365 .nl-footer a { color: inherit; text-decoration: underline; }
366 .nl-footer a:hover { color: var(--text); }
367`;
368
369// ─── Icons ────────────────────────────────────────────────────────────────────
370
371function IconSearch() {
372 return (
373 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
374 <circle cx="11" cy="11" r="7" />
375 <line x1="21" y1="21" x2="16.65" y2="16.65" />
376 </svg>
377 );
378}
379
380function IconIntent() {
381 return (
382 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
383 <path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
384 </svg>
385 );
386}
387
388// ─── Example queries ──────────────────────────────────────────────────────────
389
390const EXAMPLE_QUERIES = [
391 "find all places that write to the DB without error handling",
392 "where do we check authentication?",
393 "where do we validate user input?",
394 "find all async functions that don't await their result",
395 "where do we catch errors but ignore them?",
396];
397
398// ─── Resolve repo helper ──────────────────────────────────────────────────────
399
400async function resolveRepo(ownerName: string, repoName: string) {
401 try {
402 const [owner] = await db
403 .select()
404 .from(users)
405 .where(eq(users.username, ownerName))
406 .limit(1);
407 if (!owner) return null;
408 const [repo] = await db
409 .select()
410 .from(repositories)
411 .where(
412 and(
413 eq(repositories.ownerId, owner.id),
414 eq(repositories.name, repoName)
415 )
416 )
417 .limit(1);
418 if (!repo) return null;
419 return { owner, repo };
420 } catch {
421 return null;
422 }
423}
424
425function NotFound({ user }: { user: typeof users.$inferSelect | null | undefined }) {
426 return (
427 <Layout title="Not Found" user={user}>
428 <div class="empty-state">
429 <h2>Repository not found</h2>
430 <p>No such repository, or you don't have access.</p>
431 </div>
432 </Layout>
433 );
434}
435
436// ─── Confidence pill ─────────────────────────────────────────────────────────
437
438function ConfidencePill({ level }: { level: "high" | "medium" | "low" }) {
439 const cls =
440 level === "high"
441 ? "nl-confidence nl-confidence-high"
442 : level === "medium"
443 ? "nl-confidence nl-confidence-medium"
444 : "nl-confidence nl-confidence-low";
445 return <span class={cls}>{level}</span>;
446}
447
448// ─── GET /:owner/:repo/search/nl ─────────────────────────────────────────────
449
450nlSearchRoutes.get("/:owner/:repo/search/nl", async (c) => {
451 const { owner: ownerName, repo: repoName } = c.req.param();
452 const user = c.get("user");
453 const q = (c.req.query("q") || "").trim();
454
455 const resolved = await resolveRepo(ownerName, repoName);
456 if (!resolved) {
457 return c.html(<NotFound user={user} />, 404);
458 }
459 const { repo } = resolved;
460
461 const aiAvailable = isAiAvailable();
462
463 // Run NL search if a query was provided and AI is available
464 let searchResult: Awaited<ReturnType<typeof nlSearch>> | null = null;
465 if (q && aiAvailable) {
466 searchResult = await nlSearch(ownerName, repoName, repo.id, q);
467 }
468
469 const results = searchResult?.results ?? [];
470 const totalFilesScanned = searchResult?.totalFilesScanned ?? 0;
471 const cached = searchResult?.cached ?? false;
472
473 return c.html(
474 <Layout title={`NL Search — ${ownerName}/${repoName}`} user={user}>
475 <RepoHeader owner={ownerName} repo={repoName} />
476 <IssueNav owner={ownerName} repo={repoName} active="code" />
477
478 <div class="nl-wrap">
479 {/* ─── Header ─── */}
480 <header class="nl-head">
481 <div class="nl-head-text">
482 <div class="nl-eyebrow">
483 <span class="nl-eyebrow-dot" aria-hidden="true" />
484 Repository · Natural Language Search
485 </div>
486 <h1 class="nl-title">
487 <span class="nl-title-grad">Search by intent, not keywords.</span>
488 </h1>
489 <p class="nl-sub">
490 Describe what you're looking for in plain English — Claude reads
491 the actual code and finds the matching places.
492 </p>
493 </div>
494 <div class="nl-provider" title="Powered by Claude Sonnet">
495 <span class="dot" aria-hidden="true" />
496 Claude Sonnet
497 </div>
498 </header>
499
500 {/* ─── No AI key warning ─── */}
501 {!aiAvailable && (
502 <div class="nl-banner" role="alert">
503 <span class="nl-banner-dot" aria-hidden="true" />
504 Natural language search requires an Anthropic API key (
505 <code>ANTHROPIC_API_KEY</code>). Set the key and restart the server
506 to enable this feature.
507 </div>
508 )}
509
510 {/* ─── Search form ─── */}
511 <form
512 method="get"
513 action={`/${ownerName}/${repoName}/search/nl`}
514 class="nl-search"
515 >
516 <div class="nl-search-input-wrap">
517 <span class="nl-search-icon" aria-hidden="true">
518 <IconSearch />
519 </span>
520 <input
521 type="search"
522 name="q"
523 value={q}
524 placeholder='e.g. "find all places that write to the DB without error handling"'
525 aria-label="Natural language search query"
526 class="nl-search-input"
527 autofocus
528 disabled={!aiAvailable}
529 />
530 </div>
531 <button type="submit" class="nl-btn nl-btn-primary" disabled={!aiAvailable}>
532 <IconIntent />
533 Search
534 </button>
535 </form>
536
537 {/* ─── Example chips ─── */}
538 {!q && aiAvailable && (
539 <div class="nl-examples">
540 <span class="nl-examples-label">Try:</span>
541 {EXAMPLE_QUERIES.map((ex) => (
542 <a
543 href={`/${ownerName}/${repoName}/search/nl?q=${encodeURIComponent(ex)}`}
544 class="nl-example-chip"
545 >
546 {ex}
547 </a>
548 ))}
549 </div>
550 )}
551
552 {/* ─── Results area ─── */}
553 {!q ? (
554 /* Empty prompt state */
555 <div class="nl-empty">
556 <div class="nl-empty-orb" aria-hidden="true" />
557 <div class="nl-empty-inner">
558 <div class="nl-empty-icon" aria-hidden="true">
559 <IconIntent />
560 </div>
561 <h3 class="nl-empty-title">Ask anything about the codebase</h3>
562 <p class="nl-empty-sub">
563 Type a natural language question above — Claude will scan the
564 repository files and return the matching code locations with
565 explanations.
566 </p>
567 </div>
568 </div>
569 ) : !aiAvailable ? (
570 /* AI unavailable */
571 <div class="nl-empty">
572 <div class="nl-empty-orb" aria-hidden="true" />
573 <div class="nl-empty-inner">
574 <div class="nl-empty-icon" aria-hidden="true">
575 <IconIntent />
576 </div>
577 <h3 class="nl-empty-title">AI not configured</h3>
578 <p class="nl-empty-sub">
579 Set <code>ANTHROPIC_API_KEY</code> to enable Claude-powered
580 natural language search.
581 </p>
582 </div>
583 </div>
584 ) : results.length === 0 ? (
585 /* No results */
586 <div class="nl-empty">
587 <div class="nl-empty-orb" aria-hidden="true" />
588 <div class="nl-empty-inner">
589 <div class="nl-empty-icon" aria-hidden="true">
590 <IconSearch />
591 </div>
592 <h3 class="nl-empty-title">No results for "{q}"</h3>
593 <p class="nl-empty-sub">
594 Claude scanned {totalFilesScanned} file
595 {totalFilesScanned === 1 ? "" : "s"} and found no matches.
596 Try rephrasing your query or using different terminology.
597 </p>
598 </div>
599 </div>
600 ) : (
601 /* Results */
602 <>
603 <div class="nl-status">
604 <span>
605 <span class="num">{results.length}</span> result
606 {results.length === 1 ? "" : "s"}
607 {" · scanned "}
608 <span class="num">{totalFilesScanned}</span> file
609 {totalFilesScanned === 1 ? "" : "s"}
610 {cached && " · cached"}
611 </span>
612 <span>powered by Claude</span>
613 </div>
614 <div class="nl-results">
615 {results.map((r) => {
616 const href = `/${ownerName}/${repoName}/blob/HEAD/${r.filePath}#L${r.lineStart}`;
617 const snippetPreview =
618 r.snippet.length > 800
619 ? r.snippet.slice(0, 800) + "\n…"
620 : r.snippet;
621 return (
622 <div class="nl-result">
623 <div class="nl-result-head">
624 <a href={href} class="nl-result-path">
625 {r.filePath}
626 <span class="lines">
627 :{r.lineStart}–{r.lineEnd}
628 </span>
629 </a>
630 <ConfidencePill level={r.confidence} />
631 </div>
632 {r.explanation && (
633 <p class="nl-explanation">{r.explanation}</p>
634 )}
635 <pre class="nl-snippet">{snippetPreview}</pre>
636 </div>
637 );
638 })}
639 </div>
640 </>
641 )}
642
643 {/* ─── Footer ─── */}
644 <div class="nl-footer">
645 Natural language search powered by{" "}
646 <a
647 href={`/${ownerName}/${repoName}/search/semantic`}
648 title="Switch to embedding-based semantic search"
649 >
650 semantic search
651 </a>{" "}
652 also available · Gluecron
653 </div>
654 </div>
655
656 <style dangerouslySetInnerHTML={{ __html: nlStyles }} />
657 </Layout>
658 );
659});
660
661export { nlSearchRoutes };
662export default nlSearchRoutes;
Addedsrc/routes/repo-health.tsx+614−0View fileUnifiedSplit
@@ -0,0 +1,614 @@
1/**
2 * Repository Health Score — /:owner/:repo/health
3 *
4 * Full breakdown page for the 0-100 composite health score.
5 *
6 * GET /:owner/:repo/health — breakdown page (softAuth, public repos visible)
7 * POST /:owner/:repo/health/recompute — invalidate cache (requireAuth + repo owner)
8 */
9
10import { Hono } from "hono";
11import { db } from "../db";
12import { repositories, users } from "../db/schema";
13import { and, eq } from "drizzle-orm";
14import type { AuthEnv } from "../middleware/auth";
15import { softAuth, requireAuth } from "../middleware/auth";
16import { requireRepoAccess } from "../middleware/repo-access";
17import { Layout } from "../views/layout";
18import { RepoHeader, RepoNav } from "../views/components";
19import { getUnreadCount } from "../lib/unread";
20import {
21 getHealthScore,
22 invalidateHealthScore,
23 type HealthScoreBreakdown,
24} from "../lib/repo-health";
25
26const repoHealthRoutes = new Hono<AuthEnv>();
27
28// ─── CSS ──────────────────────────────────────────────────────────────────────
29
30const styles = `
31 .rh-wrap {
32 max-width: 1080px;
33 margin: 0 auto;
34 padding: var(--space-5) var(--space-4);
35 }
36
37 /* Sub-navigation */
38 .rh-subnav {
39 display: flex;
40 gap: 4px;
41 margin-bottom: var(--space-5);
42 border-bottom: 1px solid var(--border);
43 padding-bottom: 0;
44 }
45 .rh-subnav-link {
46 padding: 8px 14px;
47 font-size: 13px;
48 font-weight: 500;
49 color: var(--text-muted);
50 text-decoration: none;
51 border-bottom: 2px solid transparent;
52 margin-bottom: -1px;
53 transition: color 120ms ease, border-color 120ms ease;
54 border-radius: 4px 4px 0 0;
55 }
56 .rh-subnav-link:hover { color: var(--text); }
57 .rh-subnav-link.active {
58 color: var(--accent, #5865f2);
59 border-bottom-color: var(--accent, #5865f2);
60 }
61
62 /* Hero */
63 .rh-hero {
64 position: relative;
65 margin-bottom: var(--space-5);
66 padding: var(--space-5) var(--space-6);
67 background: var(--bg-elevated);
68 border: 1px solid var(--border);
69 border-radius: 16px;
70 overflow: hidden;
71 display: flex;
72 align-items: center;
73 gap: var(--space-6);
74 flex-wrap: wrap;
75 }
76 .rh-hero::before {
77 content: '';
78 position: absolute;
79 top: 0; left: 0; right: 0;
80 height: 2px;
81 pointer-events: none;
82 opacity: 0.8;
83 }
84 .rh-hero--green::before { background: linear-gradient(90deg, transparent, #34d399, transparent); }
85 .rh-hero--yellow::before { background: linear-gradient(90deg, transparent, #facc15, transparent); }
86 .rh-hero--red::before { background: linear-gradient(90deg, transparent, #f87171, transparent); }
87
88 /* SVG gauge */
89 .rh-gauge {
90 position: relative;
91 width: 140px;
92 height: 140px;
93 flex-shrink: 0;
94 }
95 .rh-gauge-svg {
96 width: 140px;
97 height: 140px;
98 transform: rotate(-90deg);
99 }
100 .rh-gauge-track {
101 fill: none;
102 stroke: var(--border);
103 stroke-width: 12;
104 }
105 .rh-gauge-fill {
106 fill: none;
107 stroke-width: 12;
108 stroke-linecap: round;
109 transition: stroke-dashoffset 600ms ease;
110 }
111 .rh-gauge-label {
112 position: absolute;
113 inset: 0;
114 display: flex;
115 flex-direction: column;
116 align-items: center;
117 justify-content: center;
118 text-align: center;
119 }
120 .rh-gauge-score {
121 font-size: 36px;
122 font-weight: 900;
123 font-variant-numeric: tabular-nums;
124 line-height: 1;
125 color: var(--text-strong);
126 }
127 .rh-gauge-max {
128 font-size: 12px;
129 color: var(--text-muted);
130 margin-top: 2px;
131 }
132
133 /* Hero text */
134 .rh-hero-body {
135 flex: 1;
136 min-width: 200px;
137 }
138 .rh-hero-eyebrow {
139 font-size: 11px;
140 font-weight: 700;
141 letter-spacing: 0.07em;
142 text-transform: uppercase;
143 color: var(--text-muted);
144 margin-bottom: 6px;
145 }
146 .rh-hero-title {
147 font-family: var(--font-display);
148 font-size: clamp(20px, 2.5vw, 28px);
149 font-weight: 800;
150 letter-spacing: -0.02em;
151 line-height: 1.15;
152 margin: 0 0 8px;
153 color: var(--text-strong);
154 }
155 .rh-hero-sub {
156 font-size: 14px;
157 color: var(--text-muted);
158 margin: 0 0 14px;
159 line-height: 1.5;
160 }
161 .rh-hero-actions {
162 display: flex;
163 align-items: center;
164 gap: 10px;
165 flex-wrap: wrap;
166 }
167 .rh-computed-at {
168 font-size: 12px;
169 color: var(--text-muted);
170 }
171
172 /* Score badge pill */
173 .rh-score-pill {
174 display: inline-flex;
175 align-items: center;
176 gap: 5px;
177 padding: 4px 12px;
178 border-radius: 9999px;
179 font-size: 12px;
180 font-weight: 700;
181 letter-spacing: 0.04em;
182 }
183 .rh-score-pill--green { background: rgba(52,211,153,.15); color: #34d399; border: 1px solid rgba(52,211,153,.3); }
184 .rh-score-pill--yellow { background: rgba(250,204,21,.15); color: #facc15; border: 1px solid rgba(250,204,21,.3); }
185 .rh-score-pill--red { background: rgba(248,113,113,.15); color: #f87171; border: 1px solid rgba(248,113,113,.3); }
186
187 /* Recompute button */
188 .rh-recompute-btn {
189 display: inline-flex;
190 align-items: center;
191 gap: 6px;
192 padding: 8px 16px;
193 border-radius: 8px;
194 font-size: 13px;
195 font-weight: 600;
196 color: #fff;
197 background: linear-gradient(135deg, #5865f2, #8b5cf6);
198 border: none;
199 cursor: pointer;
200 transition: opacity 120ms ease;
201 }
202 .rh-recompute-btn:hover { opacity: 0.85; }
203
204 /* Section */
205 .rh-section-title {
206 font-size: 15px;
207 font-weight: 700;
208 color: var(--text-strong);
209 margin: 0 0 var(--space-3) 0;
210 }
211
212 /* Signal cards */
213 .rh-signals {
214 display: flex;
215 flex-direction: column;
216 gap: 12px;
217 margin-bottom: var(--space-5);
218 }
219 .rh-signal-card {
220 background: var(--bg-elevated);
221 border: 1px solid var(--border);
222 border-radius: 12px;
223 padding: var(--space-4) var(--space-5);
224 transition: border-color 120ms ease;
225 }
226 .rh-signal-card:hover { border-color: rgba(88,101,242,0.3); }
227
228 .rh-signal-header {
229 display: flex;
230 align-items: baseline;
231 justify-content: space-between;
232 margin-bottom: 10px;
233 gap: 8px;
234 flex-wrap: wrap;
235 }
236 .rh-signal-name {
237 font-size: 14px;
238 font-weight: 600;
239 color: var(--text-strong);
240 }
241 .rh-signal-score {
242 font-size: 13px;
243 color: var(--text-muted);
244 font-variant-numeric: tabular-nums;
245 white-space: nowrap;
246 }
247 .rh-signal-score strong {
248 font-weight: 700;
249 color: var(--text);
250 }
251
252 .rh-bar-track {
253 height: 8px;
254 background: var(--bg-tertiary, rgba(255,255,255,0.06));
255 border-radius: 4px;
256 overflow: hidden;
257 margin-bottom: 8px;
258 }
259 .rh-bar-fill {
260 height: 100%;
261 border-radius: 4px;
262 transition: width 500ms ease;
263 }
264 .rh-bar-green { background: #34d399; }
265 .rh-bar-yellow { background: #facc15; }
266 .rh-bar-red { background: #f87171; }
267 .rh-bar-blue { background: #60a5fa; }
268 .rh-bar-purple { background: #a78bfa; }
269
270 .rh-signal-detail {
271 font-size: 12px;
272 color: var(--text-muted);
273 line-height: 1.5;
274 }
275 .rh-signal-detail strong { color: var(--text); font-weight: 600; }
276`;
277
278// ─── Helpers ──────────────────────────────────────────────────────────────────
279
280function scoreColor(score: number): "green" | "yellow" | "red" {
281 if (score >= 80) return "green";
282 if (score >= 50) return "yellow";
283 return "red";
284}
285
286function gaugeProps(score: number) {
287 const r = 55;
288 const circumference = 2 * Math.PI * r;
289 const dashoffset = circumference * (1 - score / 100);
290 const color =
291 score >= 80 ? "#34d399" :
292 score >= 50 ? "#facc15" :
293 "#f87171";
294 return {
295 r,
296 cx: 70,
297 cy: 70,
298 dasharray: circumference.toFixed(2),
299 dashoffset: dashoffset.toFixed(2),
300 color,
301 };
302}
303
304function barColor(pct: number): string {
305 if (pct >= 0.75) return "rh-bar-green";
306 if (pct >= 0.45) return "rh-bar-yellow";
307 return "rh-bar-red";
308}
309
310function formatHours(h: number): string {
311 if (h < 1) return `${Math.round(h * 60)}m`;
312 if (h < 24) return `${h.toFixed(1)}h`;
313 return `${(h / 24).toFixed(1)}d`;
314}
315
316// ─── Signal card rendering helpers ───────────────────────────────────────────
317
318function CiCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
319 const { score, rate, totalRuns, passedRuns } = breakdown.ciGreenRate;
320 const pct = score / 25;
321 const detail =
322 totalRuns === 0
323 ? "No gate runs in the last 30 days — benefit of the doubt applied."
324 : `${passedRuns} of ${totalRuns} gate runs passed (${Math.round(rate * 100)}%) in the last 30 days.`;
325
326 return (
327 <div class="rh-signal-card">
328 <div class="rh-signal-header">
329 <span class="rh-signal-name">CI Green Rate</span>
330 <span class="rh-signal-score">
331 <strong>{score}</strong> / 25
332 </span>
333 </div>
334 <div class="rh-bar-track">
335 <div
336 class={`rh-bar-fill ${barColor(pct)}`}
337 style={`width:${Math.round(pct * 100)}%`}
338 />
339 </div>
340 <div class="rh-signal-detail">{detail}</div>
341 </div>
342 );
343}
344
345function BusFactorCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
346 const { score, atRiskFileCount, criticalCount } = breakdown.busFactor;
347 const pct = score / 20;
348 const detail =
349 atRiskFileCount === 0
350 ? score === 15
351 ? "No bus factor analysis available yet."
352 : "No at-risk files detected — knowledge well distributed."
353 : `${atRiskFileCount} at-risk file${atRiskFileCount !== 1 ? "s" : ""} (${criticalCount} critical). High knowledge concentration detected.`;
354
355 return (
356 <div class="rh-signal-card">
357 <div class="rh-signal-header">
358 <span class="rh-signal-name">Bus Factor</span>
359 <span class="rh-signal-score">
360 <strong>{score}</strong> / 20
361 </span>
362 </div>
363 <div class="rh-bar-track">
364 <div
365 class={`rh-bar-fill ${barColor(pct)}`}
366 style={`width:${Math.round(pct * 100)}%`}
367 />
368 </div>
369 <div class="rh-signal-detail">{detail}</div>
370 </div>
371 );
372}
373
374function CveCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
375 const { score, count } = breakdown.openCves;
376 const pct = score / 20;
377 const detail =
378 count === 0
379 ? "No open CVE alerts — dependency security looks clean."
380 : `${count} open CVE alert${count !== 1 ? "s" : ""} detected in dependencies.`;
381
382 return (
383 <div class="rh-signal-card">
384 <div class="rh-signal-header">
385 <span class="rh-signal-name">Open CVEs</span>
386 <span class="rh-signal-score">
387 <strong>{score}</strong> / 20
388 </span>
389 </div>
390 <div class="rh-bar-track">
391 <div
392 class={`rh-bar-fill ${barColor(pct)}`}
393 style={`width:${Math.round(pct * 100)}%`}
394 />
395 </div>
396 <div class="rh-signal-detail">{detail}</div>
397 </div>
398 );
399}
400
401function VelocityCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
402 const { score, avgHours, sampleSize } = breakdown.reviewVelocity;
403 const pct = score / 15;
404 let detail: string;
405 if (avgHours === null) {
406 detail = "No merged PRs with human review comments in the last 30 days.";
407 } else {
408 detail = `Average time to first review: <strong>${formatHours(avgHours)}</strong> across ${sampleSize} PR${sampleSize !== 1 ? "s" : ""} (last 30 days).`;
409 }
410
411 return (
412 <div class="rh-signal-card">
413 <div class="rh-signal-header">
414 <span class="rh-signal-name">PR Review Velocity</span>
415 <span class="rh-signal-score">
416 <strong>{score}</strong> / 15
417 </span>
418 </div>
419 <div class="rh-bar-track">
420 <div
421 class={`rh-bar-fill rh-bar-blue`}
422 style={`width:${Math.round(pct * 100)}%`}
423 />
424 </div>
425 <div
426 class="rh-signal-detail"
427 dangerouslySetInnerHTML={{ __html: detail }}
428 />
429 </div>
430 );
431}
432
433function DebtCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
434 const { score, available } = breakdown.techDebt;
435 const pct = score / 20;
436 const detail = available
437 ? "Onboarding analysis available — neutral score applied (no debt-map data yet)."
438 : "No tech debt analysis available. Neutral score applied.";
439
440 return (
441 <div class="rh-signal-card">
442 <div class="rh-signal-header">
443 <span class="rh-signal-name">Tech Debt</span>
444 <span class="rh-signal-score">
445 <strong>{score}</strong> / 20
446 </span>
447 </div>
448 <div class="rh-bar-track">
449 <div
450 class={`rh-bar-fill rh-bar-purple`}
451 style={`width:${Math.round(pct * 100)}%`}
452 />
453 </div>
454 <div class="rh-signal-detail">{detail}</div>
455 </div>
456 );
457}
458
459// ─── Route: GET /:owner/:repo/health ─────────────────────────────────────────
460
461repoHealthRoutes.use("/:owner/:repo/health", softAuth);
462
463repoHealthRoutes.get(
464 "/:owner/:repo/health",
465 requireRepoAccess("read"),
466 async (c) => {
467 const { owner, repo } = c.req.param();
468 const user = c.get("user") ?? null;
469 const repository = (
470 c.get("repository" as never) as { id: string; ownerId: string } | null
471 );
472
473 if (!repository) return c.notFound();
474
475 const repoId = repository.id;
476 const isOwner = !!user && user.id === repository.ownerId;
477
478 const [breakdown, unreadCount] = await Promise.all([
479 getHealthScore(repoId),
480 user ? getUnreadCount(user.id) : Promise.resolve(0),
481 ]);
482
483 const color = scoreColor(breakdown.total);
484 const gauge = gaugeProps(breakdown.total);
485 const computedAtStr = breakdown.computedAt.toLocaleString();
486
487 return c.html(
488 <Layout
489 title={`Health Score — ${owner}/${repo}`}
490 user={user}
491 notificationCount={unreadCount}
492 >
493 <style dangerouslySetInnerHTML={{ __html: styles }} />
494 <div class="rh-wrap">
495 <RepoHeader owner={owner} repo={repo} healthScore={breakdown.total} />
496 <RepoNav owner={owner} repo={repo} active="insights" />
497
498 {/* Sub-navigation */}
499 <nav class="rh-subnav">
500 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights`}>Overview</a>
501 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights/dora`}>DORA</a>
502 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights/velocity`}>Velocity</a>
503 <a class="rh-subnav-link" href={`/${owner}/${repo}/pulse`}>Pulse</a>
504 <a class="rh-subnav-link active" href={`/${owner}/${repo}/health`}>Health</a>
505 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights/hotfiles`}>Hot Files</a>
506 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights/bus-factor`}>Bus Factor</a>
507 </nav>
508
509 {/* Hero */}
510 <div class={`rh-hero rh-hero--${color}`}>
511 {/* SVG circle gauge */}
512 <div class="rh-gauge">
513 <svg class="rh-gauge-svg" viewBox="0 0 140 140">
514 <circle
515 class="rh-gauge-track"
516 cx={gauge.cx}
517 cy={gauge.cy}
518 r={gauge.r}
519 />
520 <circle
521 class="rh-gauge-fill"
522 cx={gauge.cx}
523 cy={gauge.cy}
524 r={gauge.r}
525 stroke={gauge.color}
526 stroke-dasharray={gauge.dasharray}
527 stroke-dashoffset={gauge.dashoffset}
528 />
529 </svg>
530 <div class="rh-gauge-label">
531 <span class="rh-gauge-score">{breakdown.total}</span>
532 <span class="rh-gauge-max">/ 100</span>
533 </div>
534 </div>
535
536 <div class="rh-hero-body">
537 <div class="rh-hero-eyebrow">Repository Intelligence</div>
538 <h1 class="rh-hero-title">Health Score</h1>
539 <p class="rh-hero-sub">
540 Composite signal across CI reliability, bus factor, CVE exposure,
541 review velocity, and tech debt for{" "}
542 <strong>{owner}/{repo}</strong>.
543 </p>
544 <div class="rh-hero-actions">
545 <span class={`rh-score-pill rh-score-pill--${color}`}>
546 {breakdown.total >= 80 ? "Healthy" : breakdown.total >= 50 ? "Fair" : "Needs Attention"}
547 </span>
548 {isOwner && (
549 <form
550 method="post"
551 action={`/${owner}/${repo}/health/recompute`}
552 style="display:inline"
553 >
554 <button type="submit" class="rh-recompute-btn">
555 ↻ Recompute
556 </button>
557 </form>
558 )}
559 <span class="rh-computed-at">
560 Computed {computedAtStr}
561 </span>
562 </div>
563 </div>
564 </div>
565
566 {/* Signal breakdown */}
567 <h2 class="rh-section-title">Signal Breakdown</h2>
568 <div class="rh-signals">
569 <CiCard breakdown={breakdown} />
570 <BusFactorCard breakdown={breakdown} />
571 <CveCard breakdown={breakdown} />
572 <VelocityCard breakdown={breakdown} />
573 <DebtCard breakdown={breakdown} />
574 </div>
575 </div>
576 </Layout>
577 );
578 }
579);
580
581// ─── Route: POST /:owner/:repo/health/recompute ───────────────────────────────
582
583repoHealthRoutes.use("/:owner/:repo/health/recompute", requireAuth);
584
585repoHealthRoutes.post(
586 "/:owner/:repo/health/recompute",
587 requireRepoAccess("write"),
588 async (c) => {
589 const { owner, repo } = c.req.param();
590 const user = c.get("user")!;
591
592 // Only repo owner may force recompute
593 const repoRows = await db
594 .select({ id: repositories.id, ownerId: repositories.ownerId })
595 .from(repositories)
596 .innerJoin(users, eq(repositories.ownerId, users.id))
597 .where(
598 and(eq(users.username, owner), eq(repositories.name, repo))
599 )
600 .limit(1);
601
602 if (!repoRows.length) return c.notFound();
603 const repository = repoRows[0];
604
605 if (user.id !== repository.ownerId) {
606 return c.text("Forbidden", 403);
607 }
608
609 invalidateHealthScore(repository.id);
610 return c.redirect(`/${owner}/${repo}/health`);
611 }
612);
613
614export default repoHealthRoutes;
Modifiedsrc/views/components.tsx+27−1View fileUnifiedSplit
@@ -28,6 +28,8 @@ export const RepoHeader: FC<{
2828 isTemplate?: boolean;
2929 /** Most recent push info for Push Watch discoverability indicator. */
3030 recentPush?: RecentPush | null;
31 /** 0-100 health score badge rendered after the repo name. Optional — omit to hide. */
32 healthScore?: number;
3133}> = ({
3234 owner,
3335 repo,
@@ -39,12 +41,19 @@ export const RepoHeader: FC<{
3941 archived,
4042 isTemplate,
4143 recentPush,
44 healthScore,
4245}) => {
4346 const FIVE_MIN = 5 * 60 * 1000;
4447 const TWENTY_FOUR_HR = 24 * 60 * 60 * 1000;
4548 const isLive = recentPush != null && recentPush.ageMs < FIVE_MIN;
4649 const isRecent = recentPush != null && recentPush.ageMs < TWENTY_FOUR_HR;
4750
51 const healthColor =
52 healthScore === undefined ? null :
53 healthScore >= 80 ? "#34d399" :
54 healthScore >= 50 ? "#facc15" :
55 "#f87171";
56
4857 return (
4958 <div class="repo-header">
5059 <div>
@@ -72,6 +81,15 @@ export const RepoHeader: FC<{
7281 Template
7382 </span>
7483 )}
84 {healthScore !== undefined && healthColor && (
85 <a
86 href={`/${owner}/${repo}/health`}
87 title={`Repository health score: ${healthScore}/100 — click for breakdown`}
88 style={`display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:9999px;font-size:11px;font-weight:700;text-decoration:none;color:${healthColor};background:${healthColor}22;border:1px solid ${healthColor}44;`}
89 >
90 ♥ Health {healthScore}
91 </a>
92 )}
7593 {isLive && recentPush && (
7694 <a
7795 href={`/${owner}/${repo}/push/${recentPush.sha}`}
@@ -153,7 +171,8 @@ export const RepoNav: FC<{
153171 | "settings"
154172 | "debt-map"
155173 | "migrate"
156 | "deployments";
174 | "deployments"
175 | "nl-search";
157176}> = ({ owner, repo, active }) => (
158177 <div class="repo-nav">
159178 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
@@ -268,6 +287,13 @@ export const RepoNav: FC<{
268287 >
269288 {"\u2593"} Debt Map
270289 </a>
290 <a
291 href={`/${owner}/${repo}/search/nl`}
292 class={`repo-nav-ai${active === "nl-search" ? " active" : ""}`}
293 title="Natural Language Search \u2014 search by intent, not keywords"
294 >
295 {"\u2728"} NL Search
296 </a>
271297 </div>
272298);
273299
274300