CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
pr-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.
| 09d5f39 | 1 | /** |
| 2 | * Merge Impact Analysis — "What breaks if I merge this?" | |
| 3 | * | |
| 4 | * Computes a static analysis of a PR's changed files to identify: | |
| 5 | * - Which test files import the changed source files | |
| 6 | * - Which other source files import the changed source files | |
| 7 | * - Which downstream repos in the org depend on the changed package | |
| 8 | * - A 0-100 risk score with a plain-English summary | |
| 9 | * | |
| 10 | * No AI calls. Pure git + DB queries. Results are cached in memory for | |
| 11 | * 10 minutes per prId so the PR detail page can call this on every | |
| 12 | * request without hitting disk. | |
| 13 | */ | |
| 14 | ||
| 15 | import { db } from "../db"; | |
| 16 | import { pullRequests, repositories, users, repoDependencies } from "../db/schema"; | |
| 17 | import { eq, and, ne } from "drizzle-orm"; | |
| 18 | import { getRepoPath } from "../git/repository"; | |
| 19 | ||
| 20 | // --------------------------------------------------------------------------- | |
| 21 | // Types | |
| 22 | // --------------------------------------------------------------------------- | |
| 23 | ||
| 24 | export interface ImpactAnalysis { | |
| 25 | changedFiles: string[]; | |
| 26 | affectedTestFiles: string[]; | |
| 27 | affectedFiles: string[]; | |
| 28 | downstreamRepos: { | |
| 29 | owner: string; | |
| 30 | repo: string; | |
| 31 | matchedDependency: string; | |
| 32 | }[]; | |
| 33 | riskScore: number; | |
| 34 | riskSummary: string; | |
| 35 | } | |
| 36 | ||
| 37 | // --------------------------------------------------------------------------- | |
| 38 | // Cache (10 minute TTL per prId) | |
| 39 | // --------------------------------------------------------------------------- | |
| 40 | ||
| 41 | const IMPACT_TTL_MS = 10 * 60 * 1000; | |
| 42 | ||
| 43 | const impactCache = new Map< | |
| 44 | string, | |
| 45 | { analysis: ImpactAnalysis; expiresAt: number } | |
| 46 | >(); | |
| 47 | ||
| 48 | function getCached(prId: string): ImpactAnalysis | null { | |
| 49 | const entry = impactCache.get(prId); | |
| 50 | if (!entry) return null; | |
| 51 | if (Date.now() > entry.expiresAt) { | |
| 52 | impactCache.delete(prId); | |
| 53 | return null; | |
| 54 | } | |
| 55 | return entry.analysis; | |
| 56 | } | |
| 57 | ||
| 58 | function setCached(prId: string, analysis: ImpactAnalysis): void { | |
| 59 | impactCache.set(prId, { analysis, expiresAt: Date.now() + IMPACT_TTL_MS }); | |
| 60 | } | |
| 61 | ||
| 62 | // --------------------------------------------------------------------------- | |
| 63 | // Git helper | |
| 64 | // --------------------------------------------------------------------------- | |
| 65 | ||
| 66 | async function git( | |
| 67 | args: string[], | |
| 68 | cwd: string | |
| 69 | ): Promise<{ stdout: string; stderr: string; exitCode: number }> { | |
| 70 | const proc = Bun.spawn(["git", ...args], { | |
| 71 | cwd, | |
| 72 | stdout: "pipe", | |
| 73 | stderr: "pipe", | |
| 74 | }); | |
| 75 | const [stdout, stderr] = await Promise.all([ | |
| 76 | new Response(proc.stdout).text(), | |
| 77 | new Response(proc.stderr).text(), | |
| 78 | ]); | |
| 79 | const exitCode = await proc.exited; | |
| 80 | return { stdout, stderr, exitCode }; | |
| 81 | } | |
| 82 | ||
| 83 | // --------------------------------------------------------------------------- | |
| 84 | // Helpers | |
| 85 | // --------------------------------------------------------------------------- | |
| 86 | ||
| 87 | function isTestFile(filePath: string): boolean { | |
| 88 | return ( | |
| 89 | filePath.includes(".test.") || | |
| 90 | filePath.includes(".spec.") || | |
| 91 | filePath.includes("__tests__/") || | |
| 92 | filePath.includes("/__tests__/") || | |
| 93 | filePath.includes("/test/") || | |
| 94 | filePath.includes("/tests/") | |
| 95 | ); | |
| 96 | } | |
| 97 | ||
| 98 | function stripExtension(filePath: string): string { | |
| 99 | return filePath.replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, ""); | |
| 100 | } | |
| 101 | ||
| 102 | function basename(filePath: string): string { | |
| 103 | const parts = filePath.split("/"); | |
| 104 | return parts[parts.length - 1] ?? filePath; | |
| 105 | } | |
| 106 | ||
| 107 | /** Sensitive path patterns that add to the risk score. */ | |
| 108 | const SENSITIVE_PATTERNS = [ | |
| 109 | /auth/i, | |
| 110 | /payment/i, | |
| 111 | /billing/i, | |
| 112 | /stripe/i, | |
| 113 | /password/i, | |
| 114 | /secret/i, | |
| 115 | /migration/i, | |
| 116 | /schema/i, | |
| 117 | /database/i, | |
| 118 | /security/i, | |
| 119 | /token/i, | |
| 120 | /session/i, | |
| 121 | /credential/i, | |
| 122 | ]; | |
| 123 | ||
| 124 | function isSensitivePath(filePath: string): boolean { | |
| 125 | return SENSITIVE_PATTERNS.some((re) => re.test(filePath)); | |
| 126 | } | |
| 127 | ||
| 128 | // --------------------------------------------------------------------------- | |
| 129 | // Core analysis | |
| 130 | // --------------------------------------------------------------------------- | |
| 131 | ||
| 132 | export async function analyzeImpact( | |
| 133 | repoId: string, | |
| 134 | prId: string | |
| 135 | ): Promise<ImpactAnalysis> { | |
| 136 | // Return cached result if available | |
| 137 | const cached = getCached(prId); | |
| 138 | if (cached) return cached; | |
| 139 | ||
| 140 | // Load PR | |
| 141 | const [pr] = await db | |
| 142 | .select({ | |
| 143 | baseBranch: pullRequests.baseBranch, | |
| 144 | headBranch: pullRequests.headBranch, | |
| 145 | repositoryId: pullRequests.repositoryId, | |
| 146 | }) | |
| 147 | .from(pullRequests) | |
| 148 | .where(eq(pullRequests.id, prId)) | |
| 149 | .limit(1); | |
| 150 | ||
| 151 | if (!pr) { | |
| 152 | const empty = emptyAnalysis("PR not found"); | |
| 153 | setCached(prId, empty); | |
| 154 | return empty; | |
| 155 | } | |
| 156 | ||
| 157 | // Resolve owner/repo for git operations | |
| 158 | const [repoRow] = await db | |
| 159 | .select({ name: repositories.name, ownerId: repositories.ownerId, orgId: repositories.orgId }) | |
| 160 | .from(repositories) | |
| 161 | .where(eq(repositories.id, pr.repositoryId)) | |
| 162 | .limit(1); | |
| 163 | ||
| 164 | if (!repoRow) { | |
| 165 | const empty = emptyAnalysis("Repository not found"); | |
| 166 | setCached(prId, empty); | |
| 167 | return empty; | |
| 168 | } | |
| 169 | ||
| 170 | const [ownerRow] = await db | |
| 171 | .select({ username: users.username }) | |
| 172 | .from(users) | |
| 173 | .where(eq(users.id, repoRow.ownerId)) | |
| 174 | .limit(1); | |
| 175 | ||
| 176 | if (!ownerRow) { | |
| 177 | const empty = emptyAnalysis("Owner not found"); | |
| 178 | setCached(prId, empty); | |
| 179 | return empty; | |
| 180 | } | |
| 181 | ||
| 182 | const ownerName = ownerRow.username; | |
| 183 | const repoName = repoRow.name; | |
| 184 | const repoDir = getRepoPath(ownerName, repoName); | |
| 185 | ||
| 186 | // ── 1. Get changed files ───────────────────────────────────────────────── | |
| 187 | let changedFiles: string[] = []; | |
| 188 | try { | |
| 189 | const { stdout } = await git( | |
| 190 | ["diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`], | |
| 191 | repoDir | |
| 192 | ); | |
| 193 | changedFiles = stdout.trim().split("\n").filter(Boolean); | |
| 194 | } catch { | |
| 195 | /* non-blocking */ | |
| 196 | } | |
| 197 | ||
| 198 | if (changedFiles.length === 0) { | |
| 199 | const analysis: ImpactAnalysis = { | |
| 200 | changedFiles: [], | |
| 201 | affectedTestFiles: [], | |
| 202 | affectedFiles: [], | |
| 203 | downstreamRepos: [], | |
| 204 | riskScore: 0, | |
| 205 | riskSummary: "No changed files detected", | |
| 206 | }; | |
| 207 | setCached(prId, analysis); | |
| 208 | return analysis; | |
| 209 | } | |
| 210 | ||
| 211 | // ── 2. Find files that import changed files ────────────────────────────── | |
| 212 | const affectedTestFiles = new Set<string>(); | |
| 213 | const affectedSourceFiles = new Set<string>(); | |
| 214 | ||
| 215 | // For each changed source file, look for files that import it | |
| 216 | const sourceChangedFiles = changedFiles.filter( | |
| 217 | (f) => | |
| 218 | /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(f) && | |
| 219 | !isTestFile(f) | |
| 220 | ); | |
| 221 | ||
| 222 | for (const changedFile of sourceChangedFiles) { | |
| 223 | const fileBase = stripExtension(basename(changedFile)); | |
| 224 | const fileWithoutExt = stripExtension(changedFile); | |
| 225 | ||
| 226 | // Search for imports of this file | |
| 227 | // We look for: from "...{basename}" or require("...{basename}") | |
| 228 | const searchPatterns = [ | |
| 229 | `from.*${fileBase}`, | |
| 230 | `require.*${fileBase}`, | |
| 231 | // Also try the full path without extension | |
| 232 | `from.*${fileWithoutExt.replace(/\//g, "\\/")}`, | |
| 233 | ]; | |
| 234 | ||
| 235 | for (const pattern of searchPatterns.slice(0, 2)) { | |
| 236 | // Only use basename patterns for git grep (avoids escaping issues) | |
| 237 | try { | |
| 238 | const { stdout } = await git( | |
| 239 | [ | |
| 240 | "grep", | |
| 241 | "-l", | |
| 242 | "--extended-regexp", | |
| 243 | pattern, | |
| 244 | "--", | |
| 245 | "*.ts", | |
| 246 | "*.tsx", | |
| 247 | "*.js", | |
| 248 | "*.jsx", | |
| 249 | ], | |
| 250 | repoDir | |
| 251 | ); | |
| 252 | const matchingFiles = stdout.trim().split("\n").filter(Boolean); | |
| 253 | for (const f of matchingFiles) { | |
| 254 | // Exclude the changed file itself | |
| 255 | if (f === changedFile) continue; | |
| 256 | if (isTestFile(f)) { | |
| 257 | affectedTestFiles.add(f); | |
| 258 | } else { | |
| 259 | affectedSourceFiles.add(f); | |
| 260 | } | |
| 261 | } | |
| 262 | } catch { | |
| 263 | /* git grep exits 1 when no matches — not an error */ | |
| 264 | } | |
| 265 | } | |
| 266 | } | |
| 267 | ||
| 268 | // Remove changed files from affected sets | |
| 269 | const changedSet = new Set(changedFiles); | |
| 270 | for (const f of changedSet) { | |
| 271 | affectedTestFiles.delete(f); | |
| 272 | affectedSourceFiles.delete(f); | |
| 273 | } | |
| 274 | ||
| 275 | const affectedTestFilesList = Array.from(affectedTestFiles).slice(0, 50); | |
| 276 | const affectedSourceFilesList = Array.from(affectedSourceFiles).slice(0, 50); | |
| 277 | ||
| 278 | // ── 3. Check downstream repos ──────────────────────────────────────────── | |
| 279 | const downstreamRepos: ImpactAnalysis["downstreamRepos"] = []; | |
| 280 | try { | |
| 281 | // Check if this repo is a package (has a name in package.json) | |
| 282 | let packageName: string | null = null; | |
| 283 | try { | |
| 284 | const { stdout: pkgBlob } = await git( | |
| 285 | ["show", "HEAD:package.json"], | |
| 286 | repoDir | |
| 287 | ); | |
| 288 | const pkg = JSON.parse(pkgBlob) as Record<string, unknown>; | |
| 289 | if (typeof pkg.name === "string" && pkg.name) { | |
| 290 | packageName = pkg.name; | |
| 291 | } | |
| 292 | } catch { | |
| 293 | /* no package.json or not parseable */ | |
| 294 | } | |
| 295 | ||
| 296 | if (packageName) { | |
| 297 | // Find repos in the same org (or by the same owner) that depend on this package | |
| 298 | const orgScope = repoRow.orgId; | |
| 299 | const depRows = await db | |
| 300 | .select({ | |
| 301 | repositoryId: repoDependencies.repositoryId, | |
| 302 | depName: repoDependencies.name, | |
| 303 | }) | |
| 304 | .from(repoDependencies) | |
| 305 | .where( | |
| 306 | and( | |
| 307 | eq(repoDependencies.name, packageName), | |
| 308 | ne(repoDependencies.repositoryId, pr.repositoryId) | |
| 309 | ) | |
| 310 | ) | |
| 311 | .limit(20); | |
| 312 | ||
| 313 | for (const depRow of depRows) { | |
| 314 | // Look up the dependent repo | |
| 315 | const [depRepo] = await db | |
| 316 | .select({ | |
| 317 | name: repositories.name, | |
| 318 | ownerId: repositories.ownerId, | |
| 319 | orgId: repositories.orgId, | |
| 320 | }) | |
| 321 | .from(repositories) | |
| 322 | .where(eq(repositories.id, depRow.repositoryId)) | |
| 323 | .limit(1); | |
| 324 | ||
| 325 | if (!depRepo) continue; | |
| 326 | ||
| 327 | // Only include if same org or same owner | |
| 328 | const sameOrg = orgScope && depRepo.orgId === orgScope; | |
| 329 | const sameOwner = depRepo.ownerId === repoRow.ownerId; | |
| 330 | if (!sameOrg && !sameOwner) continue; | |
| 331 | ||
| 332 | const [depOwner] = await db | |
| 333 | .select({ username: users.username }) | |
| 334 | .from(users) | |
| 335 | .where(eq(users.id, depRepo.ownerId)) | |
| 336 | .limit(1); | |
| 337 | ||
| 338 | if (!depOwner) continue; | |
| 339 | ||
| 340 | downstreamRepos.push({ | |
| 341 | owner: depOwner.username, | |
| 342 | repo: depRepo.name, | |
| 343 | matchedDependency: depRow.depName, | |
| 344 | }); | |
| 345 | } | |
| 346 | } | |
| 347 | } catch { | |
| 348 | /* downstream check is best-effort */ | |
| 349 | } | |
| 350 | ||
| 351 | // ── 4. Risk score ───────────────────────────────────────────────────────── | |
| 352 | let riskScore = 0; | |
| 353 | ||
| 354 | // +30 if any changed file is imported by more than 5 files | |
| 355 | for (const f of changedFiles) { | |
| 356 | const importCount = | |
| 357 | affectedTestFiles.has(f) || affectedSourceFiles.has(f) ? 1 : 0; // can't easily get exact count per file here | |
| 358 | // Estimate: if total affected > 5 | |
| 359 | if (affectedTestFiles.size + affectedSourceFiles.size > 5) { | |
| 360 | riskScore += 30; | |
| 361 | break; | |
| 362 | } | |
| 363 | } | |
| 364 | ||
| 365 | // +20 if changed files include sensitive paths | |
| 366 | const hasSensitive = changedFiles.some(isSensitivePath); | |
| 367 | if (hasSensitive) riskScore += 20; | |
| 368 | ||
| 369 | // +10 per downstream repo | |
| 370 | riskScore += Math.min(downstreamRepos.length * 10, 40); | |
| 371 | ||
| 372 | // +5 per affected source file (capped) | |
| 373 | riskScore += Math.min(affectedSourceFilesList.length * 5, 30); | |
| 374 | ||
| 375 | // Cap at 100 | |
| 376 | riskScore = Math.min(riskScore, 100); | |
| 377 | ||
| 378 | // ── 5. Risk summary ─────────────────────────────────────────────────────── | |
| 379 | const riskSummary = buildRiskSummary( | |
| 380 | riskScore, | |
| 381 | changedFiles, | |
| 382 | affectedTestFilesList, | |
| 383 | affectedSourceFilesList, | |
| 384 | downstreamRepos, | |
| 385 | hasSensitive | |
| 386 | ); | |
| 387 | ||
| 388 | const analysis: ImpactAnalysis = { | |
| 389 | changedFiles, | |
| 390 | affectedTestFiles: affectedTestFilesList, | |
| 391 | affectedFiles: affectedSourceFilesList, | |
| 392 | downstreamRepos, | |
| 393 | riskScore, | |
| 394 | riskSummary, | |
| 395 | }; | |
| 396 | ||
| 397 | setCached(prId, analysis); | |
| 398 | return analysis; | |
| 399 | } | |
| 400 | ||
| 401 | function emptyAnalysis(reason: string): ImpactAnalysis { | |
| 402 | return { | |
| 403 | changedFiles: [], | |
| 404 | affectedTestFiles: [], | |
| 405 | affectedFiles: [], | |
| 406 | downstreamRepos: [], | |
| 407 | riskScore: 0, | |
| 408 | riskSummary: reason, | |
| 409 | }; | |
| 410 | } | |
| 411 | ||
| 412 | function buildRiskSummary( | |
| 413 | score: number, | |
| 414 | changedFiles: string[], | |
| 415 | testFiles: string[], | |
| 416 | sourceFiles: string[], | |
| 417 | downstream: ImpactAnalysis["downstreamRepos"], | |
| 418 | hasSensitive: boolean | |
| 419 | ): string { | |
| 420 | if (score === 0 && sourceFiles.length === 0 && testFiles.length === 0) { | |
| 421 | return "Low risk — no downstream impact detected"; | |
| 422 | } | |
| 423 | if (score <= 10 && testFiles.length > 0 && sourceFiles.length === 0) { | |
| 424 | return "Low risk — only test files affected"; | |
| 425 | } | |
| 426 | if (hasSensitive && score >= 50) { | |
| 427 | return "High risk — sensitive paths (auth/payments/schema) modified"; | |
| 428 | } | |
| 429 | if (downstream.length > 0) { | |
| 430 | return `High risk — ${downstream.length} downstream repo${downstream.length === 1 ? "" : "s"} may be affected`; | |
| 431 | } | |
| 432 | if (score >= 70) { | |
| 433 | return `High risk — ${sourceFiles.length} source file${sourceFiles.length === 1 ? "" : "s"} import these changes`; | |
| 434 | } | |
| 435 | if (score >= 40) { | |
| 436 | return `Medium risk — ${sourceFiles.length + testFiles.length} file${sourceFiles.length + testFiles.length === 1 ? "" : "s"} reference these changes`; | |
| 437 | } | |
| 438 | return `Low risk — ${changedFiles.length} file${changedFiles.length === 1 ? "" : "s"} changed with limited downstream impact`; | |
| 439 | } |