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

pulls.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.

pulls.tsxBlame7030 lines · 5 contributors
0074234Claude1/**
2 * Pull request routes — create, list, view, merge, close, comment.
b078860Claude3 *
4 * The list view (`GET /:owner/:repo/pulls`) and detail view
5 * (`GET /:owner/:repo/pulls/:number`) carry the 2026 polish: hero with
6 * gradient title + hairline strip, pill-style state tabs, soft-lift
7 * row cards, conversation thread with AI-review accent border, distinct
8 * gate-check rows, and a gradient-bordered "Merge pull request" button.
9 *
10 * All visual styling is scoped via `.prs-*` class prefixes inside inline
11 * <style> blocks so other surfaces are untouched. No business logic was
12 * changed in this polish pass — AI review triggers, auto-merge wiring,
13 * gate evaluation, and the merge handler are preserved exactly.
0074234Claude14 */
15
16import { Hono } from "hono";
f4abb8eClaude17import { eq, and, desc, asc, sql, inArray, ilike, ne, isNotNull } from "drizzle-orm";
0074234Claude18import { db } from "../db";
19import {
20 pullRequests,
21 prComments,
0a67773Claude22 prReviews,
ec9e3e3Claude23 prReviewRequests,
0074234Claude24 repositories,
25 users,
d62fb36Claude26 issues,
27 issueComments,
f4abb8eClaude28 repoCollaborators,
a2c10c5Claude29 pendingReviews,
30 pendingReviewComments,
0074234Claude31} from "../db/schema";
32import { Layout } from "../views/layout";
ea9ed4cClaude33import { RepoHeader } from "../views/components";
cb5a796Claude34import { PendingCommentsBanner } from "../views/pending-comments-banner";
47a7a0aClaude35import { DiffView, type InlineDiffComment } from "../views/diff-view";
6fc53bdClaude36import { ReactionsBar } from "../views/reactions";
37import { summariseReactions } from "../lib/reactions";
24cf2caClaude38import { loadPrTemplate } from "../lib/templates";
0074234Claude39import { renderMarkdown } from "../lib/markdown";
15db0e0Claude40import {
41 parseSlashCommand,
42 executeSlashCommand,
43 detectSlashCmdComment,
44 stripSlashCmdMarker,
45} from "../lib/pr-slash-commands";
b584e52Claude46import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude47import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude48import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude49import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
0074234Claude50import { softAuth, requireAuth } from "../middleware/auth";
51import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude52import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude53import {
54 decideInitialStatus,
55 notifyOwnerOfPendingComment,
56 countPendingForRepo,
57} from "../lib/comment-moderation";
c166384ccantynz-alt58import { isAiReviewEnabled, triggerAiReview, isAiReviewApproved } from "../lib/ai-review";
79ed944Claude59import {
60 TRIO_COMMENT_MARKER,
61 TRIO_SUMMARY_MARKER,
67dc4e1Claude62 isTrioReviewEnabled,
79ed944Claude63 type TrioPersona,
64} from "../lib/ai-review-trio";
1d4ff60Claude65import {
66 generateTestsForPr,
67 AI_TESTS_MARKER,
68} from "../lib/ai-test-generator";
0316dbbClaude69import { triggerPrTriage } from "../lib/pr-triage";
b7b5f75ccanty labs70import { logActivity } from "../lib/notify";
a74f4edccanty labs71import { fireWebhooks } from "./webhooks";
81c73c1Claude72import { generatePrSummary } from "../lib/ai-generators";
73import { isAiAvailable } from "../lib/ai-client";
cc34156Claude74import { getReviewContext, recordPrVisit, type ReviewContext } from "../lib/review-context";
534f04aClaude75import {
76 computePrRiskForPullRequest,
77 getCachedPrRisk,
78 type PrRiskScore,
79} from "../lib/pr-risk";
0316dbbClaude80import { runAllGateChecks } from "../lib/gate";
81import type { GateCheckResult } from "../lib/gate";
82import {
83 matchProtection,
84 countHumanApprovals,
85 listRequiredChecks,
86 passingCheckNames,
87 evaluateProtection,
88} from "../lib/branch-protection";
89import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude90import {
91 listBranches,
92 getRepoPath,
e883329Claude93 resolveRef,
b5dd694Claude94 getBlob,
95 createOrUpdateFileOnBranch,
b558f23Claude96 commitsBetween,
0074234Claude97} from "../git/repository";
b558f23Claude98import type { GitDiffFile, GitCommit } from "../git/repository";
240c477Claude99import { listStatuses } from "../lib/commit-statuses";
100import type { CommitStatus } from "../db/schema";
0074234Claude101import { html } from "hono/html";
4bbacbeClaude102import {
103 getPreviewForBranch,
104 previewStatusLabel,
105} from "../lib/branch-previews";
1e162a8Claude106import {
bb0f894Claude107 Flex,
108 Container,
109 Badge,
110 Button,
111 LinkButton,
112 Form,
113 FormGroup,
114 Input,
115 TextArea,
116 Select,
117 EmptyState,
118 FilterTabs,
119 TabNav,
120 List,
121 ListItem,
122 Text,
123 Alert,
124 MarkdownContent,
125 CommentBox,
126 formatRelative,
127} from "../views/ui";
0074234Claude128
ace34efClaude129import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
74d8c4dClaude130import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
a7460bfClaude131import { BOT_USERNAME } from "../lib/bot-user";
09d5f39Claude132import { analyzeImpact, type ImpactAnalysis } from "../lib/pr-impact";
133import { getPreviewDir, isPreviewExpired } from "../lib/pr-stage";
7f992cdClaude134import {
135 getCodeownersForRepo,
136 reviewersForChangedFiles,
91b054eccanty labs137 requiredOwnersApproved,
7f992cdClaude138} from "../lib/codeowners";
91b054eccanty labs139import { enqueuePullRequestWorkflows } from "../lib/pr-workflow-sync";
7f992cdClaude140import { getPatternWarning, type Pattern } from "../lib/pattern-detector";
141import { suggestPrSplit, type SplitSuggestion } from "../lib/pr-splitter";
142import {
143 joinRoom,
144 leaveRoom,
145 broadcastToRoom,
146 registerSocket,
147 unregisterSocket,
148 getRoomUsers,
149 pingSession,
150 updatePresence,
151} from "../lib/pr-presence";
152import { getBusFactorWarning, type BusFactorFile } from "../lib/bus-factor";
153import { checkMergeEligible } from "../lib/branch-rules";
154import { createBunWebSocket } from "hono/bun";
155
156const { upgradeWebSocket, websocket: presenceWebsocket } = createBunWebSocket();
157export { presenceWebsocket };
ace34efClaude158
0074234Claude159const pulls = new Hono<AuthEnv>();
160
b078860Claude161/* ──────────────────────────────────────────────────────────────────────
162 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
163 * into the issue tracker or any other route. Tokens come from layout.tsx
164 * `:root` so light/dark stays consistent if/when light mode lands.
165 * ──────────────────────────────────────────────────────────────────── */
166const PRS_LIST_STYLES = `
167 .prs-hero {
168 position: relative;
169 margin: 0 0 var(--space-5);
170 padding: 22px 26px 24px;
171 background: var(--bg-elevated);
172 border: 1px solid var(--border);
173 border-radius: 16px;
174 overflow: hidden;
175 }
176 .prs-hero::before {
177 content: '';
178 position: absolute; top: 0; left: 0; right: 0;
179 height: 2px;
6fd5915Claude180 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
b078860Claude181 opacity: 0.7;
182 pointer-events: none;
183 }
184 .prs-hero-inner {
185 position: relative;
186 display: flex;
187 justify-content: space-between;
188 align-items: flex-end;
189 gap: 20px;
190 flex-wrap: wrap;
191 }
192 .prs-hero-text { flex: 1; min-width: 280px; }
193 .prs-hero-eyebrow {
194 font-size: 12px;
195 color: var(--text-muted);
196 text-transform: uppercase;
197 letter-spacing: 0.08em;
198 font-weight: 600;
199 margin-bottom: 8px;
200 }
201 .prs-hero-title {
202 font-family: var(--font-display);
203 font-size: clamp(26px, 3.4vw, 34px);
204 font-weight: 800;
205 letter-spacing: -0.025em;
206 line-height: 1.06;
207 margin: 0 0 8px;
208 color: var(--text-strong);
209 }
210 .prs-hero-title .gradient-text {
6fd5915Claude211 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
b078860Claude212 -webkit-background-clip: text;
213 background-clip: text;
214 -webkit-text-fill-color: transparent;
215 color: transparent;
216 }
217 .prs-hero-sub {
218 font-size: 14.5px;
219 color: var(--text-muted);
220 margin: 0;
221 line-height: 1.5;
222 max-width: 620px;
223 }
224 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
225 .prs-cta {
226 display: inline-flex; align-items: center; gap: 6px;
227 padding: 10px 16px;
228 border-radius: 10px;
229 font-size: 13.5px;
230 font-weight: 600;
231 color: #fff;
6fd5915Claude232 background: linear-gradient(135deg, #5b6ee8 0%, #6f5be8 60%, #5f8fa0 140%);
233 border: 1px solid rgba(91,110,232,0.55);
234 box-shadow: 0 6px 18px -8px rgba(91,110,232,0.55);
b078860Claude235 text-decoration: none;
236 transition: transform 120ms ease, box-shadow 160ms ease;
237 }
238 .prs-cta:hover {
239 transform: translateY(-1px);
6fd5915Claude240 box-shadow: 0 10px 22px -6px rgba(91,110,232,0.6);
b078860Claude241 color: #fff;
242 }
243
244 .prs-tabs {
245 display: flex; flex-wrap: wrap; gap: 6px;
246 margin: 0 0 18px;
247 padding: 6px;
248 background: var(--bg-secondary);
249 border: 1px solid var(--border);
250 border-radius: 12px;
251 }
252 .prs-tab {
253 display: inline-flex; align-items: center; gap: 8px;
254 padding: 7px 13px;
255 font-size: 13px;
256 font-weight: 500;
257 color: var(--text-muted);
258 border-radius: 8px;
259 text-decoration: none;
260 transition: background 120ms ease, color 120ms ease;
261 }
262 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
263 .prs-tab.is-active {
264 background: var(--bg-elevated);
265 color: var(--text-strong);
266 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
267 }
268 .prs-tab-count {
269 display: inline-flex; align-items: center; justify-content: center;
270 min-width: 22px; padding: 2px 7px;
271 font-size: 11.5px;
272 font-weight: 600;
273 border-radius: 9999px;
274 background: var(--bg-tertiary);
275 color: var(--text-muted);
276 }
277 .prs-tab.is-active .prs-tab-count {
6fd5915Claude278 background: rgba(91,110,232,0.18);
b078860Claude279 color: var(--text-link);
280 }
281
282 .prs-list { display: flex; flex-direction: column; gap: 10px; }
283 .prs-row {
284 position: relative;
285 display: flex; align-items: flex-start; gap: 14px;
286 padding: 14px 16px;
287 background: var(--bg-elevated);
288 border: 1px solid var(--border);
289 border-radius: 12px;
290 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
291 }
292 .prs-row:hover {
293 transform: translateY(-1px);
294 border-color: var(--border-strong);
295 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
296 }
297 .prs-row-icon {
298 flex: 0 0 auto;
299 width: 26px; height: 26px;
300 display: inline-flex; align-items: center; justify-content: center;
301 border-radius: 9999px;
302 font-size: 13px;
303 margin-top: 2px;
304 }
305 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
6fd5915Claude306 .prs-row-icon.state-merged { color: #5b6ee8; background: rgba(91,110,232,0.16); }
b078860Claude307 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
308 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
309 .prs-row-body { flex: 1; min-width: 0; }
310 .prs-row-title {
311 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
312 font-size: 15px; font-weight: 600;
313 color: var(--text-strong);
314 line-height: 1.35;
315 margin: 0 0 6px;
316 }
317 .prs-row-number {
318 color: var(--text-muted);
319 font-weight: 400;
320 font-size: 14px;
321 }
322 .prs-row-meta {
323 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
324 font-size: 12.5px;
325 color: var(--text-muted);
326 }
327 .prs-branch-chips {
328 display: inline-flex; align-items: center; gap: 6px;
329 font-family: var(--font-mono);
330 font-size: 11.5px;
331 }
332 .prs-branch-chip {
333 padding: 2px 8px;
334 border-radius: 9999px;
335 background: var(--bg-tertiary);
336 border: 1px solid var(--border);
337 color: var(--text);
338 }
339 .prs-branch-arrow {
340 color: var(--text-faint);
341 font-size: 11px;
342 }
343 .prs-row-tags {
344 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
345 margin-left: auto;
346 }
347 .prs-tag {
348 display: inline-flex; align-items: center; gap: 4px;
349 padding: 2px 8px;
350 font-size: 11px;
351 font-weight: 600;
352 border-radius: 9999px;
353 border: 1px solid var(--border);
354 background: var(--bg-secondary);
355 color: var(--text-muted);
356 line-height: 1.6;
357 }
358 .prs-tag.is-draft {
359 color: var(--text-muted);
360 border-color: var(--border-strong);
361 }
362 .prs-tag.is-merged {
363 color: var(--text-link);
6fd5915Claude364 border-color: rgba(91,110,232,0.45);
365 background: rgba(91,110,232,0.10);
b078860Claude366 }
1aef949Claude367 .prs-tag.is-approved {
368 color: #34d399;
369 border-color: rgba(52,211,153,0.40);
370 background: rgba(52,211,153,0.08);
371 }
372 .prs-tag.is-changes {
373 color: #f87171;
374 border-color: rgba(248,113,113,0.40);
375 background: rgba(248,113,113,0.08);
376 }
2c61840Claude377 .prs-tag.is-ai {
378 color: #a78bfa;
379 border-color: rgba(167,139,250,0.40);
380 background: rgba(167,139,250,0.08);
381 }
b078860Claude382
383 .prs-empty {
ea9ed4cClaude384 position: relative;
385 padding: 56px 32px;
b078860Claude386 text-align: center;
387 border: 1px dashed var(--border);
ea9ed4cClaude388 border-radius: 16px;
389 background: var(--bg-elevated);
b078860Claude390 color: var(--text-muted);
ea9ed4cClaude391 overflow: hidden;
b078860Claude392 }
ea9ed4cClaude393 .prs-empty::before {
394 content: '';
395 position: absolute;
396 inset: -40% -20% auto auto;
397 width: 320px; height: 320px;
6fd5915Claude398 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.08) 50%, transparent 75%);
ea9ed4cClaude399 filter: blur(70px);
400 opacity: 0.55;
401 pointer-events: none;
402 animation: prsEmptyOrb 16s ease-in-out infinite;
403 }
404 @keyframes prsEmptyOrb {
405 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
406 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
407 }
408 @media (prefers-reduced-motion: reduce) {
409 .prs-empty::before { animation: none; }
410 }
411 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude412 .prs-empty strong {
413 display: block;
414 color: var(--text-strong);
ea9ed4cClaude415 font-family: var(--font-display);
416 font-size: 22px;
417 font-weight: 700;
418 letter-spacing: -0.018em;
419 margin-bottom: 2px;
420 }
421 .prs-empty-sub {
422 font-size: 14.5px;
423 color: var(--text-muted);
424 line-height: 1.55;
425 max-width: 460px;
426 margin: 0 0 18px;
b078860Claude427 }
ea9ed4cClaude428 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude429
430 @media (max-width: 720px) {
431 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
432 .prs-hero-actions { width: 100%; }
433 .prs-row-tags { margin-left: 0; }
434 }
f1dc7c7Claude435
436 /* Additional mobile rules. Additive only. */
437 @media (max-width: 720px) {
438 .prs-hero { padding: 18px 18px 20px; }
439 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
440 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
441 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
442 .prs-row { padding: 12px 14px; gap: 10px; }
443 .prs-row-icon { width: 24px; height: 24px; }
444 }
f5b9ef5Claude445
446 /* ─── Sort controls (PR list) ─── */
447 .prs-sort-row {
448 display: flex;
449 align-items: center;
450 gap: 6px;
451 margin: 0 0 12px;
452 flex-wrap: wrap;
453 }
454 .prs-sort-label {
455 font-size: 12.5px;
456 color: var(--text-muted);
457 font-weight: 600;
458 margin-right: 2px;
459 }
460 .prs-sort-opt {
461 font-size: 12.5px;
462 color: var(--text-muted);
463 text-decoration: none;
464 padding: 3px 10px;
465 border-radius: 9999px;
466 border: 1px solid transparent;
467 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
468 }
469 .prs-sort-opt:hover {
470 background: var(--bg-hover);
471 color: var(--text);
472 }
473 .prs-sort-opt.is-active {
6fd5915Claude474 background: rgba(91,110,232,0.12);
f5b9ef5Claude475 color: var(--text-link);
6fd5915Claude476 border-color: rgba(91,110,232,0.35);
f5b9ef5Claude477 font-weight: 600;
478 }
b078860Claude479`;
480
481/* ──────────────────────────────────────────────────────────────────────
482 * Inline CSS for the detail page. Same `.prs-*` namespace.
483 * ──────────────────────────────────────────────────────────────────── */
484const PRS_DETAIL_STYLES = `
485 .prs-detail-hero {
486 position: relative;
487 margin: 0 0 var(--space-4);
488 padding: 24px 26px;
489 background: var(--bg-elevated);
490 border: 1px solid var(--border);
491 border-radius: 16px;
492 overflow: hidden;
493 }
494 .prs-detail-hero::before {
495 content: '';
496 position: absolute; top: 0; left: 0; right: 0;
497 height: 2px;
6fd5915Claude498 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
b078860Claude499 opacity: 0.7;
500 pointer-events: none;
501 }
502 .prs-detail-title {
503 font-family: var(--font-display);
504 font-size: clamp(22px, 2.6vw, 28px);
505 font-weight: 700;
506 letter-spacing: -0.022em;
507 line-height: 1.2;
508 color: var(--text-strong);
509 margin: 0 0 12px;
510 }
511 .prs-detail-num {
512 color: var(--text-muted);
513 font-weight: 400;
514 }
515 .prs-state-pill {
516 display: inline-flex; align-items: center; gap: 6px;
517 padding: 6px 12px;
518 border-radius: 9999px;
519 font-size: 12.5px;
520 font-weight: 600;
521 line-height: 1;
522 border: 1px solid transparent;
523 }
524 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
6fd5915Claude525 .prs-state-pill.state-merged { color: #5b6ee8; background: rgba(91,110,232,0.16); border-color: rgba(91,110,232,0.45); }
b078860Claude526 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
527 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
528
74d8c4dClaude529 .prs-size-badge {
530 display: inline-flex;
531 align-items: center;
532 padding: 2px 8px;
533 border-radius: 20px;
534 font-size: 11px;
535 font-weight: 700;
536 letter-spacing: 0.04em;
537 border: 1px solid currentColor;
538 opacity: 0.85;
539 }
540
b078860Claude541 .prs-detail-meta {
542 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
543 font-size: 13px;
544 color: var(--text-muted);
545 }
546 .prs-detail-meta strong { color: var(--text); }
547 .prs-detail-branches {
548 display: inline-flex; align-items: center; gap: 6px;
549 font-family: var(--font-mono);
550 font-size: 12px;
551 }
552 .prs-branch-pill {
553 padding: 3px 9px;
554 border-radius: 9999px;
555 background: var(--bg-tertiary);
556 border: 1px solid var(--border);
557 color: var(--text);
558 }
559 .prs-branch-pill.is-head { color: var(--text-strong); }
560 .prs-branch-arrow-lg {
561 color: var(--accent);
562 font-size: 14px;
563 font-weight: 700;
564 }
0369e77Claude565 .prs-branch-sync {
566 display: inline-flex; align-items: center; gap: 4px;
567 font-size: 11.5px; font-weight: 600;
568 padding: 2px 8px;
569 border-radius: 9999px;
570 border: 1px solid var(--border);
571 background: var(--bg-secondary);
572 color: var(--text-muted);
573 cursor: default;
574 }
575 .prs-branch-sync.is-behind {
576 color: #f87171;
577 border-color: rgba(248,113,113,0.35);
578 background: rgba(248,113,113,0.07);
579 }
580 .prs-branch-sync.is-synced {
581 color: #34d399;
582 border-color: rgba(52,211,153,0.35);
583 background: rgba(52,211,153,0.07);
584 }
b078860Claude585
586 .prs-detail-actions {
587 display: inline-flex; gap: 8px; margin-left: auto;
588 }
589
590 .prs-detail-tabs {
591 display: flex; gap: 4px;
592 margin: 0 0 16px;
593 border-bottom: 1px solid var(--border);
594 }
595 .prs-detail-tab {
596 padding: 10px 14px;
597 font-size: 13.5px;
598 font-weight: 500;
599 color: var(--text-muted);
600 text-decoration: none;
601 border-bottom: 2px solid transparent;
602 transition: color 120ms ease, border-color 120ms ease;
603 margin-bottom: -1px;
604 }
605 .prs-detail-tab:hover { color: var(--text); }
606 .prs-detail-tab.is-active {
607 color: var(--text-strong);
608 border-bottom-color: var(--accent);
609 }
610 .prs-detail-tab-count {
611 display: inline-flex; align-items: center; justify-content: center;
612 min-width: 20px; padding: 0 6px; margin-left: 6px;
613 height: 18px;
614 font-size: 11px;
615 font-weight: 600;
616 border-radius: 9999px;
617 background: var(--bg-tertiary);
618 color: var(--text-muted);
619 }
620
621 /* Gate / check status section */
622 .prs-gate-card {
623 margin-top: 20px;
624 background: var(--bg-elevated);
625 border: 1px solid var(--border);
626 border-radius: 14px;
627 overflow: hidden;
628 }
629 .prs-gate-head {
630 display: flex; align-items: center; gap: 10px;
631 padding: 14px 18px;
632 border-bottom: 1px solid var(--border);
633 }
634 .prs-gate-head h3 {
635 margin: 0;
636 font-size: 14px;
637 font-weight: 600;
638 color: var(--text-strong);
639 }
640 .prs-gate-summary {
641 margin-left: auto;
642 font-size: 12px;
643 color: var(--text-muted);
644 }
645 .prs-gate-row {
646 display: flex; align-items: center; gap: 12px;
647 padding: 12px 18px;
648 border-bottom: 1px solid var(--border-subtle);
649 }
650 .prs-gate-row:last-child { border-bottom: 0; }
651 .prs-gate-icon {
652 flex: 0 0 auto;
653 width: 22px; height: 22px;
654 display: inline-flex; align-items: center; justify-content: center;
655 border-radius: 9999px;
656 font-size: 12px;
657 font-weight: 700;
658 }
659 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
660 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
661 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
662 .prs-gate-name {
663 font-size: 13px;
664 font-weight: 600;
665 color: var(--text);
666 min-width: 140px;
667 }
668 .prs-gate-details {
669 flex: 1; min-width: 0;
670 font-size: 12.5px;
671 color: var(--text-muted);
672 }
673 .prs-gate-pill {
674 flex: 0 0 auto;
675 padding: 3px 10px;
676 border-radius: 9999px;
677 font-size: 11px;
678 font-weight: 600;
679 line-height: 1.5;
680 border: 1px solid transparent;
681 }
682 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
683 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
684 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
685 .prs-gate-footer {
686 padding: 12px 18px;
687 background: var(--bg-secondary);
688 font-size: 12px;
689 color: var(--text-muted);
690 }
691
692 /* Comment cards */
693 .prs-comment {
694 margin-top: 14px;
695 background: var(--bg-elevated);
696 border: 1px solid var(--border);
697 border-radius: 12px;
698 overflow: hidden;
699 }
700 .prs-comment-head {
701 display: flex; align-items: center; gap: 10px;
702 padding: 10px 14px;
703 background: var(--bg-secondary);
704 border-bottom: 1px solid var(--border);
705 font-size: 13px;
706 flex-wrap: wrap;
707 }
708 .prs-comment-head strong { color: var(--text-strong); }
709 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
710 .prs-comment-loc {
711 font-family: var(--font-mono);
712 font-size: 11.5px;
713 color: var(--text-muted);
714 background: var(--bg-tertiary);
715 padding: 2px 8px;
716 border-radius: 6px;
717 }
718 .prs-comment-body { padding: 14px 18px; }
719 .prs-comment.is-ai {
6fd5915Claude720 border-color: rgba(91,110,232,0.45);
721 box-shadow: 0 0 0 1px rgba(91,110,232,0.10), 0 6px 24px -10px rgba(91,110,232,0.30);
b078860Claude722 }
723 .prs-comment.is-ai .prs-comment-head {
6fd5915Claude724 background: linear-gradient(90deg, rgba(91,110,232,0.10), rgba(95,143,160,0.06));
725 border-bottom-color: rgba(91,110,232,0.30);
b078860Claude726 }
727 .prs-ai-badge {
728 display: inline-flex; align-items: center; gap: 4px;
729 padding: 2px 9px;
730 font-size: 10.5px;
731 font-weight: 700;
732 letter-spacing: 0.04em;
733 text-transform: uppercase;
734 color: #fff;
6fd5915Claude735 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 130%);
b078860Claude736 border-radius: 9999px;
737 }
a7460bfClaude738 .prs-bot-badge {
739 display: inline-flex; align-items: center; gap: 3px;
740 padding: 1px 7px;
741 font-size: 10px;
742 font-weight: 600;
743 color: var(--fg-muted);
744 background: var(--bg-elevated);
745 border: 1px solid var(--border);
746 border-radius: 9999px;
747 }
b078860Claude748
749 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
750 .prs-files-card {
751 margin-top: 18px;
752 padding: 14px 18px;
753 display: flex; align-items: center; gap: 14px;
754 background: var(--bg-elevated);
755 border: 1px solid var(--border);
756 border-radius: 12px;
757 text-decoration: none;
758 color: inherit;
759 transition: border-color 120ms ease, transform 140ms ease;
760 }
761 .prs-files-card:hover {
6fd5915Claude762 border-color: rgba(91,110,232,0.45);
b078860Claude763 transform: translateY(-1px);
764 }
765 .prs-files-card-icon {
766 width: 36px; height: 36px;
767 display: inline-flex; align-items: center; justify-content: center;
768 border-radius: 10px;
6fd5915Claude769 background: rgba(91,110,232,0.12);
b078860Claude770 color: var(--text-link);
771 font-size: 18px;
772 }
773 .prs-files-card-text { flex: 1; min-width: 0; }
774 .prs-files-card-title {
775 font-size: 14px;
776 font-weight: 600;
777 color: var(--text-strong);
778 margin: 0 0 2px;
779 }
780 .prs-files-card-sub {
781 font-size: 12.5px;
782 color: var(--text-muted);
783 margin: 0;
784 }
785 .prs-files-card-cta {
786 font-size: 12.5px;
787 color: var(--text-link);
788 font-weight: 600;
789 }
790
791 /* Merge area */
792 .prs-merge-card {
793 position: relative;
794 margin-top: 22px;
795 padding: 18px;
796 background: var(--bg-elevated);
797 border-radius: 14px;
798 overflow: hidden;
799 }
800 .prs-merge-card::before {
801 content: '';
802 position: absolute; inset: 0;
803 padding: 1px;
804 border-radius: 14px;
6fd5915Claude805 background: linear-gradient(135deg, rgba(91,110,232,0.55) 0%, rgba(95,143,160,0.40) 100%);
b078860Claude806 -webkit-mask:
807 linear-gradient(#000 0 0) content-box,
808 linear-gradient(#000 0 0);
809 -webkit-mask-composite: xor;
810 mask-composite: exclude;
811 pointer-events: none;
812 }
813 .prs-merge-card.is-closed::before { background: var(--border-strong); }
6fd5915Claude814 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(91,110,232,0.45), rgba(95,143,160,0.30)); }
b078860Claude815 .prs-merge-head {
816 display: flex; align-items: center; gap: 12px;
817 margin-bottom: 12px;
818 }
819 .prs-merge-head strong {
820 font-family: var(--font-display);
821 font-size: 15px;
822 color: var(--text-strong);
823 font-weight: 700;
824 }
825 .prs-merge-sub {
826 font-size: 13px;
827 color: var(--text-muted);
828 margin: 0 0 12px;
829 }
830 .prs-merge-actions {
831 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
832 }
833 .prs-merge-btn {
834 display: inline-flex; align-items: center; gap: 6px;
835 padding: 9px 16px;
836 border-radius: 10px;
837 font-size: 13.5px;
838 font-weight: 600;
839 color: #fff;
6fd5915Claude840 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #5f8fa0 140%);
b078860Claude841 border: 1px solid rgba(52,211,153,0.55);
842 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
843 cursor: pointer;
844 transition: transform 120ms ease, box-shadow 160ms ease;
845 }
846 .prs-merge-btn:hover {
847 transform: translateY(-1px);
848 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
849 }
850 .prs-merge-btn[disabled],
851 .prs-merge-btn.is-disabled {
852 opacity: 0.55;
853 cursor: not-allowed;
854 transform: none;
855 box-shadow: none;
856 }
857 .prs-merge-ready-btn {
858 display: inline-flex; align-items: center; gap: 6px;
859 padding: 9px 16px;
860 border-radius: 10px;
861 font-size: 13.5px;
862 font-weight: 600;
863 color: #fff;
6fd5915Claude864 background: linear-gradient(135deg, #5b6ee8 0%, #6f5be8 60%, #5f8fa0 140%);
865 border: 1px solid rgba(91,110,232,0.55);
866 box-shadow: 0 6px 18px -8px rgba(91,110,232,0.55);
b078860Claude867 cursor: pointer;
868 transition: transform 120ms ease, box-shadow 160ms ease;
869 }
870 .prs-merge-ready-btn:hover {
871 transform: translateY(-1px);
6fd5915Claude872 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55);
b078860Claude873 }
874 .prs-merge-back-draft {
875 background: none; border: 1px solid var(--border-strong);
876 color: var(--text-muted);
877 padding: 9px 14px; border-radius: 10px;
878 font-size: 13px; cursor: pointer;
879 }
880 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
881
a164a6dClaude882 /* Merge strategy selector */
883 .prs-merge-strategy-wrap {
884 display: inline-flex; align-items: center;
885 background: var(--bg-elevated);
886 border: 1px solid var(--border);
887 border-radius: 10px;
888 overflow: hidden;
889 }
890 .prs-merge-strategy-label {
891 font-size: 11.5px; font-weight: 600;
892 color: var(--text-muted);
893 padding: 0 10px 0 12px;
894 white-space: nowrap;
895 }
896 .prs-merge-strategy-select {
897 background: transparent;
898 border: none;
899 color: var(--text);
900 font-size: 13px;
901 padding: 7px 10px 7px 4px;
902 cursor: pointer;
903 outline: none;
904 appearance: auto;
905 }
6fd5915Claude906 .prs-merge-strategy-select:focus { outline: 2px solid rgba(91,110,232,0.45); }
a164a6dClaude907
0a67773Claude908 /* Review summary banner */
909 .prs-review-summary {
910 display: flex; flex-direction: column; gap: 6px;
911 padding: 12px 16px;
912 background: var(--bg-elevated);
913 border: 1px solid var(--border);
914 border-radius: var(--r-md, 8px);
915 margin-bottom: 12px;
916 }
917 .prs-review-row {
918 display: flex; align-items: center; gap: 10px;
919 font-size: 13px;
920 }
921 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
922 .prs-review-approved .prs-review-icon { color: #34d399; }
923 .prs-review-changes .prs-review-icon { color: #f87171; }
ace34efClaude924 .prs-reviewer-avatar {
925 width: 24px; height: 24px; border-radius: 50%;
926 background: var(--accent); color: #fff;
927 display: flex; align-items: center; justify-content: center;
928 font-size: 11px; font-weight: 700; flex-shrink: 0;
929 }
0a67773Claude930
931 /* Review action buttons */
932 .prs-review-approve-btn {
933 display: inline-flex; align-items: center; gap: 5px;
934 padding: 8px 14px; border-radius: 8px; font-size: 13px;
935 font-weight: 600; cursor: pointer;
936 background: rgba(52,211,153,0.12);
937 color: #34d399;
938 border: 1px solid rgba(52,211,153,0.35);
939 transition: background 120ms;
940 }
941 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
942 .prs-review-changes-btn {
943 display: inline-flex; align-items: center; gap: 5px;
944 padding: 8px 14px; border-radius: 8px; font-size: 13px;
945 font-weight: 600; cursor: pointer;
946 background: rgba(248,113,113,0.10);
947 color: #f87171;
948 border: 1px solid rgba(248,113,113,0.30);
949 transition: background 120ms;
950 }
951 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
952
b078860Claude953 /* Inline form helpers */
954 .prs-inline-form { display: inline-flex; }
955
956 /* Comment composer */
957 .prs-composer { margin-top: 22px; }
958 .prs-composer textarea {
959 border-radius: 12px;
960 }
961
962 @media (max-width: 720px) {
963 .prs-detail-actions { margin-left: 0; }
964 .prs-merge-actions { width: 100%; }
965 .prs-merge-actions > * { flex: 1; min-width: 0; }
966 }
f1dc7c7Claude967
968 /* Additional mobile rules. Additive only. */
969 @media (max-width: 720px) {
970 .prs-detail-hero { padding: 18px; }
971 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
972 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
973 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
974 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
975 .prs-gate-name { min-width: 0; }
976 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
977 .prs-gate-summary { margin-left: 0; }
978 .prs-merge-btn,
979 .prs-merge-ready-btn,
980 .prs-merge-back-draft { min-height: 44px; }
981 .prs-comment-body { padding: 12px 14px; }
982 .prs-comment-head { padding: 10px 12px; }
983 .prs-files-card { padding: 12px 14px; }
984 }
3c03977Claude985
986 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
987 .live-pill {
988 display: inline-flex;
989 align-items: center;
990 gap: 8px;
991 padding: 4px 10px 4px 8px;
992 margin-left: 6px;
993 background: var(--bg-elevated);
994 border: 1px solid var(--border);
995 border-radius: 9999px;
996 font-size: 12px;
997 color: var(--text-muted);
998 line-height: 1;
999 vertical-align: middle;
1000 }
1001 .live-pill.is-busy { color: var(--text); }
1002 .live-pill-dot {
1003 width: 8px; height: 8px;
1004 border-radius: 9999px;
1005 background: #34d399;
1006 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
1007 animation: live-pulse 1.6s ease-in-out infinite;
1008 }
1009 @keyframes live-pulse {
1010 0%, 100% { opacity: 1; }
1011 50% { opacity: 0.55; }
1012 }
1013 .live-avatars {
1014 display: inline-flex;
1015 margin-left: 2px;
1016 }
1017 .live-avatar {
1018 display: inline-flex;
1019 align-items: center;
1020 justify-content: center;
1021 width: 22px; height: 22px;
1022 border-radius: 9999px;
1023 font-size: 10px;
1024 font-weight: 700;
1025 color: #0b1020;
1026 margin-left: -6px;
1027 border: 2px solid var(--bg-elevated);
1028 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
1029 }
1030 .live-avatar:first-child { margin-left: 0; }
1031 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
1032 .live-cursor-host {
1033 position: relative;
1034 }
1035 .live-cursor-overlay {
1036 position: absolute;
1037 inset: 0;
1038 pointer-events: none;
1039 overflow: hidden;
1040 border-radius: inherit;
1041 }
1042 .live-cursor {
1043 position: absolute;
1044 width: 2px;
1045 height: 18px;
1046 border-radius: 2px;
1047 transform: translate(-1px, 0);
1048 transition: transform 80ms linear, opacity 200ms ease;
1049 }
1050 .live-cursor::after {
1051 content: attr(data-label);
1052 position: absolute;
1053 top: -16px;
1054 left: -2px;
1055 font-size: 10px;
1056 line-height: 1;
1057 color: #0b1020;
1058 background: inherit;
1059 padding: 2px 5px;
1060 border-radius: 4px 4px 4px 0;
1061 white-space: nowrap;
1062 font-weight: 600;
1063 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
1064 }
1065 .live-cursor.is-idle { opacity: 0.4; }
1066 .live-edit-tag {
1067 display: inline-block;
1068 margin-left: 6px;
1069 padding: 1px 6px;
1070 font-size: 10px;
1071 font-weight: 600;
1072 letter-spacing: 0.02em;
1073 color: #0b1020;
1074 border-radius: 9999px;
1075 }
15db0e0Claude1076
1077 /* ─── Slash-command pill + composer hint ─── */
1078 .slash-hint {
1079 display: inline-flex;
1080 align-items: center;
1081 gap: 6px;
1082 margin-top: 6px;
1083 padding: 3px 9px;
1084 font-size: 11.5px;
1085 color: var(--text-muted);
1086 background: var(--bg-elevated);
1087 border: 1px dashed var(--border);
1088 border-radius: 9999px;
1089 width: fit-content;
1090 }
1091 .slash-hint code {
1092 background: rgba(110, 168, 255, 0.12);
1093 color: var(--text-strong);
1094 padding: 0 5px;
1095 border-radius: 4px;
1096 font-size: 11px;
1097 }
1098 .slash-pill {
1099 display: grid;
1100 grid-template-columns: auto 1fr auto;
1101 align-items: center;
1102 column-gap: 10px;
1103 row-gap: 6px;
1104 margin: 10px 0;
1105 padding: 10px 14px;
1106 background: linear-gradient(
1107 135deg,
1108 rgba(110, 168, 255, 0.08),
1109 rgba(163, 113, 247, 0.06)
1110 );
1111 border: 1px solid rgba(110, 168, 255, 0.32);
1112 border-left: 3px solid var(--accent, #6ea8ff);
1113 border-radius: var(--radius);
1114 font-size: 13px;
1115 color: var(--text);
1116 }
1117 .slash-pill-icon {
1118 font-size: 14px;
1119 line-height: 1;
1120 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
1121 }
1122 .slash-pill-actor { color: var(--text-muted); }
1123 .slash-pill-actor strong { color: var(--text-strong); }
1124 .slash-pill-cmd {
1125 background: rgba(110, 168, 255, 0.16);
1126 color: var(--text-strong);
1127 padding: 1px 6px;
1128 border-radius: 4px;
1129 font-size: 12.5px;
1130 }
1131 .slash-pill-time {
1132 color: var(--text-muted);
1133 font-size: 12px;
1134 justify-self: end;
1135 }
1136 .slash-pill-body {
1137 grid-column: 1 / -1;
1138 color: var(--text);
1139 font-size: 13px;
1140 line-height: 1.55;
1141 }
1142 .slash-pill-body p:first-child { margin-top: 0; }
1143 .slash-pill-body p:last-child { margin-bottom: 0; }
1144 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
1145 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1146 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
1147 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
6fd5915Claude1148 .slash-pill.slash-cmd-stage { border-left-color: #5f8fa0; }
4bbacbeClaude1149
1150 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
1151 .preview-prpill {
1152 display: inline-flex; align-items: center; gap: 6px;
1153 padding: 3px 10px;
1154 border-radius: 9999px;
1155 font-family: var(--font-mono);
1156 font-size: 11.5px;
1157 font-weight: 600;
1158 background: rgba(255,255,255,0.04);
1159 color: var(--text-muted);
1160 text-decoration: none;
1161 border: 1px solid var(--border);
1162 }
6fd5915Claude1163 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(91,110,232,0.45); }
4bbacbeClaude1164 .preview-prpill .preview-prpill-dot {
1165 width: 7px; height: 7px;
1166 border-radius: 9999px;
1167 background: currentColor;
1168 }
1169 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1170 .preview-prpill.is-building .preview-prpill-dot {
1171 animation: previewPrPulse 1.4s ease-in-out infinite;
1172 }
1173 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1174 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1175 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1176 @keyframes previewPrPulse {
1177 0%, 100% { opacity: 1; }
1178 50% { opacity: 0.4; }
1179 }
79ed944Claude1180
1181 /* ─── AI Trio Review — 3-column verdict cards ─── */
1182 .trio-wrap {
1183 margin-top: 18px;
1184 padding: 16px;
1185 background: var(--bg-elevated);
1186 border: 1px solid var(--border);
1187 border-radius: 14px;
1188 }
1189 .trio-header {
1190 display: flex; align-items: center; gap: 10px;
1191 margin: 0 0 12px;
1192 font-size: 13.5px;
1193 color: var(--text);
1194 }
1195 .trio-header strong { color: var(--text-strong); }
1196 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1197 .trio-header-dot {
1198 width: 8px; height: 8px; border-radius: 9999px;
6fd5915Claude1199 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
1200 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
79ed944Claude1201 }
1202 .trio-grid {
1203 display: grid;
1204 grid-template-columns: repeat(3, minmax(0, 1fr));
1205 gap: 12px;
1206 }
1207 .trio-card {
1208 background: var(--bg-secondary);
1209 border: 1px solid var(--border);
1210 border-radius: 12px;
1211 overflow: hidden;
1212 display: flex; flex-direction: column;
1213 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1214 }
1215 .trio-card-head {
1216 display: flex; align-items: center; gap: 8px;
1217 padding: 10px 12px;
1218 border-bottom: 1px solid var(--border);
1219 background: rgba(255,255,255,0.02);
1220 font-size: 13px;
1221 }
1222 .trio-card-icon {
1223 display: inline-flex; align-items: center; justify-content: center;
1224 width: 22px; height: 22px;
1225 border-radius: 9999px;
1226 font-size: 12px;
1227 background: rgba(255,255,255,0.05);
1228 }
1229 .trio-card-title {
1230 color: var(--text-strong);
1231 font-weight: 600;
1232 letter-spacing: 0.01em;
1233 }
1234 .trio-card-verdict {
1235 margin-left: auto;
1236 font-size: 11px;
1237 font-weight: 700;
1238 letter-spacing: 0.06em;
1239 text-transform: uppercase;
1240 padding: 3px 9px;
1241 border-radius: 9999px;
1242 background: var(--bg-tertiary);
1243 color: var(--text-muted);
1244 border: 1px solid var(--border-strong);
1245 }
1246 .trio-card-body {
1247 padding: 12px 14px;
1248 font-size: 13px;
1249 color: var(--text);
1250 flex: 1;
1251 min-height: 64px;
1252 line-height: 1.55;
1253 }
1254 .trio-card-body p { margin: 0 0 8px; }
1255 .trio-card-body p:last-child { margin-bottom: 0; }
1256 .trio-card-body ul { margin: 0; padding-left: 18px; }
1257 .trio-card-body code {
1258 font-family: var(--font-mono);
1259 font-size: 12px;
1260 background: var(--bg-tertiary);
1261 padding: 1px 6px;
1262 border-radius: 5px;
1263 }
1264 .trio-card-empty {
1265 color: var(--text-muted);
1266 font-style: italic;
1267 font-size: 12.5px;
1268 }
1269
1270 /* Pass state — neutral, no accent. */
1271 .trio-card.is-pass .trio-card-verdict {
1272 color: var(--green);
1273 border-color: rgba(52,211,153,0.35);
1274 background: rgba(52,211,153,0.12);
1275 }
1276
1277 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1278 .trio-card.trio-security.is-fail {
1279 border-color: rgba(248,113,113,0.55);
1280 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1281 }
1282 .trio-card.trio-security.is-fail .trio-card-head {
1283 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1284 border-bottom-color: rgba(248,113,113,0.30);
1285 }
1286 .trio-card.trio-security.is-fail .trio-card-verdict {
1287 color: #fecaca;
1288 border-color: rgba(248,113,113,0.55);
1289 background: rgba(248,113,113,0.20);
1290 }
1291
1292 .trio-card.trio-correctness.is-fail {
1293 border-color: rgba(251,191,36,0.55);
1294 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1295 }
1296 .trio-card.trio-correctness.is-fail .trio-card-head {
1297 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1298 border-bottom-color: rgba(251,191,36,0.30);
1299 }
1300 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1301 color: #fde68a;
1302 border-color: rgba(251,191,36,0.55);
1303 background: rgba(251,191,36,0.20);
1304 }
1305
1306 .trio-card.trio-style.is-fail {
1307 border-color: rgba(96,165,250,0.55);
1308 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1309 }
1310 .trio-card.trio-style.is-fail .trio-card-head {
1311 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1312 border-bottom-color: rgba(96,165,250,0.30);
1313 }
1314 .trio-card.trio-style.is-fail .trio-card-verdict {
1315 color: #bfdbfe;
1316 border-color: rgba(96,165,250,0.55);
1317 background: rgba(96,165,250,0.20);
1318 }
1319
1320 /* Disagreement callout strip — yellow, prominent. */
1321 .trio-disagreement-strip {
1322 display: flex;
1323 gap: 12px;
1324 margin-top: 14px;
1325 padding: 12px 14px;
1326 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1327 border: 1px solid rgba(251,191,36,0.45);
1328 border-radius: 10px;
1329 color: var(--text);
1330 font-size: 13px;
1331 }
1332 .trio-disagreement-icon {
1333 flex: 0 0 auto;
1334 width: 26px; height: 26px;
1335 display: inline-flex; align-items: center; justify-content: center;
1336 border-radius: 9999px;
1337 background: rgba(251,191,36,0.25);
1338 color: #fde68a;
1339 font-size: 14px;
1340 }
1341 .trio-disagreement-body strong {
1342 display: block;
1343 color: #fde68a;
1344 margin: 0 0 4px;
1345 font-weight: 700;
1346 }
1347 .trio-disagreement-list {
1348 margin: 0;
1349 padding-left: 18px;
1350 color: var(--text);
1351 font-size: 12.5px;
1352 line-height: 1.55;
1353 }
1354 .trio-disagreement-list code {
1355 font-family: var(--font-mono);
1356 font-size: 11.5px;
1357 background: var(--bg-tertiary);
1358 padding: 1px 5px;
1359 border-radius: 4px;
1360 }
1361
1362 @media (max-width: 720px) {
1363 .trio-grid { grid-template-columns: 1fr; }
1364 .trio-wrap { padding: 12px; }
1365 }
6d1bbc2Claude1366
1367 /* ─── Task list progress pill ─── */
1368 .prs-tasks-pill {
1369 display: inline-flex; align-items: center; gap: 5px;
1370 font-size: 11.5px; font-weight: 600;
1371 padding: 2px 9px; border-radius: 9999px;
1372 border: 1px solid var(--border);
1373 background: var(--bg-elevated);
1374 color: var(--text-muted);
1375 }
1376 .prs-tasks-pill.is-complete {
1377 color: #34d399;
1378 border-color: rgba(52,211,153,0.40);
1379 background: rgba(52,211,153,0.08);
1380 }
1381 .prs-tasks-progress { display: inline-block; width: 36px; height: 4px; border-radius: 9999px; background: var(--border); overflow: hidden; vertical-align: middle; }
1382 .prs-tasks-progress-bar { height: 100%; border-radius: 9999px; background: #34d399; }
1383
1384 /* ─── Update branch button ─── */
1385 .prs-update-branch-btn {
1386 display: inline-flex; align-items: center; gap: 5px;
1387 padding: 4px 12px; border-radius: 8px; font-size: 12.5px;
1388 font-weight: 600; cursor: pointer;
1389 background: rgba(96,165,250,0.10);
1390 color: #60a5fa;
1391 border: 1px solid rgba(96,165,250,0.30);
1392 transition: background 120ms;
1393 }
1394 .prs-update-branch-btn:hover { background: rgba(96,165,250,0.20); }
1395
1396 /* ─── Linked issues panel ─── */
1397 .prs-linked-issues {
1398 margin-top: 16px;
1399 border: 1px solid var(--border);
1400 border-radius: 12px;
1401 overflow: hidden;
1402 }
1403 .prs-linked-issues-head {
1404 display: flex; align-items: center; justify-content: space-between;
1405 padding: 10px 16px;
1406 background: var(--bg-elevated);
1407 border-bottom: 1px solid var(--border);
1408 font-size: 13px; font-weight: 600; color: var(--text);
1409 }
1410 .prs-linked-issues-count {
1411 font-size: 11px; font-weight: 700;
1412 padding: 1px 7px; border-radius: 9999px;
1413 background: var(--bg-tertiary);
1414 color: var(--text-muted);
1415 }
1416 .prs-linked-issue-row {
1417 display: flex; align-items: center; gap: 10px;
1418 padding: 9px 16px;
1419 border-bottom: 1px solid var(--border);
1420 font-size: 13px;
1421 text-decoration: none; color: inherit;
1422 }
1423 .prs-linked-issue-row:last-child { border-bottom: none; }
1424 .prs-linked-issue-row:hover { background: var(--bg-hover); }
1425 .prs-linked-issue-icon { flex: 0 0 auto; font-size: 14px; }
1426 .prs-linked-issue-icon.is-open { color: #34d399; }
1427 .prs-linked-issue-icon.is-closed { color: #8b949e; }
1428 .prs-linked-issue-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1429 .prs-linked-issue-num { color: var(--text-muted); font-size: 12px; }
1430 .prs-linked-issue-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
1431 .prs-linked-issue-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
1432 .prs-linked-issue-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
b558f23Claude1433
1434 /* ─── Commits tab ─── */
1435 .prs-commits-list { display: flex; flex-direction: column; gap: 0; margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1436 .prs-commit-row { display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit; }
1437 .prs-commit-row:last-child { border-bottom: none; }
1438 .prs-commit-row:hover { background: var(--bg-hover); }
1439 .prs-commit-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-top: 6px; }
1440 .prs-commit-body { flex: 1 1 auto; min-width: 0; }
1441 .prs-commit-msg { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
1442 .prs-commit-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
1443 .prs-commit-sha { flex: 0 0 auto; font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); background: var(--bg-elevated); padding: 2px 7px; border-radius: 6px; border: 1px solid var(--border); text-decoration: none; white-space: nowrap; }
1444 .prs-commit-sha:hover { color: var(--accent); }
1445 .prs-commits-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13.5px; }
1446
1447 /* ─── Edit PR title/body ─── */
1448 .prs-edit-title-wrap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1449 .prs-edit-btn { background: none; border: 1px solid var(--border); color: var(--text-muted); font-size: 12px; padding: 3px 10px; border-radius: 6px; cursor: pointer; transition: color 120ms, border-color 120ms; }
1450 .prs-edit-btn:hover { color: var(--text); border-color: var(--text-muted); }
1451 .prs-edit-form { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
1452 .prs-edit-form input[type=text] { font-size: 15px; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-elevated); color: var(--text); width: 100%; box-sizing: border-box; }
1453 .prs-edit-actions { display: flex; gap: 8px; }
1454 .prs-edit-save-btn { padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; background: var(--accent); color: #fff; border: none; cursor: pointer; }
1455 .prs-edit-cancel-btn { padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; background: var(--bg-elevated); color: var(--text); border: 1px solid var(--border); cursor: pointer; }
240c477Claude1456
1457 /* ─── CI status checks ─── */
1458 .prs-ci-card { margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1459 .prs-ci-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--bg-elevated); border-bottom: 1px solid var(--border); }
1460 .prs-ci-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text); }
1461 .prs-ci-summary { font-size: 12px; color: var(--text-muted); }
1462 .prs-ci-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
1463 .prs-ci-row:last-child { border-bottom: none; }
1464 .prs-ci-icon { flex: 0 0 auto; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; font-size: 11px; font-weight: 700; }
1465 .prs-ci-icon.is-success { background: rgba(52,211,153,0.20); color: #34d399; }
1466 .prs-ci-icon.is-pending { background: rgba(251,191,36,0.20); color: #fbbf24; }
1467 .prs-ci-icon.is-failure, .prs-ci-icon.is-error { background: rgba(248,113,113,0.20); color: #f87171; }
1468 .prs-ci-context { flex: 1 1 auto; font-size: 13px; font-weight: 500; color: var(--text); }
1469 .prs-ci-desc { font-size: 12px; color: var(--text-muted); }
1470 .prs-ci-pill { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 9999px; }
1471 .prs-ci-pill.is-success { color: #34d399; background: rgba(52,211,153,0.10); }
1472 .prs-ci-pill.is-pending { color: #fbbf24; background: rgba(251,191,36,0.10); }
1473 .prs-ci-pill.is-failure, .prs-ci-pill.is-error { color: #f87171; background: rgba(248,113,113,0.10); }
1474 .prs-ci-link { font-size: 12px; color: var(--accent); text-decoration: none; }
1475 .prs-ci-link:hover { text-decoration: underline; }
67dc4e1Claude1476
1477 /* ─── AI Trio verdict pills (header summary) ─── */
1478 .trio-pill {
1479 display: inline-flex; align-items: center; gap: 4px;
1480 padding: 2px 8px;
1481 font-size: 11px;
1482 font-weight: 700;
1483 border-radius: 9999px;
1484 border: 1px solid transparent;
1485 text-decoration: none;
1486 line-height: 1.6;
1487 letter-spacing: 0.01em;
1488 cursor: pointer;
1489 transition: opacity 120ms ease;
1490 }
1491 .trio-pill:hover { opacity: 0.8; }
1492 .trio-pill.is-pass {
1493 color: #34d399;
1494 background: rgba(52,211,153,0.10);
1495 border-color: rgba(52,211,153,0.35);
1496 }
1497 .trio-pill.is-fail {
1498 color: #f87171;
1499 background: rgba(248,113,113,0.10);
1500 border-color: rgba(248,113,113,0.35);
1501 }
1502 .trio-pill.is-pending {
1503 color: var(--text-muted);
1504 background: rgba(255,255,255,0.04);
1505 border-color: var(--border-strong);
1506 }
1507 .trio-pills-wrap {
1508 display: inline-flex; align-items: center; gap: 4px;
1509 }
1d6db4dClaude1510
1511 /* ─── Bus Factor Warning Panel ─── */
1512 .busfactor-panel {
1513 display: flex;
1514 gap: 14px;
1515 align-items: flex-start;
1516 padding: 14px 18px;
1517 margin-bottom: 16px;
1518 border-radius: 12px;
1519 border: 1px solid rgba(245,158,11,0.35);
1520 background: rgba(245,158,11,0.06);
1521 }
1522 .busfactor-critical {
1523 border-color: rgba(239,68,68,0.4);
1524 background: rgba(239,68,68,0.06);
1525 }
1526 .busfactor-high {
1527 border-color: rgba(249,115,22,0.4);
1528 background: rgba(249,115,22,0.06);
1529 }
1530 .busfactor-medium {
1531 border-color: rgba(245,158,11,0.35);
1532 background: rgba(245,158,11,0.06);
1533 }
1534 .busfactor-icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; }
1535 .busfactor-body { flex: 1; min-width: 0; }
1536 .busfactor-body strong { font-size: 14px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1537 .busfactor-body p { font-size: 13px; color: var(--text-muted); margin: 0 0 8px; }
1538 .busfactor-body ul { margin: 0; padding-left: 18px; }
1539 .busfactor-body li { font-size: 12.5px; color: var(--text-muted); margin-bottom: 3px; font-family: var(--font-mono); }
1540 .busfactor-body li strong { font-size: 12.5px; color: var(--text); display: inline; }
1541
1542 /* ─── PR Split Suggestion Panel ─── */
1543 .split-suggestion {
1544 margin-bottom: 16px;
6fd5915Claude1545 border: 1px solid rgba(91,110,232,0.35);
1d6db4dClaude1546 border-radius: 12px;
1547 overflow: hidden;
1548 }
1549 .split-header {
1550 display: flex;
1551 align-items: center;
1552 gap: 10px;
1553 padding: 12px 18px;
6fd5915Claude1554 background: rgba(91,110,232,0.06);
1d6db4dClaude1555 flex-wrap: wrap;
1556 }
1557 .split-icon { font-size: 18px; flex-shrink: 0; }
1558 .split-header strong { font-size: 14px; font-weight: 700; color: var(--text-strong); flex: 1; min-width: 200px; }
1559 .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; }
1560 .split-toggle {
1561 background: none;
6fd5915Claude1562 border: 1px solid rgba(91,110,232,0.45);
1563 color: rgba(91,110,232,0.9);
1d6db4dClaude1564 font-size: 12.5px;
1565 font-weight: 600;
1566 padding: 4px 12px;
1567 border-radius: 8px;
1568 cursor: pointer;
1569 white-space: nowrap;
1570 transition: background 120ms ease;
1571 }
6fd5915Claude1572 .split-toggle:hover { background: rgba(91,110,232,0.1); }
1d6db4dClaude1573 .split-body {
1574 padding: 16px 18px;
6fd5915Claude1575 border-top: 1px solid rgba(91,110,232,0.2);
1d6db4dClaude1576 }
1577 .split-intro { font-size: 13.5px; color: var(--text-muted); margin: 0 0 14px; }
1578 .split-pr {
1579 display: flex;
1580 gap: 14px;
1581 align-items: flex-start;
1582 padding: 12px 0;
1583 border-bottom: 1px solid var(--border);
1584 }
1585 .split-pr:last-of-type { border-bottom: none; }
1586 .split-pr-num {
1587 width: 26px; height: 26px;
1588 border-radius: 50%;
6fd5915Claude1589 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 130%);
1d6db4dClaude1590 color: #fff;
1591 font-size: 12px;
1592 font-weight: 800;
1593 display: inline-flex;
1594 align-items: center;
1595 justify-content: center;
1596 flex-shrink: 0;
1597 margin-top: 2px;
1598 }
1599 .split-pr-body { flex: 1; min-width: 0; }
1600 .split-pr-body strong { font-size: 13.5px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1601 .split-pr-body p { font-size: 12.5px; color: var(--text-muted); margin: 0 0 6px; }
1602 .split-pr-body code { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); word-break: break-all; }
1603 .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; }
1604 .split-order { font-size: 13px; color: var(--text-muted); margin: 14px 0 0; }
1605 .split-order strong { color: var(--text); }
b078860Claude1606`;
1607
25b1ff7Claude1608/* ──────────────────────────────────────────────────────────────────────
1609 * Figma-style collaborative PR presence — styles for the presence bar
1610 * above the diff and the per-line reviewer cursor pills. All scoped
1611 * with `.presence-*` prefix so they never bleed into other views.
1612 * ──────────────────────────────────────────────────────────────────── */
1613const PRESENCE_STYLES = `
1614 .presence-bar {
1615 display: flex;
1616 align-items: center;
1617 gap: 10px;
1618 padding: 8px 14px;
1619 margin: 0 0 10px;
1620 background: var(--bg-elevated);
1621 border: 1px solid var(--border);
1622 border-radius: 10px;
1623 font-size: 12.5px;
1624 color: var(--text-muted);
1625 min-height: 38px;
1626 }
1627 .presence-bar-label {
1628 font-weight: 600;
1629 color: var(--text-muted);
1630 flex-shrink: 0;
1631 }
1632 .presence-avatars {
1633 display: flex;
1634 align-items: center;
1635 gap: 6px;
1636 flex: 1;
1637 flex-wrap: wrap;
1638 }
1639 .presence-avatar {
1640 display: inline-flex;
1641 align-items: center;
1642 gap: 5px;
1643 padding: 3px 8px 3px 4px;
1644 border-radius: 9999px;
1645 font-size: 12px;
1646 font-weight: 600;
1647 color: #fff;
1648 opacity: 0.92;
1649 transition: opacity 200ms;
1650 }
1651 .presence-avatar-dot {
1652 width: 20px; height: 20px;
1653 border-radius: 9999px;
1654 background: rgba(255,255,255,0.22);
1655 display: inline-flex;
1656 align-items: center;
1657 justify-content: center;
1658 font-size: 10px;
1659 font-weight: 700;
1660 flex-shrink: 0;
1661 }
1662 .presence-count {
1663 font-size: 12px;
1664 color: var(--text-faint);
1665 flex-shrink: 0;
1666 }
1667 /* Per-line reviewer cursor pill — injected by JS into .diff-row */
1668 .presence-line-pill {
1669 position: absolute;
1670 right: 6px;
1671 top: 50%;
1672 transform: translateY(-50%);
1673 display: inline-flex;
1674 align-items: center;
1675 gap: 4px;
1676 padding: 1px 7px;
1677 border-radius: 9999px;
1678 font-size: 10.5px;
1679 font-weight: 600;
1680 color: #fff;
1681 pointer-events: none;
1682 white-space: nowrap;
1683 z-index: 10;
1684 opacity: 0.88;
1685 animation: presence-in 160ms ease;
1686 }
1687 @keyframes presence-in {
1688 from { opacity: 0; transform: translateY(-50%) scale(0.85); }
1689 to { opacity: 0.88; transform: translateY(-50%) scale(1); }
1690 }
1691 .presence-line-pill.is-typing::after {
1692 content: '…';
1693 opacity: 0.7;
1694 }
1695 /* diff rows with a cursor pill need relative positioning */
1696 .diff-row { position: relative; }
1697 /* Toast for join/leave events */
1698 .presence-toast-wrap {
1699 position: fixed;
1700 bottom: 24px;
1701 right: 24px;
1702 display: flex;
1703 flex-direction: column;
1704 gap: 8px;
1705 z-index: 9999;
1706 pointer-events: none;
1707 }
1708 .presence-toast {
1709 padding: 8px 14px;
1710 border-radius: 8px;
1711 background: var(--bg-elevated);
1712 border: 1px solid var(--border);
1713 box-shadow: 0 6px 20px -8px rgba(0,0,0,0.55);
1714 font-size: 13px;
1715 color: var(--text);
1716 opacity: 1;
1717 transition: opacity 400ms;
1718 }
1719 .presence-toast.fading { opacity: 0; }
1720`;
b271465Claude1721
1722const IMPACT_STYLES = `
09d5f39Claude1723 /* ─── Merge Impact Analysis panel (.impact-*) ─── */
1724 .impact-panel {
1725 margin-top: 20px;
1726 border: 1px solid var(--border);
1727 border-radius: 12px;
1728 overflow: hidden;
1729 background: var(--bg-elevated);
1730 }
1731 .impact-header {
1732 display: flex;
1733 align-items: center;
1734 gap: 10px;
1735 padding: 12px 16px;
1736 background: var(--bg-elevated);
1737 border-bottom: 1px solid var(--border);
1738 cursor: pointer;
1739 user-select: none;
1740 }
1741 .impact-header:hover { background: var(--bg-hover); }
1742 .impact-score {
1743 display: inline-flex;
1744 align-items: center;
1745 justify-content: center;
1746 min-width: 34px;
1747 height: 24px;
1748 border-radius: 9999px;
1749 font-size: 11.5px;
1750 font-weight: 800;
1751 padding: 0 8px;
1752 letter-spacing: 0.01em;
1753 border: 1.5px solid transparent;
1754 }
1755 .impact-score.score-low {
1756 color: #34d399;
1757 background: rgba(52,211,153,0.12);
1758 border-color: rgba(52,211,153,0.35);
1759 }
1760 .impact-score.score-medium {
1761 color: #fbbf24;
1762 background: rgba(251,191,36,0.12);
1763 border-color: rgba(251,191,36,0.35);
1764 }
1765 .impact-score.score-high {
1766 color: #f87171;
1767 background: rgba(248,113,113,0.12);
1768 border-color: rgba(248,113,113,0.35);
1769 }
1770 .impact-header strong {
1771 font-size: 13.5px;
1772 color: var(--text-strong);
1773 font-weight: 700;
1774 }
1775 .impact-summary {
1776 font-size: 12.5px;
1777 color: var(--text-muted);
1778 flex: 1;
1779 }
1780 .impact-toggle {
1781 background: none;
1782 border: none;
1783 color: var(--text-muted);
1784 font-size: 11px;
1785 cursor: pointer;
1786 padding: 2px 6px;
1787 border-radius: 4px;
1788 transition: color 120ms;
1789 line-height: 1;
1790 }
1791 .impact-toggle:hover { color: var(--text); }
1792 .impact-toggle.is-open { transform: rotate(180deg); }
1793 .impact-body {
1794 padding: 14px 16px;
1795 display: flex;
1796 flex-direction: column;
1797 gap: 14px;
1798 }
1799 .impact-body[hidden] { display: none; }
1800 .impact-section h4 {
1801 margin: 0 0 8px;
1802 font-size: 12.5px;
1803 font-weight: 600;
1804 color: var(--text-muted);
1805 text-transform: uppercase;
1806 letter-spacing: 0.06em;
1807 }
1808 .impact-file-list {
1809 display: flex;
1810 flex-direction: column;
1811 gap: 3px;
1812 margin: 0;
1813 padding: 0;
1814 list-style: none;
1815 }
1816 .impact-file-list li {
1817 font-family: var(--font-mono);
1818 font-size: 12px;
1819 color: var(--text);
1820 padding: 3px 8px;
1821 background: var(--bg-secondary);
1822 border-radius: 5px;
1823 overflow: hidden;
1824 text-overflow: ellipsis;
1825 white-space: nowrap;
1826 }
1827 .impact-downstream .impact-file-list li {
1828 background: rgba(248,113,113,0.06);
1829 border: 1px solid rgba(248,113,113,0.15);
1830 }
1831 .impact-downstream h4 {
1832 color: #f87171;
1833 }
1834 .impact-empty {
1835 font-size: 12.5px;
1836 color: var(--text-muted);
1837 font-style: italic;
1838 }
1839`;
1840
25b1ff7Claude1841
1842
81c73c1Claude1843/**
1844 * Tiny inline JS that drives the "Suggest description with AI" button.
1845 * On click, gathers form values, POSTs JSON to the given endpoint, and
1846 * pipes the response into the #pr-body textarea. All DOM lookups are
1847 * defensive — element absence is a silent no-op.
1848 *
1849 * Built as a string template so it lives next to its server-side caller
1850 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1851 * to avoid </script> breakouts.
1852 */
1853function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1854 const url = JSON.stringify(endpointUrl)
1855 .split("<").join("\\u003C")
1856 .split(">").join("\\u003E")
1857 .split("&").join("\\u0026");
1858 return (
1859 "(function(){try{" +
1860 "var btn=document.getElementById('ai-suggest-desc');" +
1861 "var status=document.getElementById('ai-suggest-status');" +
1862 "var body=document.getElementById('pr-body');" +
1863 "var form=btn&&btn.closest&&btn.closest('form');" +
1864 "if(!btn||!body||!form)return;" +
1865 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1866 "var fd=new FormData(form);" +
1867 "var title=String(fd.get('title')||'').trim();" +
1868 "var base=String(fd.get('base')||'').trim();" +
1869 "var head=String(fd.get('head')||'').trim();" +
1870 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1871 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1872 "fetch(" + url + ",{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:'title='+encodeURIComponent(title)+'&base='+encodeURIComponent(base)+'&head='+encodeURIComponent(head),credentials:'same-origin'})" +
1873 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1874 ".then(function(j){btn.disabled=false;" +
1875 "if(j&&j.ok&&typeof j.body==='string'){if(body.value&&body.value.trim().length>0){if(!confirm('Replace existing description?')){if(status)status.textContent='Cancelled.';return;}}" +
1876 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1877 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1878 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1879 "});" +
1880 "}catch(e){}})();"
1881 );
1882}
1883
3c03977Claude1884/**
1885 * Live co-editing client. Connects to the per-PR SSE feed and:
1886 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1887 * status colour per user).
1888 * - Renders tinted cursor caret overlays inside #pr-body and every
1889 * `[data-live-field]` element.
1890 * - Broadcasts the local user's cursor position (selectionStart /
1891 * selectionEnd) debounced at 100ms.
1892 * - Broadcasts content patches (`replace` of the whole textarea —
1893 * last-write-wins v1) debounced at 250ms.
1894 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1895 * to the matching local field if untouched.
1896 *
1897 * All endpoint URLs are JSON-escaped via safe replacements so they
1898 * can't break out of the <script> tag.
1899 */
25b1ff7Claude1900
1901/**
1902 * Figma-style collaborative PR presence client (WebSocket).
1903 *
1904 * Connects to `GET /:owner/:repo/pulls/:number/presence` (WebSocket upgrade).
1905 * On connect the server sends `{type:"init", users:[...]}` so the bar renders
1906 * immediately. Subsequent messages from the server drive the presence bar and
1907 * per-line cursor pills in the diff.
1908 *
1909 * Outbound messages:
1910 * {type:"cursor", line: N} — user hovered a diff line
1911 * {type:"typing", line: N, typing: bool} — textarea focus/blur in diff
1912 * {type:"ping"} — keep-alive every 10s
1913 *
1914 * Inbound messages:
1915 * {type:"init", users:[{sessionId,username,colour,line,typing}]}
1916 * {type:"join", user:{sessionId,username,colour,line,typing}}
1917 * {type:"leave", sessionId}
1918 * {type:"cursor", sessionId, username, colour, line}
1919 * {type:"typing", sessionId, username, colour, line, typing}
1920 */
1921function PR_PRESENCE_SCRIPT(owner: string, repo: string, prNum: number): string {
1922 const wsPath = JSON.stringify(`/${owner}/${repo}/pulls/${prNum}/presence`)
1923 .split("<").join("\\u003C")
1924 .split(">").join("\\u003E")
1925 .split("&").join("\\u0026");
1926 return `(function(){
1927try{
1928var wsPath=${wsPath};
1929var proto=location.protocol==='https:'?'wss:':'ws:';
1930var url=proto+'//'+location.host+wsPath;
1931var mySessionId=null;
1932// sessionId -> {username, colour, line, typing}
1933var peers={};
1934var ws=null;
1935var pingTimer=null;
1936var reconnectDelay=1500;
1937var reconnectTimer=null;
1938
1939function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
1940
1941// ── Toast ──────────────────────────────────────────────────────────────
1942var toastWrap=document.getElementById('presence-toasts');
1943function toast(msg){
1944 if(!toastWrap)return;
1945 var t=document.createElement('div');
1946 t.className='presence-toast';
1947 t.textContent=msg;
1948 toastWrap.appendChild(t);
1949 setTimeout(function(){t.classList.add('fading');setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},420);},2500);
1950}
1951
1952// ── Presence bar ───────────────────────────────────────────────────────
1953var avEl=document.getElementById('presence-avatars');
1954var countEl=document.getElementById('presence-count');
1955function renderBar(){
1956 if(!avEl)return;
1957 var ids=Object.keys(peers);
1958 var html='';
1959 for(var i=0;i<ids.length&&i<8;i++){
1960 var p=peers[ids[i]];
1961 var initials=(p.username||'?').slice(0,2).toUpperCase();
1962 html+='<span class="presence-avatar" style="background:'+esc(p.colour)+'" title="'+esc(p.username)+'">';
1963 html+='<span class="presence-avatar-dot">'+esc(initials)+'</span>';
1964 html+=esc(p.username);
1965 html+='</span>';
1966 }
1967 avEl.innerHTML=html;
1968 if(countEl){
1969 var n=ids.length;
1970 countEl.textContent=n===0?'No other reviewers':n===1?'1 reviewer online':n+' reviewers online';
1971 }
1972}
1973
1974// ── Diff cursor pills ──────────────────────────────────────────────────
1975// data-line value is like "12:x:5" or "12:5:x" — pull numeric line only
1976function lineNumFromKey(key){var m=String(key).match(/(\d+)/);return m?parseInt(m[1],10):null;}
1977function findDiffRow(line){return document.querySelector('[data-line]') &&
1978 (function(){var rows=document.querySelectorAll('[data-line]');
1979 for(var i=0;i<rows.length;i++){var n=lineNumFromKey(rows[i].getAttribute('data-line')||'');if(n===line)return rows[i];}
1980 return null;
1981 })();}
1982function removePill(sessionId){var old=document.querySelector('[data-presence-sid="'+sessionId+'"]');if(old&&old.parentNode)old.parentNode.removeChild(old);}
1983function placePill(sessionId,username,colour,line,typing){
1984 removePill(sessionId);
1985 if(line==null)return;
1986 var row=findDiffRow(line);if(!row)return;
1987 var pill=document.createElement('span');
1988 pill.className='presence-line-pill'+(typing?' is-typing':'');
1989 pill.setAttribute('data-presence-sid',sessionId);
6fd5915Claude1990 pill.style.background=colour||'#5b6ee8';
25b1ff7Claude1991 pill.textContent=(username||'?').slice(0,12)+(typing?' typing':'');
1992 row.appendChild(pill);
1993}
1994function clearPeer(sessionId){removePill(sessionId);delete peers[sessionId];}
1995
1996// ── Inbound message handler ────────────────────────────────────────────
1997function onMsg(raw){
1998 var d;try{d=JSON.parse(raw);}catch(e){return;}
1999 if(!d||!d.type)return;
2000 if(d.type==='init'){
2001 mySessionId=d.sessionId||null;
2002 peers={};
2003 (d.users||[]).forEach(function(u){
2004 if(u.sessionId===mySessionId)return;
2005 peers[u.sessionId]={username:u.username,colour:u.colour,line:u.line,typing:u.typing};
2006 placePill(u.sessionId,u.username,u.colour,u.line,u.typing);
2007 });
2008 renderBar();
2009 } else if(d.type==='join'){
2010 if(d.user&&d.user.sessionId!==mySessionId){
2011 peers[d.user.sessionId]={username:d.user.username,colour:d.user.colour,line:d.user.line,typing:d.user.typing};
2012 renderBar();
2013 toast(esc(d.user.username)+' joined the review');
2014 }
2015 } else if(d.type==='leave'){
2016 if(d.sessionId&&d.sessionId!==mySessionId){
2017 var name=peers[d.sessionId]&&peers[d.sessionId].username;
2018 clearPeer(d.sessionId);
2019 renderBar();
2020 if(name)toast(esc(name)+' left the review');
2021 }
2022 } else if(d.type==='cursor'){
2023 if(d.sessionId&&d.sessionId!==mySessionId){
2024 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=false;}
2025 placePill(d.sessionId,d.username,d.colour,d.line,false);
2026 }
2027 } else if(d.type==='typing'){
2028 if(d.sessionId&&d.sessionId!==mySessionId){
2029 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=d.typing;}
2030 placePill(d.sessionId,d.username,d.colour,d.line,d.typing);
2031 }
2032 }
2033}
2034
2035// ── Outbound helpers ───────────────────────────────────────────────────
2036function send(obj){try{if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}catch(e){}}
2037
2038// ── Mouse hover on diff rows ───────────────────────────────────────────
2039var hoverTimer=null;
2040document.addEventListener('mouseover',function(ev){
2041 var row=ev.target&&ev.target.closest&&ev.target.closest('[data-line]');
2042 if(!row)return;
2043 if(hoverTimer)clearTimeout(hoverTimer);
2044 hoverTimer=setTimeout(function(){
2045 var key=row.getAttribute('data-line')||'';
2046 var line=lineNumFromKey(key);
2047 if(line!=null)send({type:'cursor',line:line});
2048 },80);
2049});
2050
2051// ── Typing detection in diff comment textareas ─────────────────────────
2052document.addEventListener('focusin',function(ev){
2053 var ta=ev.target;
2054 if(!ta||ta.tagName!=='TEXTAREA')return;
2055 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
2056 var line=lineNumFromKey(row.getAttribute('data-line')||'');
2057 if(line!=null)send({type:'typing',line:line,typing:true});
2058});
2059document.addEventListener('focusout',function(ev){
2060 var ta=ev.target;
2061 if(!ta||ta.tagName!=='TEXTAREA')return;
2062 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
2063 var line=lineNumFromKey(row.getAttribute('data-line')||'');
2064 if(line!=null)send({type:'typing',line:line,typing:false});
2065});
2066
2067// ── WebSocket lifecycle ────────────────────────────────────────────────
2068function connect(){
2069 if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=null;}
2070 try{ws=new WebSocket(url);}catch(e){scheduleReconnect();return;}
2071 ws.onopen=function(){
2072 reconnectDelay=1500;
2073 pingTimer=setInterval(function(){send({type:'ping'});},10000);
2074 };
2075 ws.onmessage=function(ev){onMsg(ev.data);};
2076 ws.onclose=function(){
2077 if(pingTimer){clearInterval(pingTimer);pingTimer=null;}
2078 scheduleReconnect();
2079 };
2080 ws.onerror=function(){try{ws.close();}catch(e){}};
2081}
2082function scheduleReconnect(){
2083 reconnectTimer=setTimeout(function(){connect();},reconnectDelay);
2084 reconnectDelay=Math.min(reconnectDelay*2,30000);
2085}
2086
2087connect();
2088}catch(e){}})();`;
2089}
2090
3c03977Claude2091function LIVE_COEDIT_SCRIPT(prId: string): string {
2092 const idJson = JSON.stringify(prId)
2093 .split("<").join("\\u003C")
2094 .split(">").join("\\u003E")
2095 .split("&").join("\\u0026");
2096 return (
2097 "(function(){try{" +
2098 "if(typeof EventSource==='undefined')return;" +
2099 "var prId=" + idJson + ";" +
2100 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
2101 "var pill=document.getElementById('live-pill');" +
2102 "var avEl=document.getElementById('live-avatars');" +
2103 "var countEl=document.getElementById('live-count');" +
2104 "var sessionId=null;var myColor=null;" +
2105 "var presence={};" + // sessionId -> {color,status,userId,initials}
2106 "var lastApplied={};" + // field -> last server value (for echo suppression)
2107 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
2108 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
2109 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
2110 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
2111 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
2112 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
2113 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
2114 "avEl.innerHTML=html;}}" +
2115 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
2116 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
2117 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
2118 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
2119 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
2120 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
2121 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
2122 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
2123 "if(!c){c=document.createElement('div');c.className='live-cursor';c.setAttribute('data-sid',sid);c.style.background=p.color;c.setAttribute('data-label',p.label||'editor');ov.appendChild(c);}" +
2124 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
2125 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
2126 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
2127 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
2128 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
2129 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
2130 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
2131 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
2132 "}catch(e){}" +
2133 "c.style.transform='translate('+x+'px,'+y+'px)';" +
2134 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
2135 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
2136 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
2137 "var es;var delay=1000;" +
2138 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
2139 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
2140 "(d.presence||[]).forEach(function(s){presence[s.id]={color:s.color,status:s.status,userId:s.userId,initials:initials(s.userId||s.agentSessionId),label:s.userId?'user':'agent'};});renderPresence();}catch(e){}});" +
2141 "es.addEventListener('presence-join',function(m){try{var d=JSON.parse(m.data);presence[d.sessionId]={color:d.color,status:d.status,userId:d.userId,initials:initials(d.userId||d.agentSessionId),label:d.userId?'user':'agent'};renderPresence();}catch(e){}});" +
2142 "es.addEventListener('presence-update',function(m){try{var d=JSON.parse(m.data);if(presence[d.sessionId]){presence[d.sessionId].status=d.status;renderPresence();}}catch(e){}});" +
2143 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
2144 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
2145 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
2146 "var patch=d.patch;if(!patch||!patch.field)return;" +
2147 "var ta=fieldEl(patch.field);if(!ta)return;" +
2148 "if(document.activeElement===ta)return;" + // don't trample local typing
2149 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
2150 "}catch(e){}});" +
2151 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
2152 "}connect();" +
2153 "function post(suffix,body){try{return fetch(base+suffix,{method:'POST',headers:{'content-type':'application/json'},credentials:'same-origin',body:JSON.stringify(body)}).catch(function(){});}catch(e){}}" +
2154 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
2155 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
2156 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
2157 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
2158 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
2159 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
2160 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2161 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2162 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2163 "}" +
2164 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
2165 "var live=document.querySelectorAll('[data-live-field]');" +
2166 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
2167 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
2168 "window.addEventListener('beforeunload',function(){if(!sessionId)return;try{var blob=new Blob([JSON.stringify({sessionId:sessionId})],{type:'application/json'});if(navigator.sendBeacon)navigator.sendBeacon(base+'/leave',blob);else post('/leave',{sessionId:sessionId});}catch(e){}});" +
2169 "}catch(e){}})();"
2170 );
2171}
2172
0074234Claude2173async function resolveRepo(ownerName: string, repoName: string) {
2174 const [owner] = await db
2175 .select()
2176 .from(users)
2177 .where(eq(users.username, ownerName))
2178 .limit(1);
2179 if (!owner) return null;
2180 const [repo] = await db
2181 .select()
2182 .from(repositories)
2183 .where(
2184 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
2185 )
2186 .limit(1);
2187 if (!repo) return null;
2188 return { owner, repo };
2189}
2190
2191// PR Nav helper
2192const PrNav = ({
2193 owner,
2194 repo,
2195 active,
2196}: {
2197 owner: string;
2198 repo: string;
2199 active: "code" | "issues" | "pulls" | "commits";
2200}) => (
bb0f894Claude2201 <TabNav
2202 tabs={[
2203 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
2204 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
2205 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
2206 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
2207 ]}
2208 />
0074234Claude2209);
2210
534f04aClaude2211/**
2212 * Block M3 — pre-merge risk score card. Pure presentational helper.
2213 * Rendered in the conversation tab above the gate checks block. Hidden
2214 * entirely when the PR is closed/merged or there is nothing cached and
2215 * nothing in-flight.
2216 */
2217function PrRiskCard({
2218 risk,
2219 calculating,
2220}: {
2221 risk: PrRiskScore | null;
2222 calculating: boolean;
2223}) {
2224 if (!risk) {
2225 return (
2226 <div
2227 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
2228 >
2229 <strong style="font-size: 13px; color: var(--text)">
2230 Risk score: calculating…
2231 </strong>
2232 <div style="font-size: 12px; margin-top: 4px">
2233 Refresh in a moment to see the pre-merge risk score for this PR.
2234 </div>
2235 </div>
2236 );
2237 }
2238
2239 const palette = riskBandPalette(risk.band);
2240 const label = riskBandLabel(risk.band);
2241
2242 return (
2243 <div
2244 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
2245 >
2246 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
2247 <strong>Risk score:</strong>
2248 <span style={`color:${palette.border};font-weight:600`}>
2249 {palette.icon} {label} ({risk.score}/10)
2250 </span>
2251 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
2252 {risk.commitSha.slice(0, 7)}
2253 </span>
2254 </div>
2255 {risk.aiSummary && (
2256 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
2257 {risk.aiSummary}
2258 </div>
2259 )}
2260 <details style="margin-top:10px">
2261 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
2262 See full signal breakdown
2263 </summary>
2264 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
2265 <li>files changed: {risk.signals.filesChanged}</li>
2266 <li>
2267 lines added/removed: {risk.signals.linesAdded} /{" "}
2268 {risk.signals.linesRemoved}
2269 </li>
2270 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
2271 <li>
2272 schema migration touched:{" "}
2273 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
2274 </li>
2275 <li>
2276 locked / sensitive path touched:{" "}
2277 {risk.signals.lockedPathTouched ? "yes" : "no"}
2278 </li>
2279 <li>
2280 adds new dependency:{" "}
2281 {risk.signals.addsNewDependency ? "yes" : "no"}
2282 </li>
2283 <li>
2284 bumps major dependency:{" "}
2285 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
2286 </li>
2287 <li>
2288 tests added for new code:{" "}
2289 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
2290 </li>
2291 <li>
2292 diff-minus-test ratio:{" "}
2293 {risk.signals.diffMinusTestRatio.toFixed(2)}
2294 </li>
2295 </ul>
2296 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2297 How is this calculated? The score is a transparent sum of
2298 weighted signals — see <code>src/lib/pr-risk.ts</code>
2299 {" "}<code>computePrRiskScore</code>.
2300 </div>
2301 </details>
2302 {calculating && (
2303 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2304 (recomputing for the latest commit — refresh to update)
2305 </div>
2306 )}
2307 </div>
2308 );
2309}
2310
2311function riskBandPalette(band: PrRiskScore["band"]): {
2312 border: string;
2313 icon: string;
2314} {
2315 switch (band) {
2316 case "low":
2317 return { border: "var(--green)", icon: "" };
2318 case "medium":
2319 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
2320 case "high":
2321 return { border: "var(--orange, #db6d28)", icon: "⚠" };
2322 case "critical":
2323 return { border: "var(--red)", icon: "\u{1F6D1}" };
2324 }
2325}
2326
2327function riskBandLabel(band: PrRiskScore["band"]): string {
2328 switch (band) {
2329 case "low":
2330 return "LOW";
2331 case "medium":
2332 return "MEDIUM";
2333 case "high":
2334 return "HIGH";
2335 case "critical":
2336 return "CRITICAL";
2337 }
2338}
2339
09d5f39Claude2340// ---------------------------------------------------------------------------
2341// Merge Impact Analysis — collapsible panel showing affected files and
2342// downstream repos. Shown on open PRs for users with write access.
2343// ---------------------------------------------------------------------------
2344
2345function ImpactPanel({ analysis, owner }: { analysis: ImpactAnalysis; owner: string }) {
2346 const score = analysis.riskScore;
2347 const band = score <= 30 ? "low" : score <= 60 ? "medium" : "high";
2348 const hasAny =
2349 analysis.affectedTestFiles.length > 0 ||
2350 analysis.affectedFiles.length > 0 ||
2351 analysis.downstreamRepos.length > 0;
2352
2353 return (
2354 <div class="impact-panel">
2355 <div
2356 class="impact-header"
2357 onclick="(function(h){var b=h.parentElement.querySelector('.impact-body');var t=h.querySelector('.impact-toggle');if(!b)return;var hidden=b.hasAttribute('hidden');if(hidden){b.removeAttribute('hidden');t&&t.classList.add('is-open');}else{b.setAttribute('hidden','');t&&t.classList.remove('is-open');}})(this)"
2358 >
2359 <span class={`impact-score score-${band}`}>{score}</span>
2360 <strong>Merge Impact</strong>
2361 <span class="impact-summary">{analysis.riskSummary}</span>
2362 <button class="impact-toggle" type="button" aria-label="Toggle impact panel">
2363
2364 </button>
2365 </div>
2366 <div class="impact-body" hidden>
2367 <div class="impact-section">
2368 <h4>Affected test files ({analysis.affectedTestFiles.length})</h4>
2369 {analysis.affectedTestFiles.length === 0 ? (
2370 <span class="impact-empty">No test files reference the changed files.</span>
2371 ) : (
2372 <ul class="impact-file-list">
2373 {analysis.affectedTestFiles.map((f) => (
2374 <li title={f}>{f}</li>
2375 ))}
2376 </ul>
2377 )}
2378 </div>
2379 <div class="impact-section">
2380 <h4>Affected source files ({analysis.affectedFiles.length})</h4>
2381 {analysis.affectedFiles.length === 0 ? (
2382 <span class="impact-empty">No other source files import the changed files.</span>
2383 ) : (
2384 <ul class="impact-file-list">
2385 {analysis.affectedFiles.map((f) => (
2386 <li title={f}>{f}</li>
2387 ))}
2388 </ul>
2389 )}
2390 </div>
2391 {analysis.downstreamRepos.length > 0 && (
2392 <div class="impact-section impact-downstream">
2393 <h4>Downstream repos ({analysis.downstreamRepos.length})</h4>
2394 <ul class="impact-file-list">
2395 {analysis.downstreamRepos.map((r) => (
2396 <li>
2397 <a
2398 href={`/${r.owner}/${r.repo}`}
2399 style="color:var(--text-link);text-decoration:none"
2400 >
2401 {r.owner}/{r.repo}
2402 </a>
2403 {" "}
2404 <span style="color:var(--text-muted);font-size:11px">
2405 via {r.matchedDependency}
2406 </span>
2407 </li>
2408 ))}
2409 </ul>
2410 </div>
2411 )}
2412 </div>
2413 </div>
2414 );
2415}
2416
422a2d4Claude2417// ---------------------------------------------------------------------------
2418// AI Trio Review — 3-column card grid + disagreement callout.
2419//
2420// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
2421// per run: one per persona (security/correctness/style) plus a top-level
2422// summary. We surface them here as a single grid above the normal
2423// comment stream so reviewers see the verdicts at a glance.
2424// ---------------------------------------------------------------------------
2425
2426const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
2427
2428interface TrioCommentLike {
2429 body: string;
2430}
2431
2432function isTrioComment(body: string | null | undefined): boolean {
2433 if (!body) return false;
2434 return (
2435 body.includes(TRIO_SUMMARY_MARKER) ||
2436 body.includes(TRIO_COMMENT_MARKER.security) ||
2437 body.includes(TRIO_COMMENT_MARKER.correctness) ||
2438 body.includes(TRIO_COMMENT_MARKER.style)
2439 );
2440}
2441
2442function trioPersonaOfComment(body: string): TrioPersona | null {
2443 for (const p of TRIO_PERSONAS) {
2444 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
2445 }
2446 return null;
2447}
2448
2449/**
2450 * Best-effort verdict parse from a persona comment body. The body shape
2451 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
2452 * we only need the "Pass" / "Fail" word from the H2 heading.
2453 */
2454function trioVerdictOfBody(body: string): "pass" | "fail" | null {
2455 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
2456 if (!m) return null;
2457 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
2458}
2459
2460/**
2461 * Parse the disagreement bullet list out of the summary comment so we
2462 * can render it as a polished callout strip. Returns [] when nothing
2463 * matches — the comment author may have edited the marker out.
2464 */
2465function parseDisagreements(summaryBody: string): Array<{
2466 file: string;
2467 failing: string;
2468 passing: string;
2469}> {
2470 const out: Array<{ file: string; failing: string; passing: string }> = [];
2471 // Each disagreement line looks like:
2472 // - `path:42` — security, style say ✗, correctness say ✓
2473 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
2474 let m: RegExpExecArray | null;
2475 while ((m = re.exec(summaryBody)) !== null) {
2476 out.push({
2477 file: m[1].trim(),
2478 failing: m[2].trim().replace(/[,\s]+$/g, ""),
2479 passing: m[3].trim().replace(/[,\s]+$/g, ""),
2480 });
2481 }
2482 return out;
2483}
2484
2485function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
2486 // Find the most recent persona comments + summary. We iterate from
2487 // the end so re-reviews (multiple runs on the same PR) display the
2488 // freshest verdict.
2489 const latest: Partial<Record<TrioPersona, string>> = {};
2490 let summaryBody: string | null = null;
2491 for (let i = comments.length - 1; i >= 0; i--) {
2492 const body = comments[i].body || "";
2493 if (!isTrioComment(body)) continue;
2494 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
2495 summaryBody = body;
2496 continue;
2497 }
2498 const persona = trioPersonaOfComment(body);
2499 if (persona && !latest[persona]) latest[persona] = body;
2500 }
2501 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2502 if (!anyPersona && !summaryBody) return null;
2503
2504 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
2505
2506 return (
67dc4e1Claude2507 <div class="trio-wrap" id="trio-review-section">
422a2d4Claude2508 <div class="trio-header">
2509 <span class="trio-header-dot" aria-hidden="true"></span>
2510 <strong>AI Trio Review</strong>
2511 <span class="trio-header-sub">
2512 Three independent reviewers ran in parallel.
2513 </span>
2514 </div>
2515 <div class="trio-grid">
2516 {TRIO_PERSONAS.map((persona) => {
2517 const body = latest[persona];
2518 const verdict = body ? trioVerdictOfBody(body) : null;
2519 const stateClass =
2520 verdict === "fail"
2521 ? "is-fail"
2522 : verdict === "pass"
2523 ? "is-pass"
2524 : "is-pending";
2525 return (
2526 <div class={`trio-card trio-${persona} ${stateClass}`}>
2527 <div class="trio-card-head">
2528 <span class="trio-card-icon" aria-hidden="true">
2529 {persona === "security"
2530 ? "🛡"
2531 : persona === "correctness"
2532 ? "✓"
2533 : "✎"}
2534 </span>
2535 <strong class="trio-card-title">
2536 {persona[0].toUpperCase() + persona.slice(1)}
2537 </strong>
2538 <span class="trio-card-verdict">
2539 {verdict === "pass"
2540 ? "Pass"
2541 : verdict === "fail"
2542 ? "Fail"
2543 : "Pending"}
2544 </span>
2545 </div>
2546 <div class="trio-card-body">
2547 {body ? (
2548 <MarkdownContent
2549 html={renderMarkdown(stripTrioHeading(body))}
2550 />
2551 ) : (
2552 <span class="trio-card-empty">
2553 Awaiting reviewer output.
2554 </span>
2555 )}
2556 </div>
2557 </div>
2558 );
2559 })}
2560 </div>
2561 {disagreements.length > 0 && (
2562 <div class="trio-disagreement-strip" role="note">
2563 <span class="trio-disagreement-icon" aria-hidden="true">
2564
2565 </span>
2566 <div class="trio-disagreement-body">
2567 <strong>Reviewers disagree — review carefully.</strong>
2568 <ul class="trio-disagreement-list">
2569 {disagreements.map((d) => (
2570 <li>
2571 <code>{d.file}</code> — {d.failing} says ✗,{" "}
2572 {d.passing} says ✓
2573 </li>
2574 ))}
2575 </ul>
2576 </div>
2577 </div>
2578 )}
2579 </div>
2580 );
2581}
2582
2583/**
2584 * Strip the marker comment + first H2 heading from a persona body so
2585 * the card body shows just the findings list (verdict is already in
2586 * the card head). Best-effort — malformed bodies render whole.
2587 */
2588function stripTrioHeading(body: string): string {
2589 return body
2590 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
2591 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
2592 .trim();
2593}
2594
67dc4e1Claude2595/**
2596 * Three small verdict pills rendered inline in the PR header. Each pill
2597 * links to the `#trio-review-section` anchor so clicking scrolls to the
2598 * full card grid. Only shown when `AI_TRIO_REVIEW_ENABLED=1` and at
2599 * least one persona comment exists.
2600 */
2601function TrioVerdictPills({
2602 comments,
2603}: {
2604 comments: TrioCommentLike[];
2605}) {
2606 if (!isTrioReviewEnabled()) return null;
2607
2608 // Find latest persona verdicts (same logic as TrioReviewGrid).
2609 const latest: Partial<Record<TrioPersona, string>> = {};
2610 for (let i = comments.length - 1; i >= 0; i--) {
2611 const body = comments[i].body || "";
2612 if (!isTrioComment(body)) continue;
2613 if (body.includes(TRIO_SUMMARY_MARKER)) continue;
2614 const persona = trioPersonaOfComment(body);
2615 if (persona && !latest[persona]) latest[persona] = body;
2616 }
2617
2618 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2619 if (!anyPersona) return null;
2620
2621 const PERSONA_LABEL: Record<TrioPersona, string> = {
2622 security: "Security",
2623 correctness: "Correctness",
2624 style: "Style",
2625 };
2626 const PERSONA_ICON: Record<TrioPersona, string> = {
2627 security: "🛡",
2628 correctness: "✓",
2629 style: "✎",
2630 };
2631
2632 return (
2633 <span class="trio-pills-wrap" aria-label="AI Trio Review verdicts">
2634 {TRIO_PERSONAS.map((persona) => {
2635 const body = latest[persona];
2636 const verdict = body ? trioVerdictOfBody(body) : null;
2637 const stateClass =
2638 verdict === "pass"
2639 ? "is-pass"
2640 : verdict === "fail"
2641 ? "is-fail"
2642 : "is-pending";
2643 const glyph =
2644 verdict === "pass" ? "✓" : verdict === "fail" ? "✗" : "⟳";
2645 return (
2646 <a
2647 href="#trio-review-section"
2648 class={`trio-pill ${stateClass}`}
2649 title={`AI ${PERSONA_LABEL[persona]} Review — ${verdict === "pass" ? "Pass" : verdict === "fail" ? "Fail" : "Pending"}`}
2650 >
2651 <span aria-hidden="true">{PERSONA_ICON[persona]}</span>
2652 {PERSONA_LABEL[persona]} {glyph}
2653 </a>
2654 );
2655 })}
2656 </span>
2657 );
2658}
2659
2c61840Claude2660// Detect AI-generated PRs from body markers and branch naming conventions.
2661// No DB column required — pattern-matched at render time.
2662function isAiGeneratedPr(body: string | null, headBranch: string): boolean {
2663 const AI_BRANCH_PREFIXES = ["claude/", "copilot/", "ai/", "bot/", "gluecron-ai/", "devin/"];
2664 if (AI_BRANCH_PREFIXES.some((p) => headBranch.startsWith(p))) return true;
2665 if (!body) return false;
2666 const AI_BODY_MARKERS = [
2667 "🤖 Generated with",
2668 "Co-Authored-By: Claude",
2669 "Claude-Session:",
2670 "gluecron:ai-generated",
2671 "AI-generated",
2672 "generated by claude",
2673 "generated with claude code",
2674 "copilot workspace",
2675 ];
2676 const lower = body.toLowerCase();
2677 return AI_BODY_MARKERS.some((m) => lower.includes(m.toLowerCase()));
2678}
2679
0074234Claude2680// List PRs
04f6b7fClaude2681pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2682 const { owner: ownerName, repo: repoName } = c.req.param();
2683 const user = c.get("user");
2684 const state = c.req.query("state") || "open";
d790b49Claude2685 const searchQ = c.req.query("q")?.trim() || "";
80bd7c8Claude2686 const authorFilter = c.req.query("author")?.trim() || "";
f5b9ef5Claude2687 const sortPr = (c.req.query("sort") || "newest").trim();
0074234Claude2688
ea9ed4cClaude2689 // ── Loading skeleton (flag-gated) ──
2690 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
2691 // the user see the page structure before counts + select resolve.
2692 // Behind a flag for now — we don't ship flashes.
2693 if (c.req.query("skeleton") === "1") {
2694 return c.html(
2695 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2696 <RepoHeader owner={ownerName} repo={repoName} />
2697 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2698 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2699 <style
2700 dangerouslySetInnerHTML={{
2701 __html: `
404b398Claude2702 .prs-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: prs-skel-shimmer 1.4s infinite; border-radius: 6px; display: block; }
2703 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude2704 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
2705 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
2706 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
2707 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
2708 .prs-skel-row { height: 76px; border-radius: 12px; }
2709 `,
2710 }}
2711 />
2712 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
2713 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
2714 <div class="prs-skel-list" aria-hidden="true">
2715 {Array.from({ length: 6 }).map(() => (
2716 <div class="prs-skel prs-skel-row" />
2717 ))}
2718 </div>
2719 <span style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0" role="status" aria-live="polite">
2720 Loading pull requests for {ownerName}/{repoName}…
2721 </span>
2722 </Layout>
2723 );
2724 }
2725
0074234Claude2726 const resolved = await resolveRepo(ownerName, repoName);
2727 if (!resolved) return c.notFound();
2728
6fc53bdClaude2729 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
2730 const stateFilter =
2731 state === "draft"
2732 ? and(
2733 eq(pullRequests.state, "open"),
2734 eq(pullRequests.isDraft, true)
2735 )
2736 : eq(pullRequests.state, state);
2737
0074234Claude2738 const prList = await db
2739 .select({
2740 pr: pullRequests,
2741 author: { username: users.username },
2742 })
2743 .from(pullRequests)
2744 .innerJoin(users, eq(pullRequests.authorId, users.id))
2745 .where(
d790b49Claude2746 and(
2747 eq(pullRequests.repositoryId, resolved.repo.id),
2748 stateFilter,
2749 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
80bd7c8Claude2750 authorFilter ? eq(users.username, authorFilter) : undefined,
d790b49Claude2751 )
0074234Claude2752 )
f5b9ef5Claude2753 .orderBy(
2754 sortPr === "oldest" ? asc(pullRequests.createdAt)
2755 : sortPr === "updated" ? desc(pullRequests.updatedAt)
2756 : desc(pullRequests.createdAt) // newest (default)
2757 );
0074234Claude2758
0369e77Claude2759 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude2760 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude2761 const commentCountMap = new Map<string, number>();
1aef949Claude2762 if (prList.length > 0) {
2763 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude2764 const [reviewRows, commentRows] = await Promise.all([
2765 db
2766 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
2767 .from(prReviews)
2768 .where(inArray(prReviews.pullRequestId, prIds)),
2769 db
2770 .select({
2771 prId: prComments.pullRequestId,
2772 cnt: sql<number>`count(*)::int`,
2773 })
2774 .from(prComments)
2775 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
2776 .groupBy(prComments.pullRequestId),
2777 ]);
1aef949Claude2778 for (const r of reviewRows) {
2779 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
2780 if (r.state === "approved") entry.approved = true;
2781 if (r.state === "changes_requested") entry.changesRequested = true;
2782 reviewMap.set(r.prId, entry);
2783 }
0369e77Claude2784 for (const r of commentRows) {
2785 commentCountMap.set(r.prId, Number(r.cnt));
2786 }
1aef949Claude2787 }
2788
0074234Claude2789 const [counts] = await db
2790 .select({
2791 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude2792 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude2793 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
2794 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
2795 })
2796 .from(pullRequests)
2797 .where(eq(pullRequests.repositoryId, resolved.repo.id));
2798
b078860Claude2799 const openCount = counts?.open ?? 0;
2800 const mergedCount = counts?.merged ?? 0;
2801 const closedCount = counts?.closed ?? 0;
2802 const draftCount = counts?.draft ?? 0;
2803 const allCount = openCount + mergedCount + closedCount;
2804
2805 // "All" is presentational only — the DB query for state='all' matches
2806 // nothing, so we render a friendlier empty state when picked. We do NOT
2807 // change the query logic to keep this commit purely visual.
80bd7c8Claude2808 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude2809 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude2810 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
2811 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
2812 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
2813 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
2814 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude2815 ];
2816 const isAllState = state === "all";
cb5a796Claude2817 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
2818 const prListPendingCount = viewerIsOwnerOnPrList
2819 ? await countPendingForRepo(resolved.repo.id)
2820 : 0;
b078860Claude2821
0074234Claude2822 return c.html(
2823 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2824 <RepoHeader owner={ownerName} repo={repoName} />
2825 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2826 <PendingCommentsBanner
2827 owner={ownerName}
2828 repo={repoName}
2829 count={prListPendingCount}
2830 />
b078860Claude2831 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2832
2833 <div class="prs-hero">
2834 <div class="prs-hero-inner">
2835 <div class="prs-hero-text">
2836 <div class="prs-hero-eyebrow">Pull requests</div>
2837 <h1 class="prs-hero-title">
2838 Review, <span class="gradient-text">merge with AI</span>.
2839 </h1>
2840 <p class="prs-hero-sub">
2841 {openCount === 0 && allCount === 0
2842 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2843 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
2844 </p>
2845 </div>
7a28902Claude2846 <div class="prs-hero-actions">
2847 <a
2848 href={`/${ownerName}/${repoName}/pulls/insights`}
2849 class="prs-cta"
2850 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
2851 >
2852 Insights
2853 </a>
2854 {user && (
b078860Claude2855 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
2856 + New pull request
2857 </a>
7a28902Claude2858 )}
2859 </div>
b078860Claude2860 </div>
2861 </div>
2862
2863 <nav class="prs-tabs" aria-label="Pull request filters">
2864 {tabPills.map((t) => {
2865 const isActive =
2866 state === t.key ||
2867 (t.key === "open" &&
2868 state !== "merged" &&
2869 state !== "closed" &&
2870 state !== "all" &&
2871 state !== "draft");
2872 return (
2873 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2874 <span>{t.label}</span>
2875 <span class="prs-tab-count">{t.count}</span>
2876 </a>
2877 );
2878 })}
2879 </nav>
2880
d790b49Claude2881 <form
2882 method="get"
2883 action={`/${ownerName}/${repoName}/pulls`}
2884 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2885 >
2886 <input type="hidden" name="state" value={state} />
2887 <input
2888 type="search"
2889 name="q"
2890 value={searchQ}
2891 placeholder="Search pull requests…"
2892 class="issues-search-input"
2893 style="flex:1;max-width:380px"
2894 />
80bd7c8Claude2895 <input
2896 type="text"
2897 name="author"
2898 value={authorFilter}
2899 placeholder="Filter by author…"
2900 class="issues-search-input"
2901 style="max-width:200px"
2902 />
d790b49Claude2903 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2904 {(searchQ || authorFilter) && (
d790b49Claude2905 <a
2906 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2907 class="issues-filter-clear"
2908 >
2909 Clear
2910 </a>
2911 )}
2912 </form>
f5b9ef5Claude2913
2914 <div class="prs-sort-row">
2915 <span class="prs-sort-label">Sort:</span>
2916 {(["newest", "oldest", "updated"] as const).map((s) => (
2917 <a
2918 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2919 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2920 >
2921 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2922 </a>
2923 ))}
2924 </div>
2925
0074234Claude2926 {prList.length === 0 ? (
b078860Claude2927 <div class="prs-empty">
ea9ed4cClaude2928 <div class="prs-empty-inner">
2929 <strong>
80bd7c8Claude2930 {searchQ || authorFilter
2931 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2932 : isAllState
2933 ? "Pick a filter above to browse PRs."
2934 : `No ${state} pull requests.`}
ea9ed4cClaude2935 </strong>
2936 <p class="prs-empty-sub">
80bd7c8Claude2937 {searchQ || authorFilter
2938 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2939 : state === "open"
2940 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2941 : isAllState
2942 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2943 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2944 </p>
2945 <div class="prs-empty-cta">
80bd7c8Claude2946 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2947 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2948 + New pull request
2949 </a>
2950 )}
80bd7c8Claude2951 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2952 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2953 View open PRs
2954 </a>
2955 )}
2956 <a href={`/${ownerName}/${repoName}`} class="btn">
2957 Back to code
2958 </a>
2959 </div>
2960 </div>
b078860Claude2961 </div>
0074234Claude2962 ) : (
b078860Claude2963 <div class="prs-list">
2964 {prList.map(({ pr, author }) => {
2965 const stateClass =
2966 pr.state === "open"
2967 ? pr.isDraft
2968 ? "state-draft"
2969 : "state-open"
2970 : pr.state === "merged"
2971 ? "state-merged"
2972 : "state-closed";
2973 const icon =
2974 pr.state === "open"
2975 ? pr.isDraft
2976 ? "◌"
2977 : "○"
2978 : pr.state === "merged"
2979 ? "⮌"
2980 : "✓";
1aef949Claude2981 const rv = reviewMap.get(pr.id);
b078860Claude2982 return (
2983 <a
2984 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2985 class="prs-row"
2986 style="text-decoration:none;color:inherit"
0074234Claude2987 >
b078860Claude2988 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2989 {icon}
0074234Claude2990 </div>
b078860Claude2991 <div class="prs-row-body">
2992 <h3 class="prs-row-title">
2993 <span>{pr.title}</span>
2994 <span class="prs-row-number">#{pr.number}</span>
2995 </h3>
2996 <div class="prs-row-meta">
2997 <span
2998 class="prs-branch-chips"
2999 title={`${pr.headBranch} into ${pr.baseBranch}`}
3000 >
3001 <span class="prs-branch-chip">{pr.headBranch}</span>
3002 <span class="prs-branch-arrow">{"→"}</span>
3003 <span class="prs-branch-chip">{pr.baseBranch}</span>
3004 </span>
3005 <span>
3006 by{" "}
3007 <strong style="color:var(--text)">
3008 {author.username}
3009 </strong>{" "}
3010 {formatRelative(pr.createdAt)}
3011 </span>
3012 <span class="prs-row-tags">
3013 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2c61840Claude3014 {isAiGeneratedPr(pr.body, pr.headBranch) && (
3015 <span class="prs-tag is-ai" title="Opened by an AI agent">⚡ AI</span>
3016 )}
b078860Claude3017 {pr.state === "merged" && (
3018 <span class="prs-tag is-merged">Merged</span>
3019 )}
1aef949Claude3020 {rv?.approved && !rv.changesRequested && (
3021 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
3022 )}
3023 {rv?.changesRequested && (
3024 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
3025 )}
0369e77Claude3026 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
3027 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
3028 💬 {commentCountMap.get(pr.id)}
3029 </span>
3030 )}
b078860Claude3031 </span>
3032 </div>
0074234Claude3033 </div>
b078860Claude3034 </a>
3035 );
3036 })}
3037 </div>
0074234Claude3038 )}
3039 </Layout>
3040 );
3041});
3042
7a28902Claude3043/* ─────────────────────────────────────────────────────────────────────────
3044 * PR Insights — 90-day analytics for the pull request activity of a repo.
3045 * Route: GET /:owner/:repo/pulls/insights
3046 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
3047 * "insights" is not swallowed by the :number param.
3048 * ───────────────────────────────────────────────────────────────────────── */
3049
3050/** Format a millisecond duration as human-readable string. */
3051function formatMsDuration(ms: number): string {
3052 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
3053 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
3054 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
3055 return `${Math.round(ms / 86_400_000)}d`;
3056}
3057
3058/** Format an ISO week string as "Jan 15". */
3059function formatWeekLabel(isoWeek: string): string {
3060 try {
3061 const d = new Date(isoWeek);
3062 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
3063 } catch {
3064 return isoWeek.slice(5, 10);
3065 }
3066}
3067
3068const PR_INSIGHTS_STYLES = `
3069 .pri-page { padding-bottom: 48px; }
3070 .pri-hero {
3071 position: relative;
3072 margin: 0 0 var(--space-5);
3073 padding: 22px 26px 24px;
3074 background: var(--bg-elevated);
3075 border: 1px solid var(--border);
3076 border-radius: 16px;
3077 overflow: hidden;
3078 }
3079 .pri-hero::before {
3080 content: '';
3081 position: absolute; top: 0; left: 0; right: 0;
3082 height: 2px;
6fd5915Claude3083 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7a28902Claude3084 opacity: 0.7;
3085 pointer-events: none;
3086 }
3087 .pri-hero-eyebrow {
3088 font-size: 12px;
3089 color: var(--text-muted);
3090 text-transform: uppercase;
3091 letter-spacing: 0.08em;
3092 font-weight: 600;
3093 margin-bottom: 8px;
3094 }
3095 .pri-hero-title {
3096 font-family: var(--font-display);
3097 font-size: clamp(26px, 3.4vw, 34px);
3098 font-weight: 800;
3099 letter-spacing: -0.025em;
3100 line-height: 1.06;
3101 margin: 0 0 8px;
3102 color: var(--text-strong);
3103 }
3104 .pri-hero-title .gradient-text {
6fd5915Claude3105 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
7a28902Claude3106 -webkit-background-clip: text;
3107 background-clip: text;
3108 -webkit-text-fill-color: transparent;
3109 color: transparent;
3110 }
3111 .pri-hero-sub {
3112 font-size: 14.5px;
3113 color: var(--text-muted);
3114 margin: 0;
3115 line-height: 1.5;
3116 }
3117 .pri-section { margin-bottom: 32px; }
3118 .pri-section-title {
3119 font-size: 13px;
3120 font-weight: 700;
3121 text-transform: uppercase;
3122 letter-spacing: 0.06em;
3123 color: var(--text-muted);
3124 margin: 0 0 14px;
3125 }
3126 .pri-cards {
3127 display: grid;
3128 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
3129 gap: 12px;
3130 }
3131 .pri-card {
3132 padding: 16px 18px;
3133 background: var(--bg-elevated);
3134 border: 1px solid var(--border);
3135 border-radius: 12px;
3136 }
3137 .pri-card-label {
3138 font-size: 12px;
3139 font-weight: 600;
3140 color: var(--text-muted);
3141 text-transform: uppercase;
3142 letter-spacing: 0.05em;
3143 margin-bottom: 6px;
3144 }
3145 .pri-card-value {
3146 font-size: 28px;
3147 font-weight: 800;
3148 letter-spacing: -0.04em;
3149 color: var(--text-strong);
3150 line-height: 1;
3151 }
3152 .pri-card-sub {
3153 font-size: 12px;
3154 color: var(--text-muted);
3155 margin-top: 4px;
3156 }
3157 .pri-chart {
3158 display: flex;
3159 align-items: flex-end;
3160 gap: 6px;
3161 height: 120px;
3162 background: var(--bg-elevated);
3163 border: 1px solid var(--border);
3164 border-radius: 12px;
3165 padding: 16px 16px 0;
3166 }
3167 .pri-bar-col {
3168 flex: 1;
3169 display: flex;
3170 flex-direction: column;
3171 align-items: center;
3172 justify-content: flex-end;
3173 height: 100%;
3174 gap: 4px;
3175 }
3176 .pri-bar {
3177 width: 100%;
3178 min-height: 4px;
3179 border-radius: 4px 4px 0 0;
6fd5915Claude3180 background: linear-gradient(180deg, #5b6ee8 0%, #5b6ee8 100%);
7a28902Claude3181 transition: opacity 140ms;
3182 }
3183 .pri-bar:hover { opacity: 0.8; }
3184 .pri-bar-label {
3185 font-size: 10px;
3186 color: var(--text-muted);
3187 text-align: center;
3188 padding-bottom: 8px;
3189 white-space: nowrap;
3190 overflow: hidden;
3191 text-overflow: ellipsis;
3192 max-width: 100%;
3193 }
3194 .pri-table {
3195 width: 100%;
3196 border-collapse: collapse;
3197 font-size: 13.5px;
3198 }
3199 .pri-table th {
3200 text-align: left;
3201 font-size: 12px;
3202 font-weight: 600;
3203 text-transform: uppercase;
3204 letter-spacing: 0.05em;
3205 color: var(--text-muted);
3206 padding: 8px 12px;
3207 border-bottom: 1px solid var(--border);
3208 }
3209 .pri-table td {
3210 padding: 10px 12px;
3211 border-bottom: 1px solid var(--border);
3212 color: var(--text);
3213 }
3214 .pri-table tr:last-child td { border-bottom: none; }
3215 .pri-table-wrap {
3216 background: var(--bg-elevated);
3217 border: 1px solid var(--border);
3218 border-radius: 12px;
3219 overflow: hidden;
3220 }
3221 .pri-age-row {
3222 display: flex;
3223 align-items: center;
3224 gap: 12px;
3225 padding: 10px 0;
3226 border-bottom: 1px solid var(--border);
3227 font-size: 13.5px;
3228 }
3229 .pri-age-row:last-child { border-bottom: none; }
3230 .pri-age-label {
3231 flex: 0 0 80px;
3232 color: var(--text-muted);
3233 font-size: 12.5px;
3234 font-weight: 600;
3235 }
3236 .pri-age-bar-wrap {
3237 flex: 1;
3238 height: 8px;
3239 background: var(--bg-secondary);
3240 border-radius: 9999px;
3241 overflow: hidden;
3242 }
3243 .pri-age-bar {
3244 height: 100%;
3245 border-radius: 9999px;
6fd5915Claude3246 background: linear-gradient(90deg, #5b6ee8 0%, #5f8fa0 100%);
7a28902Claude3247 min-width: 4px;
3248 }
3249 .pri-age-count {
3250 flex: 0 0 32px;
3251 text-align: right;
3252 font-weight: 600;
3253 color: var(--text-strong);
3254 font-size: 13px;
3255 }
3256 .pri-sparkline {
3257 display: flex;
3258 align-items: flex-end;
3259 gap: 3px;
3260 height: 40px;
3261 }
3262 .pri-spark-bar {
3263 flex: 1;
3264 min-height: 2px;
3265 border-radius: 2px 2px 0 0;
6fd5915Claude3266 background: var(--accent, #5b6ee8);
7a28902Claude3267 opacity: 0.7;
3268 }
3269 .pri-empty {
3270 color: var(--text-muted);
3271 font-size: 14px;
3272 padding: 24px 0;
3273 text-align: center;
3274 }
3275 @media (max-width: 600px) {
3276 .pri-cards { grid-template-columns: repeat(2, 1fr); }
3277 .pri-hero { padding: 18px 18px 20px; }
3278 }
3279`;
3280
3281pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
3282 const { owner: ownerName, repo: repoName } = c.req.param();
3283 const user = c.get("user");
3284
3285 const resolved = await resolveRepo(ownerName, repoName);
3286 if (!resolved) return c.notFound();
3287
3288 const repoId = resolved.repo.id;
3289 const now = Date.now();
3290
3291 // 1. Merged PRs in last 90 days (avg merge time)
3292 const mergedPRs = await db
3293 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
3294 .from(pullRequests)
3295 .where(and(
3296 eq(pullRequests.repositoryId, repoId),
3297 eq(pullRequests.state, "merged"),
3298 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3299 ));
3300
3301 const avgMergeMs = mergedPRs.length > 0
3302 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
3303 : null;
3304
3305 // 2. PR throughput (last 8 weeks)
3306 const weeklyPRs = await db
3307 .select({
3308 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
3309 count: sql<number>`count(*)::int`,
3310 })
3311 .from(pullRequests)
3312 .where(and(
3313 eq(pullRequests.repositoryId, repoId),
3314 sql`${pullRequests.createdAt} > now() - interval '56 days'`
3315 ))
3316 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
3317 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
3318
3319 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
3320
3321 // 3. PR merge rate (last 90 days)
3322 const [rateCounts] = await db
3323 .select({
3324 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
3325 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
3326 })
3327 .from(pullRequests)
3328 .where(and(
3329 eq(pullRequests.repositoryId, repoId),
3330 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3331 ));
3332
3333 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
3334 const mergeRate = totalResolved > 0
3335 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
3336 : null;
3337
3338 // 4. Top reviewers (last 90 days)
3339 const reviewerCounts = await db
3340 .select({
3341 userId: prReviews.reviewerId,
3342 username: users.username,
3343 count: sql<number>`count(*)::int`,
3344 })
3345 .from(prReviews)
3346 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3347 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
3348 .where(and(
3349 eq(pullRequests.repositoryId, repoId),
3350 sql`${prReviews.createdAt} > now() - interval '90 days'`
3351 ))
3352 .groupBy(prReviews.reviewerId, users.username)
3353 .orderBy(desc(sql`count(*)`))
3354 .limit(5);
3355
3356 // 5. Average reviews per merged PR
3357 const [avgReviewRow] = await db
3358 .select({
3359 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
3360 })
3361 .from(pullRequests)
3362 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3363 .where(and(
3364 eq(pullRequests.repositoryId, repoId),
3365 eq(pullRequests.state, "merged"),
3366 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3367 ));
3368
3369 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
3370 ? Math.round(avgReviewRow.avgReviews * 10) / 10
3371 : null;
3372
3373 // 6. Review turnaround — avg time from PR open to first review
3374 const prsWithReviews = await db
3375 .select({
3376 createdAt: pullRequests.createdAt,
3377 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
3378 })
3379 .from(pullRequests)
3380 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3381 .where(and(
3382 eq(pullRequests.repositoryId, repoId),
3383 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3384 ))
3385 .groupBy(pullRequests.id, pullRequests.createdAt);
3386
3387 const avgReviewTurnaroundMs = prsWithReviews.length > 0
3388 ? prsWithReviews.reduce((s, row) => {
3389 const firstMs = new Date(row.firstReview).getTime();
3390 return s + Math.max(0, firstMs - row.createdAt.getTime());
3391 }, 0) / prsWithReviews.length
3392 : null;
3393
3394 // 7. Open PRs by age bucket
3395 const openPRs = await db
3396 .select({ createdAt: pullRequests.createdAt })
3397 .from(pullRequests)
3398 .where(and(
3399 eq(pullRequests.repositoryId, repoId),
3400 eq(pullRequests.state, "open")
3401 ));
3402
3403 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
3404 for (const { createdAt } of openPRs) {
3405 const ageDays = (now - createdAt.getTime()) / 86_400_000;
3406 if (ageDays < 1) ageBuckets.lt1d++;
3407 else if (ageDays < 3) ageBuckets.d1to3++;
3408 else if (ageDays < 7) ageBuckets.d3to7++;
3409 else if (ageDays < 30) ageBuckets.d7to30++;
3410 else ageBuckets.gt30d++;
3411 }
3412 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
3413
3414 // 8. 7-day merge sparkline
3415 const sparklineRows = await db
3416 .select({
3417 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
3418 count: sql<number>`count(*)::int`,
3419 })
3420 .from(pullRequests)
3421 .where(and(
3422 eq(pullRequests.repositoryId, repoId),
3423 eq(pullRequests.state, "merged"),
3424 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
3425 ))
3426 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
3427 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
3428
3429 const sparkMap = new Map<string, number>();
3430 for (const row of sparklineRows) {
3431 sparkMap.set(row.day.slice(0, 10), row.count);
3432 }
3433 const sparkline: number[] = [];
3434 for (let i = 6; i >= 0; i--) {
3435 const d = new Date(now - i * 86_400_000);
3436 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
3437 }
3438 const maxSpark = Math.max(1, ...sparkline);
3439
3440 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
3441 { label: "< 1 day", key: "lt1d" },
3442 { label: "1–3 days", key: "d1to3" },
3443 { label: "3–7 days", key: "d3to7" },
3444 { label: "7–30 days", key: "d7to30" },
3445 { label: "> 30 days", key: "gt30d" },
3446 ];
3447
3448 return c.html(
3449 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
3450 <RepoHeader owner={ownerName} repo={repoName} />
3451 <PrNav owner={ownerName} repo={repoName} active="pulls" />
3452 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
3453
3454 <div class="pri-page">
3455 {/* Hero */}
3456 <div class="pri-hero">
3457 <div class="pri-hero-eyebrow">Pull requests</div>
3458 <h1 class="pri-hero-title">
3459 PR <span class="gradient-text">Insights</span>
3460 </h1>
3461 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
3462 </div>
3463
3464 {/* Stat cards */}
3465 <div class="pri-section">
3466 <div class="pri-section-title">At a glance</div>
3467 <div class="pri-cards">
3468 <div class="pri-card">
3469 <div class="pri-card-label">Avg merge time</div>
3470 <div class="pri-card-value">
3471 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
3472 </div>
3473 <div class="pri-card-sub">last 90 days</div>
3474 </div>
3475 <div class="pri-card">
3476 <div class="pri-card-label">Total merged</div>
3477 <div class="pri-card-value">{mergedPRs.length}</div>
3478 <div class="pri-card-sub">last 90 days</div>
3479 </div>
3480 <div class="pri-card">
3481 <div class="pri-card-label">Open PRs</div>
3482 <div class="pri-card-value">{openPRs.length}</div>
3483 <div class="pri-card-sub">right now</div>
3484 </div>
3485 <div class="pri-card">
3486 <div class="pri-card-label">Merge rate</div>
3487 <div class="pri-card-value">
3488 {mergeRate != null ? `${mergeRate}%` : "—"}
3489 </div>
3490 <div class="pri-card-sub">merged vs closed</div>
3491 </div>
3492 <div class="pri-card">
3493 <div class="pri-card-label">Avg reviews / PR</div>
3494 <div class="pri-card-value">
3495 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
3496 </div>
3497 <div class="pri-card-sub">merged PRs, 90d</div>
3498 </div>
3499 <div class="pri-card">
3500 <div class="pri-card-label">Top reviewer</div>
3501 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
3502 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
3503 </div>
3504 <div class="pri-card-sub">
3505 {reviewerCounts.length > 0
3506 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
3507 : "no reviews yet"}
3508 </div>
3509 </div>
3510 </div>
3511 </div>
3512
3513 {/* Review turnaround */}
3514 <div class="pri-section">
3515 <div class="pri-section-title">Review turnaround</div>
3516 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
3517 <div class="pri-card">
3518 <div class="pri-card-label">Avg time to first review</div>
3519 <div class="pri-card-value">
3520 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
3521 </div>
3522 <div class="pri-card-sub">
3523 {prsWithReviews.length > 0
3524 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
3525 : "no reviewed PRs in 90d"}
3526 </div>
3527 </div>
3528 </div>
3529 </div>
3530
3531 {/* Weekly throughput bar chart */}
3532 <div class="pri-section">
3533 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
3534 {weeklyPRs.length === 0 ? (
3535 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
3536 ) : (
3537 <div class="pri-chart">
3538 {weeklyPRs.map((w) => (
3539 <div class="pri-bar-col">
3540 <div
3541 class="pri-bar"
3542 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
3543 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
3544 />
3545 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
3546 </div>
3547 ))}
3548 </div>
3549 )}
3550 </div>
3551
3552 {/* 7-day merge sparkline */}
3553 <div class="pri-section">
3554 <div class="pri-section-title">Merges this week (daily)</div>
3555 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
3556 <div class="pri-sparkline">
3557 {sparkline.map((v) => (
3558 <div
3559 class="pri-spark-bar"
3560 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
3561 title={`${v} merge${v === 1 ? "" : "s"}`}
3562 />
3563 ))}
3564 </div>
3565 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
3566 <span>7 days ago</span>
3567 <span>Today</span>
3568 </div>
3569 </div>
3570 </div>
3571
3572 {/* Top reviewers table */}
3573 <div class="pri-section">
3574 <div class="pri-section-title">Top reviewers (last 90 days)</div>
3575 {reviewerCounts.length === 0 ? (
3576 <div class="pri-empty">No reviews posted in the last 90 days.</div>
3577 ) : (
3578 <div class="pri-table-wrap">
3579 <table class="pri-table">
3580 <thead>
3581 <tr>
3582 <th>#</th>
3583 <th>Reviewer</th>
3584 <th>Reviews</th>
3585 </tr>
3586 </thead>
3587 <tbody>
3588 {reviewerCounts.map((r, i) => (
3589 <tr>
3590 <td style="color:var(--text-muted)">{i + 1}</td>
3591 <td>
3592 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
3593 {r.username}
3594 </a>
3595 </td>
3596 <td style="font-weight:600">{r.count}</td>
3597 </tr>
3598 ))}
3599 </tbody>
3600 </table>
3601 </div>
3602 )}
3603 </div>
3604
3605 {/* Open PRs by age */}
3606 <div class="pri-section">
3607 <div class="pri-section-title">Open PRs by age</div>
3608 {openPRs.length === 0 ? (
3609 <div class="pri-empty">No open pull requests.</div>
3610 ) : (
3611 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
3612 {ageBucketDefs.map(({ label, key }) => (
3613 <div class="pri-age-row">
3614 <span class="pri-age-label">{label}</span>
3615 <div class="pri-age-bar-wrap">
3616 <div
3617 class="pri-age-bar"
3618 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
3619 />
3620 </div>
3621 <span class="pri-age-count">{ageBuckets[key]}</span>
3622 </div>
3623 ))}
3624 </div>
3625 )}
3626 </div>
3627
3628 {/* Back link */}
3629 <div>
3630 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
3631 {"←"} Back to pull requests
3632 </a>
3633 </div>
3634 </div>
3635 </Layout>
3636 );
3637});
3638
0074234Claude3639// New PR form
3640pulls.get(
3641 "/:owner/:repo/pulls/new",
3642 softAuth,
3643 requireAuth,
04f6b7fClaude3644 requireRepoAccess("write"),
0074234Claude3645 async (c) => {
3646 const { owner: ownerName, repo: repoName } = c.req.param();
3647 const user = c.get("user")!;
3648 const branches = await listBranches(ownerName, repoName);
3649 const error = c.req.query("error");
3650 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude3651 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude3652
3653 return c.html(
3654 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
3655 <RepoHeader owner={ownerName} repo={repoName} />
3656 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude3657 <Container maxWidth={800}>
3658 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude3659 {error && (
bb0f894Claude3660 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude3661 )}
0316dbbClaude3662 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
3663 <Flex gap={12} align="center" style="margin-bottom: 16px">
3664 <Select name="base">
0074234Claude3665 {branches.map((b) => (
3666 <option value={b} selected={b === defaultBase}>
3667 {b}
3668 </option>
3669 ))}
bb0f894Claude3670 </Select>
3671 <Text muted>&larr;</Text>
3672 <Select name="head">
0074234Claude3673 {branches
3674 .filter((b) => b !== defaultBase)
3675 .concat(defaultBase === branches[0] ? [] : [branches[0]])
3676 .map((b) => (
3677 <option value={b}>{b}</option>
3678 ))}
bb0f894Claude3679 </Select>
3680 </Flex>
3681 <FormGroup>
3682 <Input
0074234Claude3683 name="title"
3684 required
3685 placeholder="Title"
bb0f894Claude3686 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]3687 aria-label="Pull request title"
0074234Claude3688 />
bb0f894Claude3689 </FormGroup>
3690 <FormGroup>
3691 <TextArea
0074234Claude3692 name="body"
81c73c1Claude3693 id="pr-body"
0074234Claude3694 rows={8}
3695 placeholder="Description (Markdown supported)"
bb0f894Claude3696 mono
0074234Claude3697 />
bb0f894Claude3698 </FormGroup>
81c73c1Claude3699 <Flex gap={8} align="center">
3700 <Button type="submit" variant="primary">
3701 Create pull request
3702 </Button>
3703 <button
3704 type="button"
3705 id="ai-suggest-desc"
3706 class="btn"
3707 style="font-weight:500"
3708 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
3709 >
3710 Suggest description with AI
3711 </button>
3712 <span
3713 id="ai-suggest-status"
3714 style="color:var(--text-muted);font-size:13px"
3715 />
3716 </Flex>
bb0f894Claude3717 </Form>
81c73c1Claude3718 <script
3719 dangerouslySetInnerHTML={{
3720 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
3721 }}
3722 />
bb0f894Claude3723 </Container>
0074234Claude3724 </Layout>
3725 );
3726 }
3727);
3728
81c73c1Claude3729// AI-suggested PR description — JSON endpoint driven by the form button.
3730// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
3731// 200; the inline script reads `ok` to decide what to do.
3732pulls.post(
3733 "/:owner/:repo/ai/pr-description",
3734 softAuth,
3735 requireAuth,
3736 requireRepoAccess("write"),
3737 async (c) => {
3738 const { owner: ownerName, repo: repoName } = c.req.param();
3739 if (!isAiAvailable()) {
3740 return c.json({
3741 ok: false,
3742 error: "AI is not available — set ANTHROPIC_API_KEY.",
3743 });
3744 }
3745 const body = await c.req.parseBody();
3746 const title = String(body.title || "").trim();
3747 const baseBranch = String(body.base || "").trim();
3748 const headBranch = String(body.head || "").trim();
3749 if (!baseBranch || !headBranch) {
3750 return c.json({ ok: false, error: "Pick base + head branches first." });
3751 }
3752 if (baseBranch === headBranch) {
3753 return c.json({ ok: false, error: "Base and head must differ." });
3754 }
3755
3756 let diff = "";
3757 try {
3758 const cwd = getRepoPath(ownerName, repoName);
3759 const proc = Bun.spawn(
3760 [
3761 "git",
3762 "diff",
3763 `${baseBranch}...${headBranch}`,
3764 "--",
3765 ],
3766 { cwd, stdout: "pipe", stderr: "pipe" }
3767 );
6ea2109Claude3768 // 30s ceiling — without this a pathological diff (huge binary or
3769 // a corrupt ref) hangs the request indefinitely.
3770 const killer = setTimeout(() => proc.kill(), 30_000);
3771 try {
3772 diff = await new Response(proc.stdout).text();
3773 await proc.exited;
3774 } finally {
3775 clearTimeout(killer);
3776 }
81c73c1Claude3777 } catch {
3778 diff = "";
3779 }
3780 if (!diff.trim()) {
3781 return c.json({
3782 ok: false,
3783 error: "No diff between branches — nothing to summarise.",
3784 });
3785 }
3786
3787 let summary = "";
3788 try {
3789 summary = await generatePrSummary(title || "(untitled)", diff);
3790 } catch (err) {
3791 const msg = err instanceof Error ? err.message : "AI request failed.";
3792 return c.json({ ok: false, error: msg });
3793 }
3794 if (!summary.trim()) {
3795 return c.json({ ok: false, error: "AI returned an empty draft." });
3796 }
3797 return c.json({ ok: true, body: summary });
3798 }
3799);
3800
0074234Claude3801// Create PR
3802pulls.post(
3803 "/:owner/:repo/pulls/new",
3804 softAuth,
3805 requireAuth,
04f6b7fClaude3806 requireRepoAccess("write"),
0074234Claude3807 async (c) => {
3808 const { owner: ownerName, repo: repoName } = c.req.param();
3809 const user = c.get("user")!;
3810 const body = await c.req.parseBody();
3811 const title = String(body.title || "").trim();
3812 const prBody = String(body.body || "").trim();
3813 const baseBranch = String(body.base || "main");
3814 const headBranch = String(body.head || "");
3815
3816 if (!title || !headBranch) {
3817 return c.redirect(
3818 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
3819 );
3820 }
3821
3822 if (baseBranch === headBranch) {
3823 return c.redirect(
3824 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
3825 );
3826 }
3827
3828 const resolved = await resolveRepo(ownerName, repoName);
3829 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3830
6fc53bdClaude3831 const isDraft = String(body.draft || "") === "1";
3832
0074234Claude3833 const [pr] = await db
3834 .insert(pullRequests)
3835 .values({
3836 repositoryId: resolved.repo.id,
3837 authorId: user.id,
3838 title,
3839 body: prBody || null,
3840 baseBranch,
3841 headBranch,
6fc53bdClaude3842 isDraft,
0074234Claude3843 })
3844 .returning();
3845
b7b5f75ccanty labs3846 void logActivity({
3847 repositoryId: resolved.repo.id,
3848 userId: user.id,
3849 action: "pr_open",
3850 targetType: "pull_request",
3851 targetId: String(pr.number),
3852 metadata: { baseBranch, headBranch, isDraft },
3853 });
a74f4edccanty labs3854 void fireWebhooks(resolved.repo.id, "pr", {
3855 action: "opened",
3856 number: pr.number,
3857 baseBranch,
3858 headBranch,
3859 });
b7b5f75ccanty labs3860
ec9e3e3Claude3861 // CODEOWNERS — auto-request reviewers based on changed files.
3862 // Fire-and-forget; errors never block PR creation.
3863 (async () => {
3864 try {
3865 const repoDir = getRepoPath(ownerName, repoName);
3866 // Get list of changed files between base and head
3867 const diffProc = Bun.spawn(
3868 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
3869 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3870 );
3871 const rawDiff = await new Response(diffProc.stdout).text();
3872 await diffProc.exited;
3873 const changedFiles = rawDiff.trim().split("\n").filter(Boolean);
3874
3875 if (changedFiles.length > 0) {
3876 // Get CODEOWNERS from the default branch of the repo
3877 const rules = await getCodeownersForRepo(
3878 ownerName,
3879 repoName,
3880 resolved.repo.defaultBranch
3881 );
3882 if (rules.length > 0) {
3883 const ownerUsernames = await reviewersForChangedFiles(
3884 resolved.repo.id,
3885 changedFiles
3886 );
3887 // Filter out the PR author
3888 const filteredOwners = ownerUsernames.filter(
3889 (u) => u !== resolved.owner.username
3890 );
3891
3892 if (filteredOwners.length > 0) {
3893 // Look up user IDs for the owner usernames
3894 const reviewerUsers = await db
3895 .select({ id: users.id, username: users.username })
3896 .from(users)
3897 .where(
3898 inArray(
3899 users.username,
3900 filteredOwners
3901 )
3902 );
3903
3904 // Create review request rows (UNIQUE constraint prevents dupes)
3905 if (reviewerUsers.length > 0) {
3906 await db
3907 .insert(prReviewRequests)
3908 .values(
3909 reviewerUsers.map((u) => ({
3910 prId: pr.id,
3911 reviewerId: u.id,
3912 requestedBy: null as string | null,
3913 }))
3914 )
3915 .onConflictDoNothing();
3916
3917 // Add a PR comment announcing the auto-assigned reviewers
3918 const mentionList = reviewerUsers
3919 .map((u) => `@${u.username}`)
3920 .join(", ");
3921 await db.insert(prComments).values({
3922 pullRequestId: pr.id,
3923 authorId: user.id,
3924 body: `AI: Requested review from ${mentionList} based on CODEOWNERS`,
3925 isAiReview: true,
3926 });
3927 }
3928 }
3929 }
3930 }
3931 } catch (err) {
3932 console.warn("[codeowners] auto-assign failed:", err instanceof Error ? err.message : err);
3933 }
3934 })();
3935
91b054eccanty labs3936 // `on: pull_request` workflow trigger — workflows are already synced
3937 // into the `workflows` table on push (push-workflow-sync.ts); PR-open
3938 // just needs to enqueue runs for the ones whose `on:` includes
3939 // `pull_request`. Fire-and-forget; must never block PR creation. Scoped
3940 // to PR open only — see pr-workflow-sync.ts header for why synchronize/
3941 // close aren't wired here yet.
3942 resolveRef(ownerName, repoName, headBranch)
3943 .then((headSha) => {
3944 if (!headSha) return;
3945 return enqueuePullRequestWorkflows({
3946 repositoryId: resolved.repo.id,
3947 headBranch,
3948 headSha,
3949 triggeredBy: user.id,
3950 });
3951 })
3952 .catch((err) =>
3953 console.warn(
3954 "[pr-workflow-sync] enqueue failed:",
3955 err instanceof Error ? err.message : err
3956 )
3957 );
3958
6fc53bdClaude3959 // Skip AI review on drafts — it runs again when the PR is marked ready.
3960 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3961 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3962 (err) => console.error("[ai-review] Failed:", err)
3963 );
3964 }
3965
3cbe3d6Claude3966 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3967 triggerPrTriage({
3968 ownerName,
3969 repoName,
3970 repositoryId: resolved.repo.id,
3971 prId: pr.id,
3972 prAuthorId: user.id,
3973 title,
3974 body: prBody,
3975 baseBranch,
3976 headBranch,
3977 }).catch((err) => console.error("[pr-triage] Failed:", err));
3978
1d4ff60Claude3979 // Chat notifier — fan out to Slack/Discord/Teams.
3980 import("../lib/chat-notifier")
3981 .then((m) =>
3982 m.notifyChatChannels({
3983 ownerUserId: resolved.repo.ownerId,
3984 repositoryId: resolved.repo.id,
3985 event: {
3986 event: "pr.opened",
3987 repo: `${ownerName}/${repoName}`,
3988 title: `#${pr.number} ${title}`,
3989 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3990 body: prBody || undefined,
3991 actor: user.username,
3992 },
3993 })
3994 )
3995 .catch((err) =>
3996 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3997 );
3998
9dd96b9Test User3999 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude4000 import("../lib/auto-merge")
4001 .then((m) => m.tryAutoMergeNow(pr.id))
4002 .catch((err) => {
4003 console.warn(
4004 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
4005 err instanceof Error ? err.message : err
4006 );
4007 });
9dd96b9Test User4008
1df50d5Claude4009 // Migration 0077 — PR preview build. Fire-and-forget; skips when
4010 // PREVIEW_DOMAIN is unset or the repo has no preview_build_command.
4011 // Resolve head SHA asynchronously so we don't block the redirect.
4012 resolveRef(ownerName, repoName, headBranch)
4013 .then((headSha) => {
4014 if (!headSha) return;
4015 return import("../lib/preview-builder").then((m) =>
4016 m.buildPreview(pr.id, resolved.repo.id, headSha)
4017 );
4018 })
4019 .catch(() => {});
4020
0074234Claude4021 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
4022 }
4023);
4024
4025// View single PR
04f6b7fClaude4026pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude4027 const { owner: ownerName, repo: repoName } = c.req.param();
4028 const prNum = parseInt(c.req.param("number"), 10);
4029 const user = c.get("user");
4030 const tab = c.req.query("tab") || "conversation";
a2c10c5Claude4031 const isSplit = c.req.query("diffview") === "split";
4032 const pendingCount = parseInt(c.req.query("pending") || "0", 10) || 0;
0074234Claude4033
4034 const resolved = await resolveRepo(ownerName, repoName);
4035 if (!resolved) return c.notFound();
4036
4037 const [pr] = await db
4038 .select()
4039 .from(pullRequests)
4040 .where(
4041 and(
4042 eq(pullRequests.repositoryId, resolved.repo.id),
4043 eq(pullRequests.number, prNum)
4044 )
4045 )
4046 .limit(1);
4047
4048 if (!pr) return c.notFound();
4049
4050 const [author] = await db
4051 .select()
4052 .from(users)
4053 .where(eq(users.id, pr.authorId))
4054 .limit(1);
4055
cb5a796Claude4056 const allCommentsRaw = await db
0074234Claude4057 .select({
4058 comment: prComments,
cb5a796Claude4059 author: { id: users.id, username: users.username },
0074234Claude4060 })
4061 .from(prComments)
4062 .innerJoin(users, eq(prComments.authorId, users.id))
4063 .where(eq(prComments.pullRequestId, pr.id))
4064 .orderBy(asc(prComments.createdAt));
4065
cb5a796Claude4066 // Filter pending/rejected/spam for non-owner, non-author viewers.
4067 // Owner always sees everything; comment author sees their own pending
4068 // with an "Awaiting approval" badge in the render below.
4069 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
4070 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
4071 if (viewerIsRepoOwner) return true;
4072 if (comment.moderationStatus === "approved") return true;
4073 if (
4074 user &&
4075 cAuthor.id === user.id &&
4076 comment.moderationStatus === "pending"
4077 ) {
4078 return true;
4079 }
4080 return false;
4081 });
4082 const prPendingCount = viewerIsRepoOwner
4083 ? await countPendingForRepo(resolved.repo.id)
4084 : 0;
4085
6fc53bdClaude4086 // Reactions for the PR body + each comment, in parallel.
4087 const [prReactions, ...prCommentReactions] = await Promise.all([
4088 summariseReactions("pr", pr.id, user?.id),
4089 ...comments.map((row) =>
4090 summariseReactions("pr_comment", row.comment.id, user?.id)
4091 ),
4092 ]);
4093
0a67773Claude4094 // Formal reviews (Approve / Request Changes)
4095 const reviewRows = await db
4096 .select({
4097 id: prReviews.id,
4098 state: prReviews.state,
4099 body: prReviews.body,
4100 isAi: prReviews.isAi,
4101 createdAt: prReviews.createdAt,
4102 reviewerUsername: users.username,
4103 reviewerId: prReviews.reviewerId,
4104 })
4105 .from(prReviews)
4106 .innerJoin(users, eq(prReviews.reviewerId, users.id))
4107 .where(eq(prReviews.pullRequestId, pr.id))
4108 .orderBy(asc(prReviews.createdAt));
4109 // Most recent review per reviewer determines the current state
4110 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
4111 for (const r of reviewRows) {
4112 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
4113 }
4114 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
4115 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
4116 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
4117
ec9e3e3Claude4118 // Requested reviewers from CODEOWNERS auto-assign (migration 0077).
4119 const requestedReviewerRows = await db
4120 .select({
4121 reviewerUsername: users.username,
4122 reviewerId: prReviewRequests.reviewerId,
4123 createdAt: prReviewRequests.createdAt,
4124 })
4125 .from(prReviewRequests)
4126 .innerJoin(users, eq(prReviewRequests.reviewerId, users.id))
4127 .where(eq(prReviewRequests.prId, pr.id))
4128 .orderBy(asc(prReviewRequests.createdAt))
4129 .catch(() => [] as { reviewerUsername: string; reviewerId: string; createdAt: Date }[]);
4130
ace34efClaude4131 // Suggested reviewers — best-effort, never throws
4132 let reviewerSuggestions: ReviewerCandidate[] = [];
4133 try {
4134 if (user) {
4135 reviewerSuggestions = await suggestReviewers(
4136 ownerName, repoName, pr.headBranch, pr.baseBranch,
4137 pr.authorId, resolved.repo.id
4138 );
4139 }
4140 } catch {
4141 // silent degradation
4142 }
4143
0074234Claude4144 const canManage =
4145 user &&
4146 (user.id === resolved.owner.id || user.id === pr.authorId);
4147
1d4ff60Claude4148 // Has any previous AI-test-generator run already tagged this PR? Used
4149 // both to hide the "Generate tests with AI" button and to short-circuit
4150 // the explicit POST handler.
4151 const hasAiTestsMarker = comments.some(({ comment }) =>
4152 (comment.body || "").includes(AI_TESTS_MARKER)
4153 );
4154
e883329Claude4155 const error = c.req.query("error");
c3e0c07Claude4156 const info = c.req.query("info");
e883329Claude4157
4158 // Get gate check status for open PRs
4159 let gateChecks: GateCheckResult[] = [];
240c477Claude4160 let ciStatuses: CommitStatus[] = [];
e883329Claude4161 if (pr.state === "open") {
6f1fd83Claude4162 try {
4163 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4164 if (headSha) {
c166384ccantynz-alt4165 const aiApproved = await isAiReviewApproved(pr.id);
6f1fd83Claude4166 const [gateResult, fetchedCiStatuses] = await Promise.all([
4167 runAllGateChecks(
4168 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
4169 ).catch(() => ({ allPassed: false, checks: [] as GateCheckResult[] })),
4170 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
4171 ]);
4172 gateChecks = gateResult.checks;
4173 ciStatuses = fetchedCiStatuses;
4174 }
4175 } catch {
4176 // git repo missing or unreachable — show PR without gate status
e883329Claude4177 }
4178 }
4179
534f04aClaude4180 // Block M3 — pre-merge risk score. Cache-only on the request path so
4181 // the page never waits on Haiku. On a cache miss for an open PR we
4182 // kick off the computation fire-and-forget; the next refresh shows it.
4183 let prRisk: PrRiskScore | null = null;
4184 let prRiskCalculating = false;
4185 if (pr.state === "open") {
4186 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
4187 if (!prRisk) {
4188 prRiskCalculating = true;
a28cedeClaude4189 void computePrRiskForPullRequest(pr.id).catch((err) => {
4190 console.warn(
4191 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
4192 err instanceof Error ? err.message : err
4193 );
4194 });
534f04aClaude4195 }
4196 }
4197
4bbacbeClaude4198 // Migration 0062 — per-branch preview URL. The head branch always
4199 // has a preview row (unless it's the default branch, which never
4200 // happens for an open PR) once it has been pushed at least once.
4201 const preview = await getPreviewForBranch(
4202 (resolved.repo as { id: string }).id,
4203 pr.headBranch
4204 );
4205
0369e77Claude4206 // Branch ahead/behind counts — how many commits head is ahead of base and
4207 // how many commits base has advanced since head branched off.
4208 let branchAhead = 0;
4209 let branchBehind = 0;
4210 if (pr.state === "open") {
4211 try {
4212 const repoDir = getRepoPath(ownerName, repoName);
4213 const [aheadProc, behindProc] = [
4214 Bun.spawn(
4215 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
4216 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4217 ),
4218 Bun.spawn(
4219 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
4220 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4221 ),
4222 ];
4223 const [aheadTxt, behindTxt] = await Promise.all([
4224 new Response(aheadProc.stdout).text(),
4225 new Response(behindProc.stdout).text(),
4226 ]);
4227 await Promise.all([aheadProc.exited, behindProc.exited]);
4228 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
4229 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
4230 } catch { /* non-blocking */ }
4231 }
4232
6d1bbc2Claude4233 // Linked issues — parse closing keywords from PR title+body, look up issues
4234 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
4235 try {
4236 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
4237 const refs = extractClosingRefsMulti([pr.title, pr.body]);
4238 if (refs.length > 0) {
4239 linkedIssues = await db
4240 .select({ number: issues.number, title: issues.title, state: issues.state })
4241 .from(issues)
4242 .where(and(
4243 eq(issues.repositoryId, resolved.repo.id),
4244 inArray(issues.number, refs),
4245 ));
4246 }
4247 } catch { /* non-blocking */ }
4248
4249 // Task list progress — count markdown checkboxes in PR body
4250 let taskTotal = 0;
4251 let taskChecked = 0;
4252 if (pr.body) {
4253 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
4254 taskTotal++;
4255 if (m[1].trim() !== "") taskChecked++;
4256 }
4257 }
4258
74d8c4dClaude4259 // M15 — PR size badge (best-effort, non-blocking)
4260 let prSizeInfo: PrSizeInfo | null = null;
4261 try {
4262 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
4263 } catch { /* swallow — purely cosmetic */ }
4264
09d5f39Claude4265 // Merge impact analysis — only for open PRs with write access (cached, fast)
4266 let impactAnalysis: ImpactAnalysis | null = null;
4267 if (pr.state === "open" && canManage) {
4268 try {
4269 impactAnalysis = await analyzeImpact(resolved.repo.id, pr.id);
4270 } catch { /* non-blocking */ }
4271 }
1d6db4dClaude4272
47a7a0aClaude4273 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude4274 let diffRaw = "";
4275 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude4276 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude4277 if (tab === "files") {
4278 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude4279 // Run the two git diffs in parallel — they're independent reads of
4280 // the same range. Previously sequential, doubling the wall time on
4281 // big PRs (100+ files = 10-30s for no reason).
0074234Claude4282 const proc = Bun.spawn(
4283 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
4284 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4285 );
4286 const statProc = Bun.spawn(
4287 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
4288 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4289 );
6ea2109Claude4290 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
4291 // would otherwise hang the whole request.
4292 const killer = setTimeout(() => {
4293 proc.kill();
4294 statProc.kill();
4295 }, 30_000);
4296 let stat = "";
4297 try {
4298 [diffRaw, stat] = await Promise.all([
4299 new Response(proc.stdout).text(),
4300 new Response(statProc.stdout).text(),
4301 ]);
4302 await Promise.all([proc.exited, statProc.exited]);
4303 } finally {
4304 clearTimeout(killer);
4305 }
0074234Claude4306
4307 diffFiles = stat
4308 .trim()
4309 .split("\n")
4310 .filter(Boolean)
4311 .map((line) => {
4312 const [add, del, filePath] = line.split("\t");
4313 return {
4314 path: filePath,
4315 status: "modified",
4316 additions: add === "-" ? 0 : parseInt(add, 10),
4317 deletions: del === "-" ? 0 : parseInt(del, 10),
4318 patch: "",
4319 };
4320 });
47a7a0aClaude4321
4322 // Fetch inline comments (file+line anchored) for the files tab
4323 const inlineRows = await db
4324 .select({
4325 id: prComments.id,
4326 filePath: prComments.filePath,
4327 lineNumber: prComments.lineNumber,
4328 body: prComments.body,
4329 isAiReview: prComments.isAiReview,
4330 createdAt: prComments.createdAt,
4331 authorUsername: users.username,
4332 })
4333 .from(prComments)
4334 .innerJoin(users, eq(prComments.authorId, users.id))
4335 .where(
4336 and(
4337 eq(prComments.pullRequestId, pr.id),
4338 eq(prComments.moderationStatus, "approved"),
4339 )
4340 )
4341 .orderBy(asc(prComments.createdAt));
4342
4343 diffInlineComments = inlineRows
4344 .filter(r => r.filePath != null && r.lineNumber != null)
4345 .map(r => ({
4346 id: r.id,
4347 filePath: r.filePath!,
4348 lineNumber: r.lineNumber!,
4349 authorUsername: r.authorUsername,
4350 body: renderMarkdown(r.body),
4351 isAiReview: r.isAiReview,
4352 createdAt: r.createdAt.toISOString(),
4353 }));
0074234Claude4354 }
4355
34e63b9Claude4356 // Proactive pattern warning — get changed file paths and check for recurring
4357 // bug patterns. Fire-and-forget safe; returns null on any error or cache miss.
4358 let patternWarning: Pattern | null = null;
4359 try {
4360 const repoDir = getRepoPath(ownerName, repoName);
4361 const nameOnlyProc = Bun.spawn(
4362 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4363 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4364 );
4365 const nameOnlyRaw = await new Response(nameOnlyProc.stdout).text();
4366 await nameOnlyProc.exited;
4367 const prChangedFiles = nameOnlyRaw.trim().split("\n").filter(Boolean);
4368 if (prChangedFiles.length > 0) {
4369 patternWarning = await getPatternWarning(resolved.repo.id, prChangedFiles);
4370 }
4371 } catch {
4372 // Non-blocking — swallow
4373 }
4374
7f992cdClaude4375 // Bus factor warning — flag files dominated by a single author.
4376 let busRiskFiles: BusFactorFile[] = [];
4377 try {
4378 const repoDir2 = getRepoPath(ownerName, repoName);
4379 const bf2Proc = Bun.spawn(
4380 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4381 { cwd: repoDir2, stdout: "pipe", stderr: "pipe" }
4382 );
4383 const bf2Raw = await new Response(bf2Proc.stdout).text();
4384 await bf2Proc.exited;
4385 const bf2Files = bf2Raw.trim().split("\n").filter(Boolean);
4386 if (bf2Files.length > 0) {
4387 busRiskFiles = await getBusFactorWarning(resolved.repo.id, ownerName, repoName, bf2Files);
4388 }
4389 } catch {
4390 // Non-blocking
4391 }
4392
4393 // PR split suggestion — AI recommends sub-PR decomposition for large PRs.
4394 let splitSuggestion: SplitSuggestion | null = null;
4395 try {
4396 splitSuggestion = await suggestPrSplit(
4397 pr.id,
4398 pr.title,
4399 ownerName,
4400 repoName,
4401 pr.baseBranch,
4402 pr.headBranch
4403 );
4404 } catch {
4405 // Non-blocking
4406 }
4407
b078860Claude4408 // ─── Derived visual state ───
4409 const stateKey =
4410 pr.state === "open"
4411 ? pr.isDraft
4412 ? "draft"
4413 : "open"
4414 : pr.state;
4415 const stateLabel =
4416 stateKey === "open"
4417 ? "Open"
4418 : stateKey === "draft"
4419 ? "Draft"
4420 : stateKey === "merged"
4421 ? "Merged"
4422 : "Closed";
4423 const stateIcon =
4424 stateKey === "open"
4425 ? "○"
4426 : stateKey === "draft"
4427 ? "◌"
4428 : stateKey === "merged"
4429 ? "⮌"
4430 : "✓";
4431 const commentCount = comments.length;
4432 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
4433 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
4434 const mergeBlocked =
4435 gateChecks.length > 0 &&
4436 gateChecks.some(
4437 (c) => !c.passed && c.name !== "Merge check"
4438 );
4439
b558f23Claude4440 // Commits tab — list commits included in this PR (base..head range)
4441 let prCommits: GitCommit[] = [];
4442 if (tab === "commits") {
4443 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
4444 }
4445
cc34156Claude4446 // Review context restore — compute BEFORE recording the visit so the
4447 // previous timestamp is available for the delta calculation.
4448 let reviewCtx: ReviewContext | null = null;
4449 if (user) {
4450 reviewCtx = await getReviewContext(pr.id, user.id, {
4451 ownerName,
4452 repoName,
4453 baseBranch: pr.baseBranch,
4454 headBranch: pr.headBranch,
4455 });
4456 // Fire-and-forget: record the visit AFTER computing context
4457 void recordPrVisit(pr.id, user.id);
4458 }
4459
0074234Claude4460 return c.html(
4461 <Layout
4462 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
4463 user={user}
4464 >
4465 <RepoHeader owner={ownerName} repo={repoName} />
4466 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude4467 <PendingCommentsBanner
4468 owner={ownerName}
4469 repo={repoName}
4470 count={prPendingCount}
4471 />
b078860Claude4472 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude4473 <div
4474 id="live-comment-banner"
4475 class="alert"
4476 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
4477 >
4478 <strong class="js-live-count">0</strong> new comment(s) —{" "}
4479 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
4480 reload to view
4481 </a>
4482 </div>
4483 <script
4484 dangerouslySetInnerHTML={{
4485 __html: liveCommentBannerScript({
4486 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
4487 bannerElementId: "live-comment-banner",
4488 }),
4489 }}
4490 />
b078860Claude4491
cc34156Claude4492 {/* Review context restore banner — shown when returning after changes */}
4493 {reviewCtx && (
4494 <div
4495 class="context-restore-banner"
4496 id="review-context"
4497 style="display:flex;align-items:flex-start;gap:12px;padding:12px 16px;margin:0 0 12px;background:var(--bg-elevated,#f8f9fa);border:1px solid var(--border,#e1e4e8);border-left:3px solid var(--accent,#0070f3);border-radius:10px"
4498 >
4499 <span class="context-icon" style="font-size:18px;flex-shrink:0;margin-top:2px" aria-hidden="true">{"↩"}</span>
4500 <div style="flex:1;min-width:0">
4501 <strong style="font-size:13.5px;color:var(--text-strong,#111)">Welcome back</strong>
4502 <p style="margin:4px 0 0;font-size:13px;color:var(--text,#333);line-height:1.5">{reviewCtx.summary}</p>
4503 <small style="font-size:11.5px;color:var(--text-muted,#777)">
4504 Last visited {formatRelative(new Date(reviewCtx.lastVisitedAt))}
4505 {reviewCtx.commitsSince > 0 && ` · ${reviewCtx.commitsSince} new commit${reviewCtx.commitsSince === 1 ? "" : "s"}`}
4506 {reviewCtx.newComments > 0 && ` · ${reviewCtx.newComments} new comment${reviewCtx.newComments === 1 ? "" : "s"}`}
4507 </small>
4508 {reviewCtx.suggestedStartLine && (
4509 <p style="margin:6px 0 0;font-size:12px;color:var(--accent,#0070f3)">
4510 Start at: <code style="font-size:11px">{reviewCtx.suggestedStartLine}</code>
4511 </p>
4512 )}
4513 </div>
4514 <button
4515 type="button"
4516 onclick="this.closest('.context-restore-banner').remove()"
4517 style="flex-shrink:0;background:none;border:none;cursor:pointer;font-size:18px;color:var(--text-muted,#777);padding:0;line-height:1"
4518 aria-label="Dismiss"
4519 >
4520 {"×"}
4521 </button>
4522 </div>
4523 )}
4524
b078860Claude4525 <div class="prs-detail-hero">
b558f23Claude4526 <div class="prs-edit-title-wrap">
4527 <h1 class="prs-detail-title" id="pr-title-display">
4528 {pr.title}{" "}
4529 <span class="prs-detail-num">#{pr.number}</span>
4530 </h1>
4531 {canManage && pr.state === "open" && (
4532 <button
4533 type="button"
4534 class="prs-edit-btn"
4535 id="pr-edit-toggle"
4536 onclick={`
4537 document.getElementById('pr-title-display').style.display='none';
4538 document.getElementById('pr-edit-toggle').style.display='none';
4539 document.getElementById('pr-edit-form').style.display='flex';
4540 document.getElementById('pr-title-input').focus();
4541 `}
4542 >
4543 Edit
4544 </button>
4545 )}
4546 </div>
4547 {canManage && pr.state === "open" && (
4548 <form
4549 id="pr-edit-form"
4550 method="post"
4551 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
4552 class="prs-edit-form"
4553 style="display:none"
4554 >
4555 <input
4556 id="pr-title-input"
4557 type="text"
4558 name="title"
4559 value={pr.title}
4560 required
4561 maxlength={256}
4562 placeholder="Pull request title"
4563 />
4564 <div class="prs-edit-actions">
4565 <button type="submit" class="prs-edit-save-btn">Save</button>
4566 <button
4567 type="button"
4568 class="prs-edit-cancel-btn"
4569 onclick={`
4570 document.getElementById('pr-edit-form').style.display='none';
4571 document.getElementById('pr-title-display').style.display='';
4572 document.getElementById('pr-edit-toggle').style.display='';
4573 `}
4574 >
4575 Cancel
4576 </button>
4577 </div>
4578 </form>
4579 )}
b078860Claude4580 <div class="prs-detail-meta">
4581 <span class={`prs-state-pill state-${stateKey}`}>
4582 <span aria-hidden="true">{stateIcon}</span>
4583 <span>{stateLabel}</span>
4584 </span>
2c61840Claude4585 {isAiGeneratedPr(pr.body, pr.headBranch) && (
4586 <span class="prs-tag is-ai" title="This pull request was opened by an AI agent">⚡ AI-generated</span>
4587 )}
74d8c4dClaude4588 {prSizeInfo && (
4589 <span
4590 class="prs-size-badge"
4591 style={`color:${prSizeInfo.color};background:${prSizeInfo.bgColor}`}
4592 title={`${prSizeInfo.linesChanged} lines changed (+${prSizeInfo.added} −${prSizeInfo.deleted})`}
4593 >
4594 {prSizeInfo.label}
4595 </span>
4596 )}
67dc4e1Claude4597 <TrioVerdictPills
4598 comments={comments.map(({ comment }) => comment)}
4599 />
b078860Claude4600 <span>
4601 <strong>{author?.username}</strong> wants to merge
4602 </span>
4603 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
4604 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
4605 <span class="prs-branch-arrow-lg">{"→"}</span>
4606 <span class="prs-branch-pill">{pr.baseBranch}</span>
4607 </span>
0369e77Claude4608 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
4609 <span
4610 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
4611 title={branchBehind > 0
4612 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
4613 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
4614 >
4615 {branchAhead > 0 ? `↑${branchAhead}` : ""}
4616 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
4617 {branchBehind > 0 ? `↓${branchBehind}` : ""}
4618 </span>
4619 )}
b078860Claude4620 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude4621 {taskTotal > 0 && (
4622 <span
4623 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
4624 title={`${taskChecked} of ${taskTotal} tasks completed`}
4625 >
4626 <span class="prs-tasks-progress" aria-hidden="true">
4627 <span
4628 class="prs-tasks-progress-bar"
4629 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
4630 ></span>
4631 </span>
4632 {taskChecked}/{taskTotal} tasks
4633 </span>
4634 )}
4635 {canManage && pr.state === "open" && branchBehind > 0 && (
4636 <form
4637 method="post"
4638 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
4639 class="prs-inline-form"
4640 >
4641 <button
4642 type="submit"
4643 class="prs-update-branch-btn"
4644 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
4645 >
4646 ↑ Update branch
4647 </button>
4648 </form>
4649 )}
3c03977Claude4650 <span
4651 id="live-pill"
4652 class="live-pill"
4653 title="People editing this PR right now"
4654 >
4655 <span class="live-pill-dot" aria-hidden="true"></span>
4656 <span>
4657 Live: <strong id="live-count">0</strong> editing
4658 </span>
4659 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
4660 </span>
4bbacbeClaude4661 {preview && (
4662 <a
4663 class={`preview-prpill is-${preview.status}`}
4664 href={
4665 preview.status === "ready"
4666 ? preview.previewUrl
4667 : `/${ownerName}/${repoName}/previews`
4668 }
4669 target={preview.status === "ready" ? "_blank" : undefined}
4670 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
4671 title={`Preview · ${previewStatusLabel(preview.status)}`}
4672 >
4673 <span class="preview-prpill-dot" aria-hidden="true"></span>
4674 <span>Preview: </span>
4675 <span>{previewStatusLabel(preview.status)}</span>
4676 </a>
4677 )}
b078860Claude4678 {canManage && pr.state === "open" && pr.isDraft && (
4679 <form
4680 method="post"
4681 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4682 class="prs-inline-form prs-detail-actions"
4683 >
4684 <button type="submit" class="prs-merge-ready-btn">
4685 Ready for review
4686 </button>
4687 </form>
4688 )}
4689 </div>
4690 </div>
3c03977Claude4691 <script
4692 dangerouslySetInnerHTML={{
4693 __html: LIVE_COEDIT_SCRIPT(pr.id),
4694 }}
4695 />
829a046Claude4696 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude4697 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude4698 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude4699
25b1ff7Claude4700 {/* Presence styles + bar (shown only on the files tab so cursor pills work) */}
b271465Claude4701 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES + IMPACT_STYLES }} />
25b1ff7Claude4702 {/* Toast container — always present for join/leave toasts */}
4703 <div id="presence-toasts" class="presence-toast-wrap" aria-live="polite" />
4704 {user && (
4705 <>
4706 <div class="presence-bar" id="presence-bar">
4707 <span class="presence-bar-label">Live reviewers</span>
4708 <div class="presence-avatars" id="presence-avatars" />
4709 <span class="presence-count" id="presence-count">Loading…</span>
4710 </div>
4711 <script
4712 dangerouslySetInnerHTML={{
4713 __html: PR_PRESENCE_SCRIPT(ownerName, repoName, pr.number),
4714 }}
4715 />
4716 </>
4717 )}
4718
b078860Claude4719 <nav class="prs-detail-tabs" aria-label="Pull request sections">
4720 <a
4721 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
4722 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
4723 >
4724 Conversation
4725 <span class="prs-detail-tab-count">{commentCount}</span>
4726 </a>
b558f23Claude4727 <a
4728 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
4729 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
4730 >
4731 Commits
4732 {branchAhead > 0 && (
4733 <span class="prs-detail-tab-count">{branchAhead}</span>
4734 )}
4735 </a>
b078860Claude4736 <a
4737 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
4738 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4739 >
4740 Files changed
4741 {diffFiles.length > 0 && (
4742 <span class="prs-detail-tab-count">{diffFiles.length}</span>
4743 )}
4744 </a>
4745 </nav>
4746
34e63b9Claude4747 {/* Proactive pattern warning — shown when a known recurring bug pattern
4748 overlaps with the files changed in this PR. */}
4749 {patternWarning && (
4750 <div class="pattern-warning" style="margin:0 0 16px;padding:12px 16px;border-radius:8px;background:var(--bg-elevated);border:1px solid #f59e0b;border-left:4px solid #f59e0b;font-size:13px;line-height:1.5">
4751 <span style="font-size:15px;margin-right:6px" aria-hidden="true">⚠️</span>
4752 <strong>Recurring pattern detected: {patternWarning.title}</strong>
4753 <span style="color:var(--fg-muted)">
4754 {" — "}
4755 This area has had {patternWarning.occurrences} similar fix
4756 {patternWarning.occurrences === 1 ? "" : "es"}.
4757 {patternWarning.rootCauseHypothesis && (
4758 <> Root cause may be in <code style="font-size:12px">{patternWarning.suggestedFile}</code>.</>
4759 )}
4760 </span>
4761 </div>
4762 )}
4763
b558f23Claude4764 {tab === "commits" ? (
4765 <div class="prs-commits-list">
4766 {prCommits.length === 0 ? (
4767 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
4768 ) : (
4769 prCommits.map((commit) => (
4770 <div class="prs-commit-row">
4771 <span class="prs-commit-dot" aria-hidden="true"></span>
4772 <div class="prs-commit-body">
4773 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
4774 <div class="prs-commit-meta">
4775 <strong>{commit.author}</strong> committed{" "}
4776 {formatRelative(new Date(commit.date))}
4777 </div>
4778 </div>
4779 <a
4780 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
4781 class="prs-commit-sha"
4782 title="View commit"
4783 >
4784 {commit.sha.slice(0, 7)}
4785 </a>
4786 </div>
4787 ))
4788 )}
4789 </div>
4790 ) : tab === "files" ? (
1d6db4dClaude4791 <>
4792 {/* PR Split Suggestion — shown when PR has >400 changed lines */}
4793 {splitSuggestion && (
4794 <div class="split-suggestion" id="pr-split-banner">
4795 <div class="split-header">
4796 <span class="split-icon" aria-hidden="true">✂️</span>
4797 <strong>This PR may be too large to review effectively</strong>
4798 <span class="split-stat">
4799 {splitSuggestion.totalLines} lines · {splitSuggestion.totalFiles} files
4800 </span>
4801 <button
4802 class="split-toggle"
4803 type="button"
4804 onclick="const b=document.getElementById('pr-split-body');const hidden=b.hasAttribute('hidden');b.toggleAttribute('hidden');this.textContent=hidden?'Hide split suggestion':'Show split suggestion';"
4805 >
4806 Show split suggestion
4807 </button>
4808 </div>
4809 <div class="split-body" id="pr-split-body" hidden>
4810 <p class="split-intro">
4811 AI suggests splitting into {splitSuggestion.suggestedPrs.length} PRs:
4812 </p>
4813 {splitSuggestion.suggestedPrs.map((sp, i) => (
4814 <div class="split-pr">
4815 <div class="split-pr-num">{i + 1}</div>
4816 <div class="split-pr-body">
4817 <strong>{sp.title}</strong>
4818 <p>{sp.rationale}</p>
4819 <code>{sp.files.join(", ")}</code>
4820 <span class="split-lines">~{sp.estimatedLines} lines</span>
4821 </div>
4822 </div>
4823 ))}
4824 {splitSuggestion.mergeOrder.length > 0 && (
4825 <p class="split-order">
4826 Suggested merge order:{" "}
4827 <strong>{splitSuggestion.mergeOrder.join(" → ")}</strong>
4828 </p>
4829 )}
4830 </div>
4831 </div>
4832 )}
4833
4834 {/* Bus Factor Warning — shown when changed files overlap at-risk files */}
4835 {busRiskFiles.length > 0 && (() => {
4836 const topRisk = busRiskFiles.some((f) => f.risk === "critical")
4837 ? "critical"
4838 : busRiskFiles.some((f) => f.risk === "high")
4839 ? "high"
4840 : "medium";
4841 return (
4842 <div class={`busfactor-panel busfactor-${topRisk}`}>
4843 <span class="busfactor-icon" aria-hidden="true">⚠️</span>
4844 <div class="busfactor-body">
4845 <strong>Knowledge concentration warning</strong>
4846 <p>
4847 {busRiskFiles.length} file{busRiskFiles.length !== 1 ? "s" : ""} in
4848 this PR {busRiskFiles.length !== 1 ? "are" : "is"} primarily
4849 maintained by one person. Consider pairing on this review.
4850 </p>
4851 <ul>
4852 {busRiskFiles.map((f) => (
4853 <li>
4854 <code>{f.path}</code> —{" "}
4855 <strong>{f.primaryAuthorPct}%</strong> by {f.primaryAuthor}
4856 </li>
4857 ))}
4858 </ul>
4859 </div>
4860 </div>
4861 );
4862 })()}
4863
4864 <DiffView
4865 raw={diffRaw}
4866 files={diffFiles}
4867 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
4868 inlineComments={diffInlineComments}
4869 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
4870 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
a2c10c5Claude4871 pendingReviewUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/review/pending/add` : undefined}
4872 submitReviewUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/review/submit` : undefined}
4873 isSplit={isSplit}
4874 pendingCount={pendingCount}
4875 owner={ownerName}
4876 repo={repoName}
4877 prNumber={pr.number}
1d6db4dClaude4878 />
4879 </>
b078860Claude4880 ) : (
4881 <>
4882 {pr.body && (
4883 <CommentBox
4884 author={author?.username ?? "unknown"}
4885 date={pr.createdAt}
4886 body={renderMarkdown(pr.body)}
4887 />
4888 )}
4889
422a2d4Claude4890 {/* Block H — AI trio review (security/correctness/style). When
4891 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
4892 hoisted into a 3-column card grid above the normal comment
4893 stream so reviewers see verdicts at a glance. Disagreements
4894 are surfaced as a yellow callout. */}
4895 <TrioReviewGrid
4896 comments={comments.map(({ comment }) => comment)}
4897 />
4898
15db0e0Claude4899 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude4900 // Skip trio comments — already rendered in TrioReviewGrid above.
4901 if (isTrioComment(comment.body)) return null;
15db0e0Claude4902 const slashCmd = detectSlashCmdComment(comment.body);
4903 if (slashCmd) {
4904 const visible = stripSlashCmdMarker(comment.body);
4905 return (
4906 <div class={`slash-pill slash-cmd-${slashCmd}`}>
4907 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
4908 <span class="slash-pill-actor">
4909 <strong>{commentAuthor.username}</strong>
4910 {" ran "}
4911 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude4912 </span>
15db0e0Claude4913 <span class="slash-pill-time">
4914 {formatRelative(comment.createdAt)}
4915 </span>
4916 <div class="slash-pill-body">
4917 <MarkdownContent html={renderMarkdown(visible)} />
4918 </div>
4919 </div>
4920 );
4921 }
cb5a796Claude4922 const isPending = comment.moderationStatus === "pending";
15db0e0Claude4923 return (
cb5a796Claude4924 <div
4925 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
4926 >
15db0e0Claude4927 <div class="prs-comment-head">
4928 <strong>{commentAuthor.username}</strong>
a7460bfClaude4929 {commentAuthor.username === BOT_USERNAME && (
4930 <span class="prs-bot-badge">&#x1F916; bot</span>
4931 )}
15db0e0Claude4932 {comment.isAiReview && (
4933 <span class="prs-ai-badge">AI Review</span>
4934 )}
cb5a796Claude4935 {isPending && (
4936 <span
4937 class="modq-pending-badge"
4938 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
4939 >
4940 Awaiting approval
4941 </span>
4942 )}
15db0e0Claude4943 <span class="prs-comment-time">
4944 commented {formatRelative(comment.createdAt)}
4945 </span>
4946 {comment.filePath && (
4947 <span class="prs-comment-loc">
4948 {comment.filePath}
4949 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
4950 </span>
4951 )}
4952 </div>
4953 <div class="prs-comment-body">
4954 <MarkdownContent html={renderMarkdown(comment.body)} />
4955 </div>
0074234Claude4956 </div>
15db0e0Claude4957 );
4958 })}
0074234Claude4959
b078860Claude4960 {/* Quick link to the Files changed tab when there's a diff to look at. */}
4961 {pr.state !== "merged" && (
4962 <a
4963 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4964 class="prs-files-card"
4965 >
4966 <span class="prs-files-card-icon" aria-hidden="true">
4967 {"▤"}
4968 </span>
4969 <div class="prs-files-card-text">
4970 <p class="prs-files-card-title">Files changed</p>
4971 <p class="prs-files-card-sub">
4972 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
4973 </p>
e883329Claude4974 </div>
b078860Claude4975 <span class="prs-files-card-cta">View diff {"→"}</span>
4976 </a>
4977 )}
4978
6d1bbc2Claude4979 {linkedIssues.length > 0 && (
4980 <div class="prs-linked-issues">
4981 <div class="prs-linked-issues-head">
4982 <span>Closing issues</span>
4983 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
4984 </div>
4985 {linkedIssues.map((issue) => (
4986 <a
4987 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
4988 class="prs-linked-issue-row"
4989 >
4990 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
4991 {issue.state === "open" ? "○" : "✓"}
4992 </span>
4993 <span class="prs-linked-issue-title">{issue.title}</span>
4994 <span class="prs-linked-issue-num">#{issue.number}</span>
4995 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
4996 {issue.state}
4997 </span>
4998 </a>
4999 ))}
5000 </div>
5001 )}
5002
b078860Claude5003 {error && (
5004 <div
5005 class="auth-error"
5006 style="margin-top: 16px; padding: 12px; background: rgba(248, 81, 73, 0.1); border: 1px solid var(--red); border-radius: var(--radius); color: var(--red)"
5007 >
5008 {decodeURIComponent(error)}
5009 </div>
5010 )}
5011
5012 {info && (
5013 <div style="margin-top: 16px; padding: 12px; background: rgba(56, 139, 253, 0.1); border: 1px solid var(--accent); border-radius: var(--radius); color: var(--text)">
5014 {decodeURIComponent(info)}
5015 </div>
5016 )}
e883329Claude5017
b078860Claude5018 {pr.state === "open" && (prRisk || prRiskCalculating) && (
5019 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
5020 )}
5021
ec9e3e3Claude5022 {/* ─── Requested reviewers (CODEOWNERS auto-assign, migration 0077) ─── */}
5023 {requestedReviewerRows.length > 0 && (
5024 <div class="prs-review-summary" style="margin-top:14px">
5025 <div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-weight:700;margin-bottom:4px">
5026 Review requested
5027 </div>
5028 {requestedReviewerRows.map((rr) => {
5029 const hasReviewed = latestReviewByReviewer.has(rr.reviewerId);
5030 const review = latestReviewByReviewer.get(rr.reviewerId);
5031 const statusIcon = !hasReviewed ? "⏳" : review?.state === "approved" ? "✓" : "✗";
5032 const statusColor = !hasReviewed
5033 ? "var(--text-muted)"
5034 : review?.state === "approved"
5035 ? "#34d399"
5036 : "#f87171";
5037 return (
5038 <div class="prs-review-row" style={`gap:8px`}>
5039 <span class="prs-reviewer-avatar">
5040 {rr.reviewerUsername.slice(0, 1).toUpperCase()}
5041 </span>
5042 <a href={`/${rr.reviewerUsername}`}
5043 style="flex:1;font-size:13px;color:var(--text);font-weight:600;text-decoration:none">
5044 {rr.reviewerUsername}
5045 </a>
5046 <span style={`font-size:12px;font-weight:600;color:${statusColor}`}>
5047 {statusIcon} {!hasReviewed ? "Pending" : review?.state === "approved" ? "Approved" : "Changes requested"}
5048 </span>
5049 </div>
5050 );
5051 })}
5052 </div>
b271465Claude5053 )}
09d5f39Claude5054 {/* ─── Merge Impact Analysis panel ─────────────────────── */}
5055 {impactAnalysis && pr.state === "open" && (
5056 <ImpactPanel analysis={impactAnalysis} owner={ownerName} />
ec9e3e3Claude5057 )}
5058
0a67773Claude5059 {/* ─── Review summary ─────────────────────────────────── */}
5060 {(approvals.length > 0 || changesRequested.length > 0) && (
5061 <div class="prs-review-summary">
5062 {approvals.length > 0 && (
5063 <div class="prs-review-row prs-review-approved">
5064 <span class="prs-review-icon">✓</span>
5065 <span>
5066 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
5067 approved this pull request
5068 </span>
5069 </div>
5070 )}
5071 {changesRequested.length > 0 && (
5072 <div class="prs-review-row prs-review-changes">
5073 <span class="prs-review-icon">✗</span>
5074 <span>
5075 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
5076 requested changes
5077 </span>
5078 </div>
5079 )}
5080 </div>
5081 )}
5082
ace34efClaude5083 {/* Suggested reviewers */}
5084 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
5085 <div class="prs-review-summary" style="margin-top:12px">
5086 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
5087 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
5088 Suggested reviewers
5089 </span>
5090 {reviewerSuggestions.map((r) => (
5091 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
5092 style="display:flex;align-items:center;gap:8px;width:100%">
5093 <input type="hidden" name="reviewerId" value={r.userId} />
5094 <span class="prs-reviewer-avatar">
5095 {r.username.slice(0, 1).toUpperCase()}
5096 </span>
5097 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
5098 {r.username}
5099 </a>
5100 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
5101 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
5102 Request
5103 </button>
5104 </form>
5105 ))}
5106 </div>
5107 </div>
5108 )}
5109
b078860Claude5110 {pr.state === "open" && gateChecks.length > 0 && (
5111 <div class="prs-gate-card">
5112 <div class="prs-gate-head">
5113 <h3>Gate checks</h3>
5114 <span class="prs-gate-summary">
5115 {gatesAllPassed
5116 ? `All ${gateChecks.length} checks passed`
5117 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
5118 </span>
c3e0c07Claude5119 </div>
b078860Claude5120 {gateChecks.map((check) => {
5121 const isAi = /ai.*review/i.test(check.name);
5122 const isSkip = check.skipped === true;
5123 const statusClass = isSkip
5124 ? "is-skip"
5125 : check.passed
5126 ? "is-pass"
5127 : "is-fail";
5128 const statusGlyph = isSkip
5129 ? "—"
5130 : check.passed
5131 ? "✓"
5132 : "✗";
5133 const statusLabel = isSkip
5134 ? "Skipped"
5135 : check.passed
5136 ? "Passed"
5137 : "Failing";
5138 return (
5139 <div
5140 class="prs-gate-row"
5141 style={
5142 isAi
6fd5915Claude5143 ? "border-left: 3px solid rgba(91,110,232,0.55); padding-left: 15px"
b078860Claude5144 : ""
5145 }
5146 >
5147 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
5148 {statusGlyph}
5149 </span>
5150 <span class="prs-gate-name">
5151 {check.name}
5152 {isAi && (
5153 <span
6fd5915Claude5154 style="margin-left:8px;display:inline-flex;align-items:center;gap:4px;padding:1px 7px;font-size:10px;font-weight:700;letter-spacing:0.04em;text-transform:uppercase;color:#fff;background:linear-gradient(135deg,#5b6ee8 0%,#5f8fa0 130%);border-radius:9999px;vertical-align:middle"
b078860Claude5155 >
5156 AI
5157 </span>
5158 )}
5159 </span>
5160 <span class="prs-gate-details">{check.details}</span>
5161 <span class={`prs-gate-pill ${statusClass}`}>
5162 {statusLabel}
e883329Claude5163 </span>
5164 </div>
b078860Claude5165 );
5166 })}
5167 <div class="prs-gate-footer">
5168 {gatesAllPassed
5169 ? "All checks passed — ready to merge."
5170 : gateChecks.some(
5171 (c) => !c.passed && c.name === "Merge check"
5172 )
5173 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
5174 : "Some checks failed — resolve issues before merging."}
5175 {aiReviewCount > 0 && (
5176 <>
5177 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
5178 </>
5179 )}
5180 </div>
5181 </div>
5182 )}
5183
240c477Claude5184 {pr.state === "open" && ciStatuses.length > 0 && (
5185 <div class="prs-ci-card">
5186 <div class="prs-ci-head">
5187 <h3>CI checks</h3>
5188 <span class="prs-ci-summary">
5189 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
5190 </span>
5191 </div>
5192 {ciStatuses.map((status) => {
5193 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
5194 return (
5195 <div class="prs-ci-row">
5196 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
5197 <span class="prs-ci-context">{status.context}</span>
5198 {status.description && (
5199 <span class="prs-ci-desc">{status.description}</span>
5200 )}
5201 <span class={`prs-ci-pill is-${status.state}`}>
5202 {status.state}
5203 </span>
5204 {status.targetUrl && (
5205 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
5206 )}
5207 </div>
5208 );
5209 })}
5210 </div>
5211 )}
5212
b078860Claude5213 {/* ─── Merge area / state-aware action card ─────────────── */}
5214 {user && pr.state === "open" && (
5215 <div
5216 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
5217 >
5218 <div class="prs-merge-head">
5219 <strong>
5220 {pr.isDraft
5221 ? "Draft — ready for review?"
5222 : mergeBlocked
5223 ? "Merge blocked"
5224 : "Ready to merge"}
5225 </strong>
e883329Claude5226 </div>
b078860Claude5227 <p class="prs-merge-sub">
5228 {pr.isDraft
5229 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
5230 : mergeBlocked
5231 ? "Resolve the failing gate checks above before this PR can land."
5232 : gateChecks.length > 0
5233 ? gatesAllPassed
5234 ? "All gates green. Merge will fast-forward into the base branch."
5235 : "Conflicts will be auto-resolved by GlueCron AI on merge."
5236 : "Run gate checks by refreshing once your branch has a recent commit."}
5237 </p>
5238 <Form
5239 method="post"
5240 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
5241 >
5242 <FormGroup>
3c03977Claude5243 <div class="live-cursor-host" style="position:relative">
5244 <textarea
5245 name="body"
5246 id="pr-comment-body"
5247 data-live-field="comment_new"
6cd2f0eClaude5248 data-md-preview=""
3c03977Claude5249 rows={5}
5250 required
5251 placeholder="Leave a comment... (Markdown supported)"
5252 style="font-family:var(--font-mono);font-size:13px;width:100%"
5253 ></textarea>
5254 </div>
15db0e0Claude5255 <span class="slash-hint" title="Type a slash-command as the first line">
5256 Type <code>/</code> for commands —{" "}
5257 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
09d5f39Claude5258 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>,{" "}
5259 <code>/stage</code>
15db0e0Claude5260 </span>
b078860Claude5261 </FormGroup>
5262 <div class="prs-merge-actions">
5263 <Button type="submit" variant="primary">
5264 Comment
5265 </Button>
0a67773Claude5266 {user && user.id !== pr.authorId && pr.state === "open" && (
5267 <>
5268 <button
5269 type="submit"
5270 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
5271 name="review_state"
5272 value="approved"
5273 class="prs-review-approve-btn"
5274 title="Approve this pull request"
5275 >
5276 ✓ Approve
5277 </button>
5278 <button
5279 type="submit"
5280 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
5281 name="review_state"
5282 value="changes_requested"
5283 class="prs-review-changes-btn"
5284 title="Request changes before merging"
5285 >
5286 ✗ Request changes
5287 </button>
5288 </>
5289 )}
b078860Claude5290 {canManage && (
5291 <>
5292 {pr.isDraft ? (
5293 <button
5294 type="submit"
5295 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
5296 formnovalidate
5297 class="prs-merge-ready-btn"
5298 >
5299 Ready for review
5300 </button>
5301 ) : (
a164a6dClaude5302 <>
5303 <div class="prs-merge-strategy-wrap">
5304 <span class="prs-merge-strategy-label">Strategy</span>
5305 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
5306 <option value="merge">Merge commit</option>
5307 <option value="squash">Squash and merge</option>
5308 <option value="ff">Fast-forward</option>
5309 </select>
5310 </div>
5311 <button
5312 type="submit"
5313 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
5314 formnovalidate
5315 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
5316 title={
5317 mergeBlocked
5318 ? "Failing gate checks must be resolved before this PR can merge."
5319 : "Merge pull request"
5320 }
5321 >
5322 {"✔"} Merge pull request
5323 </button>
5324 </>
b078860Claude5325 )}
5326 {!pr.isDraft && (
5327 <button
0074234Claude5328 type="submit"
b078860Claude5329 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
5330 formnovalidate
5331 class="prs-merge-back-draft"
5332 title="Convert back to draft"
0074234Claude5333 >
b078860Claude5334 Convert to draft
5335 </button>
5336 )}
5337 {isAiReviewEnabled() && (
5338 <button
5339 type="submit"
5340 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
5341 formnovalidate
5342 class="btn"
5343 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
5344 >
5345 Re-run AI review
5346 </button>
5347 )}
1d4ff60Claude5348 {isAiReviewEnabled() && !hasAiTestsMarker && (
5349 <button
5350 type="submit"
5351 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
5352 formnovalidate
5353 class="btn"
5354 title="Ask Claude to read this PR's diff and write tests for the new code. Tests land as a follow-up PR against this branch."
5355 >
5356 Generate tests with AI
5357 </button>
5358 )}
b078860Claude5359 <Button
5360 type="submit"
5361 variant="danger"
5362 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
5363 >
5364 Close
5365 </Button>
5366 </>
5367 )}
5368 </div>
5369 </Form>
5370 </div>
5371 )}
5372
5373 {/* Read-only footers for non-open states. */}
5374 {pr.state === "merged" && (
5375 <div class="prs-merge-card is-merged">
5376 <div class="prs-merge-head">
5377 <strong>{"⮌"} Merged</strong>
0074234Claude5378 </div>
b078860Claude5379 <p class="prs-merge-sub">
5380 This pull request was merged into{" "}
5381 <code>{pr.baseBranch}</code>.
5382 </p>
5383 </div>
5384 )}
5385 {pr.state === "closed" && (
5386 <div class="prs-merge-card is-closed">
5387 <div class="prs-merge-head">
5388 <strong>{"✕"} Closed without merging</strong>
5389 </div>
5390 <p class="prs-merge-sub">
5391 This pull request was closed and not merged.
5392 </p>
5393 </div>
5394 )}
5395 </>
5396 )}
641aa42Claude5397 {/* Keyboard hint bar — shown at the bottom of PR pages */}
5398 <div class="kbd-hints" aria-label="Keyboard shortcuts for this pull request">
5399 <kbd>c</kbd> comment &middot; <kbd>e</kbd> edit title &middot; <kbd>m</kbd> merge &middot; <kbd>a</kbd> approve &middot; <kbd>r</kbd> request changes &middot; <kbd>?</kbd> shortcuts
5400 </div>
5401 <style dangerouslySetInnerHTML={{ __html: `
5402 .kbd-hints {
5403 position: fixed;
5404 bottom: 0;
5405 left: 0;
5406 right: 0;
5407 z-index: 90;
5408 padding: 6px 24px;
5409 background: var(--bg-secondary);
5410 border-top: 1px solid var(--border);
5411 font-size: 12px;
5412 color: var(--text-muted);
5413 display: flex;
5414 align-items: center;
5415 gap: 8px;
5416 flex-wrap: wrap;
5417 }
5418 .kbd-hints kbd {
5419 font-family: var(--font-mono);
5420 font-size: 10px;
5421 background: var(--bg-elevated);
5422 border: 1px solid var(--border);
5423 border-bottom-width: 2px;
5424 border-radius: 4px;
5425 padding: 1px 5px;
5426 color: var(--text);
5427 line-height: 1.5;
5428 }
5429 /* Padding so the page footer doesn't overlap the hint bar */
5430 main { padding-bottom: 40px; }
5431 ` }} />
5432 {/* Repo context commands for command palette */}
5433 <script
5434 id="cmdk-repo-context"
5435 dangerouslySetInnerHTML={{
5436 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
5437 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
5438 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
5439 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
5440 { label: `View commits — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/commits`, kw: "history log" },
5441 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
5442 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
5443 ])};`,
5444 }}
5445 />
5446 {/* PR keyboard shortcuts script */}
5447 <script dangerouslySetInnerHTML={{ __html: `
5448 (function(){
5449 var commentBox = document.querySelector('textarea[name="body"]');
5450 var mergeBtn = document.querySelector('[data-merge-btn], .prs-merge-btn, button[form*="merge"], form[action*="/merge"] button[type="submit"]');
5451 var editBtn = document.getElementById('pr-edit-toggle');
5452 var approveUrl = ${JSON.stringify(`/${ownerName}/${repoName}/pulls/${pr.number}/review`)};
5453
5454 function isTyping(t){
5455 t = t || {};
5456 var tag = (t.tagName || '').toLowerCase();
5457 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
5458 }
5459
5460 document.addEventListener('keydown', function(e){
5461 if (isTyping(e.target)) return;
5462 if (e.metaKey || e.ctrlKey || e.altKey) return;
5463 if (e.key === 'c') {
5464 e.preventDefault();
5465 if (commentBox) { commentBox.focus(); commentBox.scrollIntoView({block:'center'}); }
5466 }
5467 if (e.key === 'e') {
5468 e.preventDefault();
5469 if (editBtn) { editBtn.click(); }
5470 }
5471 if (e.key === 'm') {
5472 e.preventDefault();
5473 var mBtn = document.querySelector('.prs-merge-btn, form[action*="/merge"] button[type="submit"]');
5474 if (mBtn) { mBtn.focus(); mBtn.scrollIntoView({block:'center'}); }
5475 }
5476 if (e.key === 'a') {
5477 e.preventDefault();
5478 // Navigate to approve review page
5479 window.location.href = approveUrl + '?action=approve';
5480 }
5481 if (e.key === 'r') {
5482 e.preventDefault();
5483 window.location.href = approveUrl + '?action=request_changes';
5484 }
5485 if (e.key === 'Escape') {
5486 var focused = document.activeElement;
5487 if (focused) focused.blur();
5488 }
5489 });
5490 })();
5491 ` }} />
0074234Claude5492 </Layout>
5493 );
5494});
5495
6d1bbc2Claude5496// Update branch — merge base into head so the PR branch is up to date.
5497// Uses a git worktree so the bare repo stays clean. Write access required.
5498pulls.post(
5499 "/:owner/:repo/pulls/:number/update-branch",
5500 softAuth,
5501 requireAuth,
5502 requireRepoAccess("write"),
5503 async (c) => {
5504 const { owner: ownerName, repo: repoName } = c.req.param();
5505 const prNum = parseInt(c.req.param("number"), 10);
5506 const user = c.get("user")!;
5507 const resolved = await resolveRepo(ownerName, repoName);
5508 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5509
5510 const [pr] = await db
5511 .select()
5512 .from(pullRequests)
5513 .where(and(
5514 eq(pullRequests.repositoryId, resolved.repo.id),
5515 eq(pullRequests.number, prNum),
5516 ))
5517 .limit(1);
5518 if (!pr || pr.state !== "open") {
5519 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5520 }
5521
5522 const repoDir = getRepoPath(ownerName, repoName);
5523 const wt = `${repoDir}/_update_wt_${Date.now()}`;
5524 const gitEnv = {
5525 ...process.env,
5526 GIT_AUTHOR_NAME: user.displayName || user.username,
5527 GIT_AUTHOR_EMAIL: user.email,
5528 GIT_COMMITTER_NAME: user.displayName || user.username,
5529 GIT_COMMITTER_EMAIL: user.email,
5530 };
5531
5532 const addWt = Bun.spawn(
5533 ["git", "worktree", "add", wt, pr.headBranch],
5534 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5535 );
5536 if (await addWt.exited !== 0) {
5537 return c.redirect(
5538 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
5539 );
5540 }
5541
5542 let ok = false;
5543 try {
5544 const mergeProc = Bun.spawn(
5545 ["git", "merge", "--no-edit", pr.baseBranch],
5546 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
5547 );
5548 if (await mergeProc.exited === 0) {
5549 ok = true;
5550 } else {
5551 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5552 }
5553 } catch {
5554 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5555 }
5556
5557 await Bun.spawn(
5558 ["git", "worktree", "remove", "--force", wt],
5559 { cwd: repoDir }
5560 ).exited.catch(() => {});
5561
5562 if (ok) {
5563 return c.redirect(
5564 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
5565 );
5566 }
5567 return c.redirect(
5568 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
5569 );
5570 }
5571);
5572
b558f23Claude5573// Edit PR title (and optionally body). Owner or author only.
5574pulls.post(
5575 "/:owner/:repo/pulls/:number/edit",
5576 softAuth,
5577 requireAuth,
5578 requireRepoAccess("write"),
5579 async (c) => {
5580 const { owner: ownerName, repo: repoName } = c.req.param();
5581 const prNum = parseInt(c.req.param("number"), 10);
5582 const user = c.get("user")!;
5583 const resolved = await resolveRepo(ownerName, repoName);
5584 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5585
5586 const [pr] = await db
5587 .select()
5588 .from(pullRequests)
5589 .where(and(
5590 eq(pullRequests.repositoryId, resolved.repo.id),
5591 eq(pullRequests.number, prNum),
5592 ))
5593 .limit(1);
5594 if (!pr || pr.state !== "open") {
5595 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5596 }
5597 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
5598 if (!canEdit) {
5599 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5600 }
5601
5602 const body = await c.req.parseBody();
5603 const newTitle = String(body.title || "").trim().slice(0, 256);
5604 if (!newTitle) {
5605 return c.redirect(
5606 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
5607 );
5608 }
5609
5610 await db
5611 .update(pullRequests)
5612 .set({ title: newTitle, updatedAt: new Date() })
5613 .where(eq(pullRequests.id, pr.id));
5614
5615 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
5616 }
5617);
5618
cb5a796Claude5619// Add comment to PR.
5620//
5621// Permission model mirrors `issues.tsx`: any logged-in user with read
5622// access can submit; `decideInitialStatus` routes non-collaborators
5623// through the moderation queue. Slash commands only fire when the
5624// comment is auto-approved — we don't want a banned/pending comment to
5625// silently trigger AI work on the PR.
0074234Claude5626pulls.post(
5627 "/:owner/:repo/pulls/:number/comment",
5628 softAuth,
5629 requireAuth,
cb5a796Claude5630 requireRepoAccess("read"),
0074234Claude5631 async (c) => {
5632 const { owner: ownerName, repo: repoName } = c.req.param();
5633 const prNum = parseInt(c.req.param("number"), 10);
5634 const user = c.get("user")!;
5635 const body = await c.req.parseBody();
5636 const commentBody = String(body.body || "").trim();
47a7a0aClaude5637 const filePathRaw = String(body.file_path || "").trim();
5638 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
5639 const inlineFilePath = filePathRaw || undefined;
5640 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude5641
5642 if (!commentBody) {
5643 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5644 }
5645
5646 const resolved = await resolveRepo(ownerName, repoName);
5647 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5648
5649 const [pr] = await db
5650 .select()
5651 .from(pullRequests)
5652 .where(
5653 and(
5654 eq(pullRequests.repositoryId, resolved.repo.id),
5655 eq(pullRequests.number, prNum)
5656 )
5657 )
5658 .limit(1);
5659
5660 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5661
cb5a796Claude5662 const decision = await decideInitialStatus({
5663 commenterUserId: user.id,
5664 repositoryId: resolved.repo.id,
5665 kind: "pr",
5666 threadId: pr.id,
5667 });
5668
d4ac5c3Claude5669 const [inserted] = await db
5670 .insert(prComments)
5671 .values({
5672 pullRequestId: pr.id,
5673 authorId: user.id,
5674 body: commentBody,
cb5a796Claude5675 moderationStatus: decision.status,
47a7a0aClaude5676 filePath: inlineFilePath,
5677 lineNumber: inlineLineNumber,
d4ac5c3Claude5678 })
5679 .returning();
5680
cb5a796Claude5681 // Live update: only when the comment is actually visible.
5682 if (inserted && decision.status === "approved") {
b7b5f75ccanty labs5683 void logActivity({
5684 repositoryId: resolved.repo.id,
5685 userId: user.id,
5686 action: "comment",
5687 targetType: "pull_request",
5688 targetId: String(prNum),
5689 metadata: { commentId: inserted.id },
5690 });
a74f4edccanty labs5691 void fireWebhooks(resolved.repo.id, "pr", {
5692 action: "commented",
5693 number: prNum,
5694 });
b7b5f75ccanty labs5695
d4ac5c3Claude5696 try {
5697 const { publish } = await import("../lib/sse");
5698 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
5699 event: "pr-comment",
5700 data: {
5701 pullRequestId: pr.id,
5702 commentId: inserted.id,
5703 authorId: user.id,
5704 authorUsername: user.username,
5705 },
5706 });
5707 } catch {
5708 /* SSE is best-effort */
5709 }
b7ecb14Claude5710 // Notify the PR author — fire-and-forget, never blocks the response.
5711 if (pr.authorId && pr.authorId !== user.id) {
5712 void import("../lib/notify").then(({ createNotification }) =>
5713 createNotification({
5714 userId: pr.authorId,
5715 type: "pr_comment",
5716 title: `New comment on "${pr.title}"`,
5717 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
5718 url: `/${ownerName}/${repoName}/pulls/${prNum}`,
5719 repoId: resolved.repo.id,
5720 })
5721 ).catch(() => { /* never block the response */ });
5722 }
d4ac5c3Claude5723 }
0074234Claude5724
cb5a796Claude5725 if (decision.status === "pending") {
5726 void notifyOwnerOfPendingComment({
5727 repositoryId: resolved.repo.id,
5728 commenterUsername: user.username,
5729 kind: "pr",
5730 threadNumber: prNum,
5731 ownerUsername: ownerName,
5732 repoName,
5733 });
5734 return c.redirect(
5735 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5736 );
5737 }
5738 if (decision.status === "rejected") {
5739 // Silent ban path — same UX as 'pending' so we don't leak the gate.
5740 return c.redirect(
5741 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5742 );
5743 }
5744
15db0e0Claude5745 // Slash-command handoff. We always store the original comment above
5746 // first so free-form text that happens to start with `/` is preserved
5747 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude5748 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude5749 const parsed = parseSlashCommand(commentBody);
5750 if (parsed) {
5751 try {
5752 const result = await executeSlashCommand({
5753 command: parsed.command,
5754 args: parsed.args,
5755 prId: pr.id,
5756 userId: user.id,
5757 repositoryId: resolved.repo.id,
5758 });
5759 await db.insert(prComments).values({
5760 pullRequestId: pr.id,
5761 authorId: user.id,
5762 body: result.body,
5763 });
5764 } catch (err) {
5765 // Defence-in-depth — executeSlashCommand promises not to throw,
5766 // but if it ever does we want the PR thread to know.
5767 await db
5768 .insert(prComments)
5769 .values({
5770 pullRequestId: pr.id,
5771 authorId: user.id,
5772 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
5773 })
5774 .catch(() => {});
5775 }
5776 }
5777
47a7a0aClaude5778 // Inline comments go back to the files tab; conversation comments to the conversation tab
5779 const redirectTab = inlineFilePath ? "?tab=files" : "";
5780 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude5781 }
5782);
5783
a2c10c5Claude5784// ─── Batched PR review workflow ─────────────────────────────────────────────
5785// Reviewers can stage multiple inline comments in a pending_reviews session,
5786// then submit them all at once as an Approve / Request Changes / Comment review.
5787
5788// GET /:owner/:repo/pulls/:number/review/pending
5789// Returns {count, comments} for the current user's pending review session.
5790pulls.get(
5791 "/:owner/:repo/pulls/:number/review/pending",
5792 softAuth,
5793 requireAuth,
5794 requireRepoAccess("read"),
5795 async (c) => {
5796 const { owner: ownerName, repo: repoName } = c.req.param();
5797 const prNum = parseInt(c.req.param("number"), 10);
5798 const user = c.get("user")!;
5799
5800 const resolved = await resolveRepo(ownerName, repoName);
5801 if (!resolved) return c.json({ count: 0, comments: [] });
5802
5803 const [pr] = await db
5804 .select()
5805 .from(pullRequests)
5806 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5807 .limit(1);
5808 if (!pr) return c.json({ count: 0, comments: [] });
5809
5810 const [review] = await db
5811 .select()
5812 .from(pendingReviews)
5813 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5814 .limit(1);
5815
5816 if (!review) return c.json({ count: 0, comments: [] });
5817
5818 const comments = await db
5819 .select({ id: pendingReviewComments.id, filePath: pendingReviewComments.filePath, lineNumber: pendingReviewComments.lineNumber, body: pendingReviewComments.body })
5820 .from(pendingReviewComments)
5821 .where(eq(pendingReviewComments.reviewId, review.id))
5822 .orderBy(asc(pendingReviewComments.createdAt));
5823
5824 return c.json({ count: comments.length, comments });
5825 }
5826);
5827
5828// POST /:owner/:repo/pulls/:number/review/pending/add
5829// Adds a comment to the current user's pending review (creates session if needed).
5830pulls.post(
5831 "/:owner/:repo/pulls/:number/review/pending/add",
5832 softAuth,
5833 requireAuth,
5834 requireRepoAccess("read"),
5835 async (c) => {
5836 const { owner: ownerName, repo: repoName } = c.req.param();
5837 const prNum = parseInt(c.req.param("number"), 10);
5838 const user = c.get("user")!;
5839 const body = await c.req.parseBody();
5840 const filePath = String(body.filePath || "").trim();
5841 const lineNumber = parseInt(String(body.lineNumber || ""), 10);
5842 const commentBody = String(body.body || "").trim();
5843
5844 if (!filePath || !Number.isFinite(lineNumber) || lineNumber <= 0 || !commentBody) {
5845 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5846 }
5847
5848 const resolved = await resolveRepo(ownerName, repoName);
5849 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5850
5851 const [pr] = await db
5852 .select()
5853 .from(pullRequests)
5854 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5855 .limit(1);
5856 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5857
5858 // Upsert the pending_reviews session row
5859 let [review] = await db
5860 .select()
5861 .from(pendingReviews)
5862 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5863 .limit(1);
5864
5865 if (!review) {
5866 [review] = await db
5867 .insert(pendingReviews)
5868 .values({ prId: pr.id, authorId: user.id })
5869 .onConflictDoNothing()
5870 .returning();
5871 // If onConflictDoNothing returned nothing (race), fetch again
5872 if (!review) {
5873 [review] = await db
5874 .select()
5875 .from(pendingReviews)
5876 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5877 .limit(1);
5878 }
5879 }
5880
5881 if (!review) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5882
5883 await db.insert(pendingReviewComments).values({
5884 reviewId: review.id,
5885 filePath,
5886 lineNumber,
5887 body: commentBody,
5888 });
5889
5890 // Count total pending comments for this review
5891 const countRows = await db
5892 .select({ id: pendingReviewComments.id })
5893 .from(pendingReviewComments)
5894 .where(eq(pendingReviewComments.reviewId, review.id));
5895
5896 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files&pending=${countRows.length}`);
5897 }
5898);
5899
5900// POST /:owner/:repo/pulls/:number/review/pending/:commentId/delete
5901// Removes a single comment from the pending review session.
5902pulls.post(
5903 "/:owner/:repo/pulls/:number/review/pending/:commentId/delete",
5904 softAuth,
5905 requireAuth,
5906 requireRepoAccess("read"),
5907 async (c) => {
5908 const { owner: ownerName, repo: repoName, commentId } = c.req.param();
5909 const prNum = parseInt(c.req.param("number"), 10);
5910 const user = c.get("user")!;
5911
5912 const resolved = await resolveRepo(ownerName, repoName);
5913 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5914
5915 const [pr] = await db
5916 .select()
5917 .from(pullRequests)
5918 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5919 .limit(1);
5920 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5921
5922 // Verify ownership via the review session
5923 const [review] = await db
5924 .select()
5925 .from(pendingReviews)
5926 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5927 .limit(1);
5928
5929 if (review) {
5930 await db
5931 .delete(pendingReviewComments)
5932 .where(and(eq(pendingReviewComments.id, commentId), eq(pendingReviewComments.reviewId, review.id)));
5933 }
5934
5935 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5936 }
5937);
5938
5939// POST /:owner/:repo/pulls/:number/review/submit
5940// Submits the pending review: posts all staged comments + a formal review state.
5941pulls.post(
5942 "/:owner/:repo/pulls/:number/review/submit",
5943 softAuth,
5944 requireAuth,
5945 requireRepoAccess("read"),
5946 async (c) => {
5947 const { owner: ownerName, repo: repoName } = c.req.param();
5948 const prNum = parseInt(c.req.param("number"), 10);
5949 const user = c.get("user")!;
5950 const body = await c.req.parseBody();
5951 const reviewBody = String(body.reviewBody || "").trim();
5952 const reviewStateRaw = String(body.reviewState || "comment");
5953
5954 const reviewState =
5955 reviewStateRaw === "approve"
5956 ? "approved"
5957 : reviewStateRaw === "request_changes"
5958 ? "changes_requested"
5959 : "commented";
5960
5961 const resolved = await resolveRepo(ownerName, repoName);
5962 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5963
5964 const [pr] = await db
5965 .select()
5966 .from(pullRequests)
5967 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5968 .limit(1);
5969 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5970
5971 const [review] = await db
5972 .select()
5973 .from(pendingReviews)
5974 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5975 .limit(1);
5976
5977 if (review) {
5978 const pendingComments = await db
5979 .select()
5980 .from(pendingReviewComments)
5981 .where(eq(pendingReviewComments.reviewId, review.id))
5982 .orderBy(asc(pendingReviewComments.createdAt));
5983
5984 // Post each pending comment as a real pr_comment
5985 for (const pc of pendingComments) {
5986 await db.insert(prComments).values({
5987 pullRequestId: pr.id,
5988 authorId: user.id,
5989 body: pc.body,
5990 filePath: pc.filePath,
5991 lineNumber: pc.lineNumber,
5992 isAiReview: false,
5993 });
5994 }
5995
5996 // Delete the pending review session (cascades to comments)
5997 await db.delete(pendingReviews).where(eq(pendingReviews.id, review.id));
5998 }
5999
6000 // Insert the formal review record
6001 await db.insert(prReviews).values({
6002 pullRequestId: pr.id,
6003 reviewerId: user.id,
6004 state: reviewState,
6005 body: reviewBody || null,
6006 isAi: false,
6007 });
6008
6009 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=conversation`);
6010 }
6011);
6012
b5dd694Claude6013// Apply a suggestion from a PR comment — commits the suggested code to the
6014// head branch on behalf of the logged-in user.
6015pulls.post(
6016 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
6017 softAuth,
6018 requireAuth,
6019 requireRepoAccess("read"),
6020 async (c) => {
6021 const { owner: ownerName, repo: repoName } = c.req.param();
6022 const prNum = parseInt(c.req.param("number"), 10);
6023 const commentId = c.req.param("commentId"); // UUID
6024 const user = c.get("user")!;
6025
6026 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
6027
6028 const resolved = await resolveRepo(ownerName, repoName);
6029 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6030
6031 const [pr] = await db
6032 .select()
6033 .from(pullRequests)
6034 .where(
6035 and(
6036 eq(pullRequests.repositoryId, resolved.repo.id),
6037 eq(pullRequests.number, prNum)
6038 )
6039 )
6040 .limit(1);
6041
6042 if (!pr || pr.state !== "open") {
6043 return c.redirect(`${backUrl}&error=pr_not_open`);
6044 }
6045
6046 // Only PR author or repo owner may apply suggestions.
6047 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
6048 return c.redirect(`${backUrl}&error=forbidden`);
6049 }
6050
6051 // Load the comment.
6052 const [comment] = await db
6053 .select()
6054 .from(prComments)
6055 .where(
6056 and(
6057 eq(prComments.id, commentId),
6058 eq(prComments.pullRequestId, pr.id)
6059 )
6060 )
6061 .limit(1);
6062
6063 if (!comment) {
6064 return c.redirect(`${backUrl}&error=comment_not_found`);
6065 }
6066
6067 // Parse suggestion block from comment body.
6068 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
6069 if (!m) {
6070 return c.redirect(`${backUrl}&error=no_suggestion`);
6071 }
6072 const suggestionCode = m[1];
6073
6074 // Get the commenter's details for the commit message co-author line.
6075 const [commenter] = await db
6076 .select()
6077 .from(users)
6078 .where(eq(users.id, comment.authorId))
6079 .limit(1);
6080
6081 // Fetch current file content from head branch.
6082 if (!comment.filePath) {
6083 return c.redirect(`${backUrl}&error=file_not_found`);
6084 }
6085 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
6086 if (!blob) {
6087 return c.redirect(`${backUrl}&error=file_not_found`);
6088 }
6089
6090 // Apply the patch — replace the target line(s) with suggestion lines.
6091 const lines = blob.content.split('\n');
6092 const lineIdx = (comment.lineNumber ?? 1) - 1;
6093 if (lineIdx < 0 || lineIdx >= lines.length) {
6094 return c.redirect(`${backUrl}&error=line_out_of_range`);
6095 }
6096 const suggestionLines = suggestionCode.split('\n');
6097 lines.splice(lineIdx, 1, ...suggestionLines);
6098 const newContent = lines.join('\n');
6099
6100 // Commit the change.
6101 const coAuthorLine = commenter
6102 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
6103 : "";
6104 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
6105
6106 const result = await createOrUpdateFileOnBranch({
6107 owner: ownerName,
6108 name: repoName,
6109 branch: pr.headBranch,
6110 filePath: comment.filePath,
6111 bytes: new TextEncoder().encode(newContent),
6112 message: commitMessage,
6113 authorName: user.username,
6114 authorEmail: `${user.username}@users.noreply.gluecron.com`,
6115 });
6116
6117 if ("error" in result) {
6118 return c.redirect(`${backUrl}&error=apply_failed`);
6119 }
6120
6121 // Post a follow-up comment noting the suggestion was applied.
6122 await db.insert(prComments).values({
6123 pullRequestId: pr.id,
6124 authorId: user.id,
6125 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
6126 });
6127
6128 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
6129 }
6130);
6131
0a67773Claude6132// Formal review — Approve / Request Changes / Comment
6133pulls.post(
6134 "/:owner/:repo/pulls/:number/review",
6135 softAuth,
6136 requireAuth,
6137 requireRepoAccess("read"),
6138 async (c) => {
6139 const { owner: ownerName, repo: repoName } = c.req.param();
6140 const prNum = parseInt(c.req.param("number"), 10);
6141 const user = c.get("user")!;
6142 const body = await c.req.parseBody();
6143 const reviewBody = String(body.body || "").trim();
6144 const reviewState = String(body.review_state || "commented");
6145
6146 const validStates = ["approved", "changes_requested", "commented"];
6147 if (!validStates.includes(reviewState)) {
6148 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6149 }
6150
6151 const resolved = await resolveRepo(ownerName, repoName);
6152 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6153
6154 const [pr] = await db
6155 .select()
6156 .from(pullRequests)
6157 .where(
6158 and(
6159 eq(pullRequests.repositoryId, resolved.repo.id),
6160 eq(pullRequests.number, prNum)
6161 )
6162 )
6163 .limit(1);
6164 if (!pr || pr.state !== "open") {
6165 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6166 }
6167 // Authors can't review their own PR
6168 if (pr.authorId === user.id) {
6169 return c.redirect(
6170 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
6171 );
6172 }
6173
6174 await db.insert(prReviews).values({
6175 pullRequestId: pr.id,
6176 reviewerId: user.id,
6177 state: reviewState,
6178 body: reviewBody || null,
6179 });
6180
6181 const stateLabel =
6182 reviewState === "approved" ? "Approved"
6183 : reviewState === "changes_requested" ? "Changes requested"
6184 : "Commented";
6185 return c.redirect(
6186 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
6187 );
6188 }
6189);
6190
e883329Claude6191// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude6192// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
6193// but we keep it at "write" for v1 so trusted collaborators can ship.
6194// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
6195// surface. Branch-protection rules (evaluated below) are the current mechanism
6196// for locking down merges further on specific branches.
0074234Claude6197pulls.post(
6198 "/:owner/:repo/pulls/:number/merge",
6199 softAuth,
6200 requireAuth,
04f6b7fClaude6201 requireRepoAccess("write"),
0074234Claude6202 async (c) => {
6203 const { owner: ownerName, repo: repoName } = c.req.param();
6204 const prNum = parseInt(c.req.param("number"), 10);
6205 const user = c.get("user")!;
6206
a164a6dClaude6207 // Read merge strategy from form (default: merge commit)
6208 let mergeStrategy = "merge";
6209 try {
6210 const body = await c.req.parseBody();
6211 const s = body.merge_strategy;
6212 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
6213 } catch { /* ignore parse errors — default to merge commit */ }
6214
0074234Claude6215 const resolved = await resolveRepo(ownerName, repoName);
6216 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6217
6218 const [pr] = await db
6219 .select()
6220 .from(pullRequests)
6221 .where(
6222 and(
6223 eq(pullRequests.repositoryId, resolved.repo.id),
6224 eq(pullRequests.number, prNum)
6225 )
6226 )
6227 .limit(1);
6228
6229 if (!pr || pr.state !== "open") {
6230 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6231 }
6232
6fc53bdClaude6233 // Draft PRs cannot be merged — must be marked ready first.
6234 if (pr.isDraft) {
6235 return c.redirect(
6236 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6237 "This PR is a draft. Mark it as ready for review before merging."
6238 )}`
6239 );
6240 }
6241
ec9e3e3Claude6242 // Required reviews check — branch-protection `required_approvals` gate.
6243 // Evaluated before running expensive gate checks so the feedback is fast.
6244 {
6245 const eligibility = await checkMergeEligible(pr.id, resolved.repo.id, pr.baseBranch);
6246 if (!eligibility.eligible && eligibility.reason) {
6247 return c.redirect(
6248 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(eligibility.reason)}`
6249 );
6250 }
6251 }
6252
e883329Claude6253 // Resolve head SHA
6254 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
6255 if (!headSha) {
6256 return c.redirect(
6257 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
6258 );
6259 }
6260
58c39f5Claude6261 // Check if AI review approved this PR.
6262 // The AI summary comment body uses:
6263 // "**AI review:** no blocking issues found." → approved
6264 // "**AI review:** flagged N item(s)..." → not approved
6265 // "severity: blocking" → explicit blocking (future)
6266 // If no AI comments exist yet, treat as approved (gate hasn't run).
c166384ccantynz-alt6267 const aiApproved = await isAiReviewApproved(pr.id);
e883329Claude6268
6269 // Run all green gate checks (GateTest + mergeability + AI review)
6270 const gateResult = await runAllGateChecks(
6271 ownerName,
6272 repoName,
6273 pr.baseBranch,
6274 pr.headBranch,
6275 headSha,
6276 aiApproved
0074234Claude6277 );
6278
e883329Claude6279 // If GateTest or AI review failed (hard blocks), reject the merge
6280 const hardFailures = gateResult.checks.filter(
6281 (check) => !check.passed && check.name !== "Merge check"
6282 );
6283 if (hardFailures.length > 0) {
6284 const errorMsg = hardFailures
6285 .map((f) => `${f.name}: ${f.details}`)
6286 .join("; ");
0074234Claude6287 return c.redirect(
e883329Claude6288 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude6289 );
6290 }
6291
1e162a8Claude6292 // D5 — Branch-protection enforcement. Looks up the matching rule for the
6293 // base branch and blocks the merge if requireAiApproval / requireGreenGates
6294 // / requireHumanReview / requiredApprovals are not satisfied. Independent
6295 // of repo-global settings, so owners can lock specific branches down
6296 // further than the repo default.
6297 const protectionRule = await matchProtection(
6298 resolved.repo.id,
6299 pr.baseBranch
6300 );
6301 if (protectionRule) {
6302 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude6303 const required = await listRequiredChecks(protectionRule.id);
6304 const passingNames = required.length > 0
6305 ? await passingCheckNames(resolved.repo.id, headSha)
6306 : [];
6307 const decision = evaluateProtection(
6308 protectionRule,
6309 {
6310 aiApproved,
6311 humanApprovalCount: humanApprovals,
6312 gateResultGreen: hardFailures.length === 0,
6313 hasFailedGates: hardFailures.length > 0,
6314 passingCheckNames: passingNames,
6315 },
6316 required.map((r) => r.checkName)
6317 );
91b054eccanty labs6318
6319 // CODEOWNERS enforcement — additive to evaluateProtection(), only
6320 // when the rule already requires human review at all (no new DB
6321 // column for this pass). Fail-open on any internal error: a bug here
6322 // must never hard-block every merge platform-wide.
6323 if (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) {
6324 try {
6325 const codeownersDiffProc = Bun.spawn(
6326 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
6327 { cwd: getRepoPath(ownerName, repoName), stdout: "pipe", stderr: "pipe" }
6328 );
6329 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
6330 await codeownersDiffProc.exited;
6331 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
6332 if (changedPaths.length > 0) {
6333 const { satisfied, missingOwners } = await requiredOwnersApproved(
6334 ownerName,
6335 repoName,
6336 resolved.repo.defaultBranch,
6337 pr.id,
6338 changedPaths
6339 );
6340 if (!satisfied) {
6341 decision.allowed = false;
6342 decision.reasons.push(
6343 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
6344 );
6345 }
6346 }
6347 } catch (err) {
6348 console.warn(
6349 "[codeowners] merge enforcement failed:",
6350 err instanceof Error ? err.message : err
6351 );
6352 }
6353 }
6354
1e162a8Claude6355 if (!decision.allowed) {
6356 return c.redirect(
6357 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6358 decision.reasons.join(" ")
6359 )}`
6360 );
6361 }
6362 }
6363
e883329Claude6364 // Attempt the merge — with auto conflict resolution if needed
6365 const repoDir = getRepoPath(ownerName, repoName);
6366 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
6367 const hasConflicts = mergeCheck && !mergeCheck.passed;
6368
6369 if (hasConflicts && isAiReviewEnabled()) {
6370 // Use Claude to auto-resolve conflicts
6371 const mergeResult = await mergeWithAutoResolve(
6372 ownerName,
6373 repoName,
6374 pr.baseBranch,
6375 pr.headBranch,
6376 `Merge pull request #${pr.number}: ${pr.title}`
6377 );
6378
6379 if (!mergeResult.success) {
6380 return c.redirect(
6381 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
6382 );
6383 }
6384
6385 // Post a comment about the auto-resolution
6386 if (mergeResult.resolvedFiles.length > 0) {
6387 await db.insert(prComments).values({
6388 pullRequestId: pr.id,
6389 authorId: user.id,
6390 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
6391 isAiReview: true,
6392 });
6393 }
6394 } else {
a164a6dClaude6395 // Worktree-based merge: supports merge-commit, squash, and fast-forward
6396 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
6397 const gitEnv = {
6398 ...process.env,
6399 GIT_AUTHOR_NAME: user.displayName || user.username,
6400 GIT_AUTHOR_EMAIL: user.email,
6401 GIT_COMMITTER_NAME: user.displayName || user.username,
6402 GIT_COMMITTER_EMAIL: user.email,
6403 };
6404
6405 // Create linked worktree on the base branch
6406 const addWt = Bun.spawn(
6407 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude6408 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
6409 );
a164a6dClaude6410 if (await addWt.exited !== 0) {
6411 return c.redirect(
6412 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
6413 );
6414 }
6415
6416 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
6417 let mergeOk = false;
6418
6419 try {
6420 if (mergeStrategy === "squash") {
6421 // Squash: stage all changes without committing
6422 const squashProc = Bun.spawn(
6423 ["git", "merge", "--squash", headSha],
6424 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6425 );
6426 if (await squashProc.exited !== 0) {
6427 const errTxt = await new Response(squashProc.stderr).text();
6428 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
6429 }
6430 // Commit the squashed changes
6431 const commitProc = Bun.spawn(
6432 ["git", "commit", "-m", commitMsg],
6433 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6434 );
6435 if (await commitProc.exited !== 0) {
6436 const errTxt = await new Response(commitProc.stderr).text();
6437 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
6438 }
6439 mergeOk = true;
6440 } else if (mergeStrategy === "ff") {
6441 // Fast-forward only — fail if FF is not possible
6442 const ffProc = Bun.spawn(
6443 ["git", "merge", "--ff-only", headSha],
6444 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6445 );
6446 if (await ffProc.exited !== 0) {
6447 const errTxt = await new Response(ffProc.stderr).text();
6448 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
6449 }
6450 mergeOk = true;
6451 } else {
6452 // Default: merge commit (--no-ff always creates a merge commit)
6453 const mergeProc = Bun.spawn(
6454 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
6455 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6456 );
6457 if (await mergeProc.exited !== 0) {
6458 const errTxt = await new Response(mergeProc.stderr).text();
6459 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
6460 }
6461 mergeOk = true;
6462 }
6463 } catch (err) {
6464 // Always clean up the worktree before redirecting
6465 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
6466 const msg = err instanceof Error ? err.message : "Merge failed";
6467 return c.redirect(
6468 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
6469 );
6470 }
6471
6472 // Clean up worktree (changes are now in the bare repo via linked worktree)
6473 await Bun.spawn(
6474 ["git", "worktree", "remove", "--force", wt],
6475 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
6476 ).exited.catch(() => {});
e883329Claude6477
a164a6dClaude6478 if (!mergeOk) {
e883329Claude6479 return c.redirect(
a164a6dClaude6480 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude6481 );
6482 }
6483 }
6484
0074234Claude6485 await db
6486 .update(pullRequests)
6487 .set({
6488 state: "merged",
6489 mergedAt: new Date(),
6490 mergedBy: user.id,
6491 updatedAt: new Date(),
6492 })
6493 .where(eq(pullRequests.id, pr.id));
6494
b7b5f75ccanty labs6495 void logActivity({
6496 repositoryId: resolved.repo.id,
6497 userId: user.id,
6498 action: "pr_merge",
6499 targetType: "pull_request",
6500 targetId: String(pr.number),
6501 metadata: { baseBranch: pr.baseBranch, headBranch: pr.headBranch, mergeStrategy },
6502 });
a74f4edccanty labs6503 void fireWebhooks(resolved.repo.id, "pr", {
6504 action: "merged",
6505 number: pr.number,
6506 mergeStrategy,
6507 });
b7b5f75ccanty labs6508
8809b87Claude6509 // Chat notifier — fan out merge event to Slack/Discord/Teams.
6510 import("../lib/chat-notifier")
6511 .then((m) =>
6512 m.notifyChatChannels({
6513 ownerUserId: resolved.repo.ownerId,
6514 repositoryId: resolved.repo.id,
6515 event: {
6516 event: "pr.merged",
6517 repo: `${ownerName}/${repoName}`,
6518 title: `#${pr.number} ${pr.title}`,
6519 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
6520 actor: user.username,
6521 },
6522 })
6523 )
6524 .catch((err) =>
6525 console.warn(`[chat-notifier] PR merge notify failed:`, err)
6526 );
6527
d62fb36Claude6528 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
6529 // and auto-close each matching open issue with a back-link comment. Bounded
6530 // to the same repo for v1 (cross-repo refs ignored). Failures never block
6531 // the merge redirect.
6532 try {
6533 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
6534 const refs = extractClosingRefsMulti([pr.title, pr.body]);
6535 for (const n of refs) {
6536 const [issue] = await db
6537 .select()
6538 .from(issues)
6539 .where(
6540 and(
6541 eq(issues.repositoryId, resolved.repo.id),
6542 eq(issues.number, n)
6543 )
6544 )
6545 .limit(1);
6546 if (!issue || issue.state !== "open") continue;
6547 await db
6548 .update(issues)
6549 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
6550 .where(eq(issues.id, issue.id));
6551 await db.insert(issueComments).values({
6552 issueId: issue.id,
6553 authorId: user.id,
6554 body: `Closed by pull request #${pr.number}.`,
6555 });
6556 }
6557 } catch {
6558 // Never block the merge on close-keyword failures.
6559 }
6560
0074234Claude6561 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6562 }
6563);
6564
6fc53bdClaude6565// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
6566// hasn't run yet on this PR.
6567pulls.post(
6568 "/:owner/:repo/pulls/:number/ready",
6569 softAuth,
6570 requireAuth,
04f6b7fClaude6571 requireRepoAccess("write"),
6fc53bdClaude6572 async (c) => {
6573 const { owner: ownerName, repo: repoName } = c.req.param();
6574 const prNum = parseInt(c.req.param("number"), 10);
6575 const user = c.get("user")!;
6576
6577 const resolved = await resolveRepo(ownerName, repoName);
6578 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6579
6580 const [pr] = await db
6581 .select()
6582 .from(pullRequests)
6583 .where(
6584 and(
6585 eq(pullRequests.repositoryId, resolved.repo.id),
6586 eq(pullRequests.number, prNum)
6587 )
6588 )
6589 .limit(1);
6590 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6591
6592 // Only the author or repo owner can toggle draft state.
6593 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
6594 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6595 }
6596
6597 if (pr.state === "open" && pr.isDraft) {
6598 await db
6599 .update(pullRequests)
6600 .set({ isDraft: false, updatedAt: new Date() })
6601 .where(eq(pullRequests.id, pr.id));
6602
6603 if (isAiReviewEnabled()) {
6604 triggerAiReview(
6605 ownerName,
6606 repoName,
6607 pr.id,
6608 pr.title,
0316dbbClaude6609 pr.body || "",
6fc53bdClaude6610 pr.baseBranch,
6611 pr.headBranch
6612 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
6613 }
6614 }
6615
6616 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6617 }
6618);
6619
6620// Convert a PR back to draft.
6621pulls.post(
6622 "/:owner/:repo/pulls/:number/draft",
6623 softAuth,
6624 requireAuth,
04f6b7fClaude6625 requireRepoAccess("write"),
6fc53bdClaude6626 async (c) => {
6627 const { owner: ownerName, repo: repoName } = c.req.param();
6628 const prNum = parseInt(c.req.param("number"), 10);
6629 const user = c.get("user")!;
6630
6631 const resolved = await resolveRepo(ownerName, repoName);
6632 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6633
6634 const [pr] = await db
6635 .select()
6636 .from(pullRequests)
6637 .where(
6638 and(
6639 eq(pullRequests.repositoryId, resolved.repo.id),
6640 eq(pullRequests.number, prNum)
6641 )
6642 )
6643 .limit(1);
6644 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6645
6646 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
6647 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6648 }
6649
6650 if (pr.state === "open" && !pr.isDraft) {
6651 await db
6652 .update(pullRequests)
6653 .set({ isDraft: true, updatedAt: new Date() })
6654 .where(eq(pullRequests.id, pr.id));
6655 }
6656
6657 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6658 }
6659);
6660
0074234Claude6661// Close PR
6662pulls.post(
6663 "/:owner/:repo/pulls/:number/close",
6664 softAuth,
6665 requireAuth,
04f6b7fClaude6666 requireRepoAccess("write"),
0074234Claude6667 async (c) => {
6668 const { owner: ownerName, repo: repoName } = c.req.param();
6669 const prNum = parseInt(c.req.param("number"), 10);
6670
6671 const resolved = await resolveRepo(ownerName, repoName);
6672 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6673
6674 await db
6675 .update(pullRequests)
6676 .set({
6677 state: "closed",
6678 closedAt: new Date(),
6679 updatedAt: new Date(),
6680 })
6681 .where(
6682 and(
6683 eq(pullRequests.repositoryId, resolved.repo.id),
6684 eq(pullRequests.number, prNum)
6685 )
6686 );
a74f4edccanty labs6687 void fireWebhooks(resolved.repo.id, "pr", {
6688 action: "closed",
6689 number: prNum,
6690 });
0074234Claude6691
6692 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6693 }
6694);
6695
c3e0c07Claude6696// Re-run AI review on demand (e.g. after a force-push). Bypasses the
6697// idempotency marker via { force: true }. Write-access only.
6698pulls.post(
6699 "/:owner/:repo/pulls/:number/ai-rereview",
6700 softAuth,
6701 requireAuth,
6702 requireRepoAccess("write"),
6703 async (c) => {
6704 const { owner: ownerName, repo: repoName } = c.req.param();
6705 const prNum = parseInt(c.req.param("number"), 10);
6706 const resolved = await resolveRepo(ownerName, repoName);
6707 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6708
6709 const [pr] = await db
6710 .select()
6711 .from(pullRequests)
6712 .where(
6713 and(
6714 eq(pullRequests.repositoryId, resolved.repo.id),
6715 eq(pullRequests.number, prNum)
6716 )
6717 )
6718 .limit(1);
6719 if (!pr) {
6720 return c.redirect(`/${ownerName}/${repoName}/pulls`);
6721 }
6722
6723 if (!isAiReviewEnabled()) {
6724 return c.redirect(
6725 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6726 "AI review is not configured (ANTHROPIC_API_KEY)."
6727 )}`
6728 );
6729 }
6730
6731 // Fire-and-forget but with { force: true } to bypass the
6732 // already-reviewed marker. The function still never throws.
6733 triggerAiReview(
6734 ownerName,
6735 repoName,
6736 pr.id,
6737 pr.title || "",
6738 pr.body || "",
6739 pr.baseBranch,
6740 pr.headBranch,
6741 { force: true }
a28cedeClaude6742 ).catch((err) => {
6743 console.warn(
6744 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
6745 err instanceof Error ? err.message : err
6746 );
6747 });
c3e0c07Claude6748
6749 return c.redirect(
6750 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6751 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
6752 )}`
6753 );
6754 }
6755);
6756
1d4ff60Claude6757// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
6758// the PR's head branch carrying just the new test files. Write-access only.
6759// Idempotent — if `ai:added-tests` was previously applied we redirect with
6760// an `info` banner instead of re-firing.
6761pulls.post(
6762 "/:owner/:repo/pulls/:number/generate-tests",
6763 softAuth,
6764 requireAuth,
6765 requireRepoAccess("write"),
6766 async (c) => {
6767 const { owner: ownerName, repo: repoName } = c.req.param();
6768 const prNum = parseInt(c.req.param("number"), 10);
6769 const resolved = await resolveRepo(ownerName, repoName);
6770 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6771
6772 const [pr] = await db
6773 .select()
6774 .from(pullRequests)
6775 .where(
6776 and(
6777 eq(pullRequests.repositoryId, resolved.repo.id),
6778 eq(pullRequests.number, prNum)
6779 )
6780 )
6781 .limit(1);
6782 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6783
6784 if (!isAiReviewEnabled()) {
6785 return c.redirect(
6786 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6787 "AI test generation is not configured (ANTHROPIC_API_KEY)."
6788 )}`
6789 );
6790 }
6791
6792 // Fire-and-forget. The lib never throws.
6793 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
6794 .then((res) => {
6795 if (!res.ok) {
6796 console.warn(
6797 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
6798 );
6799 }
6800 })
6801 .catch((err) => {
6802 console.warn(
6803 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
6804 err instanceof Error ? err.message : err
6805 );
6806 });
6807
6808 return c.redirect(
6809 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6810 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
6811 )}`
6812 );
6813 }
6814);
6815
ace34efClaude6816// ─── Request review ───────────────────────────────────────────────────────────
6817pulls.post(
6818 "/:owner/:repo/pulls/:number/request-review",
6819 softAuth,
6820 requireAuth,
6821 requireRepoAccess("write"),
6822 async (c) => {
6823 const { owner: ownerName, repo: repoName } = c.req.param();
6824 const prNum = parseInt(c.req.param("number"), 10);
6825 const user = c.get("user")!;
6826
6827 const resolved = await resolveRepo(ownerName, repoName);
6828 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6829
6830 const [pr] = await db
6831 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
6832 .from(pullRequests)
6833 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
6834 .limit(1);
6835
6836 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6837
6838 const body = await c.req.formData().catch(() => null);
6839 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
6840
6841 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
6842 return c.redirect(
6843 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
6844 );
6845 }
6846
f4abb8eClaude6847 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
6848 // requesting reviews from arbitrary user IDs outside this repository.
6849 const isOwner = reviewerId === resolved.owner.id;
6850 if (!isOwner) {
6851 const [collab] = await db
6852 .select({ id: repoCollaborators.id })
6853 .from(repoCollaborators)
6854 .where(
6855 and(
6856 eq(repoCollaborators.repositoryId, resolved.repo.id),
6857 eq(repoCollaborators.userId, reviewerId),
6858 isNotNull(repoCollaborators.acceptedAt)
6859 )
6860 )
6861 .limit(1);
6862 if (!collab) {
6863 return c.redirect(
6864 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
6865 );
6866 }
6867 }
6868
ace34efClaude6869 const { requestReview } = await import("../lib/reviewer-suggest");
6870 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
6871
6872 const msg = result.ok
6873 ? "Review requested successfully."
6874 : `Failed to request review: ${result.error ?? "unknown error"}`;
6875
6876 return c.redirect(
6877 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
6878 );
6879 }
6880);
6881
25b1ff7Claude6882// ─── WebSocket presence endpoint ─────────────────────────────────────────────
6883//
6884// GET /:owner/:repo/pulls/:number/presence (WebSocket upgrade)
6885//
6886// Unauthenticated connections are rejected with 401. On connect:
6887// → server sends {type:"init", sessionId, users:[...]}
6888// → server broadcasts {type:"join", user} to all other sessions in the room
6889//
6890// Accepted client messages:
6891// {type:"cursor", line: number} — user hovering a diff line
6892// {type:"typing", line: number, typing: bool} — textarea focus/blur
6893// {type:"ping"} — keep-alive (updates lastSeen)
6894//
6895// The WS `data` payload we store on each socket carries everything needed in
6896// the event handlers so no closure tricks are required.
6897
6898pulls.get(
6899 "/:owner/:repo/pulls/:number/presence",
6900 softAuth,
6901 upgradeWebSocket(async (c) => {
6902 const { owner: ownerName, repo: repoName, number: prNumStr } = c.req.param();
6903 const prNum = parseInt(prNumStr ?? "0", 10);
6904 const user = c.get("user");
6905
6906 // Auth check — no anonymous presence
6907 if (!user) {
6908 // upgradeWebSocket doesn't support returning a non-101 directly;
6909 // we return a dummy handler that immediately closes with 4001.
6910 return {
6911 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6912 ws.close(4001, "Unauthorized");
6913 },
6914 onMessage() {},
6915 onClose() {},
6916 };
6917 }
6918
6919 // Resolve repo to get its numeric id for the room key
6920 const resolved = await resolveRepo(ownerName, repoName);
6921 if (!resolved || isNaN(prNum)) {
6922 return {
6923 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6924 ws.close(4004, "Not found");
6925 },
6926 onMessage() {},
6927 onClose() {},
6928 };
6929 }
6930
6931 const prId = `${resolved.repo.id}:${prNum}`;
6932 const sessionId = `${user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
6933
6934 return {
6935 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6936 // Register and join room
6937 registerSocket(prId, sessionId, {
6938 send: (data: string) => ws.send(data),
6939 readyState: ws.readyState,
6940 });
6941 const presenceUser = joinRoom(prId, sessionId, {
6942 userId: user.id,
6943 username: user.username,
6944 });
6945
6946 // Send init snapshot to the new joiner
6947 const currentUsers = getRoomUsers(prId);
6948 ws.send(
6949 JSON.stringify({
6950 type: "init",
6951 sessionId,
6952 users: currentUsers,
6953 })
6954 );
6955
6956 // Broadcast join to all OTHER sessions
6957 broadcastToRoom(
6958 prId,
6959 {
6960 type: "join",
6961 user: { ...presenceUser, sessionId },
6962 },
6963 sessionId
6964 );
6965 },
6966
6967 onMessage(evt: MessageEvent, _ws: import("hono/ws").WSContext) {
6968 let msg: { type: string; line?: number; typing?: boolean };
6969 try {
6970 msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
6971 } catch {
6972 return;
6973 }
6974
6975 if (msg.type === "ping") {
6976 pingSession(prId, sessionId);
6977 return;
6978 }
6979
6980 if (msg.type === "cursor") {
6981 const line = typeof msg.line === "number" ? msg.line : null;
6982 const updated = updatePresence(prId, sessionId, line, false);
6983 if (updated) {
6984 broadcastToRoom(
6985 prId,
6986 {
6987 type: "cursor",
6988 sessionId,
6989 username: updated.username,
6990 colour: updated.colour,
6991 line,
6992 },
6993 sessionId
6994 );
6995 }
6996 return;
6997 }
6998
6999 if (msg.type === "typing") {
7000 const line = typeof msg.line === "number" ? msg.line : null;
7001 const typing = !!msg.typing;
7002 const updated = updatePresence(prId, sessionId, line, typing);
7003 if (updated) {
7004 broadcastToRoom(
7005 prId,
7006 {
7007 type: "typing",
7008 sessionId,
7009 username: updated.username,
7010 colour: updated.colour,
7011 line,
7012 typing,
7013 },
7014 sessionId
7015 );
7016 }
7017 return;
7018 }
7019 },
7020
7021 onClose() {
7022 leaveRoom(prId, sessionId);
7023 unregisterSocket(prId, sessionId);
7024 broadcastToRoom(prId, { type: "leave", sessionId });
7025 },
7026 };
7027 })
7028);
7029
0074234Claude7030export default pulls;