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.
| 10ec057 | 1 | /** |
| 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 | ||
| 19 | import { db } from "../db"; | |
| 20 | import { | |
| 21 | pullRequests, | |
| 22 | repositories, | |
| 23 | users, | |
| 24 | repoDependencies, | |
| 25 | crossRepoImpactCache, | |
| 26 | } from "../db/schema"; | |
| 27 | import { eq, and, ne } from "drizzle-orm"; | |
| 28 | import { getRepoPath } from "../git/repository"; | |
| 29 | import { isAiAvailable, getAnthropic, MODEL_SONNET, extractText } from "./ai-client"; | |
| 30 | ||
| 31 | // --------------------------------------------------------------------------- | |
| 32 | // Types | |
| 33 | // --------------------------------------------------------------------------- | |
| 34 | ||
| 35 | export 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 | ||
| 46 | export 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 | ||
| 58 | const CACHE_TTL_MS = 15 * 60 * 1000; | |
| 59 | ||
| 60 | interface CacheEntry { | |
| 61 | report: CrossRepoReport; | |
| 62 | expiresAt: number; | |
| 63 | } | |
| 64 | ||
| 65 | const memoryCache = new Map<string, CacheEntry>(); | |
| 66 | ||
| 67 | function 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 | ||
| 77 | function 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 | ||
| 85 | async 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 | */ | |
| 111 | function 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 | ||
| 146 | async 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 | */ | |
| 174 | async 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 | ||
| 190 | async 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 | ||
| 202 | Here is a brief diff excerpt: | |
| 203 | \`\`\` | |
| 204 | ${diffExcerpt.slice(0, 2000)} | |
| 205 | \`\`\` | |
| 206 | ||
| 207 | For each changed export, write exactly ONE sentence describing what callers need to update. Be concrete and brief. | |
| 208 | Respond 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 | ||
| 236 | async 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 | ||
| 259 | async 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 | ||
| 278 | export 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 | ||
| 473 | export function invalidateCrossRepoCache(prId: string): void { | |
| 474 | memoryCache.delete(prId); | |
| 475 | } | |
| 476 | ||
| 477 | function 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 | } |