Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ai-explain.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

ai-explain.tsBlame520 lines · 1 contributor
3cbe3d6Claude1/**
2 * Block D6 — AI-generated "Explain this codebase" feature.
3 *
4 * Given a repo + commit sha, gathers a representative sample of files
5 * (README, manifest, top-level and main sources) and asks Claude to
6 * produce a concise Markdown overview. Results are cached per-sha in
7 * the `codebase_explanations` table so repeat views are free.
8 *
9 * Exposed functions never throw — any failure returns a safe fallback
10 * shape so the route handler can render something sensible.
11 */
12
13import { and, eq } from "drizzle-orm";
14import { db } from "../db";
15import { codebaseExplanations } from "../db/schema";
16import { getBlob, getTree } from "../git/repository";
17import type { GitTreeEntry } from "../git/repository";
18import {
19 MODEL_SONNET,
20 extractText,
21 getAnthropic,
22 isAiAvailable,
23} from "./ai-client";
24
25export interface ExplainArgs {
26 owner: string;
27 repo: string;
28 repositoryId: string;
29 commitSha: string;
30 force?: boolean;
31}
32
33export interface ExplainResult {
34 summary: string;
35 markdown: string;
36 model: string;
37 cached: boolean;
38}
39
40/** Rough budget for total characters collected from the tree. */
41const MAX_TOTAL_CHARS = 60_000;
42/** Cap on number of files sampled. */
43const MAX_FILES = 25;
44/** Skip files bigger than this before even reading them. */
45const MAX_FILE_SIZE = 32_000;
46
47const FALLBACK: ExplainResult = {
48 summary: "",
49 markdown: "_Unable to generate explanation._",
50 model: "fallback",
51 cached: false,
52};
53
54/**
55 * Read the cached explanation row for a repo + commit, if present.
56 * Returns `null` on cache miss or any DB failure.
57 */
58export async function getCachedExplanation(
59 repositoryId: string,
60 commitSha: string
61): Promise<ExplainResult | null> {
62 try {
63 const [row] = await db
64 .select()
65 .from(codebaseExplanations)
66 .where(
67 and(
68 eq(codebaseExplanations.repositoryId, repositoryId),
69 eq(codebaseExplanations.commitSha, commitSha)
70 )
71 )
72 .limit(1);
73 if (!row) return null;
74 return {
75 summary: row.summary,
76 markdown: row.markdown,
77 model: row.model,
78 cached: true,
79 };
80 } catch {
81 return null;
82 }
83}
84
85/**
86 * Produce (or return a cached) explanation for a codebase at a commit.
87 * Never throws.
88 */
89export async function explainCodebase(
90 args: ExplainArgs
91): Promise<ExplainResult> {
92 try {
93 if (!args.force) {
94 const cached = await getCachedExplanation(
95 args.repositoryId,
96 args.commitSha
97 );
98 if (cached) return cached;
99 }
100
101 // Gather a representative slice of the tree.
102 const samples = await gatherRepresentativeFiles(
103 args.owner,
104 args.repo,
105 args.commitSha
106 );
107
108 // If we couldn't read any files AND can't call Claude, there's nothing
109 // useful to say — return the canonical unable-to-generate marker so the
110 // UI can render a friendly message.
111 if (samples.files.length === 0 && !isAiAvailable()) {
112 return { ...FALLBACK };
113 }
114
115 let result: { summary: string; markdown: string; model: string };
116 if (isAiAvailable() && samples.files.length > 0) {
117 try {
118 result = await callClaude(args.owner, args.repo, samples);
119 } catch {
120 result = buildFallbackMarkdown(args.owner, args.repo, samples);
121 }
122 } else {
123 result = buildFallbackMarkdown(args.owner, args.repo, samples);
124 }
125
126 // Best-effort upsert; never let cache writes break the response.
127 try {
128 await upsertExplanation(
129 args.repositoryId,
130 args.commitSha,
131 result.summary,
132 result.markdown,
133 result.model
134 );
135 } catch {
136 /* swallow — we still return the fresh result */
137 }
138
139 return { ...result, cached: false };
140 } catch {
141 return { ...FALLBACK };
142 }
143}
144
145async function upsertExplanation(
146 repositoryId: string,
147 commitSha: string,
148 summary: string,
149 markdown: string,
150 model: string
151): Promise<void> {
152 // Try update first; if no rows, insert. Avoids needing onConflict helpers.
153 const existing = await db
154 .select({ id: codebaseExplanations.id })
155 .from(codebaseExplanations)
156 .where(
157 and(
158 eq(codebaseExplanations.repositoryId, repositoryId),
159 eq(codebaseExplanations.commitSha, commitSha)
160 )
161 )
162 .limit(1);
163 if (existing.length > 0) {
164 await db
165 .update(codebaseExplanations)
166 .set({ summary, markdown, model, generatedAt: new Date() })
167 .where(eq(codebaseExplanations.id, existing[0].id));
168 } else {
169 await db.insert(codebaseExplanations).values({
170 repositoryId,
171 commitSha,
172 summary,
173 markdown,
174 model,
175 });
176 }
177}
178
179// ---------------------------------------------------------------------------
180// Tree sampling
181// ---------------------------------------------------------------------------
182
183interface SampledFile {
184 path: string;
185 content: string;
186}
187
188interface Samples {
189 files: SampledFile[];
190 topLevelTree: GitTreeEntry[];
191 packageJson: Record<string, unknown> | null;
192}
193
194const PRIORITY_ROOT_FILES = [
195 "README.md",
196 "README",
197 "readme.md",
198 "Readme.md",
199 "README.rst",
200 "README.txt",
201 "package.json",
202 "pyproject.toml",
203 "Cargo.toml",
204 "go.mod",
205 "Gemfile",
206 "pom.xml",
207 "build.gradle",
208 "Dockerfile",
209 "tsconfig.json",
210];
211
212const CANDIDATE_SRC_DIRS = ["src", "lib", "app", "server", "backend", "pkg"];
213
214async function gatherRepresentativeFiles(
215 owner: string,
216 repo: string,
217 sha: string
218): Promise<Samples> {
219 const out: SampledFile[] = [];
220 let totalChars = 0;
221 const seen = new Set<string>();
222
223 let root: GitTreeEntry[] = [];
224 try {
225 root = await getTree(owner, repo, sha, "");
226 } catch {
227 root = [];
228 }
229
230 async function tryAdd(path: string): Promise<void> {
231 if (out.length >= MAX_FILES) return;
232 if (totalChars >= MAX_TOTAL_CHARS) return;
233 if (seen.has(path)) return;
234 seen.add(path);
235 try {
236 const blob = await getBlob(owner, repo, sha, path);
237 if (!blob || blob.isBinary) return;
238 if (!blob.content) return;
239 if (blob.size && blob.size > MAX_FILE_SIZE) {
240 const snippet = blob.content.slice(0, MAX_FILE_SIZE);
241 out.push({ path, content: snippet });
242 totalChars += snippet.length;
243 return;
244 }
245 const content = blob.content.slice(
246 0,
247 Math.max(0, MAX_TOTAL_CHARS - totalChars)
248 );
249 if (!content) return;
250 out.push({ path, content });
251 totalChars += content.length;
252 } catch {
253 /* skip file */
254 }
255 }
256
257 // 1. Root-level priority files.
258 for (const name of PRIORITY_ROOT_FILES) {
259 if (root.find((e) => e.type === "blob" && e.name === name)) {
260 await tryAdd(name);
261 }
262 }
263
264 // 2. Any other top-level config-ish blob the priority list missed.
265 for (const entry of root) {
266 if (out.length >= MAX_FILES) break;
267 if (entry.type !== "blob") continue;
268 if (seen.has(entry.name)) continue;
269 if (!looksLikeManifest(entry.name)) continue;
270 await tryAdd(entry.name);
271 }
272
273 // 3. Index/main entry files within common source directories.
274 for (const dir of CANDIDATE_SRC_DIRS) {
275 if (out.length >= MAX_FILES) break;
276 if (totalChars >= MAX_TOTAL_CHARS) break;
277 const dirEntry = root.find((e) => e.type === "tree" && e.name === dir);
278 if (!dirEntry) continue;
279 let children: GitTreeEntry[] = [];
280 try {
281 children = await getTree(owner, repo, sha, dir);
282 } catch {
283 children = [];
284 }
285 // Prefer entry-point names at top of that dir.
286 const entryNames = [
287 "index.ts",
288 "index.tsx",
289 "index.js",
290 "main.ts",
291 "main.tsx",
292 "main.js",
293 "app.ts",
294 "app.tsx",
295 "mod.rs",
296 "lib.rs",
297 "__init__.py",
298 "main.py",
299 "server.ts",
300 "server.js",
301 ];
302 for (const name of entryNames) {
303 const hit = children.find((e) => e.type === "blob" && e.name === name);
304 if (hit) await tryAdd(`${dir}/${name}`);
305 }
306 // Pull a few more source files from this directory to give context.
307 for (const child of children) {
308 if (out.length >= MAX_FILES) break;
309 if (totalChars >= MAX_TOTAL_CHARS) break;
310 if (child.type !== "blob") continue;
311 if (!isLikelySource(child.name)) continue;
312 await tryAdd(`${dir}/${child.name}`);
313 }
314 }
315
316 // 4. As a last resort, pull any remaining top-level source blobs.
317 for (const entry of root) {
318 if (out.length >= MAX_FILES) break;
319 if (totalChars >= MAX_TOTAL_CHARS) break;
320 if (entry.type !== "blob") continue;
321 if (!isLikelySource(entry.name)) continue;
322 await tryAdd(entry.name);
323 }
324
325 let pkg: Record<string, unknown> | null = null;
326 const packageFile = out.find((f) => f.path === "package.json");
327 if (packageFile) {
328 try {
329 pkg = JSON.parse(packageFile.content) as Record<string, unknown>;
330 } catch {
331 pkg = null;
332 }
333 }
334
335 return { files: out, topLevelTree: root, packageJson: pkg };
336}
337
338function looksLikeManifest(name: string): boolean {
339 const lower = name.toLowerCase();
340 return (
341 lower.endsWith(".toml") ||
342 lower.endsWith(".yaml") ||
343 lower.endsWith(".yml") ||
344 lower.endsWith(".json") ||
345 lower === "makefile" ||
346 lower === "dockerfile" ||
347 lower === "procfile"
348 );
349}
350
351function isLikelySource(name: string): boolean {
352 const lower = name.toLowerCase();
353 return (
354 lower.endsWith(".ts") ||
355 lower.endsWith(".tsx") ||
356 lower.endsWith(".js") ||
357 lower.endsWith(".jsx") ||
358 lower.endsWith(".mjs") ||
359 lower.endsWith(".cjs") ||
360 lower.endsWith(".py") ||
361 lower.endsWith(".rs") ||
362 lower.endsWith(".go") ||
363 lower.endsWith(".rb") ||
364 lower.endsWith(".java") ||
365 lower.endsWith(".kt") ||
366 lower.endsWith(".swift") ||
367 lower.endsWith(".c") ||
368 lower.endsWith(".cc") ||
369 lower.endsWith(".cpp") ||
370 lower.endsWith(".h") ||
371 lower.endsWith(".hpp")
372 );
373}
374
375// ---------------------------------------------------------------------------
376// Claude prompt + fallback
377// ---------------------------------------------------------------------------
378
379async function callClaude(
380 owner: string,
381 repo: string,
382 samples: Samples
383): Promise<{ summary: string; markdown: string; model: string }> {
384 const client = getAnthropic();
385 const treeListing = samples.topLevelTree
386 .slice(0, 80)
387 .map((e) => (e.type === "tree" ? `${e.name}/` : e.name))
388 .join("\n");
389
390 const fileBlob = samples.files
391 .map(
392 (f) =>
393 `----- FILE: ${f.path} -----\n${f.content.slice(0, 10_000)}`
394 )
395 .join("\n\n");
396
397 const prompt = `You are documenting an open-source repository named "${owner}/${repo}".
398
399Based on the top-level tree and the files below, write a concise, helpful Markdown overview for a new contributor. Use GitHub-flavoured Markdown.
400
401Start with exactly one line: a single-sentence summary of what this project is, on its own (no heading, no bold).
402
403Then the following sections, in order, each as an H2 header:
404
405## What this project does
406A short paragraph expanding on the summary. Focus on the user-visible value.
407
408## Architecture
409A short paragraph or bullet list describing how the code is organised and which pieces talk to which. Mention the language/runtime.
410
411## Key modules
412A bulleted list of the most important files or directories with a one-sentence explanation each. Use backticks for paths.
413
414## Build + run
415A short list of commands a developer would run to build and start the project, inferred from the manifest or README. If unsure, say so.
416
417Keep the whole response under ~800 words. Do not invent features that are not visible. Do not include a top-level H1.
418
419Top-level tree:
420\`\`\`
421${treeListing || "(empty)"}
422\`\`\`
423
424Representative files:
425${fileBlob}`;
426
427 const message = await client.messages.create({
428 model: MODEL_SONNET,
429 max_tokens: 2048,
430 messages: [{ role: "user", content: prompt }],
431 });
432
433 const markdown = extractText(message).trim();
434 const summary = extractSummary(markdown);
435 return { summary, markdown, model: MODEL_SONNET };
436}
437
438function extractSummary(markdown: string): string {
439 if (!markdown) return "";
440 for (const raw of markdown.split("\n")) {
441 const line = raw.trim();
442 if (!line) continue;
443 if (line.startsWith("#")) continue;
444 if (line.startsWith("```")) continue;
445 return line.replace(/^[*_>]+|[*_>]+$/g, "").slice(0, 280);
446 }
447 return "";
448}
449
450function buildFallbackMarkdown(
451 owner: string,
452 repo: string,
453 samples: Samples
454): { summary: string; markdown: string; model: string } {
455 const pkg = samples.packageJson;
456 const nameRaw = pkg && typeof pkg.name === "string" ? pkg.name : `${owner}/${repo}`;
457 const description =
458 pkg && typeof pkg.description === "string" && pkg.description.trim()
459 ? pkg.description.trim()
460 : `The ${owner}/${repo} repository.`;
461
462 const scripts =
463 pkg && pkg.scripts && typeof pkg.scripts === "object"
464 ? Object.keys(pkg.scripts as Record<string, unknown>)
465 : [];
466
467 const treeLines = samples.topLevelTree
468 .slice(0, 40)
469 .map((e) => `- \`${e.name}${e.type === "tree" ? "/" : ""}\``);
470
471 const lines: string[] = [];
472 lines.push(description);
473 lines.push("");
474 lines.push("## What this project does");
475 lines.push("");
476 lines.push(
477 pkg
478 ? `\`${nameRaw}\` — ${description}`
479 : `${description} An automatically-generated overview is shown here because no AI backend is configured.`
480 );
481 lines.push("");
482 lines.push("## Architecture");
483 lines.push("");
484 lines.push(
485 samples.topLevelTree.length > 0
486 ? "Top-level layout of the repository:"
487 : "(No files found at the default commit.)"
488 );
489 if (treeLines.length > 0) {
490 lines.push("");
491 lines.push(...treeLines);
492 }
493 lines.push("");
494 lines.push("## Key modules");
495 lines.push("");
496 if (samples.files.length === 0) {
497 lines.push("- _No representative files could be sampled._");
498 } else {
499 for (const f of samples.files.slice(0, 10)) {
500 lines.push(`- \`${f.path}\``);
501 }
502 }
503 lines.push("");
504 lines.push("## Build + run");
505 lines.push("");
506 if (scripts.length > 0) {
507 lines.push("Detected package scripts:");
508 lines.push("");
509 for (const s of scripts.slice(0, 12)) {
510 lines.push(`- \`npm run ${s}\``);
511 }
512 } else {
513 lines.push(
514 "No build scripts were detected automatically. See the README for build instructions."
515 );
516 }
517
518 const markdown = lines.join("\n");
519 return { summary: description, markdown, model: "fallback" };
520}