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

cross-repo-impact.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.

cross-repo-impact.tsBlame486 lines · 1 contributor
10ec057Claude1/**
2 * Cross-Repo Dependency Impact Detection
3 *
4 * When a PR changes a package's public API (renamed exports, changed type
5 * signatures, bumped version), downstream repos that depend on THIS repo
6 * get silently broken. This module surfaces those risks before merge.
7 *
8 * Steps:
9 * 1. Get PR diff (changed files via git diff base...head --name-only)
10 * 2. Find changed exported symbols (regex on +export / -export lines)
11 * 3. Look up repo_dependencies for repos that depend on this package
12 * 4. For each downstream repo, grep for usage of the changed symbols
13 * 5. Score risk: high/medium/low based on symbol usage + test coverage
14 * 6. Cache results in memory for 15 minutes (keyed by prId)
15 * 7. Use Claude to generate one-sentence migration notes per changed export
16 * 8. Cap at 20 downstream repos
17 */
18
19import { db } from "../db";
20import {
21 pullRequests,
22 repositories,
23 users,
24 repoDependencies,
25 crossRepoImpactCache,
26} from "../db/schema";
27import { eq, and, ne } from "drizzle-orm";
28import { getRepoPath } from "../git/repository";
29import { isAiAvailable, getAnthropic, MODEL_SONNET, extractText } from "./ai-client";
30
31// ---------------------------------------------------------------------------
32// Types
33// ---------------------------------------------------------------------------
34
35export interface DownstreamImpact {
36 repoId: string;
37 repoName: string;
38 ownerName: string;
39 dependencyName: string; // e.g. "@myorg/auth-lib"
40 currentVersion: string;
41 riskLevel: "high" | "medium" | "low";
42 changedExports: string[]; // function/type names that changed in the PR diff
43 suggestedFixPrUrl?: string; // if we opened a fix PR
44}
45
46export interface CrossRepoReport {
47 prId: string;
48 affectedRepos: DownstreamImpact[];
49 totalRisk: number; // 0-100
50 analyzedAt: Date;
51 cachedUntil: Date;
52}
53
54// ---------------------------------------------------------------------------
55// In-memory cache (15 min TTL)
56// ---------------------------------------------------------------------------
57
58const CACHE_TTL_MS = 15 * 60 * 1000;
59
60interface CacheEntry {
61 report: CrossRepoReport;
62 expiresAt: number;
63}
64
65const memoryCache = new Map<string, CacheEntry>();
66
67function getCached(prId: string): CrossRepoReport | null {
68 const entry = memoryCache.get(prId);
69 if (!entry) return null;
70 if (Date.now() > entry.expiresAt) {
71 memoryCache.delete(prId);
72 return null;
73 }
74 return entry.report;
75}
76
77function setMemoryCache(prId: string, report: CrossRepoReport): void {
78 memoryCache.set(prId, { report, expiresAt: Date.now() + CACHE_TTL_MS });
79}
80
81// ---------------------------------------------------------------------------
82// Git helper
83// ---------------------------------------------------------------------------
84
85async function git(
86 args: string[],
87 cwd: string
88): Promise<{ stdout: string; stderr: string; exitCode: number }> {
89 const proc = Bun.spawn(["git", ...args], {
90 cwd,
91 stdout: "pipe",
92 stderr: "pipe",
93 });
94 const [stdout, stderr] = await Promise.all([
95 new Response(proc.stdout).text(),
96 new Response(proc.stderr).text(),
97 ]);
98 const exitCode = await proc.exited;
99 return { stdout, stderr, exitCode };
100}
101
102// ---------------------------------------------------------------------------
103// Symbol extraction helpers
104// ---------------------------------------------------------------------------
105
106/**
107 * Parse lines from a diff for changed export declarations.
108 * Looks for lines beginning with + or - that contain an export keyword.
109 * Returns deduplicated symbol names extracted from those lines.
110 */
111function extractChangedExports(diffText: string): string[] {
112 const exportLineRe = /^[+-]export\s+(?:default\s+)?(?:async\s+)?(?:function|class|type|interface|const|let|var|enum|abstract\s+class)\s+(\w+)/m;
113 const lines = diffText.split("\n");
114 const changed = new Set<string>();
115
116 for (const line of lines) {
117 if (!line.startsWith("+") && !line.startsWith("-")) continue;
118 // Skip diff header lines like +++ or ---
119 if (line.startsWith("+++") || line.startsWith("---")) continue;
120
121 const trimmed = line.slice(1).trim();
122 // Match: export [default] [async] function/class/type/interface/const/let/var/enum Name
123 const m = trimmed.match(
124 /^export\s+(?:default\s+)?(?:async\s+)?(?:function|class|type|interface|const|let|var|enum|abstract\s+class)\s+(\w+)/
125 );
126 if (m && m[1]) {
127 changed.add(m[1]);
128 }
129 // Also catch: export { Name, Name2 }
130 const namedExport = trimmed.match(/^export\s+\{([^}]+)\}/);
131 if (namedExport && namedExport[1]) {
132 for (const name of namedExport[1].split(",")) {
133 const clean = name.trim().replace(/\s+as\s+\w+/, "").trim();
134 if (clean && /^\w+$/.test(clean)) changed.add(clean);
135 }
136 }
137 }
138
139 return Array.from(changed);
140}
141
142// ---------------------------------------------------------------------------
143// Determine if a downstream repo imports any of the changed symbols
144// ---------------------------------------------------------------------------
145
146async function repoUsesSymbols(
147 repoDir: string,
148 symbols: string[]
149): Promise<string[]> {
150 if (symbols.length === 0) return [];
151
152 const used: string[] = [];
153 for (const sym of symbols) {
154 try {
155 // git grep -l <sym> -- *.ts *.tsx *.js *.jsx
156 const { stdout, exitCode } = await git(
157 ["grep", "-l", "--extended-regexp", `\\b${sym}\\b`, "--", "*.ts", "*.tsx", "*.js", "*.jsx"],
158 repoDir
159 );
160 // exit 1 = no matches (not an error)
161 if (exitCode === 0 && stdout.trim()) {
162 used.push(sym);
163 }
164 } catch {
165 // git grep may fail if git not available or repo empty
166 }
167 }
168 return used;
169}
170
171/**
172 * Check if a repo has any test files (rough proxy for test coverage).
173 */
174async function repoHasTests(repoDir: string): Promise<boolean> {
175 try {
176 const { stdout } = await git(
177 ["ls-files", "--", "*.test.ts", "*.test.tsx", "*.spec.ts", "*.spec.tsx", "*.test.js", "*.spec.js"],
178 repoDir
179 );
180 return stdout.trim().length > 0;
181 } catch {
182 return false;
183 }
184}
185
186// ---------------------------------------------------------------------------
187// AI migration note generation
188// ---------------------------------------------------------------------------
189
190async function generateMigrationNotes(
191 changedExports: string[],
192 packageName: string,
193 diffExcerpt: string
194): Promise<Record<string, string>> {
195 if (!isAiAvailable() || changedExports.length === 0) return {};
196
197 try {
198 const client = getAnthropic();
199 const prompt = `You are a migration assistant. The package "${packageName}" changed the following exports in a PR:
200${changedExports.map((e) => `- ${e}`).join("\n")}
201
202Here is a brief diff excerpt:
203\`\`\`
204${diffExcerpt.slice(0, 2000)}
205\`\`\`
206
207For each changed export, write exactly ONE sentence describing what callers need to update. Be concrete and brief.
208Respond with a JSON object mapping export name to migration note string. Example:
209{"myFunction": "Rename the first parameter from 'id' to 'userId'.", "MyType": "Add the required 'createdAt: Date' field."}`;
210
211 const message = await client.messages.create({
212 model: MODEL_SONNET,
213 max_tokens: 512,
214 messages: [{ role: "user", content: prompt }],
215 });
216
217 const text = extractText(message);
218 const jsonMatch = text.match(/\{[\s\S]*\}/);
219 if (jsonMatch) {
220 try {
221 return JSON.parse(jsonMatch[0]) as Record<string, string>;
222 } catch {
223 return {};
224 }
225 }
226 } catch {
227 // AI unavailable — degrade gracefully
228 }
229 return {};
230}
231
232// ---------------------------------------------------------------------------
233// DB cache helpers
234// ---------------------------------------------------------------------------
235
236async function loadDbCache(prId: string): Promise<CrossRepoReport | null> {
237 try {
238 const rows = await db
239 .select()
240 .from(crossRepoImpactCache)
241 .where(eq(crossRepoImpactCache.prId, prId))
242 .limit(1);
243
244 if (!rows.length) return null;
245
246 const row = rows[0];
247 if (row.cachedUntil < new Date()) {
248 // Expired — delete stale row
249 await db.delete(crossRepoImpactCache).where(eq(crossRepoImpactCache.prId, prId)).catch(() => {});
250 return null;
251 }
252
253 return row.report as CrossRepoReport;
254 } catch {
255 return null;
256 }
257}
258
259async function saveDbCache(report: CrossRepoReport): Promise<void> {
260 try {
261 // Upsert: delete then insert
262 await db.delete(crossRepoImpactCache).where(eq(crossRepoImpactCache.prId, report.prId)).catch(() => {});
263 await db.insert(crossRepoImpactCache).values({
264 prId: report.prId,
265 report: report as unknown as Record<string, unknown>,
266 analyzedAt: report.analyzedAt,
267 cachedUntil: report.cachedUntil,
268 });
269 } catch {
270 // Best-effort — memory cache still works
271 }
272}
273
274// ---------------------------------------------------------------------------
275// Main export
276// ---------------------------------------------------------------------------
277
278export async function analyzeCrossRepoImpact(
279 repoId: string,
280 prId: string,
281 ownerName: string,
282 repoName: string
283): Promise<CrossRepoReport> {
284 // 1. Check memory cache
285 const memHit = getCached(prId);
286 if (memHit) return memHit;
287
288 // 2. Check DB cache
289 const dbHit = await loadDbCache(prId);
290 if (dbHit) {
291 setMemoryCache(prId, dbHit);
292 return dbHit;
293 }
294
295 // 3. Load PR info
296 const [pr] = await db
297 .select({
298 baseBranch: pullRequests.baseBranch,
299 headBranch: pullRequests.headBranch,
300 repositoryId: pullRequests.repositoryId,
301 })
302 .from(pullRequests)
303 .where(eq(pullRequests.id, prId))
304 .limit(1);
305
306 if (!pr) {
307 return emptyReport(prId, "PR not found");
308 }
309
310 const repoDir = getRepoPath(ownerName, repoName);
311
312 // 4. Get changed file names
313 let changedFiles: string[] = [];
314 try {
315 const { stdout } = await git(
316 ["diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
317 repoDir
318 );
319 changedFiles = stdout.trim().split("\n").filter(Boolean);
320 } catch {
321 /* non-blocking */
322 }
323
324 // 5. Get full diff text for export extraction + AI notes
325 let diffText = "";
326 try {
327 const { stdout } = await git(
328 ["diff", `${pr.baseBranch}...${pr.headBranch}`, "--unified=2"],
329 repoDir
330 );
331 // Cap at 100KB to avoid huge diffs
332 diffText = stdout.slice(0, 100_000);
333 } catch {
334 /* non-blocking */
335 }
336
337 // 6. Extract changed exports from diff
338 const changedExports = extractChangedExports(diffText);
339
340 // 7. Get this repo's package name from package.json on default branch
341 let packageName: string | null = null;
342 try {
343 const { stdout: pkgBlob } = await git(["show", "HEAD:package.json"], repoDir);
344 const pkg = JSON.parse(pkgBlob) as Record<string, unknown>;
345 if (typeof pkg.name === "string" && pkg.name) {
346 packageName = pkg.name;
347 }
348 } catch {
349 /* no package.json or not parseable */
350 }
351
352 // 8. Find downstream repos in repo_dependencies
353 const affectedRepos: DownstreamImpact[] = [];
354
355 if (packageName && packageName.trim()) {
356 const depRows = await db
357 .select({
358 repositoryId: repoDependencies.repositoryId,
359 name: repoDependencies.name,
360 versionSpec: repoDependencies.versionSpec,
361 })
362 .from(repoDependencies)
363 .where(
364 and(
365 eq(repoDependencies.name, packageName),
366 ne(repoDependencies.repositoryId, repoId)
367 )
368 )
369 .limit(20)
370 .catch(() => []);
371
372 // Process each downstream repo (cap at 20)
373 for (const depRow of depRows.slice(0, 20)) {
374 // Load downstream repo info
375 const [depRepo] = await db
376 .select({
377 id: repositories.id,
378 name: repositories.name,
379 ownerId: repositories.ownerId,
380 defaultBranch: repositories.defaultBranch,
381 })
382 .from(repositories)
383 .where(eq(repositories.id, depRow.repositoryId))
384 .limit(1)
385 .catch(() => []);
386
387 if (!depRepo) continue;
388
389 const [depOwner] = await db
390 .select({ username: users.username })
391 .from(users)
392 .where(eq(users.id, depRepo.ownerId))
393 .limit(1)
394 .catch(() => []);
395
396 if (!depOwner) continue;
397
398 const downstreamRepoDir = getRepoPath(depOwner.username, depRepo.name);
399
400 // Check which changed symbols are used in the downstream repo
401 let usedSymbols: string[] = [];
402 let hasTests = false;
403 try {
404 [usedSymbols, hasTests] = await Promise.all([
405 repoUsesSymbols(downstreamRepoDir, changedExports),
406 repoHasTests(downstreamRepoDir),
407 ]);
408 } catch {
409 /* git may not be available for this repo */
410 }
411
412 // Determine risk level
413 let riskLevel: "high" | "medium" | "low";
414 if (usedSymbols.length > 0 && !hasTests) {
415 riskLevel = "high";
416 } else if (usedSymbols.length > 0 && hasTests) {
417 riskLevel = "medium";
418 } else {
419 riskLevel = "low";
420 }
421
422 affectedRepos.push({
423 repoId: depRepo.id,
424 repoName: depRepo.name,
425 ownerName: depOwner.username,
426 dependencyName: depRow.name,
427 currentVersion: depRow.versionSpec ?? "unknown",
428 riskLevel,
429 changedExports: usedSymbols.length > 0 ? usedSymbols : changedExports,
430 });
431 }
432 }
433
434 // 9. Generate AI migration notes (best-effort, non-blocking)
435 if (changedExports.length > 0 && affectedRepos.some((r) => r.riskLevel !== "low")) {
436 try {
437 await generateMigrationNotes(changedExports, packageName ?? repoName, diffText);
438 } catch {
439 /* non-blocking */
440 }
441 }
442
443 // 10. Compute total risk score (0-100)
444 const highCount = affectedRepos.filter((r) => r.riskLevel === "high").length;
445 const mediumCount = affectedRepos.filter((r) => r.riskLevel === "medium").length;
446 const lowCount = affectedRepos.filter((r) => r.riskLevel === "low").length;
447
448 let totalRisk = 0;
449 totalRisk += Math.min(highCount * 30, 60);
450 totalRisk += Math.min(mediumCount * 15, 30);
451 totalRisk += Math.min(lowCount * 5, 10);
452 totalRisk += Math.min(changedExports.length * 5, 20);
453 totalRisk = Math.min(totalRisk, 100);
454
455 const now = new Date();
456 const cachedUntil = new Date(now.getTime() + CACHE_TTL_MS);
457
458 const report: CrossRepoReport = {
459 prId,
460 affectedRepos,
461 totalRisk,
462 analyzedAt: now,
463 cachedUntil,
464 };
465
466 // 11. Persist to memory + DB cache
467 setMemoryCache(prId, report);
468 await saveDbCache(report);
469
470 return report;
471}
472
473export function invalidateCrossRepoCache(prId: string): void {
474 memoryCache.delete(prId);
475}
476
477function emptyReport(prId: string, _reason: string): CrossRepoReport {
478 const now = new Date();
479 return {
480 prId,
481 affectedRepos: [],
482 totalRisk: 0,
483 analyzedAt: now,
484 cachedUntil: new Date(now.getTime() + CACHE_TTL_MS),
485 };
486}