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-archaeology.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-archaeology.tsBlame487 lines · 1 contributor
53299fbClaude1/**
2 * AI Code Archaeology — excavate the "why" behind any file.
3 *
4 * Given a file path in a repository, searches git history, PRs, and issues
5 * to reconstruct the reasoning and original motivation behind the code.
6 *
7 * Uses a 30-minute in-memory cache keyed on `${repoId}:${filePath}`.
8 * The query does not affect the cache key — same file, same archaeology.
9 */
10
11import { basename } from "path";
12import { eq, and, desc, ilike, or } from "drizzle-orm";
13import { db } from "../db";
14import { pullRequests, prComments, issues, issueComments } from "../db/schema";
15import { getRepoPath, getBlob } from "../git/repository";
16import {
17 getAnthropic,
18 isAiAvailable,
19 extractText,
20 MODEL_SONNET,
21} from "./ai-client";
22
23// ---------------------------------------------------------------------------
24// Public types
25// ---------------------------------------------------------------------------
26
27export interface ArchaeologyFinding {
28 type: "commit" | "pr" | "issue";
29 id: string; // commit sha, pr number (string), or issue number (string)
30 title: string;
31 summary: string; // 1-2 sentences explaining relevance
32 date: string; // ISO string
33 url: string; // relative URL e.g. /owner/repo/commit/sha
34 author: string;
35}
36
37export interface ArchaeologyReport {
38 filePath: string;
39 query: string; // the "why" question asked
40 explanation: string; // Claude's synthesized answer (markdown)
41 findings: ArchaeologyFinding[];
42 confidence: "high" | "medium" | "low";
43 analyzedAt: Date;
44}
45
46// ---------------------------------------------------------------------------
47// In-memory cache (30-min TTL, keyed on repoId:filePath)
48// ---------------------------------------------------------------------------
49
50interface CacheEntry {
51 report: ArchaeologyReport;
52 expiresAt: number;
53}
54
55const cache = new Map<string, CacheEntry>();
56const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
57
58function getCached(repoId: string, filePath: string): ArchaeologyReport | null {
59 const key = `${repoId}:${filePath}`;
60 const entry = cache.get(key);
61 if (!entry) return null;
62 if (Date.now() > entry.expiresAt) {
63 cache.delete(key);
64 return null;
65 }
66 return entry.report;
67}
68
69function setCached(repoId: string, filePath: string, report: ArchaeologyReport): void {
70 const key = `${repoId}:${filePath}`;
71 cache.set(key, { report, expiresAt: Date.now() + CACHE_TTL_MS });
72}
73
74export function invalidateCache(repoId: string, filePath: string): void {
75 const key = `${repoId}:${filePath}`;
76 cache.delete(key);
77}
78
79// ---------------------------------------------------------------------------
80// Git helpers (run in the repo's bare git dir)
81// ---------------------------------------------------------------------------
82
83interface GitLogEntry {
84 sha: string;
85 message: string;
86 author: string;
87 date: string;
88}
89
90async function gitExec(
91 cmd: string[],
92 cwd: string
93): Promise<{ stdout: string; exitCode: number }> {
94 const proc = Bun.spawn(cmd, {
95 cwd,
96 env: process.env as Record<string, string>,
97 stdout: "pipe",
98 stderr: "pipe",
99 });
100 const stdout = await new Response(proc.stdout).text();
101 const exitCode = await proc.exited;
102 return { stdout, exitCode };
103}
104
105/**
106 * Step 1 — Run `git log --follow --oneline --max-count=20 -- <filePath>`
107 * and also grab author + date via a custom format. Cap at 20 commits.
108 */
109async function getFileGitLog(
110 repoPath: string,
111 filePath: string
112): Promise<GitLogEntry[]> {
113 try {
114 // Use null-delimiter format: sha%x00message%x00author%x00date
115 const { stdout, exitCode } = await gitExec(
116 [
117 "git",
118 "log",
119 "--follow",
120 "--format=%H%x00%s%x00%an%x00%aI",
121 "--max-count=20",
122 "--",
123 filePath,
124 ],
125 repoPath
126 );
127 if (exitCode !== 0) return [];
128 return stdout
129 .trim()
130 .split("\n")
131 .filter(Boolean)
132 .map((line) => {
133 const [sha, message, author, date] = line.split("\0");
134 return { sha, message: message || "(no message)", author: author || "unknown", date: date || "" };
135 });
136 } catch {
137 return [];
138 }
139}
140
141// ---------------------------------------------------------------------------
142// DB helpers
143// ---------------------------------------------------------------------------
144
145interface PrRecord {
146 number: number;
147 title: string;
148 body: string | null;
149 createdAt: Date;
150 comments: Array<{ body: string }>;
151}
152
153interface IssueRecord {
154 number: number;
155 title: string;
156 body: string | null;
157 createdAt: Date;
158 comments: Array<{ body: string }>;
159}
160
161async function findRelatedPRs(
162 repoId: string,
163 fileName: string
164): Promise<PrRecord[]> {
165 try {
166 const pattern = `%${fileName}%`;
167 const prs = await db
168 .select({
169 id: pullRequests.id,
170 number: pullRequests.number,
171 title: pullRequests.title,
172 body: pullRequests.body,
173 createdAt: pullRequests.createdAt,
174 })
175 .from(pullRequests)
176 .where(
177 and(
178 eq(pullRequests.repositoryId, repoId),
179 or(
180 ilike(pullRequests.title, pattern),
181 ilike(pullRequests.body, pattern)
182 )
183 )
184 )
185 .orderBy(desc(pullRequests.createdAt))
186 .limit(5);
187
188 const results: PrRecord[] = [];
189 for (const pr of prs) {
190 const comments = await db
191 .select({ body: prComments.body })
192 .from(prComments)
193 .where(
194 and(
195 eq(prComments.pullRequestId, pr.id),
196 eq(prComments.isAiReview, false)
197 )
198 )
199 .orderBy(desc(prComments.createdAt))
200 .limit(3);
201
202 results.push({
203 number: pr.number,
204 title: pr.title,
205 body: pr.body,
206 createdAt: pr.createdAt,
207 comments: comments.map((c) => ({
208 body: c.body.slice(0, 500),
209 })),
210 });
211 }
212 return results;
213 } catch {
214 return [];
215 }
216}
217
218async function findRelatedIssues(
219 repoId: string,
220 fileName: string
221): Promise<IssueRecord[]> {
222 try {
223 const pattern = `%${fileName}%`;
224 const found = await db
225 .select({
226 id: issues.id,
227 number: issues.number,
228 title: issues.title,
229 body: issues.body,
230 createdAt: issues.createdAt,
231 })
232 .from(issues)
233 .where(
234 and(
235 eq(issues.repositoryId, repoId),
236 or(
237 ilike(issues.title, pattern),
238 ilike(issues.body, pattern)
239 )
240 )
241 )
242 .orderBy(desc(issues.createdAt))
243 .limit(5);
244
245 const results: IssueRecord[] = [];
246 for (const issue of found) {
247 const comments = await db
248 .select({ body: issueComments.body })
249 .from(issueComments)
250 .where(eq(issueComments.issueId, issue.id))
251 .orderBy(desc(issueComments.createdAt))
252 .limit(2);
253
254 results.push({
255 number: issue.number,
256 title: issue.title,
257 body: issue.body,
258 createdAt: issue.createdAt,
259 comments: comments.map((c) => ({
260 body: c.body.slice(0, 300),
261 })),
262 });
263 }
264 return results;
265 } catch {
266 return [];
267 }
268}
269
270// ---------------------------------------------------------------------------
271// Confidence heuristic
272// ---------------------------------------------------------------------------
273
274function deriveConfidence(
275 commits: GitLogEntry[],
276 prs: PrRecord[],
277 issues: IssueRecord[]
278): "high" | "medium" | "low" {
279 const total = commits.length + prs.length + issues.length;
280 if (total >= 8) return "high";
281 if (total >= 3) return "medium";
282 return "low";
283}
284
285// ---------------------------------------------------------------------------
286// Main excavate function
287// ---------------------------------------------------------------------------
288
289export async function excavate(
290 ownerName: string,
291 repoName: string,
292 repoId: string,
293 filePath: string,
294 query: string
295): Promise<ArchaeologyReport> {
296 // Check cache first
297 const cached = getCached(repoId, filePath);
298 if (cached) {
299 // Return cached with updated query
300 return { ...cached, query };
301 }
302
303 // Guard: AI not available
304 if (!isAiAvailable()) {
305 const report: ArchaeologyReport = {
306 filePath,
307 query,
308 explanation: "ANTHROPIC_API_KEY not set — AI archaeology is unavailable.",
309 findings: [],
310 confidence: "low",
311 analyzedAt: new Date(),
312 };
313 return report;
314 }
315
316 try {
317 const repoPath = getRepoPath(ownerName, repoName);
318 const fileName = basename(filePath);
319
320 // Step 1: Git log for this file
321 const commits = await getFileGitLog(repoPath, filePath);
322
323 // Step 2: Load current file content (cap at 10KB)
324 let fileContent = "";
325 try {
326 const blob = await getBlob(ownerName, repoName, "HEAD", filePath);
327 if (blob && !blob.isBinary) {
328 fileContent = blob.content.slice(0, 10 * 1024);
329 }
330 } catch {
331 // file may not exist — continue
332 }
333
334 // First 50 lines of file content for Claude
335 const first50Lines = fileContent
336 .split("\n")
337 .slice(0, 50)
338 .join("\n");
339
340 // Step 3: Related PRs
341 const prs = await findRelatedPRs(repoId, fileName);
342
343 // Step 4: Related issues
344 const relatedIssues = await findRelatedIssues(repoId, fileName);
345
346 // Step 5: Claude synthesis
347 const gitLogText = commits.length > 0
348 ? commits
349 .map(
350 (c) =>
351 `- ${c.sha.slice(0, 8)} | ${c.date.slice(0, 10)} | ${c.author} | ${c.message}`
352 )
353 .join("\n")
354 : "(no commits found for this file)";
355
356 const prText = prs.length > 0
357 ? prs
358 .map((pr) => {
359 const bodySnippet = pr.body ? pr.body.slice(0, 800) : "";
360 const commentsText = pr.comments.length > 0
361 ? pr.comments.map((c) => ` Comment: ${c.body}`).join("\n")
362 : "";
363 return `PR #${pr.number} (${pr.createdAt.toISOString().slice(0, 10)}): ${pr.title}\n${bodySnippet ? `Body: ${bodySnippet}` : ""}${commentsText ? `\n${commentsText}` : ""}`;
364 })
365 .join("\n\n")
366 : "(no related PRs found)";
367
368 const issueText = relatedIssues.length > 0
369 ? relatedIssues
370 .map((issue) => {
371 const bodySnippet = issue.body ? issue.body.slice(0, 600) : "";
372 const commentsText = issue.comments.length > 0
373 ? issue.comments.map((c) => ` Comment: ${c.body}`).join("\n")
374 : "";
375 return `Issue #${issue.number} (${issue.createdAt.toISOString().slice(0, 10)}): ${issue.title}\n${bodySnippet ? `Body: ${bodySnippet}` : ""}${commentsText ? `\n${commentsText}` : ""}`;
376 })
377 .join("\n\n")
378 : "(no related issues found)";
379
380 const userPrompt = `File: ${filePath}
381Question: ${query}
382
383Current file content (first 50 lines):
384\`\`\`
385${first50Lines || "(file is empty or binary)"}
386\`\`\`
387
388Git history (recent commits touching this file):
389${gitLogText}
390
391Related PRs:
392${prText}
393
394Related issues:
395${issueText}
396
397Synthesize: why does this code exist? What problem does it solve? What decisions were made?`;
398
399 let explanation = "";
400 try {
401 const anthropic = getAnthropic();
402 const message = await anthropic.messages.create({
403 model: MODEL_SONNET,
404 max_tokens: 2048,
405 system:
406 "You are a software archaeologist. Given the git history, PR discussions, and issue tracker for a file, synthesize a clear explanation of WHY this code exists — the original motivation, key decisions, and any important context. Be concise but complete. Use markdown.",
407 messages: [{ role: "user", content: userPrompt }],
408 });
409 explanation = extractText(message);
410 } catch (err) {
411 explanation = `Unable to generate explanation: ${err instanceof Error ? err.message : String(err)}`;
412 }
413
414 // Step 6: Build findings list sorted by date desc
415 const findings: ArchaeologyFinding[] = [];
416
417 // Add commits
418 for (const commit of commits) {
419 findings.push({
420 type: "commit",
421 id: commit.sha,
422 title: commit.message,
423 summary: `Commit by ${commit.author} touching this file.`,
424 date: commit.date,
425 url: `/${ownerName}/${repoName}/commit/${commit.sha}`,
426 author: commit.author,
427 });
428 }
429
430 // Add PRs
431 for (const pr of prs) {
432 findings.push({
433 type: "pr",
434 id: String(pr.number),
435 title: pr.title,
436 summary: `Pull request referencing ${fileName}.`,
437 date: pr.createdAt.toISOString(),
438 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
439 author: "",
440 });
441 }
442
443 // Add issues
444 for (const issue of relatedIssues) {
445 findings.push({
446 type: "issue",
447 id: String(issue.number),
448 title: issue.title,
449 summary: `Issue referencing ${fileName}.`,
450 date: issue.createdAt.toISOString(),
451 url: `/${ownerName}/${repoName}/issues/${issue.number}`,
452 author: "",
453 });
454 }
455
456 // Sort by date descending
457 findings.sort((a, b) => {
458 const da = a.date ? new Date(a.date).getTime() : 0;
459 const db2 = b.date ? new Date(b.date).getTime() : 0;
460 return db2 - da;
461 });
462
463 const confidence = deriveConfidence(commits, prs, relatedIssues);
464
465 const report: ArchaeologyReport = {
466 filePath,
467 query,
468 explanation,
469 findings,
470 confidence,
471 analyzedAt: new Date(),
472 };
473
474 setCached(repoId, filePath, report);
475 return report;
476 } catch (err) {
477 // Never throws — return a degraded report
478 return {
479 filePath,
480 query,
481 explanation: `Archaeology failed: ${err instanceof Error ? err.message : String(err)}`,
482 findings: [],
483 confidence: "low",
484 analyzedAt: new Date(),
485 };
486 }
487}