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

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.

pr-impact.tsBlame439 lines · 1 contributor
09d5f39Claude1/**
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
15import { db } from "../db";
16import { pullRequests, repositories, users, repoDependencies } from "../db/schema";
17import { eq, and, ne } from "drizzle-orm";
18import { getRepoPath } from "../git/repository";
19
20// ---------------------------------------------------------------------------
21// Types
22// ---------------------------------------------------------------------------
23
24export 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
41const IMPACT_TTL_MS = 10 * 60 * 1000;
42
43const impactCache = new Map<
44 string,
45 { analysis: ImpactAnalysis; expiresAt: number }
46>();
47
48function 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
58function 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
66async 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
87function 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
98function stripExtension(filePath: string): string {
99 return filePath.replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, "");
100}
101
102function 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. */
108const 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
124function isSensitivePath(filePath: string): boolean {
125 return SENSITIVE_PATTERNS.some((re) => re.test(filePath));
126}
127
128// ---------------------------------------------------------------------------
129// Core analysis
130// ---------------------------------------------------------------------------
131
132export 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
401function emptyAnalysis(reason: string): ImpactAnalysis {
402 return {
403 changedFiles: [],
404 affectedTestFiles: [],
405 affectedFiles: [],
406 downstreamRepos: [],
407 riskScore: 0,
408 riskSummary: reason,
409 };
410}
411
412function 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}