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

Merge pull request #105 from ccantynz-alt/feat/stage-impact

Merge pull request #105 from ccantynz-alt/feat/stage-impact

Feat/stage impact
CC LABS App committed on June 7, 2026Parents: 2fef29e d2b3c80
1 file changed+0292d7a5c0c2759641de36ff71c253e23699e321bf47
1 changed file+0−292
Modifiedsrc/routes/pulls.tsx+0−292View fileUnifiedSplit
126126import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
127127import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
128128import { BOT_USERNAME } from "../lib/bot-user";
129import {
130 getCodeownersForRepo,
131 reviewersForChangedFiles,
132} from "../lib/codeowners";
133import { checkMergeEligible } from "../lib/branch-rules";
134import {
135 joinRoom,
136 leaveRoom,
137 updatePresence,
138 pingSession,
139 getRoomUsers,
140 broadcastToRoom,
141 registerSocket,
142 unregisterSocket,
143} from "../lib/pr-presence";
144import { upgradeWebSocket, websocket as presenceWebsocket } from "hono/bun";
145
146export { presenceWebsocket };
147import { getBusFactorWarning, type BusFactorFile } from "../lib/bus-factor";
148import { suggestPrSplit, type SplitSuggestion } from "../lib/pr-splitter";
149129import { analyzeImpact, type ImpactAnalysis } from "../lib/pr-impact";
150130import { getPreviewDir, isPreviewExpired } from "../lib/pr-stage";
151131
14961476 display: inline-flex; align-items: center; gap: 4px;
14971477 }
14981478
1499 /* ─── Bus Factor Warning Panel ─── */
1500 .busfactor-panel {
1501 display: flex;
1502 gap: 14px;
1503 align-items: flex-start;
1504 padding: 14px 18px;
1505 margin-bottom: 16px;
1506 border-radius: 12px;
1507 border: 1px solid rgba(245,158,11,0.35);
1508 background: rgba(245,158,11,0.06);
1509 }
1510 .busfactor-critical {
1511 border-color: rgba(239,68,68,0.4);
1512 background: rgba(239,68,68,0.06);
1513 }
1514 .busfactor-high {
1515 border-color: rgba(249,115,22,0.4);
1516 background: rgba(249,115,22,0.06);
1517 }
1518 .busfactor-medium {
1519 border-color: rgba(245,158,11,0.35);
1520 background: rgba(245,158,11,0.06);
1521 }
1522 .busfactor-icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; }
1523 .busfactor-body { flex: 1; min-width: 0; }
1524 .busfactor-body strong { font-size: 14px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1525 .busfactor-body p { font-size: 13px; color: var(--text-muted); margin: 0 0 8px; }
1526 .busfactor-body ul { margin: 0; padding-left: 18px; }
1527 .busfactor-body li { font-size: 12.5px; color: var(--text-muted); margin-bottom: 3px; font-family: var(--font-mono); }
1528 .busfactor-body li strong { font-size: 12.5px; color: var(--text); display: inline; }
1529
1530 /* ─── PR Split Suggestion Panel ─── */
1531 .split-suggestion {
1532 margin-bottom: 16px;
1533 border: 1px solid rgba(140,109,255,0.35);
1534 border-radius: 12px;
1535 overflow: hidden;
1536 }
1537 .split-header {
1538 display: flex;
1539 align-items: center;
1540 gap: 10px;
1541 padding: 12px 18px;
1542 background: rgba(140,109,255,0.06);
1543 flex-wrap: wrap;
1544 }
1545 .split-icon { font-size: 18px; flex-shrink: 0; }
1546 .split-header strong { font-size: 14px; font-weight: 700; color: var(--text-strong); flex: 1; min-width: 200px; }
1547 .split-stat { font-size: 12px; color: var(--text-muted); background: var(--bg-elevated); padding: 2px 9px; border-radius: 9999px; border: 1px solid var(--border); white-space: nowrap; }
1548 .split-toggle {
1549 background: none;
1550 border: 1px solid rgba(140,109,255,0.45);
1551 color: rgba(140,109,255,0.9);
1552 font-size: 12.5px;
1553 font-weight: 600;
1554 padding: 4px 12px;
1555 border-radius: 8px;
1556 cursor: pointer;
1557 white-space: nowrap;
1558 transition: background 120ms ease;
1559 }
1560 .split-toggle:hover { background: rgba(140,109,255,0.1); }
1561 .split-body {
1562 padding: 16px 18px;
1563 border-top: 1px solid rgba(140,109,255,0.2);
1564 }
1565 .split-intro { font-size: 13.5px; color: var(--text-muted); margin: 0 0 14px; }
1566 .split-pr {
1567 display: flex;
1568 gap: 14px;
1569 align-items: flex-start;
1570 padding: 12px 0;
1571 border-bottom: 1px solid var(--border);
1572 }
1573 .split-pr:last-of-type { border-bottom: none; }
1574 .split-pr-num {
1575 width: 26px; height: 26px;
1576 border-radius: 50%;
1577 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
1578 color: #fff;
1579 font-size: 12px;
1580 font-weight: 800;
1581 display: inline-flex;
1582 align-items: center;
1583 justify-content: center;
1584 flex-shrink: 0;
1585 margin-top: 2px;
1586 }
1587 .split-pr-body { flex: 1; min-width: 0; }
1588 .split-pr-body strong { font-size: 13.5px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1589 .split-pr-body p { font-size: 12.5px; color: var(--text-muted); margin: 0 0 6px; }
1590 .split-pr-body code { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); word-break: break-all; }
1591 .split-lines { display: inline-block; margin-left: 10px; font-size: 11.5px; color: var(--text-muted); background: var(--bg-tertiary); padding: 1px 7px; border-radius: 9999px; }
1592 .split-order { font-size: 13px; color: var(--text-muted); margin: 14px 0 0; }
1593 .split-order strong { color: var(--text); }
1594`;
1595
1596/* ──────────────────────────────────────────────────────────────────────
1597 * Figma-style collaborative PR presence — styles for the presence bar
1598 * above the diff and the per-line reviewer cursor pills. All scoped
1599 * with `.presence-*` prefix so they never bleed into other views.
1600 * ──────────────────────────────────────────────────────────────────── */
1601const PRESENCE_STYLES = `
1602 .presence-bar {
1603 display: flex;
1604 align-items: center;
1605 gap: 10px;
1606 padding: 8px 14px;
1607 margin: 0 0 10px;
1608 background: var(--bg-elevated);
1609 border: 1px solid var(--border);
1610 border-radius: 10px;
1611 font-size: 12.5px;
1612 color: var(--text-muted);
1613 min-height: 38px;
1614 }
1615 .presence-bar-label {
1616 font-weight: 600;
1617 color: var(--text-muted);
1618 flex-shrink: 0;
1619 }
1620 .presence-avatars {
1621 display: flex;
1622 align-items: center;
1623 gap: 6px;
1624 flex: 1;
1625 flex-wrap: wrap;
1626 }
1627 .presence-avatar {
1628 display: inline-flex;
1629 align-items: center;
1630 gap: 5px;
1631 padding: 3px 8px 3px 4px;
1632 border-radius: 9999px;
1633 font-size: 12px;
1634 font-weight: 600;
1635 color: #fff;
1636 opacity: 0.92;
1637 transition: opacity 200ms;
1638 }
1639 .presence-avatar-dot {
1640 width: 20px; height: 20px;
1641 border-radius: 9999px;
1642 background: rgba(255,255,255,0.22);
1643 display: inline-flex;
1644 align-items: center;
1645 justify-content: center;
1646 font-size: 10px;
1647 font-weight: 700;
1648 flex-shrink: 0;
1649 }
1650 .presence-count {
1651 font-size: 12px;
1652 color: var(--text-faint);
1653 flex-shrink: 0;
1654 }
1655 /* Per-line reviewer cursor pill — injected by JS into .diff-row */
1656 .presence-line-pill {
1657 position: absolute;
1658 right: 6px;
1659 top: 50%;
1660 transform: translateY(-50%);
1661 display: inline-flex;
1662 align-items: center;
1663 gap: 4px;
1664 padding: 1px 7px;
1665 border-radius: 9999px;
1666 font-size: 10.5px;
1667 font-weight: 600;
1668 color: #fff;
1669 pointer-events: none;
1670 white-space: nowrap;
1671 z-index: 10;
1672 opacity: 0.88;
1673 animation: presence-in 160ms ease;
1674 }
1675 @keyframes presence-in {
1676 from { opacity: 0; transform: translateY(-50%) scale(0.85); }
1677 to { opacity: 0.88; transform: translateY(-50%) scale(1); }
1678 }
1679 .presence-line-pill.is-typing::after {
1680 content: '…';
1681 opacity: 0.7;
1682 }
1683 /* diff rows with a cursor pill need relative positioning */
1684 .diff-row { position: relative; }
1685 /* Toast for join/leave events */
1686 .presence-toast-wrap {
1687 position: fixed;
1688 bottom: 24px;
1689 right: 24px;
1690 display: flex;
1691 flex-direction: column;
1692 gap: 8px;
1693 z-index: 9999;
1694 pointer-events: none;
1695 }
1696 .presence-toast {
1697 padding: 8px 14px;
1698 border-radius: 8px;
1699 background: var(--bg-elevated);
1700 border: 1px solid var(--border);
1701 box-shadow: 0 6px 20px -8px rgba(0,0,0,0.55);
1702 font-size: 13px;
1703 color: var(--text);
1704 opacity: 1;
1705 transition: opacity 400ms;
1706 }
1707 .presence-toast.fading { opacity: 0; }
1708`;
17091479 /* ─── Merge Impact Analysis panel (.impact-*) ─── */
17101480 .impact-panel {
17111481 margin-top: 20px;
41843954 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
41853955 } catch { /* swallow — purely cosmetic */ }
41863956
4187 // Bus factor warning — non-blocking. Get changed files list first.
4188 let busRiskFiles: BusFactorFile[] = [];
4189 let splitSuggestion: SplitSuggestion | null = null;
4190 try {
4191 // Get names of files changed in this PR
4192 const repoDir = getRepoPath(ownerName, repoName);
4193 const nameOnlyProc = Bun.spawn(
4194 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4195 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4196 );
4197 const nameOnlyRaw = await new Response(nameOnlyProc.stdout).text();
4198 await nameOnlyProc.exited;
4199 const prChangedFiles = nameOnlyRaw.trim().split("\n").filter(Boolean);
4200
4201 // Bus factor — check cache for at-risk files that overlap changed files
4202 [busRiskFiles] = await Promise.all([
4203 getBusFactorWarning(resolved.repo.id, ownerName, repoName, prChangedFiles),
4204 ]);
4205
4206 // PR Split suggestion — only when PR is large (>400 lines)
4207 if (prSizeInfo && prSizeInfo.linesChanged > 400) {
4208 splitSuggestion = await suggestPrSplit(
4209 pr.id,
4210 pr.title,
4211 ownerName,
4212 repoName,
4213 pr.baseBranch,
4214 pr.headBranch
4215 );
4216 }
4217 } catch { /* always degrade gracefully */ }
42183957 // Merge impact analysis — only for open PRs with write access (cached, fast)
42193958 let impactAnalysis: ImpactAnalysis | null = null;
42203959 if (pr.state === "open" && canManage) {
49294668 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
49304669 )}
49314670
4932 {/* ─── Requested reviewers (CODEOWNERS auto-assign, migration 0077) ─── */}
4933 {requestedReviewerRows.length > 0 && (
4934 <div class="prs-review-summary" style="margin-top:14px">
4935 <div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-weight:700;margin-bottom:4px">
4936 Review requested
4937 </div>
4938 {requestedReviewerRows.map((rr) => {
4939 const hasReviewed = latestReviewByReviewer.has(rr.reviewerId);
4940 const review = latestReviewByReviewer.get(rr.reviewerId);
4941 const statusIcon = !hasReviewed ? "⏳" : review?.state === "approved" ? "✓" : "✗";
4942 const statusColor = !hasReviewed
4943 ? "var(--text-muted)"
4944 : review?.state === "approved"
4945 ? "#34d399"
4946 : "#f87171";
4947 return (
4948 <div class="prs-review-row" style={`gap:8px`}>
4949 <span class="prs-reviewer-avatar">
4950 {rr.reviewerUsername.slice(0, 1).toUpperCase()}
4951 </span>
4952 <a href={`/${rr.reviewerUsername}`}
4953 style="flex:1;font-size:13px;color:var(--text);font-weight:600;text-decoration:none">
4954 {rr.reviewerUsername}
4955 </a>
4956 <span style={`font-size:12px;font-weight:600;color:${statusColor}`}>
4957 {statusIcon} {!hasReviewed ? "Pending" : review?.state === "approved" ? "Approved" : "Changes requested"}
4958 </span>
4959 </div>
4960 );
4961 })}
4962 </div>
49634671 {/* ─── Merge Impact Analysis panel ─────────────────────── */}
49644672 {impactAnalysis && pr.state === "open" && (
49654673 <ImpactPanel analysis={impactAnalysis} owner={ownerName} />
49664674