Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit10ec057unknown_key

feat: add cross-repo-impact lib and route files

feat: add cross-repo-impact lib and route files

Commits src/lib/cross-repo-impact.ts and src/routes/cross-repo-impact.tsx
which were not captured in the previous commit due to git rename detection.

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 7, 2026Parent: f5d020f
2 files changed+1297010ec0578cf8da2a3ec28e64eea0b19c931c0bed1
2 changed files+1297−0
Addedsrc/lib/cross-repo-impact.ts+486−0View fileUnifiedSplit
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
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}
Addedsrc/routes/cross-repo-impact.tsx+811−0View fileUnifiedSplit
1/**
2 * Cross-Repo Dependency Impact Detection — PR-level downstream analysis
3 *
4 * GET /:owner/:repo/pulls/:number/cross-repo-impact
5 * Render the full report page showing downstream repos at risk.
6 *
7 * POST /:owner/:repo/pulls/:number/cross-repo-impact/analyze
8 * Trigger (or re-trigger) analysis and redirect back to GET.
9 *
10 * POST /:owner/:repo/pulls/:number/cross-repo-impact/open-fix-pr/:downstreamRepoId
11 * Open a draft PR on the downstream repo with a migration note.
12 */
13
14import { Hono } from "hono";
15import { db } from "../db";
16import {
17 repositories,
18 users,
19 pullRequests,
20 prComments,
21} from "../db/schema";
22import { and, eq } from "drizzle-orm";
23import type { AuthEnv } from "../middleware/auth";
24import { requireAuth, softAuth } from "../middleware/auth";
25import { requireRepoAccess } from "../middleware/repo-access";
26import { Layout } from "../views/layout";
27import { RepoHeader, RepoNav } from "../views/components";
28import { getUnreadCount } from "../lib/unread";
29import {
30 analyzeCrossRepoImpact,
31 type CrossRepoReport,
32 type DownstreamImpact,
33} from "../lib/cross-repo-impact";
34
35export const crossRepoImpactRoutes = new Hono<AuthEnv>();
36
37// ─── CSS ──────────────────────────────────────────────────────────────────────
38
39const styles = `
40 .cri-wrap {
41 max-width: 1200px;
42 margin: 0 auto;
43 padding: var(--space-5) var(--space-4);
44 }
45
46 /* Sub-nav (PR-level) */
47 .cri-subnav {
48 display: flex;
49 gap: 4px;
50 margin-bottom: var(--space-5);
51 border-bottom: 1px solid var(--border);
52 padding-bottom: 0;
53 }
54 .cri-subnav-link {
55 padding: 8px 14px;
56 font-size: 13px;
57 font-weight: 500;
58 color: var(--text-muted);
59 text-decoration: none;
60 border-bottom: 2px solid transparent;
61 margin-bottom: -1px;
62 transition: color 120ms ease, border-color 120ms ease;
63 border-radius: 4px 4px 0 0;
64 }
65 .cri-subnav-link:hover { color: var(--text); }
66 .cri-subnav-link.active {
67 color: var(--accent, #5865f2);
68 border-bottom-color: var(--accent, #5865f2);
69 }
70
71 /* Hero */
72 .cri-hero {
73 position: relative;
74 margin-bottom: var(--space-5);
75 padding: var(--space-5) var(--space-6);
76 background: var(--bg-elevated);
77 border: 1px solid var(--border);
78 border-radius: 16px;
79 overflow: hidden;
80 }
81 .cri-hero::before {
82 content: '';
83 position: absolute;
84 top: 0; left: 0; right: 0;
85 height: 2px;
86 background: linear-gradient(90deg, transparent 0%, #8b5cf6 30%, #3b82f6 70%, transparent 100%);
87 opacity: 0.75;
88 pointer-events: none;
89 }
90 .cri-hero-orb {
91 position: absolute;
92 inset: -30% -15% auto auto;
93 width: 460px; height: 460px;
94 background: radial-gradient(circle, rgba(139,92,246,0.14), rgba(59,130,246,0.07) 45%, transparent 70%);
95 filter: blur(80px);
96 opacity: 0.75;
97 pointer-events: none;
98 z-index: 0;
99 }
100 .cri-hero-inner { position: relative; z-index: 1; max-width: 760px; }
101 .cri-hero-eyebrow {
102 display: inline-flex; align-items: center; gap: 6px;
103 font-size: 11px; font-weight: 700; letter-spacing: 0.07em;
104 text-transform: uppercase;
105 color: #8b5cf6;
106 margin-bottom: 10px;
107 }
108 .cri-hero-title {
109 font-family: var(--font-display);
110 font-size: clamp(20px, 3vw, 28px);
111 font-weight: 800;
112 letter-spacing: -0.025em;
113 line-height: 1.1;
114 margin: 0 0 8px;
115 color: var(--text-strong);
116 }
117 .cri-hero-sub {
118 font-size: 14px;
119 color: var(--text-muted);
120 margin: 0;
121 line-height: 1.5;
122 }
123 .cri-hero-actions {
124 display: flex;
125 gap: 10px;
126 align-items: center;
127 margin-top: 16px;
128 flex-wrap: wrap;
129 }
130 .cri-analyze-btn {
131 display: inline-flex; align-items: center; gap: 6px;
132 padding: 8px 16px;
133 border-radius: 8px;
134 font-size: 13px; font-weight: 600;
135 color: #fff;
136 background: linear-gradient(135deg, #8b5cf6 0%, #3b82f6 130%);
137 border: none;
138 cursor: pointer;
139 transition: opacity 120ms ease;
140 text-decoration: none;
141 }
142 .cri-analyze-btn:hover { opacity: 0.85; }
143 .cri-analyzed-at {
144 font-size: 12px;
145 color: var(--text-muted);
146 }
147
148 /* Stats */
149 .cri-stats {
150 display: flex;
151 gap: 16px;
152 margin-bottom: var(--space-5);
153 flex-wrap: wrap;
154 }
155 .cri-stat-card {
156 flex: 1 1 160px;
157 padding: 14px 18px;
158 background: var(--bg-elevated);
159 border: 1px solid var(--border);
160 border-radius: 12px;
161 min-width: 120px;
162 }
163 .cri-stat-value {
164 font-family: var(--font-display);
165 font-size: 26px;
166 font-weight: 800;
167 line-height: 1;
168 margin-bottom: 4px;
169 color: var(--text-strong);
170 font-variant-numeric: tabular-nums;
171 }
172 .cri-stat-label {
173 font-size: 12px;
174 color: var(--text-muted);
175 font-weight: 500;
176 }
177 .cri-stat-card.is-high .cri-stat-value { color: #ef4444; }
178 .cri-stat-card.is-medium .cri-stat-value { color: #f59e0b; }
179 .cri-stat-card.is-low .cri-stat-value { color: #22c55e; }
180
181 /* Risk score ring */
182 .cri-risk-ring {
183 display: inline-flex;
184 align-items: center;
185 gap: 8px;
186 padding: 6px 12px;
187 border-radius: 20px;
188 font-size: 13px;
189 font-weight: 700;
190 }
191 .cri-risk-ring.is-high { color: #ef4444; background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); }
192 .cri-risk-ring.is-medium { color: #f59e0b; background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); }
193 .cri-risk-ring.is-low { color: #22c55e; background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.3); }
194
195 /* Downstream repo table */
196 .cri-table-wrap {
197 background: var(--bg-elevated);
198 border: 1px solid var(--border);
199 border-radius: 12px;
200 overflow: hidden;
201 margin-bottom: var(--space-4);
202 }
203 .cri-table {
204 width: 100%;
205 border-collapse: collapse;
206 }
207 .cri-table th {
208 padding: 10px 16px;
209 font-size: 11px;
210 font-weight: 700;
211 text-transform: uppercase;
212 letter-spacing: 0.05em;
213 color: var(--text-muted);
214 background: var(--bg-secondary, rgba(255,255,255,0.03));
215 border-bottom: 1px solid var(--border);
216 text-align: left;
217 }
218 .cri-table td {
219 padding: 12px 16px;
220 font-size: 13px;
221 border-bottom: 1px solid var(--border);
222 vertical-align: top;
223 }
224 .cri-table tr:last-child td { border-bottom: none; }
225 .cri-table tr:hover td { background: rgba(255,255,255,0.02); }
226
227 /* Risk pills */
228 .cri-pill {
229 display: inline-flex;
230 align-items: center;
231 padding: 2px 9px;
232 border-radius: 9999px;
233 font-size: 10.5px;
234 font-weight: 700;
235 letter-spacing: 0.04em;
236 text-transform: uppercase;
237 }
238 .cri-pill.is-high { color: #ef4444; background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); }
239 .cri-pill.is-medium { color: #f59e0b; background: rgba(245,158,11,0.12); border: 1px solid rgba(245,158,11,0.3); }
240 .cri-pill.is-low { color: #22c55e; background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.3); }
241
242 /* Symbol chips */
243 .cri-symbols {
244 display: flex;
245 flex-wrap: wrap;
246 gap: 4px;
247 max-width: 280px;
248 }
249 .cri-symbol-chip {
250 font-family: var(--font-mono);
251 font-size: 11px;
252 padding: 2px 7px;
253 background: rgba(139,92,246,0.1);
254 border: 1px solid rgba(139,92,246,0.25);
255 border-radius: 4px;
256 color: #a78bfa;
257 white-space: nowrap;
258 }
259
260 /* Fix PR button */
261 .cri-fix-btn {
262 display: inline-flex; align-items: center; gap: 5px;
263 padding: 5px 12px;
264 border-radius: 6px;
265 font-size: 12px; font-weight: 600;
266 color: #fff;
267 background: #5865f2;
268 border: none;
269 cursor: pointer;
270 transition: opacity 120ms ease;
271 }
272 .cri-fix-btn:hover { opacity: 0.85; }
273 .cri-fix-btn:disabled { opacity: 0.5; cursor: not-allowed; }
274
275 .cri-repo-link {
276 font-weight: 600;
277 color: var(--accent, #5865f2);
278 text-decoration: none;
279 }
280 .cri-repo-link:hover { text-decoration: underline; }
281 .cri-owner-prefix { color: var(--text-muted); font-weight: 400; }
282 .cri-version-tag {
283 font-family: var(--font-mono);
284 font-size: 11px;
285 color: var(--text-muted);
286 margin-top: 2px;
287 }
288
289 /* Empty state */
290 .cri-empty {
291 text-align: center;
292 padding: 64px 24px;
293 color: var(--text-muted);
294 }
295 .cri-empty-icon { font-size: 40px; margin-bottom: 12px; }
296 .cri-empty-title { font-size: 18px; font-weight: 700; color: var(--text-strong); margin-bottom: 6px; }
297 .cri-empty-sub { font-size: 14px; }
298
299 /* Alert banner */
300 .cri-alert {
301 padding: 12px 16px;
302 border-radius: 8px;
303 font-size: 13px;
304 margin-bottom: var(--space-4);
305 border-left: 3px solid;
306 }
307 .cri-alert.is-info { color: #93c5fd; background: rgba(59,130,246,0.08); border-color: #3b82f6; }
308 .cri-alert.is-warn { color: #fcd34d; background: rgba(245,158,11,0.08); border-color: #f59e0b; }
309`;
310
311// ─── Helpers ──────────────────────────────────────────────────────────────────
312
313function totalRiskClass(score: number): string {
314 if (score >= 60) return "is-high";
315 if (score >= 30) return "is-medium";
316 return "is-low";
317}
318
319function riskLabel(score: number): string {
320 if (score >= 60) return "High";
321 if (score >= 30) return "Medium";
322 return "Low";
323}
324
325// ─── Repo + PR resolution helper ─────────────────────────────────────────────
326
327async function resolveRepo(ownerName: string, repoName: string) {
328 const rows = await db
329 .select({
330 id: repositories.id,
331 ownerId: repositories.ownerId,
332 isPrivate: repositories.isPrivate,
333 })
334 .from(repositories)
335 .innerJoin(users, eq(repositories.ownerId, users.id))
336 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
337 .limit(1);
338 return rows[0] ?? null;
339}
340
341async function resolvePr(repoId: string, prNumber: number) {
342 const rows = await db
343 .select({
344 id: pullRequests.id,
345 number: pullRequests.number,
346 title: pullRequests.title,
347 state: pullRequests.state,
348 baseBranch: pullRequests.baseBranch,
349 headBranch: pullRequests.headBranch,
350 })
351 .from(pullRequests)
352 .where(
353 and(
354 eq(pullRequests.repositoryId, repoId),
355 eq(pullRequests.number, prNumber)
356 )
357 )
358 .limit(1);
359 return rows[0] ?? null;
360}
361
362// ─── Route: GET /:owner/:repo/pulls/:number/cross-repo-impact ─────────────────
363
364crossRepoImpactRoutes.use(
365 "/:owner/:repo/pulls/:number/cross-repo-impact",
366 softAuth
367);
368
369crossRepoImpactRoutes.get(
370 "/:owner/:repo/pulls/:number/cross-repo-impact",
371 requireRepoAccess("read"),
372 async (c) => {
373 const user = c.get("user") ?? null;
374 const { owner: ownerName, repo: repoName, number: prNumberStr } = c.req.param() as {
375 owner: string;
376 repo: string;
377 number: string;
378 };
379 const prNumber = parseInt(prNumberStr, 10);
380 if (isNaN(prNumber)) return c.notFound();
381
382 const repo = await resolveRepo(ownerName, repoName);
383 if (!repo) return c.notFound();
384
385 const pr = await resolvePr(repo.id, prNumber);
386 if (!pr) return c.notFound();
387
388 const unreadCount = user ? await getUnreadCount(user.id) : 0;
389 const isOwner = !!user && user.id === repo.ownerId;
390
391 // Try to load cached report (memory + DB, no re-analysis here)
392 let report: CrossRepoReport | null = null;
393 try {
394 // Import the cache-checking logic only (don't re-run full analysis)
395 const { analyzeCrossRepoImpact: analyze } = await import(
396 "../lib/cross-repo-impact"
397 );
398 // If there's a cached version it returns fast; if not, report = null
399 // We check the DB directly to avoid running analysis on page load
400 const { crossRepoImpactCache } = await import("../db/schema");
401 const cacheRows = await db
402 .select()
403 .from(crossRepoImpactCache)
404 .where(eq(crossRepoImpactCache.prId, pr.id))
405 .limit(1);
406
407 if (cacheRows.length > 0 && cacheRows[0].cachedUntil > new Date()) {
408 report = cacheRows[0].report as CrossRepoReport;
409 }
410 } catch {
411 // Degrade gracefully — show "no analysis yet"
412 }
413
414 const highCount = report?.affectedRepos.filter((r) => r.riskLevel === "high").length ?? 0;
415 const mediumCount = report?.affectedRepos.filter((r) => r.riskLevel === "medium").length ?? 0;
416 const lowCount = report?.affectedRepos.filter((r) => r.riskLevel === "low").length ?? 0;
417 const totalRisk = report?.totalRisk ?? 0;
418
419 const baseUrl = `/${ownerName}/${repoName}/pulls/${prNumber}`;
420
421 return c.html(
422 <Layout
423 title={`Cross-Repo ImpactPR #${prNumber} — ${ownerName}/${repoName}`}
424 user={user}
425 notificationCount={unreadCount}
426 >
427 <style dangerouslySetInnerHTML={{ __html: styles }} />
428 <div class="cri-wrap">
429 <RepoHeader owner={ownerName} repo={repoName} />
430 <RepoNav owner={ownerName} repo={repoName} active="pulls" />
431
432 {/* PR sub-navigation */}
433 <nav class="cri-subnav">
434 <a class="cri-subnav-link" href={`${baseUrl}`}>
435 Conversation
436 </a>
437 <a class="cri-subnav-link" href={`${baseUrl}/files`}>
438 Files
439 </a>
440 <a class="cri-subnav-link active" href={`${baseUrl}/cross-repo-impact`}>
441 Cross-Repo Impact
442 </a>
443 </nav>
444
445 {/* Hero */}
446 <div class="cri-hero">
447 <div class="cri-hero-orb" aria-hidden="true" />
448 <div class="cri-hero-inner">
449 <div class="cri-hero-eyebrow">&#9888; Dependency Risk</div>
450 <h1 class="cri-hero-title">
451 Cross-Repo Dependency Impact
452 </h1>
453 <p class="cri-hero-sub">
454 Detects downstream repositories that import exported symbols
455 changed by this PR. Run before merging to prevent silent
456 breaking changes in dependent packages.
457 </p>
458 <div class="cri-hero-actions">
459 {isOwner && (
460 <form
461 method="post"
462 action={`${baseUrl}/cross-repo-impact/analyze`}
463 >
464 <button type="submit" class="cri-analyze-btn">
465 &#8635; {report ? "Re-analyze" : "Analyze"}
466 </button>
467 </form>
468 )}
469 {report && (
470 <>
471 <span class={`cri-risk-ring ${totalRiskClass(totalRisk)}`}>
472 {riskLabel(totalRisk)} Risk &mdash; {totalRisk}/100
473 </span>
474 <span class="cri-analyzed-at">
475 Analyzed {new Date(report.analyzedAt).toLocaleString()} &middot;
476 valid until {new Date(report.cachedUntil).toLocaleTimeString()}
477 </span>
478 </>
479 )}
480 </div>
481 </div>
482 </div>
483
484 {report ? (
485 <>
486 {/* Stats */}
487 <div class="cri-stats">
488 <div class="cri-stat-card is-high">
489 <div class="cri-stat-value">{highCount}</div>
490 <div class="cri-stat-label">High risk repos</div>
491 </div>
492 <div class="cri-stat-card is-medium">
493 <div class="cri-stat-value">{mediumCount}</div>
494 <div class="cri-stat-label">Medium risk repos</div>
495 </div>
496 <div class="cri-stat-card is-low">
497 <div class="cri-stat-value">{lowCount}</div>
498 <div class="cri-stat-label">Low risk repos</div>
499 </div>
500 <div class="cri-stat-card">
501 <div class="cri-stat-value">{report.affectedRepos.length}</div>
502 <div class="cri-stat-label">Total downstream</div>
503 </div>
504 </div>
505
506 {report.affectedRepos.length === 0 ? (
507 <div class="cri-empty">
508 <div class="cri-empty-icon">&#10003;</div>
509 <div class="cri-empty-title">No downstream impact detected</div>
510 <p class="cri-empty-sub">
511 No other repos in the dependency graph import the exports
512 changed by this PR. Safe to merge.
513 </p>
514 </div>
515 ) : (
516 <>
517 {highCount > 0 && (
518 <div class="cri-alert is-warn">
519 <strong>Warning:</strong> {highCount} downstream repo{highCount !== 1 ? "s" : ""} import
520 changed symbols and have no test coverage. Merging may cause
521 silent runtime failures. Consider opening fix PRs first.
522 </div>
523 )}
524
525 {/* Downstream repo table */}
526 <div class="cri-table-wrap">
527 <table class="cri-table">
528 <thead>
529 <tr>
530 <th>Downstream Repo</th>
531 <th>Dependency</th>
532 <th>Risk</th>
533 <th>Changed Symbols</th>
534 {isOwner && <th>Action</th>}
535 </tr>
536 </thead>
537 <tbody>
538 {report.affectedRepos.map((impact) => (
539 <tr key={impact.repoId}>
540 <td>
541 <a
542 class="cri-repo-link"
543 href={`/${impact.ownerName}/${impact.repoName}`}
544 >
545 <span class="cri-owner-prefix">{impact.ownerName}/</span>
546 {impact.repoName}
547 </a>
548 </td>
549 <td>
550 <div style="font-family: var(--font-mono); font-size: 12px;">
551 {impact.dependencyName}
552 </div>
553 <div class="cri-version-tag">{impact.currentVersion}</div>
554 </td>
555 <td>
556 <span class={`cri-pill is-${impact.riskLevel}`}>
557 {impact.riskLevel}
558 </span>
559 </td>
560 <td>
561 <div class="cri-symbols">
562 {impact.changedExports.slice(0, 8).map((sym) => (
563 <span class="cri-symbol-chip" key={sym}>{sym}</span>
564 ))}
565 {impact.changedExports.length > 8 && (
566 <span class="cri-symbol-chip">
567 +{impact.changedExports.length - 8} more
568 </span>
569 )}
570 {impact.changedExports.length === 0 && (
571 <span style="color: var(--text-muted); font-size: 12px;">
572 (dep declared, no symbol match)
573 </span>
574 )}
575 </div>
576 </td>
577 {isOwner && (
578 <td>
579 {impact.suggestedFixPrUrl ? (
580 <a
581 class="cri-fix-btn"
582 href={impact.suggestedFixPrUrl}
583 >
584 View Fix PR
585 </a>
586 ) : (
587 <form
588 method="post"
589 action={`${baseUrl}/cross-repo-impact/open-fix-pr/${impact.repoId}`}
590 >
591 <button type="submit" class="cri-fix-btn">
592 Open Fix PR
593 </button>
594 </form>
595 )}
596 </td>
597 )}
598 </tr>
599 ))}
600 </tbody>
601 </table>
602 </div>
603
604 <div class="cri-alert is-info">
605 Analysis is based on <code>export</code> keyword changes in the PR diff and
606 dependency declarations in <code>repo_dependencies</code>. Results are cached for 15 minutes.
607 Re-analyze after rebasing or updating the diff.
608 </div>
609 </>
610 )}
611 </>
612 ) : (
613 <div class="cri-empty">
614 <div class="cri-empty-icon">&#128202;</div>
615 <div class="cri-empty-title">No analysis yet</div>
616 <p class="cri-empty-sub">
617 {isOwner
618 ? "Click Analyze to detect downstream repos that may break when this PR is merged."
619 : "The repo owner hasn't run a cross-repo impact analysis on this PR yet."}
620 </p>
621 </div>
622 )}
623 </div>
624 </Layout>
625 );
626 }
627);
628
629// ─── Route: POST /:owner/:repo/pulls/:number/cross-repo-impact/analyze ────────
630
631crossRepoImpactRoutes.use(
632 "/:owner/:repo/pulls/:number/cross-repo-impact/analyze",
633 requireAuth
634);
635
636crossRepoImpactRoutes.post(
637 "/:owner/:repo/pulls/:number/cross-repo-impact/analyze",
638 requireRepoAccess("write"),
639 async (c) => {
640 const user = c.get("user")!;
641 const { owner: ownerName, repo: repoName, number: prNumberStr } = c.req.param() as {
642 owner: string;
643 repo: string;
644 number: string;
645 };
646 const prNumber = parseInt(prNumberStr, 10);
647 if (isNaN(prNumber)) return c.notFound();
648
649 const repo = await resolveRepo(ownerName, repoName);
650 if (!repo) return c.notFound();
651
652 if (user.id !== repo.ownerId) {
653 return c.text("Forbidden", 403);
654 }
655
656 const pr = await resolvePr(repo.id, prNumber);
657 if (!pr) return c.notFound();
658
659 // Fire analysis in the background (non-blocking)
660 analyzeCrossRepoImpact(repo.id, pr.id, ownerName, repoName).catch(() => {});
661
662 // Small delay to let the analysis start before redirect
663 await new Promise<void>((resolve) => setTimeout(resolve, 400));
664
665 return c.redirect(
666 `/${ownerName}/${repoName}/pulls/${prNumber}/cross-repo-impact`
667 );
668 }
669);
670
671// ─── Route: POST /…/open-fix-pr/:downstreamRepoId ─────────────────────────────
672
673crossRepoImpactRoutes.use(
674 "/:owner/:repo/pulls/:number/cross-repo-impact/open-fix-pr/:downstreamRepoId",
675 requireAuth
676);
677
678crossRepoImpactRoutes.post(
679 "/:owner/:repo/pulls/:number/cross-repo-impact/open-fix-pr/:downstreamRepoId",
680 requireRepoAccess("write"),
681 async (c) => {
682 const user = c.get("user")!;
683 const {
684 owner: ownerName,
685 repo: repoName,
686 number: prNumberStr,
687 downstreamRepoId,
688 } = c.req.param() as {
689 owner: string;
690 repo: string;
691 number: string;
692 downstreamRepoId: string;
693 };
694 const prNumber = parseInt(prNumberStr, 10);
695 if (isNaN(prNumber)) return c.notFound();
696
697 const repo = await resolveRepo(ownerName, repoName);
698 if (!repo) return c.notFound();
699
700 if (user.id !== repo.ownerId) {
701 return c.text("Forbidden", 403);
702 }
703
704 const pr = await resolvePr(repo.id, prNumber);
705 if (!pr) return c.notFound();
706
707 // Load the cached report to find the downstream impact entry
708 let impact: DownstreamImpact | undefined;
709 try {
710 const { crossRepoImpactCache } = await import("../db/schema");
711 const cacheRows = await db
712 .select()
713 .from(crossRepoImpactCache)
714 .where(eq(crossRepoImpactCache.prId, pr.id))
715 .limit(1);
716 if (cacheRows.length > 0) {
717 const report = cacheRows[0].report as CrossRepoReport;
718 impact = report.affectedRepos.find((r) => r.repoId === downstreamRepoId);
719 }
720 } catch {
721 /* best-effort */
722 }
723
724 if (!impact) {
725 return c.redirect(
726 `/${ownerName}/${repoName}/pulls/${prNumber}/cross-repo-impact`
727 );
728 }
729
730 // Load the downstream repo details
731 const [downstreamRepo] = await db
732 .select({
733 id: repositories.id,
734 name: repositories.name,
735 ownerId: repositories.ownerId,
736 defaultBranch: repositories.defaultBranch,
737 })
738 .from(repositories)
739 .where(eq(repositories.id, downstreamRepoId))
740 .limit(1)
741 .catch(() => []);
742
743 if (!downstreamRepo) {
744 return c.redirect(
745 `/${ownerName}/${repoName}/pulls/${prNumber}/cross-repo-impact`
746 );
747 }
748
749 // Build migration PR body
750 const changedSymbolsList = impact.changedExports.length
751 ? impact.changedExports.map((s) => `- \`${s}\``).join("\n")
752 : "_(see PR diff for details)_";
753
754 const prBody = `## Migration: Updated dependency \`${impact.dependencyName}\`
755
756This PR was automatically created by the cross-repo impact analysis on **${ownerName}/${repoName} #${prNumber}**.
757
758### What changed upstream
759
760The following exported symbols were changed in [${ownerName}/${repoName} PR #${prNumber}](/${ownerName}/${repoName}/pulls/${prNumber}):
761
762${changedSymbolsList}
763
764### What to update in this repo
765
766Review all imports of \`${impact.dependencyName}\` and update any usages of the listed symbols to match the new API.
767
768**Risk level:** ${impact.riskLevel.toUpperCase()}
769
770---
771_Generated by Gluecron cross-repo impact detection._`;
772
773 // Insert a draft PR into the downstream repo using existing schema pattern
774 try {
775 const newPr = await db
776 .insert(pullRequests)
777 .values({
778 repositoryId: downstreamRepo.id,
779 authorId: user.id,
780 title: `fix: update ${impact.dependencyName} API usage after upstream changes`,
781 body: prBody,
782 state: "open",
783 baseBranch: downstreamRepo.defaultBranch,
784 headBranch: `fix/dep-update-${impact.dependencyName.replace(/[^a-z0-9]/gi, "-")}-${Date.now()}`,
785 isDraft: true,
786 })
787 .returning({ id: pullRequests.id, number: pullRequests.number });
788
789 if (newPr.length > 0) {
790 const fixPrUrl = `/${impact.ownerName}/${impact.repoName}/pulls/${newPr[0].number}`;
791
792 // Post a comment on the original PR linking the fix PR
793 await db.insert(prComments).values({
794 pullRequestId: pr.id,
795 authorId: user.id,
796 body: `**Cross-repo impact fix:** Opened a draft migration PR on \`${impact.ownerName}/${impact.repoName}\`: [${fixPrUrl}](${fixPrUrl})`,
797 }).catch(() => {});
798
799 return c.redirect(fixPrUrl);
800 }
801 } catch {
802 /* best-effort — fall through to redirect */
803 }
804
805 return c.redirect(
806 `/${ownerName}/${repoName}/pulls/${prNumber}/cross-repo-impact`
807 );
808 }
809);
810
811export default crossRepoImpactRoutes;
0812