Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

cross-repo-impact.tsx

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.tsxBlame811 lines · 1 contributor
10ec057Claude1/**
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 Impact — PR #${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;