Commit0a69faaunknown_key
feat: Explain This Repo — one-click AI codebase analysis and onboarding guide
feat: Explain This Repo — one-click AI codebase analysis and onboarding guide Adds a rich AI-powered codebase analysis dashboard at /:owner/:repo/explain with async job tracking, structured JSON output (summary, tech stack, architecture, entry points, getting-started guide, health score, suggested issues), DB caching via repo_explain_cache table (migration 0077), and a polished dark-theme result UI. https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
5 files changed+1625−00a69faa68220570a76235ef39366d3ced8081eb3
5 changed files+1625−0
Addeddrizzle/0086_explain_cache.sql+10−0View fileUnifiedSplit
@@ -0,0 +1,10 @@
1-- Migration 0077: repo_explain_cache table
2-- Stores the structured AI analysis result (JSON) for the "Explain This Repo"
3-- feature. Keyed per-repo; replaced on regeneration.
4CREATE TABLE IF NOT EXISTS repo_explain_cache (
5 id serial PRIMARY KEY,
6 repo_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
7 result jsonb NOT NULL,
8 created_at timestamp DEFAULT now(),
9 UNIQUE(repo_id)
10);
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -109,6 +109,7 @@ import adminSecurityRoutes from "./routes/admin-security";
109109import settingsSessionsRoutes from "./routes/settings-sessions";
110110import advisoriesRoutes from "./routes/advisories";
111111import aiChangelogRoutes from "./routes/ai-changelog";
112import explainRoutes from "./routes/explain";
112113import aiExplainRoutes from "./routes/ai-explain";
113114import aiTestsRoutes from "./routes/ai-tests";
114115import askRoutes from "./routes/ask";
@@ -642,6 +643,9 @@ app.route("/", claudeWebRoutes);
642643// Note: adminOpsRoutes is mounted earlier (before insightRoutes) — see comment above.
643644app.route("/", advisoriesRoutes);
644645app.route("/", aiChangelogRoutes);
646// "Explain This Repo" — rich structured AI analysis dashboard (mounted before
647// the simpler aiExplainRoutes so the new routes win on /:owner/:repo/explain).
648app.route("/", explainRoutes);
645649app.route("/", aiExplainRoutes);
646650app.route("/", aiTestsRoutes);
647651app.route("/", askRoutes);
Modifiedsrc/db/schema.ts+16−0View fileUnifiedSplit
@@ -1688,6 +1688,22 @@ export const codeChunks = pgTable(
16881688export type CodebaseExplanation = typeof codebaseExplanations.$inferSelect;
16891689export type DepUpdateRun = typeof depUpdateRuns.$inferSelect;
16901690
1691// ---------------------------------------------------------------------------
1692// Migration 0077 — repo_explain_cache: structured AI analysis per repo
1693// ---------------------------------------------------------------------------
1694
1695export const repoExplainCache = pgTable("repo_explain_cache", {
1696 id: serial("id").primaryKey(),
1697 repoId: uuid("repo_id")
1698 .notNull()
1699 .unique()
1700 .references(() => repositories.id, { onDelete: "cascade" }),
1701 result: jsonb("result").notNull(),
1702 createdAt: timestamp("created_at").defaultNow(),
1703});
1704
1705export type RepoExplainCache = typeof repoExplainCache.$inferSelect;
1706
16911707// ---------------------------------------------------------------------------
16921708// Block E2 — Discussions (migration 0013 + 0077)
16931709// ---------------------------------------------------------------------------
Addedsrc/lib/repo-explainer.ts+672−0View fileUnifiedSplit
@@ -0,0 +1,672 @@
1/**
2 * repo-explainer.ts
3 *
4 * Core AI analysis engine for the "Explain This Repo" feature.
5 * Reads the repo's file tree + key files, then calls the Anthropic API
6 * (or falls back to heuristic analysis) to produce a structured JSON result.
7 *
8 * Never throws — all errors produce a degraded-but-valid result.
9 */
10
11import { eq, and } from "drizzle-orm";
12import { db } from "../db";
13import { repoExplainCache, repositories, users } from "../db/schema";
14import { getBlob, getTree, getDefaultBranch, resolveRef } from "../git/repository";
15import type { GitTreeEntry } from "../git/repository";
16import { config } from "./config";
17
18// ---------------------------------------------------------------------------
19// Types
20// ---------------------------------------------------------------------------
21
22export interface EntryPoint {
23 file: string;
24 role: string;
25}
26
27export interface SuggestedIssue {
28 title: string;
29 description: string;
30}
31
32export interface ExplainJobResult {
33 summary: string;
34 techStack: string[];
35 architecture: string;
36 entryPoints: EntryPoint[];
37 gettingStarted: string;
38 healthScore: "Elite" | "Strong" | "Improving" | "Needs Attention";
39 suggestedIssues: SuggestedIssue[];
40}
41
42export interface ExplainJob {
43 id: string;
44 repoId: string;
45 owner: string;
46 repo: string;
47 status: "running" | "done" | "failed";
48 result?: ExplainJobResult;
49 error?: string;
50 createdAt: Date;
51 completedAt?: Date;
52}
53
54// ---------------------------------------------------------------------------
55// In-memory job store (per-process; good enough for a single-process Bun app)
56// ---------------------------------------------------------------------------
57
58export const explainJobs = new Map<string, ExplainJob>();
59
60// ---------------------------------------------------------------------------
61// File sampling config
62// ---------------------------------------------------------------------------
63
64const MAX_TOTAL_CHARS = 80_000;
65const MAX_FILES = 50;
66const MAX_FILE_SIZE = 8_000; // chars per file (2000 lines * ~40 chars/line avg)
67
68const PRIORITY_ROOT_FILES = [
69 "README.md",
70 "README",
71 "readme.md",
72 "Readme.md",
73 "README.rst",
74 "README.txt",
75 "CLAUDE.md",
76 "package.json",
77 "pyproject.toml",
78 "Cargo.toml",
79 "go.mod",
80 "Gemfile",
81 "pom.xml",
82 "build.gradle",
83 "Dockerfile",
84 "tsconfig.json",
85 "bun.lockb",
86 ".env.example",
87];
88
89const CANDIDATE_SRC_DIRS = ["src", "lib", "app", "server", "backend", "pkg", "cmd"];
90
91// ---------------------------------------------------------------------------
92// DB cache helpers
93// ---------------------------------------------------------------------------
94
95export async function getCachedExplainResult(
96 repoId: string
97): Promise<ExplainJobResult | null> {
98 try {
99 const [row] = await db
100 .select()
101 .from(repoExplainCache)
102 .where(eq(repoExplainCache.repoId, repoId))
103 .limit(1);
104 if (!row) return null;
105 return row.result as ExplainJobResult;
106 } catch {
107 return null;
108 }
109}
110
111async function upsertExplainCache(
112 repoId: string,
113 result: ExplainJobResult
114): Promise<void> {
115 try {
116 // Try update first
117 const existing = await db
118 .select({ id: repoExplainCache.id })
119 .from(repoExplainCache)
120 .where(eq(repoExplainCache.repoId, repoId))
121 .limit(1);
122 if (existing.length > 0) {
123 await db
124 .update(repoExplainCache)
125 .set({ result, createdAt: new Date() })
126 .where(eq(repoExplainCache.id, existing[0].id));
127 } else {
128 await db.insert(repoExplainCache).values({ repoId, result });
129 }
130 } catch {
131 // Swallow — cache miss is not a fatal error
132 }
133}
134
135// ---------------------------------------------------------------------------
136// Repo resolution helpers
137// ---------------------------------------------------------------------------
138
139export async function resolveRepoForExplain(
140 owner: string,
141 repoName: string
142): Promise<{ repoId: string; ownerId: string } | null> {
143 try {
144 const [ownerRow] = await db
145 .select({ id: users.id })
146 .from(users)
147 .where(eq(users.username, owner))
148 .limit(1);
149 if (!ownerRow) return null;
150
151 const [repoRow] = await db
152 .select({ id: repositories.id, ownerId: repositories.ownerId })
153 .from(repositories)
154 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repoName)))
155 .limit(1);
156 if (!repoRow) return null;
157
158 return { repoId: repoRow.id, ownerId: repoRow.ownerId };
159 } catch {
160 return null;
161 }
162}
163
164// ---------------------------------------------------------------------------
165// Main entry point: kick off an explain job asynchronously
166// ---------------------------------------------------------------------------
167
168export function startExplainJob(
169 jobId: string,
170 owner: string,
171 repoName: string,
172 repoId: string
173): void {
174 const job: ExplainJob = {
175 id: jobId,
176 repoId,
177 owner,
178 repo: repoName,
179 status: "running",
180 createdAt: new Date(),
181 };
182 explainJobs.set(jobId, job);
183
184 // Fire-and-forget
185 runExplainJob(job, owner, repoName, repoId).catch(() => {
186 const j = explainJobs.get(jobId);
187 if (j) {
188 j.status = "failed";
189 j.error = "Unexpected error during analysis";
190 j.completedAt = new Date();
191 }
192 });
193}
194
195async function runExplainJob(
196 job: ExplainJob,
197 owner: string,
198 repoName: string,
199 repoId: string
200): Promise<void> {
201 try {
202 const result = await explainRepo(owner, repoName);
203 await upsertExplainCache(repoId, result);
204 job.result = result;
205 job.status = "done";
206 job.completedAt = new Date();
207 } catch (err) {
208 job.status = "failed";
209 job.error = err instanceof Error ? err.message : "Analysis failed";
210 job.completedAt = new Date();
211 }
212}
213
214// ---------------------------------------------------------------------------
215// Core analysis function
216// ---------------------------------------------------------------------------
217
218export async function explainRepo(
219 owner: string,
220 repoName: string
221): Promise<ExplainJobResult> {
222 const branch = await getDefaultBranch(owner, repoName);
223 if (!branch) {
224 return buildHeuristicResult(owner, repoName, null, []);
225 }
226
227 const sha = await resolveRef(owner, repoName, branch);
228 if (!sha) {
229 return buildHeuristicResult(owner, repoName, null, []);
230 }
231
232 const samples = await gatherRepresentativeFiles(owner, repoName, sha);
233
234 const apiKey = config.anthropicApiKey;
235 if (apiKey && samples.files.length > 0) {
236 try {
237 return await callAnthropicForStructuredResult(
238 owner,
239 repoName,
240 samples,
241 apiKey
242 );
243 } catch {
244 // Fall through to heuristic
245 }
246 }
247
248 return buildHeuristicResult(owner, repoName, samples.packageJson, samples.topLevelTree);
249}
250
251// ---------------------------------------------------------------------------
252// File gathering
253// ---------------------------------------------------------------------------
254
255interface SampledFile {
256 path: string;
257 content: string;
258}
259
260interface Samples {
261 files: SampledFile[];
262 topLevelTree: GitTreeEntry[];
263 packageJson: Record<string, unknown> | null;
264}
265
266async function gatherRepresentativeFiles(
267 owner: string,
268 repo: string,
269 sha: string
270): Promise<Samples> {
271 const out: SampledFile[] = [];
272 let totalChars = 0;
273 const seen = new Set<string>();
274
275 let root: GitTreeEntry[] = [];
276 try {
277 root = await getTree(owner, repo, sha, "");
278 } catch {
279 root = [];
280 }
281
282 async function tryAdd(path: string): Promise<void> {
283 if (out.length >= MAX_FILES) return;
284 if (totalChars >= MAX_TOTAL_CHARS) return;
285 if (seen.has(path)) return;
286 seen.add(path);
287 try {
288 const blob = await getBlob(owner, repo, sha, path);
289 if (!blob || blob.isBinary) return;
290 if (!blob.content) return;
291 const snippet = blob.content.slice(0, Math.min(MAX_FILE_SIZE, MAX_TOTAL_CHARS - totalChars));
292 if (!snippet) return;
293 out.push({ path, content: snippet });
294 totalChars += snippet.length;
295 } catch {
296 /* skip */
297 }
298 }
299
300 // 1. Priority root-level files
301 for (const name of PRIORITY_ROOT_FILES) {
302 if (root.find((e) => e.type === "blob" && e.name === name)) {
303 await tryAdd(name);
304 }
305 }
306
307 // 2. Other manifest/config files at root
308 for (const entry of root) {
309 if (out.length >= MAX_FILES) break;
310 if (entry.type !== "blob") continue;
311 if (seen.has(entry.name)) continue;
312 if (!looksLikeManifest(entry.name)) continue;
313 await tryAdd(entry.name);
314 }
315
316 // 3. Entry-point files in common src dirs
317 for (const dir of CANDIDATE_SRC_DIRS) {
318 if (out.length >= MAX_FILES) break;
319 if (totalChars >= MAX_TOTAL_CHARS) break;
320 const dirEntry = root.find((e) => e.type === "tree" && e.name === dir);
321 if (!dirEntry) continue;
322 let children: GitTreeEntry[] = [];
323 try {
324 children = await getTree(owner, repo, sha, dir);
325 } catch {
326 children = [];
327 }
328 const entryNames = [
329 "index.ts", "index.tsx", "index.js", "main.ts", "main.tsx", "main.js",
330 "app.ts", "app.tsx", "mod.rs", "lib.rs", "__init__.py", "main.py",
331 "server.ts", "server.js",
332 ];
333 for (const name of entryNames) {
334 const hit = children.find((e) => e.type === "blob" && e.name === name);
335 if (hit) await tryAdd(`${dir}/${name}`);
336 }
337 for (const child of children) {
338 if (out.length >= MAX_FILES) break;
339 if (totalChars >= MAX_TOTAL_CHARS) break;
340 if (child.type !== "blob") continue;
341 if (!isLikelySource(child.name)) continue;
342 await tryAdd(`${dir}/${child.name}`);
343 }
344 }
345
346 // 4. Remaining top-level source files
347 for (const entry of root) {
348 if (out.length >= MAX_FILES) break;
349 if (totalChars >= MAX_TOTAL_CHARS) break;
350 if (entry.type !== "blob") continue;
351 if (!isLikelySource(entry.name)) continue;
352 await tryAdd(entry.name);
353 }
354
355 let pkg: Record<string, unknown> | null = null;
356 const packageFile = out.find((f) => f.path === "package.json");
357 if (packageFile) {
358 try {
359 pkg = JSON.parse(packageFile.content) as Record<string, unknown>;
360 } catch {
361 pkg = null;
362 }
363 }
364
365 return { files: out, topLevelTree: root, packageJson: pkg };
366}
367
368function looksLikeManifest(name: string): boolean {
369 const lower = name.toLowerCase();
370 return (
371 lower.endsWith(".toml") ||
372 lower.endsWith(".yaml") ||
373 lower.endsWith(".yml") ||
374 lower.endsWith(".json") ||
375 lower === "makefile" ||
376 lower === "dockerfile" ||
377 lower === "procfile"
378 );
379}
380
381function isLikelySource(name: string): boolean {
382 const lower = name.toLowerCase();
383 return (
384 lower.endsWith(".ts") ||
385 lower.endsWith(".tsx") ||
386 lower.endsWith(".js") ||
387 lower.endsWith(".jsx") ||
388 lower.endsWith(".mjs") ||
389 lower.endsWith(".cjs") ||
390 lower.endsWith(".py") ||
391 lower.endsWith(".rs") ||
392 lower.endsWith(".go") ||
393 lower.endsWith(".rb") ||
394 lower.endsWith(".java") ||
395 lower.endsWith(".kt") ||
396 lower.endsWith(".swift") ||
397 lower.endsWith(".c") ||
398 lower.endsWith(".cc") ||
399 lower.endsWith(".cpp") ||
400 lower.endsWith(".h") ||
401 lower.endsWith(".hpp")
402 );
403}
404
405// ---------------------------------------------------------------------------
406// Anthropic API call — structured JSON result
407// ---------------------------------------------------------------------------
408
409async function callAnthropicForStructuredResult(
410 owner: string,
411 repoName: string,
412 samples: Samples,
413 apiKey: string
414): Promise<ExplainJobResult> {
415 const treeListing = samples.topLevelTree
416 .slice(0, 80)
417 .map((e) => (e.type === "tree" ? `${e.name}/` : e.name))
418 .join("\n");
419
420 const fileContents = samples.files
421 .map((f) => `----- FILE: ${f.path} -----\n${f.content}`)
422 .join("\n\n");
423
424 const systemPrompt = `You are a senior software architect. Analyze this codebase and return a JSON object with the following keys (no markdown wrapper, just raw JSON):
425
426{
427 "summary": "2-3 sentence plain-English overview of what this project is and does",
428 "techStack": ["array", "of", "technology", "names", "detected"],
429 "architecture": "Markdown description of the architecture — folder layout, key patterns, data flows. Use bullet points or a short diagram.",
430 "entryPoints": [
431 {"file": "src/index.ts", "role": "Server entry point — starts the HTTP server"},
432 {"file": "src/app.tsx", "role": "Hono app — middleware + route composition"}
433 ],
434 "gettingStarted": "Step-by-step Markdown guide for a new developer to get the project running locally",
435 "healthScore": "one of: Elite | Strong | Improving | Needs Attention",
436 "suggestedIssues": [
437 {"title": "Short issue title", "description": "1-2 sentence description of the improvement"},
438 {"title": "Another issue", "description": "Why this matters for the project"},
439 {"title": "Third suggestion", "description": "Concrete actionable task"}
440 ]
441}
442
443Rules:
444- techStack: include runtime, language, framework, DB, and notable libraries
445- healthScore: base on code quality signals visible in the files (tests, docs, types, CI)
446- suggestedIssues: exactly 3 items, actionable and specific to THIS codebase
447- Be specific to what you see in the code — do not invent features
448- Return ONLY the JSON object, nothing else`;
449
450 const userContent = `Repository: ${owner}/${repoName}
451
452File tree:
453\`\`\`
454${treeListing || "(empty)"}
455\`\`\`
456
457Key files:
458${fileContents}`;
459
460 const response = await fetch("https://api.anthropic.com/v1/messages", {
461 method: "POST",
462 headers: {
463 "x-api-key": apiKey,
464 "anthropic-version": "2023-06-01",
465 "content-type": "application/json",
466 },
467 body: JSON.stringify({
468 model: "claude-opus-4-8",
469 max_tokens: 4000,
470 system: systemPrompt,
471 messages: [{ role: "user", content: userContent }],
472 }),
473 });
474
475 if (!response.ok) {
476 throw new Error(`Anthropic API error: ${response.status}`);
477 }
478
479 const data = (await response.json()) as {
480 content: Array<{ type: string; text: string }>;
481 };
482
483 const text = data.content?.find((b) => b.type === "text")?.text ?? "";
484
485 // Parse JSON — handle possible ```json fences
486 let parsed: ExplainJobResult | null = null;
487 const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
488 if (fenced) {
489 try {
490 parsed = JSON.parse(fenced[1]) as ExplainJobResult;
491 } catch {
492 // fall through
493 }
494 }
495 if (!parsed) {
496 const braceMatch = text.match(/\{[\s\S]*\}/);
497 if (braceMatch) {
498 try {
499 parsed = JSON.parse(braceMatch[0]) as ExplainJobResult;
500 } catch {
501 // fall through
502 }
503 }
504 }
505
506 if (!parsed) {
507 throw new Error("Failed to parse structured JSON from AI response");
508 }
509
510 return normalizeResult(parsed, owner, repoName, samples);
511}
512
513// ---------------------------------------------------------------------------
514// Heuristic fallback (no API key or API failure)
515// ---------------------------------------------------------------------------
516
517function buildHeuristicResult(
518 owner: string,
519 repoName: string,
520 pkg: Record<string, unknown> | null,
521 topLevelTree: GitTreeEntry[]
522): ExplainJobResult {
523 const description =
524 pkg && typeof pkg.description === "string" && pkg.description.trim()
525 ? pkg.description.trim()
526 : `The ${owner}/${repoName} repository.`;
527
528 const techStack = detectTechStackHeuristic(pkg, topLevelTree);
529 const scripts =
530 pkg && pkg.scripts && typeof pkg.scripts === "object"
531 ? Object.keys(pkg.scripts as Record<string, unknown>)
532 : [];
533
534 const gettingStartedLines: string[] = [
535 "```bash",
536 "# Clone the repo",
537 `git clone <repo-url> && cd ${repoName}`,
538 "",
539 ];
540 if (scripts.includes("install") || pkg?.dependencies || pkg?.devDependencies) {
541 gettingStartedLines.push("# Install dependencies");
542 gettingStartedLines.push(pkg ? "bun install # or npm install" : "# see README");
543 gettingStartedLines.push("");
544 }
545 if (scripts.includes("dev")) {
546 gettingStartedLines.push("# Start dev server");
547 gettingStartedLines.push("bun dev");
548 } else if (scripts.includes("start")) {
549 gettingStartedLines.push("bun start");
550 } else {
551 gettingStartedLines.push("# See README for run instructions");
552 }
553 gettingStartedLines.push("```");
554
555 const entryPoints: EntryPoint[] = topLevelTree
556 .filter((e) => e.type === "blob" && isLikelySource(e.name))
557 .slice(0, 5)
558 .map((e) => ({ file: e.name, role: "Top-level source file" }));
559
560 return {
561 summary: description,
562 techStack,
563 architecture:
564 "Architecture analysis requires an `ANTHROPIC_API_KEY` to be configured. " +
565 "The heuristic scan detected the technologies listed in the Tech Stack section.\n\n" +
566 "Top-level layout:\n\n" +
567 topLevelTree
568 .slice(0, 20)
569 .map((e) => `- \`${e.name}${e.type === "tree" ? "/" : ""}\``)
570 .join("\n"),
571 entryPoints: entryPoints.length > 0 ? entryPoints : [
572 { file: "See README", role: "Entry point not auto-detected" },
573 ],
574 gettingStarted: gettingStartedLines.join("\n"),
575 healthScore: "Improving",
576 suggestedIssues: [
577 {
578 title: "Add ANTHROPIC_API_KEY for AI-powered analysis",
579 description:
580 "Set the ANTHROPIC_API_KEY environment variable to enable full AI-powered codebase analysis, architecture diagrams, and smart onboarding suggestions.",
581 },
582 {
583 title: "Add a comprehensive README",
584 description:
585 "A good README with setup steps, architecture overview, and contributing guidelines helps new developers onboard faster.",
586 },
587 {
588 title: "Add automated tests",
589 description:
590 "A test suite with good coverage makes it safe to refactor and ship faster with confidence.",
591 },
592 ],
593 };
594}
595
596function detectTechStackHeuristic(
597 pkg: Record<string, unknown> | null,
598 topLevelTree: GitTreeEntry[]
599): string[] {
600 const stack: string[] = [];
601 const names = topLevelTree.map((e) => e.name.toLowerCase());
602
603 if (names.includes("package.json") || names.includes("bun.lockb")) {
604 stack.push("TypeScript", "Node.js");
605 if (names.includes("bun.lockb")) stack.push("Bun");
606 }
607 if (names.includes("cargo.toml")) stack.push("Rust");
608 if (names.includes("go.mod")) stack.push("Go");
609 if (names.includes("pyproject.toml") || names.includes("requirements.txt")) stack.push("Python");
610 if (names.includes("gemfile")) stack.push("Ruby");
611 if (names.includes("pom.xml") || names.includes("build.gradle")) stack.push("Java");
612 if (names.includes("dockerfile")) stack.push("Docker");
613
614 if (pkg) {
615 const deps = {
616 ...(pkg.dependencies as Record<string, string> | undefined),
617 ...(pkg.devDependencies as Record<string, string> | undefined),
618 };
619 const dkeys = Object.keys(deps);
620 if (dkeys.includes("hono")) stack.push("Hono");
621 if (dkeys.includes("react") || dkeys.includes("react-dom")) stack.push("React");
622 if (dkeys.includes("next")) stack.push("Next.js");
623 if (dkeys.includes("drizzle-orm")) stack.push("Drizzle ORM");
624 if (dkeys.some((k) => k.includes("postgres") || k.includes("pg") || k.includes("neon")))
625 stack.push("PostgreSQL");
626 if (dkeys.includes("sqlite") || dkeys.includes("better-sqlite3"))
627 stack.push("SQLite");
628 if (dkeys.includes("express")) stack.push("Express");
629 if (dkeys.includes("fastify")) stack.push("Fastify");
630 if (dkeys.includes("vite")) stack.push("Vite");
631 if (dkeys.includes("tailwindcss")) stack.push("Tailwind CSS");
632 if (dkeys.some((k) => k.startsWith("@anthropic"))) stack.push("Anthropic Claude");
633 }
634
635 return [...new Set(stack)].slice(0, 12);
636}
637
638// ---------------------------------------------------------------------------
639// Normalize AI result — fill in any missing fields
640// ---------------------------------------------------------------------------
641
642function normalizeResult(
643 raw: Partial<ExplainJobResult>,
644 owner: string,
645 repoName: string,
646 samples: Samples
647): ExplainJobResult {
648 const validScores = ["Elite", "Strong", "Improving", "Needs Attention"] as const;
649
650 return {
651 summary: typeof raw.summary === "string" && raw.summary
652 ? raw.summary
653 : `The ${owner}/${repoName} repository.`,
654 techStack: Array.isArray(raw.techStack) ? raw.techStack.slice(0, 15) : [],
655 architecture: typeof raw.architecture === "string" && raw.architecture
656 ? raw.architecture
657 : "_Architecture not detected._",
658 entryPoints: Array.isArray(raw.entryPoints)
659 ? (raw.entryPoints as EntryPoint[]).slice(0, 10)
660 : [],
661 gettingStarted: typeof raw.gettingStarted === "string" && raw.gettingStarted
662 ? raw.gettingStarted
663 : "_See README for setup instructions._",
664 healthScore:
665 validScores.includes(raw.healthScore as typeof validScores[number])
666 ? (raw.healthScore as ExplainJobResult["healthScore"])
667 : "Improving",
668 suggestedIssues: Array.isArray(raw.suggestedIssues)
669 ? (raw.suggestedIssues as SuggestedIssue[]).slice(0, 3)
670 : [],
671 };
672}
Addedsrc/routes/explain.tsx+923−0View fileUnifiedSplit
@@ -0,0 +1,923 @@
1/**
2 * "Explain This Repo" — AI-powered codebase analysis dashboard.
3 *
4 * Routes:
5 * GET /:owner/:repo/explain — landing / cached result
6 * POST /:owner/:repo/explain — trigger analysis, redirect to job page
7 * GET /:owner/:repo/explain/:jobId — progress polling / result page
8 * GET /:owner/:repo/explain/:jobId/raw — JSON result
9 *
10 * Analysis runs asynchronously in the background. The result page
11 * polls with a meta-refresh every 3 seconds while status=running.
12 * Completed results are cached in `repo_explain_cache` (DB) and served
13 * directly on subsequent visits.
14 */
15
16import { Hono } from "hono";
17import { html } from "hono/html";
18import { eq, and } from "drizzle-orm";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { Layout } from "../views/layout";
22import { RepoHeader } from "../views/components";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { renderMarkdown } from "../lib/markdown";
26import {
27 explainJobs,
28 startExplainJob,
29 getCachedExplainResult,
30 resolveRepoForExplain,
31} from "../lib/repo-explainer";
32import type { ExplainJobResult, ExplainJob } from "../lib/repo-explainer";
33
34const explainRoutes = new Hono<AuthEnv>();
35
36// ---------------------------------------------------------------------------
37// Scoped CSS
38// ---------------------------------------------------------------------------
39
40const STYLES = `
41 .explain-wrap { max-width: 1040px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
42
43 /* ── Hero ── */
44 .explain-hero {
45 position: relative;
46 margin-bottom: var(--space-5);
47 padding: var(--space-5) var(--space-6);
48 background: var(--bg-elevated);
49 border: 1px solid var(--border);
50 border-radius: 16px;
51 overflow: hidden;
52 }
53 .explain-hero::before {
54 content: '';
55 position: absolute;
56 top: 0; left: 0; right: 0;
57 height: 2px;
58 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
59 opacity: 0.7;
60 pointer-events: none;
61 }
62 .explain-hero-orb {
63 position: absolute;
64 inset: -20% -10% auto auto;
65 width: 460px; height: 460px;
66 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
67 filter: blur(90px);
68 opacity: 0.7;
69 pointer-events: none;
70 z-index: 0;
71 }
72 .explain-hero-inner { position: relative; z-index: 1; }
73 .explain-eyebrow {
74 font-size: 12px;
75 color: var(--text-muted);
76 margin-bottom: var(--space-2);
77 letter-spacing: 0.02em;
78 display: inline-flex; align-items: center; gap: 8px;
79 }
80 .explain-eyebrow .pill {
81 display: inline-flex; align-items: center; justify-content: center;
82 width: 18px; height: 18px; border-radius: 6px;
83 background: rgba(140,109,255,0.14);
84 color: #b69dff;
85 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
86 }
87 .explain-title {
88 font-size: clamp(28px, 4vw, 40px);
89 font-family: var(--font-display);
90 font-weight: 800;
91 letter-spacing: -0.028em;
92 line-height: 1.05;
93 margin: 0 0 var(--space-2);
94 color: var(--text-strong);
95 }
96 .explain-title-grad {
97 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
98 -webkit-background-clip: text;
99 background-clip: text;
100 -webkit-text-fill-color: transparent;
101 color: transparent;
102 }
103 .explain-sub {
104 font-size: 15px; color: var(--text-muted);
105 margin: 0 0 var(--space-4);
106 line-height: 1.55; max-width: 620px;
107 }
108 .explain-trigger-btn {
109 display: inline-flex; align-items: center; gap: 8px;
110 padding: 11px 22px;
111 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
112 color: #fff;
113 border: 1px solid transparent;
114 border-radius: 10px;
115 font-size: 14px; font-weight: 600;
116 text-decoration: none; cursor: pointer;
117 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
118 font-family: inherit;
119 transition: transform 120ms ease, box-shadow 120ms ease;
120 }
121 .explain-trigger-btn:hover {
122 transform: translateY(-1px);
123 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
124 }
125 .explain-trigger-btn svg { display: block; }
126
127 /* ── Progress / running state ── */
128 .explain-progress {
129 display: flex; align-items: flex-start; gap: var(--space-4);
130 padding: var(--space-5) var(--space-6);
131 background: var(--bg-elevated);
132 border: 1px solid var(--border);
133 border-radius: 14px;
134 margin-bottom: var(--space-5);
135 }
136 .explain-spinner {
137 width: 36px; height: 36px; flex-shrink: 0;
138 border: 3px solid rgba(140,109,255,0.18);
139 border-top-color: #8c6dff;
140 border-radius: 50%;
141 animation: explain-spin 0.8s linear infinite;
142 }
143 @keyframes explain-spin {
144 to { transform: rotate(360deg); }
145 }
146 .explain-progress-text h3 {
147 margin: 0 0 4px;
148 font-size: 15px; font-weight: 700; color: var(--text-strong);
149 }
150 .explain-progress-text p {
151 margin: 0; font-size: 13px; color: var(--text-muted); line-height: 1.5;
152 }
153
154 /* ── Error state ── */
155 .explain-error {
156 padding: var(--space-5);
157 background: rgba(239,68,68,0.07);
158 border: 1px solid rgba(239,68,68,0.25);
159 border-radius: 14px;
160 color: #ef4444;
161 margin-bottom: var(--space-5);
162 }
163 .explain-error h3 { margin: 0 0 6px; font-size: 15px; }
164 .explain-error p { margin: 0; font-size: 13px; opacity: 0.9; }
165
166 /* ── Result dashboard ── */
167 .explain-dashboard {
168 display: grid;
169 grid-template-columns: 1fr 1fr;
170 gap: var(--space-4);
171 margin-bottom: var(--space-5);
172 }
173 @media (max-width: 680px) {
174 .explain-dashboard { grid-template-columns: 1fr; }
175 }
176
177 .explain-card {
178 background: var(--bg-elevated);
179 border: 1px solid var(--border);
180 border-radius: 14px;
181 overflow: hidden;
182 }
183 .explain-card-full { grid-column: 1 / -1; }
184 .explain-card-head {
185 display: flex; align-items: center; gap: 10px;
186 padding: 12px 16px;
187 background: var(--bg-tertiary);
188 border-bottom: 1px solid var(--border);
189 }
190 .explain-card-icon {
191 display: flex; align-items: center; justify-content: center;
192 width: 22px; height: 22px; border-radius: 6px;
193 background: rgba(140,109,255,0.12);
194 color: #b69dff;
195 flex-shrink: 0;
196 }
197 .explain-card-title {
198 font-size: 13px; font-weight: 700;
199 color: var(--text-strong);
200 margin: 0;
201 letter-spacing: -0.01em;
202 }
203 .explain-card-body { padding: 16px; }
204
205 /* Summary */
206 .explain-summary-text {
207 font-size: 14.5px; line-height: 1.65; color: var(--text);
208 margin: 0;
209 }
210
211 /* Health score */
212 .explain-health-score {
213 display: inline-flex; align-items: center; gap: 8px;
214 padding: 8px 14px;
215 border-radius: 10px;
216 font-size: 15px; font-weight: 700;
217 margin-bottom: 10px;
218 }
219 .explain-health-score.elite { background: rgba(52,211,153,0.12); color: #10b981; border: 1px solid rgba(52,211,153,0.28); }
220 .explain-health-score.strong { background: rgba(96,165,250,0.12); color: #3b82f6; border: 1px solid rgba(96,165,250,0.28); }
221 .explain-health-score.improving { background: rgba(251,191,36,0.12); color: #f59e0b; border: 1px solid rgba(251,191,36,0.28); }
222 .explain-health-score.needs-attention { background: rgba(239,68,68,0.12); color: #ef4444; border: 1px solid rgba(239,68,68,0.28); }
223 .explain-health-dot {
224 width: 8px; height: 8px; border-radius: 50%; background: currentColor;
225 }
226 .explain-health-desc { font-size: 12.5px; color: var(--text-muted); line-height: 1.5; margin: 0; }
227
228 /* Tech stack chips */
229 .explain-chips {
230 display: flex; flex-wrap: wrap; gap: 8px;
231 }
232 .explain-chip {
233 display: inline-flex; align-items: center;
234 padding: 4px 10px;
235 background: var(--bg-tertiary);
236 border: 1px solid var(--border);
237 border-radius: 9999px;
238 font-size: 12px; font-weight: 600;
239 color: var(--text);
240 }
241
242 /* Architecture — rendered markdown inside dark card */
243 .explain-arch-body {
244 font-size: 13.5px; line-height: 1.65; color: var(--text);
245 }
246 .explain-arch-body .markdown-body {
247 color: var(--text);
248 background: transparent;
249 font-size: 13.5px;
250 }
251 .explain-arch-body .markdown-body h1,
252 .explain-arch-body .markdown-body h2,
253 .explain-arch-body .markdown-body h3 {
254 color: var(--text-strong);
255 border-bottom-color: var(--border);
256 }
257 .explain-arch-body .markdown-body a { color: var(--link); }
258 .explain-arch-body .markdown-body code {
259 background: var(--bg-tertiary);
260 color: var(--text);
261 padding: 1px 5px;
262 border-radius: 4px;
263 font-family: var(--font-mono);
264 font-size: 12px;
265 }
266 .explain-arch-body .markdown-body pre {
267 background: var(--bg);
268 border: 1px solid var(--border);
269 border-radius: 8px;
270 padding: 12px 14px;
271 overflow-x: auto;
272 }
273 .explain-arch-body .markdown-body pre code {
274 background: transparent; color: inherit; padding: 0;
275 }
276
277 /* Entry points table */
278 .explain-ep-table {
279 width: 100%; border-collapse: collapse;
280 font-size: 13px;
281 }
282 .explain-ep-table th {
283 text-align: left; padding: 6px 10px;
284 font-size: 11px; font-weight: 700;
285 color: var(--text-muted);
286 text-transform: uppercase; letter-spacing: 0.04em;
287 border-bottom: 1px solid var(--border);
288 }
289 .explain-ep-table td {
290 padding: 8px 10px;
291 border-bottom: 1px solid var(--border);
292 vertical-align: top;
293 color: var(--text);
294 }
295 .explain-ep-table tr:last-child td { border-bottom: none; }
296 .explain-ep-table td:first-child {
297 font-family: var(--font-mono); font-size: 12px;
298 color: var(--text-strong);
299 white-space: nowrap;
300 }
301 .explain-ep-table a { color: var(--link); text-decoration: none; }
302 .explain-ep-table a:hover { text-decoration: underline; }
303
304 /* Getting started — rendered markdown */
305 .explain-gs-body .markdown-body {
306 color: var(--text);
307 background: transparent;
308 font-size: 13.5px;
309 line-height: 1.65;
310 }
311 .explain-gs-body .markdown-body code {
312 background: var(--bg-tertiary);
313 color: var(--text);
314 padding: 1px 5px;
315 border-radius: 4px;
316 font-family: var(--font-mono);
317 font-size: 12px;
318 }
319 .explain-gs-body .markdown-body pre {
320 background: var(--bg);
321 border: 1px solid var(--border);
322 border-radius: 8px;
323 padding: 12px 14px;
324 overflow-x: auto;
325 }
326 .explain-gs-body .markdown-body pre code {
327 background: transparent; color: inherit; padding: 0;
328 }
329
330 /* Suggested issues cards */
331 .explain-issues { display: flex; flex-direction: column; gap: 10px; }
332 .explain-issue-card {
333 padding: 12px 14px;
334 background: var(--bg);
335 border: 1px solid var(--border);
336 border-radius: 10px;
337 }
338 .explain-issue-card h4 {
339 margin: 0 0 4px;
340 font-size: 13.5px; font-weight: 700; color: var(--text-strong);
341 }
342 .explain-issue-card p {
343 margin: 0 0 10px;
344 font-size: 12.5px; color: var(--text-muted); line-height: 1.55;
345 }
346 .explain-issue-btn {
347 display: inline-flex; align-items: center; gap: 6px;
348 padding: 5px 12px;
349 background: var(--bg-elevated);
350 border: 1px solid var(--border);
351 border-radius: 8px;
352 font-size: 12px; font-weight: 600;
353 color: var(--text);
354 text-decoration: none;
355 cursor: pointer; font-family: inherit;
356 transition: background 100ms ease, border-color 100ms ease;
357 }
358 .explain-issue-btn:hover {
359 background: var(--bg-tertiary);
360 border-color: var(--border-strong, var(--border));
361 }
362
363 /* Share + actions bar */
364 .explain-actions-bar {
365 display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
366 margin-bottom: var(--space-5);
367 }
368 .explain-share-btn {
369 display: inline-flex; align-items: center; gap: 6px;
370 padding: 8px 14px;
371 background: var(--bg-elevated);
372 border: 1px solid var(--border);
373 border-radius: 10px;
374 font-size: 13px; font-weight: 600;
375 color: var(--text); text-decoration: none;
376 cursor: pointer; font-family: inherit;
377 transition: background 100ms ease;
378 }
379 .explain-share-btn:hover { background: var(--bg-tertiary); }
380
381 /* Powered-by */
382 .explain-poweredby {
383 margin-top: var(--space-5);
384 text-align: center;
385 color: var(--text-muted);
386 font-size: 11.5px;
387 }
388 .explain-poweredby-pill {
389 display: inline-flex; align-items: center; gap: 6px;
390 padding: 4px 10px;
391 border-radius: 9999px;
392 background: rgba(140,109,255,0.08);
393 border: 1px solid rgba(140,109,255,0.22);
394 color: var(--text-muted);
395 font-size: 11px; letter-spacing: 0.04em;
396 text-transform: uppercase; font-weight: 600;
397 }
398 .explain-poweredby-pill .dot {
399 width: 6px; height: 6px; border-radius: 50%;
400 background: linear-gradient(135deg, #8c6dff, #36c5d6);
401 }
402
403 /* Cached badge */
404 .explain-cached-pill {
405 display: inline-flex; align-items: center; gap: 5px;
406 padding: 2px 8px; border-radius: 9999px;
407 font-size: 10.5px; font-weight: 600; letter-spacing: 0.04em;
408 text-transform: uppercase;
409 background: rgba(52,211,153,0.12); color: #6ee7b7;
410 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
411 }
412 .explain-cached-pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
413`;
414
415// ---------------------------------------------------------------------------
416// Shared repo resolution
417// ---------------------------------------------------------------------------
418
419async function resolveRepo(owner: string, repo: string) {
420 const [ownerRow] = await db
421 .select({ id: users.id, username: users.username })
422 .from(users)
423 .where(eq(users.username, owner))
424 .limit(1);
425 if (!ownerRow) return null;
426
427 const [repoRow] = await db
428 .select({ id: repositories.id, ownerId: repositories.ownerId })
429 .from(repositories)
430 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repo)))
431 .limit(1);
432 if (!repoRow) return null;
433
434 return { repoId: repoRow.id, ownerId: repoRow.ownerId, ownerUsername: ownerRow.username };
435}
436
437// ---------------------------------------------------------------------------
438// Shared page scaffolding helpers
439// ---------------------------------------------------------------------------
440
441function HealthBadge({ score }: { score: string }) {
442 const cls = {
443 "Elite": "elite",
444 "Strong": "strong",
445 "Improving": "improving",
446 "Needs Attention": "needs-attention",
447 }[score] ?? "improving";
448 const emoji = {
449 "Elite": "★",
450 "Strong": "●",
451 "Improving": "◐",
452 "Needs Attention": "○",
453 }[score] ?? "●";
454 return (
455 <span class={`explain-health-score ${cls}`}>
456 <span class="explain-health-dot" aria-hidden="true" />
457 {emoji} {score}
458 </span>
459 );
460}
461
462function TechChips({ stack }: { stack: string[] }) {
463 return (
464 <div class="explain-chips">
465 {stack.map((t) => <span class="explain-chip">{t}</span>)}
466 </div>
467 );
468}
469
470function ResultDashboard({
471 result,
472 owner,
473 repo,
474 cached,
475}: {
476 result: ExplainJobResult;
477 owner: string;
478 repo: string;
479 cached?: boolean;
480}) {
481 const issueNewBase = `/${owner}/${repo}/issues/new`;
482 return (
483 <>
484 <div class="explain-actions-bar">
485 {cached && (
486 <span class="explain-cached-pill">
487 <span class="dot" />
488 cached
489 </span>
490 )}
491 <a
492 href={`/share/${owner}`}
493 class="explain-share-btn"
494 title="Share this analysis"
495 >
496 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
497 <circle cx="18" cy="5" r="3" />
498 <circle cx="6" cy="12" r="3" />
499 <circle cx="18" cy="19" r="3" />
500 <line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
501 <line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
502 </svg>
503 Share analysis
504 </a>
505 </div>
506
507 <div class="explain-dashboard">
508 {/* Summary */}
509 <div class="explain-card explain-card-full">
510 <div class="explain-card-head">
511 <span class="explain-card-icon" aria-hidden="true">
512 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
513 <path d="M12 20h9" /><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
514 </svg>
515 </span>
516 <p class="explain-card-title">Summary</p>
517 </div>
518 <div class="explain-card-body">
519 <p class="explain-summary-text">{result.summary}</p>
520 </div>
521 </div>
522
523 {/* Health Score */}
524 <div class="explain-card">
525 <div class="explain-card-head">
526 <span class="explain-card-icon" aria-hidden="true">
527 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
528 <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
529 </svg>
530 </span>
531 <p class="explain-card-title">Health Score</p>
532 </div>
533 <div class="explain-card-body">
534 <HealthBadge score={result.healthScore} />
535 <p class="explain-health-desc">
536 Based on code quality signals visible in the repository — tests, documentation, type coverage, and CI configuration.
537 </p>
538 </div>
539 </div>
540
541 {/* Tech Stack */}
542 <div class="explain-card">
543 <div class="explain-card-head">
544 <span class="explain-card-icon" aria-hidden="true">
545 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
546 <polygon points="12 2 2 7 12 12 22 7 12 2" />
547 <polyline points="2 17 12 22 22 17" />
548 <polyline points="2 12 12 17 22 12" />
549 </svg>
550 </span>
551 <p class="explain-card-title">Tech Stack</p>
552 </div>
553 <div class="explain-card-body">
554 {result.techStack.length > 0
555 ? <TechChips stack={result.techStack} />
556 : <p style="color:var(--text-muted);font-size:13px;margin:0">No tech stack detected.</p>
557 }
558 </div>
559 </div>
560
561 {/* Architecture */}
562 <div class="explain-card explain-card-full">
563 <div class="explain-card-head">
564 <span class="explain-card-icon" aria-hidden="true">
565 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
566 <rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" />
567 <rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
568 </svg>
569 </span>
570 <p class="explain-card-title">Architecture</p>
571 </div>
572 <div class="explain-card-body">
573 <div class="explain-arch-body">
574 <div class="markdown-body">
575 {html([renderMarkdown(result.architecture)] as unknown as TemplateStringsArray)}
576 </div>
577 </div>
578 </div>
579 </div>
580
581 {/* Entry Points */}
582 <div class="explain-card explain-card-full">
583 <div class="explain-card-head">
584 <span class="explain-card-icon" aria-hidden="true">
585 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
586 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
587 <polyline points="14 2 14 8 20 8" />
588 </svg>
589 </span>
590 <p class="explain-card-title">Key Entry Points</p>
591 </div>
592 <div class="explain-card-body">
593 {result.entryPoints.length > 0 ? (
594 <table class="explain-ep-table">
595 <thead>
596 <tr>
597 <th>File</th>
598 <th>Role</th>
599 </tr>
600 </thead>
601 <tbody>
602 {result.entryPoints.map((ep) => (
603 <tr>
604 <td>
605 <a href={`/${owner}/${repo}/blob/main/${ep.file}`}>
606 {ep.file}
607 </a>
608 </td>
609 <td>{ep.role}</td>
610 </tr>
611 ))}
612 </tbody>
613 </table>
614 ) : (
615 <p style="color:var(--text-muted);font-size:13px;margin:0">No entry points detected.</p>
616 )}
617 </div>
618 </div>
619
620 {/* Getting Started */}
621 <div class="explain-card explain-card-full">
622 <div class="explain-card-head">
623 <span class="explain-card-icon" aria-hidden="true">
624 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
625 <polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
626 </svg>
627 </span>
628 <p class="explain-card-title">Getting Started</p>
629 </div>
630 <div class="explain-card-body">
631 <div class="explain-gs-body">
632 <div class="markdown-body">
633 {html([renderMarkdown(result.gettingStarted)] as unknown as TemplateStringsArray)}
634 </div>
635 </div>
636 </div>
637 </div>
638
639 {/* Suggested Issues */}
640 <div class="explain-card explain-card-full">
641 <div class="explain-card-head">
642 <span class="explain-card-icon" aria-hidden="true">
643 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
644 <circle cx="12" cy="12" r="10" />
645 <line x1="12" y1="8" x2="12" y2="12" />
646 <line x1="12" y1="16" x2="12.01" y2="16" />
647 </svg>
648 </span>
649 <p class="explain-card-title">Suggested First Tasks</p>
650 </div>
651 <div class="explain-card-body">
652 {result.suggestedIssues.length > 0 ? (
653 <div class="explain-issues">
654 {result.suggestedIssues.map((issue) => {
655 const params = new URLSearchParams({
656 title: issue.title,
657 body: issue.description,
658 });
659 return (
660 <div class="explain-issue-card">
661 <h4>{issue.title}</h4>
662 <p>{issue.description}</p>
663 <a
664 href={`${issueNewBase}?${params.toString()}`}
665 class="explain-issue-btn"
666 >
667 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
668 <circle cx="12" cy="12" r="10" />
669 <line x1="12" y1="8" x2="12" y2="16" />
670 <line x1="8" y1="12" x2="16" y2="12" />
671 </svg>
672 Open Issue
673 </a>
674 </div>
675 );
676 })}
677 </div>
678 ) : (
679 <p style="color:var(--text-muted);font-size:13px;margin:0">No suggestions generated.</p>
680 )}
681 </div>
682 </div>
683 </div>
684 </>
685 );
686}
687
688// ---------------------------------------------------------------------------
689// Route: GET /:owner/:repo/explain — landing page or cached result
690// ---------------------------------------------------------------------------
691
692explainRoutes.get("/:owner/:repo/explain", softAuth, async (c) => {
693 const { owner, repo } = c.req.param();
694 const user = c.get("user");
695
696 const resolved = await resolveRepo(owner, repo);
697 if (!resolved) {
698 return c.html(
699 <Layout title="Not Found" user={user}>
700 <div class="container" style="padding: var(--space-6);">
701 <h2>Repository not found</h2>
702 </div>
703 </Layout>,
704 404
705 );
706 }
707
708 const cached = await getCachedExplainResult(resolved.repoId);
709 const canTrigger = !!user;
710
711 return c.html(
712 <Layout title={`Explain — ${owner}/${repo}`} user={user}>
713 <RepoHeader owner={owner} repo={repo} />
714 <div class="explain-wrap">
715 <section class="explain-hero">
716 <div class="explain-hero-orb" aria-hidden="true" />
717 <div class="explain-hero-inner">
718 <div class="explain-eyebrow">
719 <span class="pill" aria-hidden="true">
720 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
721 <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 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
722 </svg>
723 </span>
724 AI · gluecron · explain
725 </div>
726 <h1 class="explain-title">
727 <span class="explain-title-grad">Explain</span>{" "}
728 <span style="color:var(--text-strong)">{owner}/{repo}</span>
729 </h1>
730 <p class="explain-sub">
731 One click — AI reads the entire codebase and generates an architecture overview,
732 tech stack analysis, key entry points, onboarding guide, and suggested first tasks.
733 </p>
734
735 {!cached && (
736 canTrigger ? (
737 <form method="post" action={`/${owner}/${repo}/explain`} style="display:inline">
738 <button type="submit" class="explain-trigger-btn">
739 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
740 <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 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
741 </svg>
742 Explain This Repo
743 </button>
744 </form>
745 ) : (
746 <a href="/login" class="explain-trigger-btn">
747 Sign in to explain this repo
748 </a>
749 )
750 )}
751
752 {cached && (
753 <form method="post" action={`/${owner}/${repo}/explain`} style="display:inline">
754 <button type="submit" class="explain-trigger-btn" style="background:var(--bg-elevated);color:var(--text);border:1px solid var(--border);box-shadow:none">
755 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
756 <polyline points="23 4 23 10 17 10" />
757 <polyline points="1 20 1 14 7 14" />
758 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
759 </svg>
760 Regenerate
761 </button>
762 </form>
763 )}
764 </div>
765 </section>
766
767 {cached ? (
768 <ResultDashboard result={cached} owner={owner} repo={repo} cached />
769 ) : (
770 <div style="padding:var(--space-6);text-align:center;color:var(--text-muted);font-size:14px;">
771 No analysis yet. Click "Explain This Repo" to generate one.
772 </div>
773 )}
774
775 <div class="explain-poweredby">
776 <span class="explain-poweredby-pill">
777 <span class="dot" aria-hidden="true" />
778 Powered by Claude
779 </span>
780 </div>
781 </div>
782 <style dangerouslySetInnerHTML={{ __html: STYLES }} />
783 </Layout>
784 );
785});
786
787// ---------------------------------------------------------------------------
788// Route: POST /:owner/:repo/explain — trigger analysis
789// ---------------------------------------------------------------------------
790
791explainRoutes.post("/:owner/:repo/explain", softAuth, async (c) => {
792 const { owner, repo } = c.req.param();
793 const user = c.get("user");
794
795 if (!user) {
796 return c.redirect(`/login`);
797 }
798
799 const resolved = await resolveRepo(owner, repo);
800 if (!resolved) return c.notFound();
801
802 const jobId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
803 startExplainJob(jobId, owner, repo, resolved.repoId);
804
805 return c.redirect(`/${owner}/${repo}/explain/${jobId}`);
806});
807
808// ---------------------------------------------------------------------------
809// Route: GET /:owner/:repo/explain/:jobId/raw — JSON result
810// ---------------------------------------------------------------------------
811
812explainRoutes.get("/:owner/:repo/explain/:jobId/raw", async (c) => {
813 const { jobId } = c.req.param();
814 const job = explainJobs.get(jobId);
815 if (!job) return c.json({ error: "Job not found" }, 404);
816 return c.json(job);
817});
818
819// ---------------------------------------------------------------------------
820// Route: GET /:owner/:repo/explain/:jobId — progress / result page
821// ---------------------------------------------------------------------------
822
823explainRoutes.get("/:owner/:repo/explain/:jobId", softAuth, async (c) => {
824 const { owner, repo, jobId } = c.req.param();
825 const user = c.get("user");
826
827 const job = explainJobs.get(jobId);
828 if (!job) {
829 return c.html(
830 <Layout title="Job not found" user={user}>
831 <div class="container" style="padding:var(--space-6)">
832 <h2>Analysis job not found</h2>
833 <p>
834 The job may have expired. <a href={`/${owner}/${repo}/explain`}>Start a new analysis</a>.
835 </p>
836 </div>
837 </Layout>,
838 404
839 );
840 }
841
842 const isRunning = job.status === "running";
843 const isFailed = job.status === "failed";
844 const isDone = job.status === "done";
845
846 return c.html(
847 <Layout title={`Explain — ${owner}/${repo}`} user={user}>
848 {/* Auto-refresh while running */}
849 {isRunning && (
850 <meta http-equiv="refresh" content={`3;url=/${owner}/${repo}/explain/${jobId}`} />
851 )}
852 <RepoHeader owner={owner} repo={repo} />
853 <div class="explain-wrap">
854 <section class="explain-hero">
855 <div class="explain-hero-orb" aria-hidden="true" />
856 <div class="explain-hero-inner">
857 <div class="explain-eyebrow">
858 <span class="pill" aria-hidden="true">
859 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
860 <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 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
861 </svg>
862 </span>
863 AI · gluecron · explain
864 </div>
865 <h1 class="explain-title">
866 <span class="explain-title-grad">Explain</span>{" "}
867 <span style="color:var(--text-strong)">{owner}/{repo}</span>
868 </h1>
869 </div>
870 </section>
871
872 {isRunning && (
873 <div class="explain-progress">
874 <div class="explain-spinner" aria-label="Analyzing…" />
875 <div class="explain-progress-text">
876 <h3>Analyzing codebase…</h3>
877 <p>
878 Claude is reading the file tree and key source files.
879 This usually takes 10–30 seconds. This page refreshes automatically.
880 </p>
881 </div>
882 </div>
883 )}
884
885 {isFailed && (
886 <div class="explain-error">
887 <h3>Analysis failed</h3>
888 <p>{job.error ?? "An unexpected error occurred. Please try again."}</p>
889 </div>
890 )}
891
892 {isDone && job.result && (
893 <ResultDashboard result={job.result} owner={owner} repo={repo} />
894 )}
895
896 <div style="margin-top:var(--space-4);font-size:13px;color:var(--text-muted);">
897 <a href={`/${owner}/${repo}/explain`} style="color:var(--link)">
898 ← Back to explain page
899 </a>
900 {" · "}
901 <a
902 href={`/${owner}/${repo}/explain/${jobId}/raw`}
903 style="color:var(--link)"
904 target="_blank"
905 rel="noopener noreferrer"
906 >
907 View raw JSON
908 </a>
909 </div>
910
911 <div class="explain-poweredby">
912 <span class="explain-poweredby-pill">
913 <span class="dot" aria-hidden="true" />
914 Powered by Claude
915 </span>
916 </div>
917 </div>
918 <style dangerouslySetInnerHTML={{ __html: STYLES }} />
919 </Layout>
920 );
921});
922
923export default explainRoutes;
0924