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

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.tsxBlame6667 lines · 3 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,
0074234Claude29} from "../db/schema";
30import { Layout } from "../views/layout";
ea9ed4cClaude31import { RepoHeader } from "../views/components";
cb5a796Claude32import { PendingCommentsBanner } from "../views/pending-comments-banner";
47a7a0aClaude33import { DiffView, type InlineDiffComment } from "../views/diff-view";
6fc53bdClaude34import { ReactionsBar } from "../views/reactions";
35import { summariseReactions } from "../lib/reactions";
24cf2caClaude36import { loadPrTemplate } from "../lib/templates";
0074234Claude37import { renderMarkdown } from "../lib/markdown";
15db0e0Claude38import {
39 parseSlashCommand,
40 executeSlashCommand,
41 detectSlashCmdComment,
42 stripSlashCmdMarker,
43} from "../lib/pr-slash-commands";
b584e52Claude44import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude45import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude46import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude47import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
0074234Claude48import { softAuth, requireAuth } from "../middleware/auth";
49import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude50import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude51import {
52 decideInitialStatus,
53 notifyOwnerOfPendingComment,
54 countPendingForRepo,
55} from "../lib/comment-moderation";
0316dbbClaude56import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
79ed944Claude57import {
58 TRIO_COMMENT_MARKER,
59 TRIO_SUMMARY_MARKER,
67dc4e1Claude60 isTrioReviewEnabled,
79ed944Claude61 type TrioPersona,
62} from "../lib/ai-review-trio";
1d4ff60Claude63import {
64 generateTestsForPr,
65 AI_TESTS_MARKER,
66} from "../lib/ai-test-generator";
0316dbbClaude67import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude68import { generatePrSummary } from "../lib/ai-generators";
69import { isAiAvailable } from "../lib/ai-client";
cc34156Claude70import { getReviewContext, recordPrVisit, type ReviewContext } from "../lib/review-context";
534f04aClaude71import {
72 computePrRiskForPullRequest,
73 getCachedPrRisk,
74 type PrRiskScore,
75} from "../lib/pr-risk";
0316dbbClaude76import { runAllGateChecks } from "../lib/gate";
77import type { GateCheckResult } from "../lib/gate";
78import {
79 matchProtection,
80 countHumanApprovals,
81 listRequiredChecks,
82 passingCheckNames,
83 evaluateProtection,
84} from "../lib/branch-protection";
85import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude86import {
87 listBranches,
88 getRepoPath,
e883329Claude89 resolveRef,
b5dd694Claude90 getBlob,
91 createOrUpdateFileOnBranch,
b558f23Claude92 commitsBetween,
0074234Claude93} from "../git/repository";
b558f23Claude94import type { GitDiffFile, GitCommit } from "../git/repository";
240c477Claude95import { listStatuses } from "../lib/commit-statuses";
96import type { CommitStatus } from "../db/schema";
0074234Claude97import { html } from "hono/html";
4bbacbeClaude98import {
99 getPreviewForBranch,
100 previewStatusLabel,
101} from "../lib/branch-previews";
1e162a8Claude102import {
bb0f894Claude103 Flex,
104 Container,
105 Badge,
106 Button,
107 LinkButton,
108 Form,
109 FormGroup,
110 Input,
111 TextArea,
112 Select,
113 EmptyState,
114 FilterTabs,
115 TabNav,
116 List,
117 ListItem,
118 Text,
119 Alert,
120 MarkdownContent,
121 CommentBox,
122 formatRelative,
123} from "../views/ui";
0074234Claude124
ace34efClaude125import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
74d8c4dClaude126import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
a7460bfClaude127import { BOT_USERNAME } from "../lib/bot-user";
09d5f39Claude128import { analyzeImpact, type ImpactAnalysis } from "../lib/pr-impact";
129import { getPreviewDir, isPreviewExpired } from "../lib/pr-stage";
7f992cdClaude130import {
131 getCodeownersForRepo,
132 reviewersForChangedFiles,
133} from "../lib/codeowners";
134import { getPatternWarning, type Pattern } from "../lib/pattern-detector";
135import { suggestPrSplit, type SplitSuggestion } from "../lib/pr-splitter";
136import {
137 joinRoom,
138 leaveRoom,
139 broadcastToRoom,
140 registerSocket,
141 unregisterSocket,
142 getRoomUsers,
143 pingSession,
144 updatePresence,
145} from "../lib/pr-presence";
146import { getBusFactorWarning, type BusFactorFile } from "../lib/bus-factor";
147import { checkMergeEligible } from "../lib/branch-rules";
148import { createBunWebSocket } from "hono/bun";
149
150const { upgradeWebSocket, websocket: presenceWebsocket } = createBunWebSocket();
151export { presenceWebsocket };
ace34efClaude152
0074234Claude153const pulls = new Hono<AuthEnv>();
154
b078860Claude155/* ──────────────────────────────────────────────────────────────────────
156 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
157 * into the issue tracker or any other route. Tokens come from layout.tsx
158 * `:root` so light/dark stays consistent if/when light mode lands.
159 * ──────────────────────────────────────────────────────────────────── */
160const PRS_LIST_STYLES = `
161 .prs-hero {
162 position: relative;
163 margin: 0 0 var(--space-5);
164 padding: 22px 26px 24px;
165 background: var(--bg-elevated);
166 border: 1px solid var(--border);
167 border-radius: 16px;
168 overflow: hidden;
169 }
170 .prs-hero::before {
171 content: '';
172 position: absolute; top: 0; left: 0; right: 0;
173 height: 2px;
6fd5915Claude174 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
b078860Claude175 opacity: 0.7;
176 pointer-events: none;
177 }
178 .prs-hero-inner {
179 position: relative;
180 display: flex;
181 justify-content: space-between;
182 align-items: flex-end;
183 gap: 20px;
184 flex-wrap: wrap;
185 }
186 .prs-hero-text { flex: 1; min-width: 280px; }
187 .prs-hero-eyebrow {
188 font-size: 12px;
189 color: var(--text-muted);
190 text-transform: uppercase;
191 letter-spacing: 0.08em;
192 font-weight: 600;
193 margin-bottom: 8px;
194 }
195 .prs-hero-title {
196 font-family: var(--font-display);
197 font-size: clamp(26px, 3.4vw, 34px);
198 font-weight: 800;
199 letter-spacing: -0.025em;
200 line-height: 1.06;
201 margin: 0 0 8px;
202 color: var(--text-strong);
203 }
204 .prs-hero-title .gradient-text {
6fd5915Claude205 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
b078860Claude206 -webkit-background-clip: text;
207 background-clip: text;
208 -webkit-text-fill-color: transparent;
209 color: transparent;
210 }
211 .prs-hero-sub {
212 font-size: 14.5px;
213 color: var(--text-muted);
214 margin: 0;
215 line-height: 1.5;
216 max-width: 620px;
217 }
218 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
219 .prs-cta {
220 display: inline-flex; align-items: center; gap: 6px;
221 padding: 10px 16px;
222 border-radius: 10px;
223 font-size: 13.5px;
224 font-weight: 600;
225 color: #fff;
6fd5915Claude226 background: linear-gradient(135deg, #5b6ee8 0%, #6f5be8 60%, #5f8fa0 140%);
227 border: 1px solid rgba(91,110,232,0.55);
228 box-shadow: 0 6px 18px -8px rgba(91,110,232,0.55);
b078860Claude229 text-decoration: none;
230 transition: transform 120ms ease, box-shadow 160ms ease;
231 }
232 .prs-cta:hover {
233 transform: translateY(-1px);
6fd5915Claude234 box-shadow: 0 10px 22px -6px rgba(91,110,232,0.6);
b078860Claude235 color: #fff;
236 }
237
238 .prs-tabs {
239 display: flex; flex-wrap: wrap; gap: 6px;
240 margin: 0 0 18px;
241 padding: 6px;
242 background: var(--bg-secondary);
243 border: 1px solid var(--border);
244 border-radius: 12px;
245 }
246 .prs-tab {
247 display: inline-flex; align-items: center; gap: 8px;
248 padding: 7px 13px;
249 font-size: 13px;
250 font-weight: 500;
251 color: var(--text-muted);
252 border-radius: 8px;
253 text-decoration: none;
254 transition: background 120ms ease, color 120ms ease;
255 }
256 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
257 .prs-tab.is-active {
258 background: var(--bg-elevated);
259 color: var(--text-strong);
260 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
261 }
262 .prs-tab-count {
263 display: inline-flex; align-items: center; justify-content: center;
264 min-width: 22px; padding: 2px 7px;
265 font-size: 11.5px;
266 font-weight: 600;
267 border-radius: 9999px;
268 background: var(--bg-tertiary);
269 color: var(--text-muted);
270 }
271 .prs-tab.is-active .prs-tab-count {
6fd5915Claude272 background: rgba(91,110,232,0.18);
b078860Claude273 color: var(--text-link);
274 }
275
276 .prs-list { display: flex; flex-direction: column; gap: 10px; }
277 .prs-row {
278 position: relative;
279 display: flex; align-items: flex-start; gap: 14px;
280 padding: 14px 16px;
281 background: var(--bg-elevated);
282 border: 1px solid var(--border);
283 border-radius: 12px;
284 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
285 }
286 .prs-row:hover {
287 transform: translateY(-1px);
288 border-color: var(--border-strong);
289 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
290 }
291 .prs-row-icon {
292 flex: 0 0 auto;
293 width: 26px; height: 26px;
294 display: inline-flex; align-items: center; justify-content: center;
295 border-radius: 9999px;
296 font-size: 13px;
297 margin-top: 2px;
298 }
299 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
6fd5915Claude300 .prs-row-icon.state-merged { color: #5b6ee8; background: rgba(91,110,232,0.16); }
b078860Claude301 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
302 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
303 .prs-row-body { flex: 1; min-width: 0; }
304 .prs-row-title {
305 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
306 font-size: 15px; font-weight: 600;
307 color: var(--text-strong);
308 line-height: 1.35;
309 margin: 0 0 6px;
310 }
311 .prs-row-number {
312 color: var(--text-muted);
313 font-weight: 400;
314 font-size: 14px;
315 }
316 .prs-row-meta {
317 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
318 font-size: 12.5px;
319 color: var(--text-muted);
320 }
321 .prs-branch-chips {
322 display: inline-flex; align-items: center; gap: 6px;
323 font-family: var(--font-mono);
324 font-size: 11.5px;
325 }
326 .prs-branch-chip {
327 padding: 2px 8px;
328 border-radius: 9999px;
329 background: var(--bg-tertiary);
330 border: 1px solid var(--border);
331 color: var(--text);
332 }
333 .prs-branch-arrow {
334 color: var(--text-faint);
335 font-size: 11px;
336 }
337 .prs-row-tags {
338 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
339 margin-left: auto;
340 }
341 .prs-tag {
342 display: inline-flex; align-items: center; gap: 4px;
343 padding: 2px 8px;
344 font-size: 11px;
345 font-weight: 600;
346 border-radius: 9999px;
347 border: 1px solid var(--border);
348 background: var(--bg-secondary);
349 color: var(--text-muted);
350 line-height: 1.6;
351 }
352 .prs-tag.is-draft {
353 color: var(--text-muted);
354 border-color: var(--border-strong);
355 }
356 .prs-tag.is-merged {
357 color: var(--text-link);
6fd5915Claude358 border-color: rgba(91,110,232,0.45);
359 background: rgba(91,110,232,0.10);
b078860Claude360 }
1aef949Claude361 .prs-tag.is-approved {
362 color: #34d399;
363 border-color: rgba(52,211,153,0.40);
364 background: rgba(52,211,153,0.08);
365 }
366 .prs-tag.is-changes {
367 color: #f87171;
368 border-color: rgba(248,113,113,0.40);
369 background: rgba(248,113,113,0.08);
370 }
b078860Claude371
372 .prs-empty {
ea9ed4cClaude373 position: relative;
374 padding: 56px 32px;
b078860Claude375 text-align: center;
376 border: 1px dashed var(--border);
ea9ed4cClaude377 border-radius: 16px;
378 background: var(--bg-elevated);
b078860Claude379 color: var(--text-muted);
ea9ed4cClaude380 overflow: hidden;
b078860Claude381 }
ea9ed4cClaude382 .prs-empty::before {
383 content: '';
384 position: absolute;
385 inset: -40% -20% auto auto;
386 width: 320px; height: 320px;
6fd5915Claude387 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.08) 50%, transparent 75%);
ea9ed4cClaude388 filter: blur(70px);
389 opacity: 0.55;
390 pointer-events: none;
391 animation: prsEmptyOrb 16s ease-in-out infinite;
392 }
393 @keyframes prsEmptyOrb {
394 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
395 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
396 }
397 @media (prefers-reduced-motion: reduce) {
398 .prs-empty::before { animation: none; }
399 }
400 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude401 .prs-empty strong {
402 display: block;
403 color: var(--text-strong);
ea9ed4cClaude404 font-family: var(--font-display);
405 font-size: 22px;
406 font-weight: 700;
407 letter-spacing: -0.018em;
408 margin-bottom: 2px;
409 }
410 .prs-empty-sub {
411 font-size: 14.5px;
412 color: var(--text-muted);
413 line-height: 1.55;
414 max-width: 460px;
415 margin: 0 0 18px;
b078860Claude416 }
ea9ed4cClaude417 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude418
419 @media (max-width: 720px) {
420 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
421 .prs-hero-actions { width: 100%; }
422 .prs-row-tags { margin-left: 0; }
423 }
f1dc7c7Claude424
425 /* Additional mobile rules. Additive only. */
426 @media (max-width: 720px) {
427 .prs-hero { padding: 18px 18px 20px; }
428 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
429 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
430 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
431 .prs-row { padding: 12px 14px; gap: 10px; }
432 .prs-row-icon { width: 24px; height: 24px; }
433 }
f5b9ef5Claude434
435 /* ─── Sort controls (PR list) ─── */
436 .prs-sort-row {
437 display: flex;
438 align-items: center;
439 gap: 6px;
440 margin: 0 0 12px;
441 flex-wrap: wrap;
442 }
443 .prs-sort-label {
444 font-size: 12.5px;
445 color: var(--text-muted);
446 font-weight: 600;
447 margin-right: 2px;
448 }
449 .prs-sort-opt {
450 font-size: 12.5px;
451 color: var(--text-muted);
452 text-decoration: none;
453 padding: 3px 10px;
454 border-radius: 9999px;
455 border: 1px solid transparent;
456 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
457 }
458 .prs-sort-opt:hover {
459 background: var(--bg-hover);
460 color: var(--text);
461 }
462 .prs-sort-opt.is-active {
6fd5915Claude463 background: rgba(91,110,232,0.12);
f5b9ef5Claude464 color: var(--text-link);
6fd5915Claude465 border-color: rgba(91,110,232,0.35);
f5b9ef5Claude466 font-weight: 600;
467 }
b078860Claude468`;
469
470/* ──────────────────────────────────────────────────────────────────────
471 * Inline CSS for the detail page. Same `.prs-*` namespace.
472 * ──────────────────────────────────────────────────────────────────── */
473const PRS_DETAIL_STYLES = `
474 .prs-detail-hero {
475 position: relative;
476 margin: 0 0 var(--space-4);
477 padding: 24px 26px;
478 background: var(--bg-elevated);
479 border: 1px solid var(--border);
480 border-radius: 16px;
481 overflow: hidden;
482 }
483 .prs-detail-hero::before {
484 content: '';
485 position: absolute; top: 0; left: 0; right: 0;
486 height: 2px;
6fd5915Claude487 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
b078860Claude488 opacity: 0.7;
489 pointer-events: none;
490 }
491 .prs-detail-title {
492 font-family: var(--font-display);
493 font-size: clamp(22px, 2.6vw, 28px);
494 font-weight: 700;
495 letter-spacing: -0.022em;
496 line-height: 1.2;
497 color: var(--text-strong);
498 margin: 0 0 12px;
499 }
500 .prs-detail-num {
501 color: var(--text-muted);
502 font-weight: 400;
503 }
504 .prs-state-pill {
505 display: inline-flex; align-items: center; gap: 6px;
506 padding: 6px 12px;
507 border-radius: 9999px;
508 font-size: 12.5px;
509 font-weight: 600;
510 line-height: 1;
511 border: 1px solid transparent;
512 }
513 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
6fd5915Claude514 .prs-state-pill.state-merged { color: #5b6ee8; background: rgba(91,110,232,0.16); border-color: rgba(91,110,232,0.45); }
b078860Claude515 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
516 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
517
74d8c4dClaude518 .prs-size-badge {
519 display: inline-flex;
520 align-items: center;
521 padding: 2px 8px;
522 border-radius: 20px;
523 font-size: 11px;
524 font-weight: 700;
525 letter-spacing: 0.04em;
526 border: 1px solid currentColor;
527 opacity: 0.85;
528 }
529
b078860Claude530 .prs-detail-meta {
531 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
532 font-size: 13px;
533 color: var(--text-muted);
534 }
535 .prs-detail-meta strong { color: var(--text); }
536 .prs-detail-branches {
537 display: inline-flex; align-items: center; gap: 6px;
538 font-family: var(--font-mono);
539 font-size: 12px;
540 }
541 .prs-branch-pill {
542 padding: 3px 9px;
543 border-radius: 9999px;
544 background: var(--bg-tertiary);
545 border: 1px solid var(--border);
546 color: var(--text);
547 }
548 .prs-branch-pill.is-head { color: var(--text-strong); }
549 .prs-branch-arrow-lg {
550 color: var(--accent);
551 font-size: 14px;
552 font-weight: 700;
553 }
0369e77Claude554 .prs-branch-sync {
555 display: inline-flex; align-items: center; gap: 4px;
556 font-size: 11.5px; font-weight: 600;
557 padding: 2px 8px;
558 border-radius: 9999px;
559 border: 1px solid var(--border);
560 background: var(--bg-secondary);
561 color: var(--text-muted);
562 cursor: default;
563 }
564 .prs-branch-sync.is-behind {
565 color: #f87171;
566 border-color: rgba(248,113,113,0.35);
567 background: rgba(248,113,113,0.07);
568 }
569 .prs-branch-sync.is-synced {
570 color: #34d399;
571 border-color: rgba(52,211,153,0.35);
572 background: rgba(52,211,153,0.07);
573 }
b078860Claude574
575 .prs-detail-actions {
576 display: inline-flex; gap: 8px; margin-left: auto;
577 }
578
579 .prs-detail-tabs {
580 display: flex; gap: 4px;
581 margin: 0 0 16px;
582 border-bottom: 1px solid var(--border);
583 }
584 .prs-detail-tab {
585 padding: 10px 14px;
586 font-size: 13.5px;
587 font-weight: 500;
588 color: var(--text-muted);
589 text-decoration: none;
590 border-bottom: 2px solid transparent;
591 transition: color 120ms ease, border-color 120ms ease;
592 margin-bottom: -1px;
593 }
594 .prs-detail-tab:hover { color: var(--text); }
595 .prs-detail-tab.is-active {
596 color: var(--text-strong);
597 border-bottom-color: var(--accent);
598 }
599 .prs-detail-tab-count {
600 display: inline-flex; align-items: center; justify-content: center;
601 min-width: 20px; padding: 0 6px; margin-left: 6px;
602 height: 18px;
603 font-size: 11px;
604 font-weight: 600;
605 border-radius: 9999px;
606 background: var(--bg-tertiary);
607 color: var(--text-muted);
608 }
609
610 /* Gate / check status section */
611 .prs-gate-card {
612 margin-top: 20px;
613 background: var(--bg-elevated);
614 border: 1px solid var(--border);
615 border-radius: 14px;
616 overflow: hidden;
617 }
618 .prs-gate-head {
619 display: flex; align-items: center; gap: 10px;
620 padding: 14px 18px;
621 border-bottom: 1px solid var(--border);
622 }
623 .prs-gate-head h3 {
624 margin: 0;
625 font-size: 14px;
626 font-weight: 600;
627 color: var(--text-strong);
628 }
629 .prs-gate-summary {
630 margin-left: auto;
631 font-size: 12px;
632 color: var(--text-muted);
633 }
634 .prs-gate-row {
635 display: flex; align-items: center; gap: 12px;
636 padding: 12px 18px;
637 border-bottom: 1px solid var(--border-subtle);
638 }
639 .prs-gate-row:last-child { border-bottom: 0; }
640 .prs-gate-icon {
641 flex: 0 0 auto;
642 width: 22px; height: 22px;
643 display: inline-flex; align-items: center; justify-content: center;
644 border-radius: 9999px;
645 font-size: 12px;
646 font-weight: 700;
647 }
648 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
649 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
650 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
651 .prs-gate-name {
652 font-size: 13px;
653 font-weight: 600;
654 color: var(--text);
655 min-width: 140px;
656 }
657 .prs-gate-details {
658 flex: 1; min-width: 0;
659 font-size: 12.5px;
660 color: var(--text-muted);
661 }
662 .prs-gate-pill {
663 flex: 0 0 auto;
664 padding: 3px 10px;
665 border-radius: 9999px;
666 font-size: 11px;
667 font-weight: 600;
668 line-height: 1.5;
669 border: 1px solid transparent;
670 }
671 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
672 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
673 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
674 .prs-gate-footer {
675 padding: 12px 18px;
676 background: var(--bg-secondary);
677 font-size: 12px;
678 color: var(--text-muted);
679 }
680
681 /* Comment cards */
682 .prs-comment {
683 margin-top: 14px;
684 background: var(--bg-elevated);
685 border: 1px solid var(--border);
686 border-radius: 12px;
687 overflow: hidden;
688 }
689 .prs-comment-head {
690 display: flex; align-items: center; gap: 10px;
691 padding: 10px 14px;
692 background: var(--bg-secondary);
693 border-bottom: 1px solid var(--border);
694 font-size: 13px;
695 flex-wrap: wrap;
696 }
697 .prs-comment-head strong { color: var(--text-strong); }
698 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
699 .prs-comment-loc {
700 font-family: var(--font-mono);
701 font-size: 11.5px;
702 color: var(--text-muted);
703 background: var(--bg-tertiary);
704 padding: 2px 8px;
705 border-radius: 6px;
706 }
707 .prs-comment-body { padding: 14px 18px; }
708 .prs-comment.is-ai {
6fd5915Claude709 border-color: rgba(91,110,232,0.45);
710 box-shadow: 0 0 0 1px rgba(91,110,232,0.10), 0 6px 24px -10px rgba(91,110,232,0.30);
b078860Claude711 }
712 .prs-comment.is-ai .prs-comment-head {
6fd5915Claude713 background: linear-gradient(90deg, rgba(91,110,232,0.10), rgba(95,143,160,0.06));
714 border-bottom-color: rgba(91,110,232,0.30);
b078860Claude715 }
716 .prs-ai-badge {
717 display: inline-flex; align-items: center; gap: 4px;
718 padding: 2px 9px;
719 font-size: 10.5px;
720 font-weight: 700;
721 letter-spacing: 0.04em;
722 text-transform: uppercase;
723 color: #fff;
6fd5915Claude724 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 130%);
b078860Claude725 border-radius: 9999px;
726 }
a7460bfClaude727 .prs-bot-badge {
728 display: inline-flex; align-items: center; gap: 3px;
729 padding: 1px 7px;
730 font-size: 10px;
731 font-weight: 600;
732 color: var(--fg-muted);
733 background: var(--bg-elevated);
734 border: 1px solid var(--border);
735 border-radius: 9999px;
736 }
b078860Claude737
738 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
739 .prs-files-card {
740 margin-top: 18px;
741 padding: 14px 18px;
742 display: flex; align-items: center; gap: 14px;
743 background: var(--bg-elevated);
744 border: 1px solid var(--border);
745 border-radius: 12px;
746 text-decoration: none;
747 color: inherit;
748 transition: border-color 120ms ease, transform 140ms ease;
749 }
750 .prs-files-card:hover {
6fd5915Claude751 border-color: rgba(91,110,232,0.45);
b078860Claude752 transform: translateY(-1px);
753 }
754 .prs-files-card-icon {
755 width: 36px; height: 36px;
756 display: inline-flex; align-items: center; justify-content: center;
757 border-radius: 10px;
6fd5915Claude758 background: rgba(91,110,232,0.12);
b078860Claude759 color: var(--text-link);
760 font-size: 18px;
761 }
762 .prs-files-card-text { flex: 1; min-width: 0; }
763 .prs-files-card-title {
764 font-size: 14px;
765 font-weight: 600;
766 color: var(--text-strong);
767 margin: 0 0 2px;
768 }
769 .prs-files-card-sub {
770 font-size: 12.5px;
771 color: var(--text-muted);
772 margin: 0;
773 }
774 .prs-files-card-cta {
775 font-size: 12.5px;
776 color: var(--text-link);
777 font-weight: 600;
778 }
779
780 /* Merge area */
781 .prs-merge-card {
782 position: relative;
783 margin-top: 22px;
784 padding: 18px;
785 background: var(--bg-elevated);
786 border-radius: 14px;
787 overflow: hidden;
788 }
789 .prs-merge-card::before {
790 content: '';
791 position: absolute; inset: 0;
792 padding: 1px;
793 border-radius: 14px;
6fd5915Claude794 background: linear-gradient(135deg, rgba(91,110,232,0.55) 0%, rgba(95,143,160,0.40) 100%);
b078860Claude795 -webkit-mask:
796 linear-gradient(#000 0 0) content-box,
797 linear-gradient(#000 0 0);
798 -webkit-mask-composite: xor;
799 mask-composite: exclude;
800 pointer-events: none;
801 }
802 .prs-merge-card.is-closed::before { background: var(--border-strong); }
6fd5915Claude803 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(91,110,232,0.45), rgba(95,143,160,0.30)); }
b078860Claude804 .prs-merge-head {
805 display: flex; align-items: center; gap: 12px;
806 margin-bottom: 12px;
807 }
808 .prs-merge-head strong {
809 font-family: var(--font-display);
810 font-size: 15px;
811 color: var(--text-strong);
812 font-weight: 700;
813 }
814 .prs-merge-sub {
815 font-size: 13px;
816 color: var(--text-muted);
817 margin: 0 0 12px;
818 }
819 .prs-merge-actions {
820 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
821 }
822 .prs-merge-btn {
823 display: inline-flex; align-items: center; gap: 6px;
824 padding: 9px 16px;
825 border-radius: 10px;
826 font-size: 13.5px;
827 font-weight: 600;
828 color: #fff;
6fd5915Claude829 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #5f8fa0 140%);
b078860Claude830 border: 1px solid rgba(52,211,153,0.55);
831 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
832 cursor: pointer;
833 transition: transform 120ms ease, box-shadow 160ms ease;
834 }
835 .prs-merge-btn:hover {
836 transform: translateY(-1px);
837 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
838 }
839 .prs-merge-btn[disabled],
840 .prs-merge-btn.is-disabled {
841 opacity: 0.55;
842 cursor: not-allowed;
843 transform: none;
844 box-shadow: none;
845 }
846 .prs-merge-ready-btn {
847 display: inline-flex; align-items: center; gap: 6px;
848 padding: 9px 16px;
849 border-radius: 10px;
850 font-size: 13.5px;
851 font-weight: 600;
852 color: #fff;
6fd5915Claude853 background: linear-gradient(135deg, #5b6ee8 0%, #6f5be8 60%, #5f8fa0 140%);
854 border: 1px solid rgba(91,110,232,0.55);
855 box-shadow: 0 6px 18px -8px rgba(91,110,232,0.55);
b078860Claude856 cursor: pointer;
857 transition: transform 120ms ease, box-shadow 160ms ease;
858 }
859 .prs-merge-ready-btn:hover {
860 transform: translateY(-1px);
6fd5915Claude861 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55);
b078860Claude862 }
863 .prs-merge-back-draft {
864 background: none; border: 1px solid var(--border-strong);
865 color: var(--text-muted);
866 padding: 9px 14px; border-radius: 10px;
867 font-size: 13px; cursor: pointer;
868 }
869 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
870
a164a6dClaude871 /* Merge strategy selector */
872 .prs-merge-strategy-wrap {
873 display: inline-flex; align-items: center;
874 background: var(--bg-elevated);
875 border: 1px solid var(--border);
876 border-radius: 10px;
877 overflow: hidden;
878 }
879 .prs-merge-strategy-label {
880 font-size: 11.5px; font-weight: 600;
881 color: var(--text-muted);
882 padding: 0 10px 0 12px;
883 white-space: nowrap;
884 }
885 .prs-merge-strategy-select {
886 background: transparent;
887 border: none;
888 color: var(--text);
889 font-size: 13px;
890 padding: 7px 10px 7px 4px;
891 cursor: pointer;
892 outline: none;
893 appearance: auto;
894 }
6fd5915Claude895 .prs-merge-strategy-select:focus { outline: 2px solid rgba(91,110,232,0.45); }
a164a6dClaude896
0a67773Claude897 /* Review summary banner */
898 .prs-review-summary {
899 display: flex; flex-direction: column; gap: 6px;
900 padding: 12px 16px;
901 background: var(--bg-elevated);
902 border: 1px solid var(--border);
903 border-radius: var(--r-md, 8px);
904 margin-bottom: 12px;
905 }
906 .prs-review-row {
907 display: flex; align-items: center; gap: 10px;
908 font-size: 13px;
909 }
910 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
911 .prs-review-approved .prs-review-icon { color: #34d399; }
912 .prs-review-changes .prs-review-icon { color: #f87171; }
ace34efClaude913 .prs-reviewer-avatar {
914 width: 24px; height: 24px; border-radius: 50%;
915 background: var(--accent); color: #fff;
916 display: flex; align-items: center; justify-content: center;
917 font-size: 11px; font-weight: 700; flex-shrink: 0;
918 }
0a67773Claude919
920 /* Review action buttons */
921 .prs-review-approve-btn {
922 display: inline-flex; align-items: center; gap: 5px;
923 padding: 8px 14px; border-radius: 8px; font-size: 13px;
924 font-weight: 600; cursor: pointer;
925 background: rgba(52,211,153,0.12);
926 color: #34d399;
927 border: 1px solid rgba(52,211,153,0.35);
928 transition: background 120ms;
929 }
930 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
931 .prs-review-changes-btn {
932 display: inline-flex; align-items: center; gap: 5px;
933 padding: 8px 14px; border-radius: 8px; font-size: 13px;
934 font-weight: 600; cursor: pointer;
935 background: rgba(248,113,113,0.10);
936 color: #f87171;
937 border: 1px solid rgba(248,113,113,0.30);
938 transition: background 120ms;
939 }
940 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
941
b078860Claude942 /* Inline form helpers */
943 .prs-inline-form { display: inline-flex; }
944
945 /* Comment composer */
946 .prs-composer { margin-top: 22px; }
947 .prs-composer textarea {
948 border-radius: 12px;
949 }
950
951 @media (max-width: 720px) {
952 .prs-detail-actions { margin-left: 0; }
953 .prs-merge-actions { width: 100%; }
954 .prs-merge-actions > * { flex: 1; min-width: 0; }
955 }
f1dc7c7Claude956
957 /* Additional mobile rules. Additive only. */
958 @media (max-width: 720px) {
959 .prs-detail-hero { padding: 18px; }
960 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
961 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
962 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
963 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
964 .prs-gate-name { min-width: 0; }
965 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
966 .prs-gate-summary { margin-left: 0; }
967 .prs-merge-btn,
968 .prs-merge-ready-btn,
969 .prs-merge-back-draft { min-height: 44px; }
970 .prs-comment-body { padding: 12px 14px; }
971 .prs-comment-head { padding: 10px 12px; }
972 .prs-files-card { padding: 12px 14px; }
973 }
3c03977Claude974
975 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
976 .live-pill {
977 display: inline-flex;
978 align-items: center;
979 gap: 8px;
980 padding: 4px 10px 4px 8px;
981 margin-left: 6px;
982 background: var(--bg-elevated);
983 border: 1px solid var(--border);
984 border-radius: 9999px;
985 font-size: 12px;
986 color: var(--text-muted);
987 line-height: 1;
988 vertical-align: middle;
989 }
990 .live-pill.is-busy { color: var(--text); }
991 .live-pill-dot {
992 width: 8px; height: 8px;
993 border-radius: 9999px;
994 background: #34d399;
995 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
996 animation: live-pulse 1.6s ease-in-out infinite;
997 }
998 @keyframes live-pulse {
999 0%, 100% { opacity: 1; }
1000 50% { opacity: 0.55; }
1001 }
1002 .live-avatars {
1003 display: inline-flex;
1004 margin-left: 2px;
1005 }
1006 .live-avatar {
1007 display: inline-flex;
1008 align-items: center;
1009 justify-content: center;
1010 width: 22px; height: 22px;
1011 border-radius: 9999px;
1012 font-size: 10px;
1013 font-weight: 700;
1014 color: #0b1020;
1015 margin-left: -6px;
1016 border: 2px solid var(--bg-elevated);
1017 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
1018 }
1019 .live-avatar:first-child { margin-left: 0; }
1020 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
1021 .live-cursor-host {
1022 position: relative;
1023 }
1024 .live-cursor-overlay {
1025 position: absolute;
1026 inset: 0;
1027 pointer-events: none;
1028 overflow: hidden;
1029 border-radius: inherit;
1030 }
1031 .live-cursor {
1032 position: absolute;
1033 width: 2px;
1034 height: 18px;
1035 border-radius: 2px;
1036 transform: translate(-1px, 0);
1037 transition: transform 80ms linear, opacity 200ms ease;
1038 }
1039 .live-cursor::after {
1040 content: attr(data-label);
1041 position: absolute;
1042 top: -16px;
1043 left: -2px;
1044 font-size: 10px;
1045 line-height: 1;
1046 color: #0b1020;
1047 background: inherit;
1048 padding: 2px 5px;
1049 border-radius: 4px 4px 4px 0;
1050 white-space: nowrap;
1051 font-weight: 600;
1052 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
1053 }
1054 .live-cursor.is-idle { opacity: 0.4; }
1055 .live-edit-tag {
1056 display: inline-block;
1057 margin-left: 6px;
1058 padding: 1px 6px;
1059 font-size: 10px;
1060 font-weight: 600;
1061 letter-spacing: 0.02em;
1062 color: #0b1020;
1063 border-radius: 9999px;
1064 }
15db0e0Claude1065
1066 /* ─── Slash-command pill + composer hint ─── */
1067 .slash-hint {
1068 display: inline-flex;
1069 align-items: center;
1070 gap: 6px;
1071 margin-top: 6px;
1072 padding: 3px 9px;
1073 font-size: 11.5px;
1074 color: var(--text-muted);
1075 background: var(--bg-elevated);
1076 border: 1px dashed var(--border);
1077 border-radius: 9999px;
1078 width: fit-content;
1079 }
1080 .slash-hint code {
1081 background: rgba(110, 168, 255, 0.12);
1082 color: var(--text-strong);
1083 padding: 0 5px;
1084 border-radius: 4px;
1085 font-size: 11px;
1086 }
1087 .slash-pill {
1088 display: grid;
1089 grid-template-columns: auto 1fr auto;
1090 align-items: center;
1091 column-gap: 10px;
1092 row-gap: 6px;
1093 margin: 10px 0;
1094 padding: 10px 14px;
1095 background: linear-gradient(
1096 135deg,
1097 rgba(110, 168, 255, 0.08),
1098 rgba(163, 113, 247, 0.06)
1099 );
1100 border: 1px solid rgba(110, 168, 255, 0.32);
1101 border-left: 3px solid var(--accent, #6ea8ff);
1102 border-radius: var(--radius);
1103 font-size: 13px;
1104 color: var(--text);
1105 }
1106 .slash-pill-icon {
1107 font-size: 14px;
1108 line-height: 1;
1109 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
1110 }
1111 .slash-pill-actor { color: var(--text-muted); }
1112 .slash-pill-actor strong { color: var(--text-strong); }
1113 .slash-pill-cmd {
1114 background: rgba(110, 168, 255, 0.16);
1115 color: var(--text-strong);
1116 padding: 1px 6px;
1117 border-radius: 4px;
1118 font-size: 12.5px;
1119 }
1120 .slash-pill-time {
1121 color: var(--text-muted);
1122 font-size: 12px;
1123 justify-self: end;
1124 }
1125 .slash-pill-body {
1126 grid-column: 1 / -1;
1127 color: var(--text);
1128 font-size: 13px;
1129 line-height: 1.55;
1130 }
1131 .slash-pill-body p:first-child { margin-top: 0; }
1132 .slash-pill-body p:last-child { margin-bottom: 0; }
1133 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
1134 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1135 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
1136 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
6fd5915Claude1137 .slash-pill.slash-cmd-stage { border-left-color: #5f8fa0; }
4bbacbeClaude1138
1139 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
1140 .preview-prpill {
1141 display: inline-flex; align-items: center; gap: 6px;
1142 padding: 3px 10px;
1143 border-radius: 9999px;
1144 font-family: var(--font-mono);
1145 font-size: 11.5px;
1146 font-weight: 600;
1147 background: rgba(255,255,255,0.04);
1148 color: var(--text-muted);
1149 text-decoration: none;
1150 border: 1px solid var(--border);
1151 }
6fd5915Claude1152 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(91,110,232,0.45); }
4bbacbeClaude1153 .preview-prpill .preview-prpill-dot {
1154 width: 7px; height: 7px;
1155 border-radius: 9999px;
1156 background: currentColor;
1157 }
1158 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1159 .preview-prpill.is-building .preview-prpill-dot {
1160 animation: previewPrPulse 1.4s ease-in-out infinite;
1161 }
1162 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1163 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1164 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1165 @keyframes previewPrPulse {
1166 0%, 100% { opacity: 1; }
1167 50% { opacity: 0.4; }
1168 }
79ed944Claude1169
1170 /* ─── AI Trio Review — 3-column verdict cards ─── */
1171 .trio-wrap {
1172 margin-top: 18px;
1173 padding: 16px;
1174 background: var(--bg-elevated);
1175 border: 1px solid var(--border);
1176 border-radius: 14px;
1177 }
1178 .trio-header {
1179 display: flex; align-items: center; gap: 10px;
1180 margin: 0 0 12px;
1181 font-size: 13.5px;
1182 color: var(--text);
1183 }
1184 .trio-header strong { color: var(--text-strong); }
1185 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1186 .trio-header-dot {
1187 width: 8px; height: 8px; border-radius: 9999px;
6fd5915Claude1188 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
1189 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
79ed944Claude1190 }
1191 .trio-grid {
1192 display: grid;
1193 grid-template-columns: repeat(3, minmax(0, 1fr));
1194 gap: 12px;
1195 }
1196 .trio-card {
1197 background: var(--bg-secondary);
1198 border: 1px solid var(--border);
1199 border-radius: 12px;
1200 overflow: hidden;
1201 display: flex; flex-direction: column;
1202 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1203 }
1204 .trio-card-head {
1205 display: flex; align-items: center; gap: 8px;
1206 padding: 10px 12px;
1207 border-bottom: 1px solid var(--border);
1208 background: rgba(255,255,255,0.02);
1209 font-size: 13px;
1210 }
1211 .trio-card-icon {
1212 display: inline-flex; align-items: center; justify-content: center;
1213 width: 22px; height: 22px;
1214 border-radius: 9999px;
1215 font-size: 12px;
1216 background: rgba(255,255,255,0.05);
1217 }
1218 .trio-card-title {
1219 color: var(--text-strong);
1220 font-weight: 600;
1221 letter-spacing: 0.01em;
1222 }
1223 .trio-card-verdict {
1224 margin-left: auto;
1225 font-size: 11px;
1226 font-weight: 700;
1227 letter-spacing: 0.06em;
1228 text-transform: uppercase;
1229 padding: 3px 9px;
1230 border-radius: 9999px;
1231 background: var(--bg-tertiary);
1232 color: var(--text-muted);
1233 border: 1px solid var(--border-strong);
1234 }
1235 .trio-card-body {
1236 padding: 12px 14px;
1237 font-size: 13px;
1238 color: var(--text);
1239 flex: 1;
1240 min-height: 64px;
1241 line-height: 1.55;
1242 }
1243 .trio-card-body p { margin: 0 0 8px; }
1244 .trio-card-body p:last-child { margin-bottom: 0; }
1245 .trio-card-body ul { margin: 0; padding-left: 18px; }
1246 .trio-card-body code {
1247 font-family: var(--font-mono);
1248 font-size: 12px;
1249 background: var(--bg-tertiary);
1250 padding: 1px 6px;
1251 border-radius: 5px;
1252 }
1253 .trio-card-empty {
1254 color: var(--text-muted);
1255 font-style: italic;
1256 font-size: 12.5px;
1257 }
1258
1259 /* Pass state — neutral, no accent. */
1260 .trio-card.is-pass .trio-card-verdict {
1261 color: var(--green);
1262 border-color: rgba(52,211,153,0.35);
1263 background: rgba(52,211,153,0.12);
1264 }
1265
1266 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1267 .trio-card.trio-security.is-fail {
1268 border-color: rgba(248,113,113,0.55);
1269 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1270 }
1271 .trio-card.trio-security.is-fail .trio-card-head {
1272 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1273 border-bottom-color: rgba(248,113,113,0.30);
1274 }
1275 .trio-card.trio-security.is-fail .trio-card-verdict {
1276 color: #fecaca;
1277 border-color: rgba(248,113,113,0.55);
1278 background: rgba(248,113,113,0.20);
1279 }
1280
1281 .trio-card.trio-correctness.is-fail {
1282 border-color: rgba(251,191,36,0.55);
1283 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1284 }
1285 .trio-card.trio-correctness.is-fail .trio-card-head {
1286 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1287 border-bottom-color: rgba(251,191,36,0.30);
1288 }
1289 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1290 color: #fde68a;
1291 border-color: rgba(251,191,36,0.55);
1292 background: rgba(251,191,36,0.20);
1293 }
1294
1295 .trio-card.trio-style.is-fail {
1296 border-color: rgba(96,165,250,0.55);
1297 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1298 }
1299 .trio-card.trio-style.is-fail .trio-card-head {
1300 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1301 border-bottom-color: rgba(96,165,250,0.30);
1302 }
1303 .trio-card.trio-style.is-fail .trio-card-verdict {
1304 color: #bfdbfe;
1305 border-color: rgba(96,165,250,0.55);
1306 background: rgba(96,165,250,0.20);
1307 }
1308
1309 /* Disagreement callout strip — yellow, prominent. */
1310 .trio-disagreement-strip {
1311 display: flex;
1312 gap: 12px;
1313 margin-top: 14px;
1314 padding: 12px 14px;
1315 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1316 border: 1px solid rgba(251,191,36,0.45);
1317 border-radius: 10px;
1318 color: var(--text);
1319 font-size: 13px;
1320 }
1321 .trio-disagreement-icon {
1322 flex: 0 0 auto;
1323 width: 26px; height: 26px;
1324 display: inline-flex; align-items: center; justify-content: center;
1325 border-radius: 9999px;
1326 background: rgba(251,191,36,0.25);
1327 color: #fde68a;
1328 font-size: 14px;
1329 }
1330 .trio-disagreement-body strong {
1331 display: block;
1332 color: #fde68a;
1333 margin: 0 0 4px;
1334 font-weight: 700;
1335 }
1336 .trio-disagreement-list {
1337 margin: 0;
1338 padding-left: 18px;
1339 color: var(--text);
1340 font-size: 12.5px;
1341 line-height: 1.55;
1342 }
1343 .trio-disagreement-list code {
1344 font-family: var(--font-mono);
1345 font-size: 11.5px;
1346 background: var(--bg-tertiary);
1347 padding: 1px 5px;
1348 border-radius: 4px;
1349 }
1350
1351 @media (max-width: 720px) {
1352 .trio-grid { grid-template-columns: 1fr; }
1353 .trio-wrap { padding: 12px; }
1354 }
6d1bbc2Claude1355
1356 /* ─── Task list progress pill ─── */
1357 .prs-tasks-pill {
1358 display: inline-flex; align-items: center; gap: 5px;
1359 font-size: 11.5px; font-weight: 600;
1360 padding: 2px 9px; border-radius: 9999px;
1361 border: 1px solid var(--border);
1362 background: var(--bg-elevated);
1363 color: var(--text-muted);
1364 }
1365 .prs-tasks-pill.is-complete {
1366 color: #34d399;
1367 border-color: rgba(52,211,153,0.40);
1368 background: rgba(52,211,153,0.08);
1369 }
1370 .prs-tasks-progress { display: inline-block; width: 36px; height: 4px; border-radius: 9999px; background: var(--border); overflow: hidden; vertical-align: middle; }
1371 .prs-tasks-progress-bar { height: 100%; border-radius: 9999px; background: #34d399; }
1372
1373 /* ─── Update branch button ─── */
1374 .prs-update-branch-btn {
1375 display: inline-flex; align-items: center; gap: 5px;
1376 padding: 4px 12px; border-radius: 8px; font-size: 12.5px;
1377 font-weight: 600; cursor: pointer;
1378 background: rgba(96,165,250,0.10);
1379 color: #60a5fa;
1380 border: 1px solid rgba(96,165,250,0.30);
1381 transition: background 120ms;
1382 }
1383 .prs-update-branch-btn:hover { background: rgba(96,165,250,0.20); }
1384
1385 /* ─── Linked issues panel ─── */
1386 .prs-linked-issues {
1387 margin-top: 16px;
1388 border: 1px solid var(--border);
1389 border-radius: 12px;
1390 overflow: hidden;
1391 }
1392 .prs-linked-issues-head {
1393 display: flex; align-items: center; justify-content: space-between;
1394 padding: 10px 16px;
1395 background: var(--bg-elevated);
1396 border-bottom: 1px solid var(--border);
1397 font-size: 13px; font-weight: 600; color: var(--text);
1398 }
1399 .prs-linked-issues-count {
1400 font-size: 11px; font-weight: 700;
1401 padding: 1px 7px; border-radius: 9999px;
1402 background: var(--bg-tertiary);
1403 color: var(--text-muted);
1404 }
1405 .prs-linked-issue-row {
1406 display: flex; align-items: center; gap: 10px;
1407 padding: 9px 16px;
1408 border-bottom: 1px solid var(--border);
1409 font-size: 13px;
1410 text-decoration: none; color: inherit;
1411 }
1412 .prs-linked-issue-row:last-child { border-bottom: none; }
1413 .prs-linked-issue-row:hover { background: var(--bg-hover); }
1414 .prs-linked-issue-icon { flex: 0 0 auto; font-size: 14px; }
1415 .prs-linked-issue-icon.is-open { color: #34d399; }
1416 .prs-linked-issue-icon.is-closed { color: #8b949e; }
1417 .prs-linked-issue-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1418 .prs-linked-issue-num { color: var(--text-muted); font-size: 12px; }
1419 .prs-linked-issue-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
1420 .prs-linked-issue-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
1421 .prs-linked-issue-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
b558f23Claude1422
1423 /* ─── Commits tab ─── */
1424 .prs-commits-list { display: flex; flex-direction: column; gap: 0; margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1425 .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; }
1426 .prs-commit-row:last-child { border-bottom: none; }
1427 .prs-commit-row:hover { background: var(--bg-hover); }
1428 .prs-commit-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-top: 6px; }
1429 .prs-commit-body { flex: 1 1 auto; min-width: 0; }
1430 .prs-commit-msg { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
1431 .prs-commit-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
1432 .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; }
1433 .prs-commit-sha:hover { color: var(--accent); }
1434 .prs-commits-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13.5px; }
1435
1436 /* ─── Edit PR title/body ─── */
1437 .prs-edit-title-wrap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1438 .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; }
1439 .prs-edit-btn:hover { color: var(--text); border-color: var(--text-muted); }
1440 .prs-edit-form { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
1441 .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; }
1442 .prs-edit-actions { display: flex; gap: 8px; }
1443 .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; }
1444 .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; }
240c477Claude1445
1446 /* ─── CI status checks ─── */
1447 .prs-ci-card { margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1448 .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); }
1449 .prs-ci-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text); }
1450 .prs-ci-summary { font-size: 12px; color: var(--text-muted); }
1451 .prs-ci-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
1452 .prs-ci-row:last-child { border-bottom: none; }
1453 .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; }
1454 .prs-ci-icon.is-success { background: rgba(52,211,153,0.20); color: #34d399; }
1455 .prs-ci-icon.is-pending { background: rgba(251,191,36,0.20); color: #fbbf24; }
1456 .prs-ci-icon.is-failure, .prs-ci-icon.is-error { background: rgba(248,113,113,0.20); color: #f87171; }
1457 .prs-ci-context { flex: 1 1 auto; font-size: 13px; font-weight: 500; color: var(--text); }
1458 .prs-ci-desc { font-size: 12px; color: var(--text-muted); }
1459 .prs-ci-pill { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 9999px; }
1460 .prs-ci-pill.is-success { color: #34d399; background: rgba(52,211,153,0.10); }
1461 .prs-ci-pill.is-pending { color: #fbbf24; background: rgba(251,191,36,0.10); }
1462 .prs-ci-pill.is-failure, .prs-ci-pill.is-error { color: #f87171; background: rgba(248,113,113,0.10); }
1463 .prs-ci-link { font-size: 12px; color: var(--accent); text-decoration: none; }
1464 .prs-ci-link:hover { text-decoration: underline; }
67dc4e1Claude1465
1466 /* ─── AI Trio verdict pills (header summary) ─── */
1467 .trio-pill {
1468 display: inline-flex; align-items: center; gap: 4px;
1469 padding: 2px 8px;
1470 font-size: 11px;
1471 font-weight: 700;
1472 border-radius: 9999px;
1473 border: 1px solid transparent;
1474 text-decoration: none;
1475 line-height: 1.6;
1476 letter-spacing: 0.01em;
1477 cursor: pointer;
1478 transition: opacity 120ms ease;
1479 }
1480 .trio-pill:hover { opacity: 0.8; }
1481 .trio-pill.is-pass {
1482 color: #34d399;
1483 background: rgba(52,211,153,0.10);
1484 border-color: rgba(52,211,153,0.35);
1485 }
1486 .trio-pill.is-fail {
1487 color: #f87171;
1488 background: rgba(248,113,113,0.10);
1489 border-color: rgba(248,113,113,0.35);
1490 }
1491 .trio-pill.is-pending {
1492 color: var(--text-muted);
1493 background: rgba(255,255,255,0.04);
1494 border-color: var(--border-strong);
1495 }
1496 .trio-pills-wrap {
1497 display: inline-flex; align-items: center; gap: 4px;
1498 }
1d6db4dClaude1499
1500 /* ─── Bus Factor Warning Panel ─── */
1501 .busfactor-panel {
1502 display: flex;
1503 gap: 14px;
1504 align-items: flex-start;
1505 padding: 14px 18px;
1506 margin-bottom: 16px;
1507 border-radius: 12px;
1508 border: 1px solid rgba(245,158,11,0.35);
1509 background: rgba(245,158,11,0.06);
1510 }
1511 .busfactor-critical {
1512 border-color: rgba(239,68,68,0.4);
1513 background: rgba(239,68,68,0.06);
1514 }
1515 .busfactor-high {
1516 border-color: rgba(249,115,22,0.4);
1517 background: rgba(249,115,22,0.06);
1518 }
1519 .busfactor-medium {
1520 border-color: rgba(245,158,11,0.35);
1521 background: rgba(245,158,11,0.06);
1522 }
1523 .busfactor-icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; }
1524 .busfactor-body { flex: 1; min-width: 0; }
1525 .busfactor-body strong { font-size: 14px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1526 .busfactor-body p { font-size: 13px; color: var(--text-muted); margin: 0 0 8px; }
1527 .busfactor-body ul { margin: 0; padding-left: 18px; }
1528 .busfactor-body li { font-size: 12.5px; color: var(--text-muted); margin-bottom: 3px; font-family: var(--font-mono); }
1529 .busfactor-body li strong { font-size: 12.5px; color: var(--text); display: inline; }
1530
1531 /* ─── PR Split Suggestion Panel ─── */
1532 .split-suggestion {
1533 margin-bottom: 16px;
6fd5915Claude1534 border: 1px solid rgba(91,110,232,0.35);
1d6db4dClaude1535 border-radius: 12px;
1536 overflow: hidden;
1537 }
1538 .split-header {
1539 display: flex;
1540 align-items: center;
1541 gap: 10px;
1542 padding: 12px 18px;
6fd5915Claude1543 background: rgba(91,110,232,0.06);
1d6db4dClaude1544 flex-wrap: wrap;
1545 }
1546 .split-icon { font-size: 18px; flex-shrink: 0; }
1547 .split-header strong { font-size: 14px; font-weight: 700; color: var(--text-strong); flex: 1; min-width: 200px; }
1548 .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; }
1549 .split-toggle {
1550 background: none;
6fd5915Claude1551 border: 1px solid rgba(91,110,232,0.45);
1552 color: rgba(91,110,232,0.9);
1d6db4dClaude1553 font-size: 12.5px;
1554 font-weight: 600;
1555 padding: 4px 12px;
1556 border-radius: 8px;
1557 cursor: pointer;
1558 white-space: nowrap;
1559 transition: background 120ms ease;
1560 }
6fd5915Claude1561 .split-toggle:hover { background: rgba(91,110,232,0.1); }
1d6db4dClaude1562 .split-body {
1563 padding: 16px 18px;
6fd5915Claude1564 border-top: 1px solid rgba(91,110,232,0.2);
1d6db4dClaude1565 }
1566 .split-intro { font-size: 13.5px; color: var(--text-muted); margin: 0 0 14px; }
1567 .split-pr {
1568 display: flex;
1569 gap: 14px;
1570 align-items: flex-start;
1571 padding: 12px 0;
1572 border-bottom: 1px solid var(--border);
1573 }
1574 .split-pr:last-of-type { border-bottom: none; }
1575 .split-pr-num {
1576 width: 26px; height: 26px;
1577 border-radius: 50%;
6fd5915Claude1578 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 130%);
1d6db4dClaude1579 color: #fff;
1580 font-size: 12px;
1581 font-weight: 800;
1582 display: inline-flex;
1583 align-items: center;
1584 justify-content: center;
1585 flex-shrink: 0;
1586 margin-top: 2px;
1587 }
1588 .split-pr-body { flex: 1; min-width: 0; }
1589 .split-pr-body strong { font-size: 13.5px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1590 .split-pr-body p { font-size: 12.5px; color: var(--text-muted); margin: 0 0 6px; }
1591 .split-pr-body code { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); word-break: break-all; }
1592 .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; }
1593 .split-order { font-size: 13px; color: var(--text-muted); margin: 14px 0 0; }
1594 .split-order strong { color: var(--text); }
b078860Claude1595`;
1596
25b1ff7Claude1597/* ──────────────────────────────────────────────────────────────────────
1598 * Figma-style collaborative PR presence — styles for the presence bar
1599 * above the diff and the per-line reviewer cursor pills. All scoped
1600 * with `.presence-*` prefix so they never bleed into other views.
1601 * ──────────────────────────────────────────────────────────────────── */
1602const PRESENCE_STYLES = `
1603 .presence-bar {
1604 display: flex;
1605 align-items: center;
1606 gap: 10px;
1607 padding: 8px 14px;
1608 margin: 0 0 10px;
1609 background: var(--bg-elevated);
1610 border: 1px solid var(--border);
1611 border-radius: 10px;
1612 font-size: 12.5px;
1613 color: var(--text-muted);
1614 min-height: 38px;
1615 }
1616 .presence-bar-label {
1617 font-weight: 600;
1618 color: var(--text-muted);
1619 flex-shrink: 0;
1620 }
1621 .presence-avatars {
1622 display: flex;
1623 align-items: center;
1624 gap: 6px;
1625 flex: 1;
1626 flex-wrap: wrap;
1627 }
1628 .presence-avatar {
1629 display: inline-flex;
1630 align-items: center;
1631 gap: 5px;
1632 padding: 3px 8px 3px 4px;
1633 border-radius: 9999px;
1634 font-size: 12px;
1635 font-weight: 600;
1636 color: #fff;
1637 opacity: 0.92;
1638 transition: opacity 200ms;
1639 }
1640 .presence-avatar-dot {
1641 width: 20px; height: 20px;
1642 border-radius: 9999px;
1643 background: rgba(255,255,255,0.22);
1644 display: inline-flex;
1645 align-items: center;
1646 justify-content: center;
1647 font-size: 10px;
1648 font-weight: 700;
1649 flex-shrink: 0;
1650 }
1651 .presence-count {
1652 font-size: 12px;
1653 color: var(--text-faint);
1654 flex-shrink: 0;
1655 }
1656 /* Per-line reviewer cursor pill — injected by JS into .diff-row */
1657 .presence-line-pill {
1658 position: absolute;
1659 right: 6px;
1660 top: 50%;
1661 transform: translateY(-50%);
1662 display: inline-flex;
1663 align-items: center;
1664 gap: 4px;
1665 padding: 1px 7px;
1666 border-radius: 9999px;
1667 font-size: 10.5px;
1668 font-weight: 600;
1669 color: #fff;
1670 pointer-events: none;
1671 white-space: nowrap;
1672 z-index: 10;
1673 opacity: 0.88;
1674 animation: presence-in 160ms ease;
1675 }
1676 @keyframes presence-in {
1677 from { opacity: 0; transform: translateY(-50%) scale(0.85); }
1678 to { opacity: 0.88; transform: translateY(-50%) scale(1); }
1679 }
1680 .presence-line-pill.is-typing::after {
1681 content: '…';
1682 opacity: 0.7;
1683 }
1684 /* diff rows with a cursor pill need relative positioning */
1685 .diff-row { position: relative; }
1686 /* Toast for join/leave events */
1687 .presence-toast-wrap {
1688 position: fixed;
1689 bottom: 24px;
1690 right: 24px;
1691 display: flex;
1692 flex-direction: column;
1693 gap: 8px;
1694 z-index: 9999;
1695 pointer-events: none;
1696 }
1697 .presence-toast {
1698 padding: 8px 14px;
1699 border-radius: 8px;
1700 background: var(--bg-elevated);
1701 border: 1px solid var(--border);
1702 box-shadow: 0 6px 20px -8px rgba(0,0,0,0.55);
1703 font-size: 13px;
1704 color: var(--text);
1705 opacity: 1;
1706 transition: opacity 400ms;
1707 }
1708 .presence-toast.fading { opacity: 0; }
1709`;
b271465Claude1710
1711const IMPACT_STYLES = `
09d5f39Claude1712 /* ─── Merge Impact Analysis panel (.impact-*) ─── */
1713 .impact-panel {
1714 margin-top: 20px;
1715 border: 1px solid var(--border);
1716 border-radius: 12px;
1717 overflow: hidden;
1718 background: var(--bg-elevated);
1719 }
1720 .impact-header {
1721 display: flex;
1722 align-items: center;
1723 gap: 10px;
1724 padding: 12px 16px;
1725 background: var(--bg-elevated);
1726 border-bottom: 1px solid var(--border);
1727 cursor: pointer;
1728 user-select: none;
1729 }
1730 .impact-header:hover { background: var(--bg-hover); }
1731 .impact-score {
1732 display: inline-flex;
1733 align-items: center;
1734 justify-content: center;
1735 min-width: 34px;
1736 height: 24px;
1737 border-radius: 9999px;
1738 font-size: 11.5px;
1739 font-weight: 800;
1740 padding: 0 8px;
1741 letter-spacing: 0.01em;
1742 border: 1.5px solid transparent;
1743 }
1744 .impact-score.score-low {
1745 color: #34d399;
1746 background: rgba(52,211,153,0.12);
1747 border-color: rgba(52,211,153,0.35);
1748 }
1749 .impact-score.score-medium {
1750 color: #fbbf24;
1751 background: rgba(251,191,36,0.12);
1752 border-color: rgba(251,191,36,0.35);
1753 }
1754 .impact-score.score-high {
1755 color: #f87171;
1756 background: rgba(248,113,113,0.12);
1757 border-color: rgba(248,113,113,0.35);
1758 }
1759 .impact-header strong {
1760 font-size: 13.5px;
1761 color: var(--text-strong);
1762 font-weight: 700;
1763 }
1764 .impact-summary {
1765 font-size: 12.5px;
1766 color: var(--text-muted);
1767 flex: 1;
1768 }
1769 .impact-toggle {
1770 background: none;
1771 border: none;
1772 color: var(--text-muted);
1773 font-size: 11px;
1774 cursor: pointer;
1775 padding: 2px 6px;
1776 border-radius: 4px;
1777 transition: color 120ms;
1778 line-height: 1;
1779 }
1780 .impact-toggle:hover { color: var(--text); }
1781 .impact-toggle.is-open { transform: rotate(180deg); }
1782 .impact-body {
1783 padding: 14px 16px;
1784 display: flex;
1785 flex-direction: column;
1786 gap: 14px;
1787 }
1788 .impact-body[hidden] { display: none; }
1789 .impact-section h4 {
1790 margin: 0 0 8px;
1791 font-size: 12.5px;
1792 font-weight: 600;
1793 color: var(--text-muted);
1794 text-transform: uppercase;
1795 letter-spacing: 0.06em;
1796 }
1797 .impact-file-list {
1798 display: flex;
1799 flex-direction: column;
1800 gap: 3px;
1801 margin: 0;
1802 padding: 0;
1803 list-style: none;
1804 }
1805 .impact-file-list li {
1806 font-family: var(--font-mono);
1807 font-size: 12px;
1808 color: var(--text);
1809 padding: 3px 8px;
1810 background: var(--bg-secondary);
1811 border-radius: 5px;
1812 overflow: hidden;
1813 text-overflow: ellipsis;
1814 white-space: nowrap;
1815 }
1816 .impact-downstream .impact-file-list li {
1817 background: rgba(248,113,113,0.06);
1818 border: 1px solid rgba(248,113,113,0.15);
1819 }
1820 .impact-downstream h4 {
1821 color: #f87171;
1822 }
1823 .impact-empty {
1824 font-size: 12.5px;
1825 color: var(--text-muted);
1826 font-style: italic;
1827 }
1828`;
1829
25b1ff7Claude1830
1831
81c73c1Claude1832/**
1833 * Tiny inline JS that drives the "Suggest description with AI" button.
1834 * On click, gathers form values, POSTs JSON to the given endpoint, and
1835 * pipes the response into the #pr-body textarea. All DOM lookups are
1836 * defensive — element absence is a silent no-op.
1837 *
1838 * Built as a string template so it lives next to its server-side caller
1839 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1840 * to avoid </script> breakouts.
1841 */
1842function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1843 const url = JSON.stringify(endpointUrl)
1844 .split("<").join("\\u003C")
1845 .split(">").join("\\u003E")
1846 .split("&").join("\\u0026");
1847 return (
1848 "(function(){try{" +
1849 "var btn=document.getElementById('ai-suggest-desc');" +
1850 "var status=document.getElementById('ai-suggest-status');" +
1851 "var body=document.getElementById('pr-body');" +
1852 "var form=btn&&btn.closest&&btn.closest('form');" +
1853 "if(!btn||!body||!form)return;" +
1854 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1855 "var fd=new FormData(form);" +
1856 "var title=String(fd.get('title')||'').trim();" +
1857 "var base=String(fd.get('base')||'').trim();" +
1858 "var head=String(fd.get('head')||'').trim();" +
1859 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1860 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1861 "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'})" +
1862 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1863 ".then(function(j){btn.disabled=false;" +
1864 "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;}}" +
1865 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1866 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1867 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1868 "});" +
1869 "}catch(e){}})();"
1870 );
1871}
1872
3c03977Claude1873/**
1874 * Live co-editing client. Connects to the per-PR SSE feed and:
1875 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1876 * status colour per user).
1877 * - Renders tinted cursor caret overlays inside #pr-body and every
1878 * `[data-live-field]` element.
1879 * - Broadcasts the local user's cursor position (selectionStart /
1880 * selectionEnd) debounced at 100ms.
1881 * - Broadcasts content patches (`replace` of the whole textarea —
1882 * last-write-wins v1) debounced at 250ms.
1883 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1884 * to the matching local field if untouched.
1885 *
1886 * All endpoint URLs are JSON-escaped via safe replacements so they
1887 * can't break out of the <script> tag.
1888 */
25b1ff7Claude1889
1890/**
1891 * Figma-style collaborative PR presence client (WebSocket).
1892 *
1893 * Connects to `GET /:owner/:repo/pulls/:number/presence` (WebSocket upgrade).
1894 * On connect the server sends `{type:"init", users:[...]}` so the bar renders
1895 * immediately. Subsequent messages from the server drive the presence bar and
1896 * per-line cursor pills in the diff.
1897 *
1898 * Outbound messages:
1899 * {type:"cursor", line: N} — user hovered a diff line
1900 * {type:"typing", line: N, typing: bool} — textarea focus/blur in diff
1901 * {type:"ping"} — keep-alive every 10s
1902 *
1903 * Inbound messages:
1904 * {type:"init", users:[{sessionId,username,colour,line,typing}]}
1905 * {type:"join", user:{sessionId,username,colour,line,typing}}
1906 * {type:"leave", sessionId}
1907 * {type:"cursor", sessionId, username, colour, line}
1908 * {type:"typing", sessionId, username, colour, line, typing}
1909 */
1910function PR_PRESENCE_SCRIPT(owner: string, repo: string, prNum: number): string {
1911 const wsPath = JSON.stringify(`/${owner}/${repo}/pulls/${prNum}/presence`)
1912 .split("<").join("\\u003C")
1913 .split(">").join("\\u003E")
1914 .split("&").join("\\u0026");
1915 return `(function(){
1916try{
1917var wsPath=${wsPath};
1918var proto=location.protocol==='https:'?'wss:':'ws:';
1919var url=proto+'//'+location.host+wsPath;
1920var mySessionId=null;
1921// sessionId -> {username, colour, line, typing}
1922var peers={};
1923var ws=null;
1924var pingTimer=null;
1925var reconnectDelay=1500;
1926var reconnectTimer=null;
1927
1928function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
1929
1930// ── Toast ──────────────────────────────────────────────────────────────
1931var toastWrap=document.getElementById('presence-toasts');
1932function toast(msg){
1933 if(!toastWrap)return;
1934 var t=document.createElement('div');
1935 t.className='presence-toast';
1936 t.textContent=msg;
1937 toastWrap.appendChild(t);
1938 setTimeout(function(){t.classList.add('fading');setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},420);},2500);
1939}
1940
1941// ── Presence bar ───────────────────────────────────────────────────────
1942var avEl=document.getElementById('presence-avatars');
1943var countEl=document.getElementById('presence-count');
1944function renderBar(){
1945 if(!avEl)return;
1946 var ids=Object.keys(peers);
1947 var html='';
1948 for(var i=0;i<ids.length&&i<8;i++){
1949 var p=peers[ids[i]];
1950 var initials=(p.username||'?').slice(0,2).toUpperCase();
1951 html+='<span class="presence-avatar" style="background:'+esc(p.colour)+'" title="'+esc(p.username)+'">';
1952 html+='<span class="presence-avatar-dot">'+esc(initials)+'</span>';
1953 html+=esc(p.username);
1954 html+='</span>';
1955 }
1956 avEl.innerHTML=html;
1957 if(countEl){
1958 var n=ids.length;
1959 countEl.textContent=n===0?'No other reviewers':n===1?'1 reviewer online':n+' reviewers online';
1960 }
1961}
1962
1963// ── Diff cursor pills ──────────────────────────────────────────────────
1964// data-line value is like "12:x:5" or "12:5:x" — pull numeric line only
1965function lineNumFromKey(key){var m=String(key).match(/(\d+)/);return m?parseInt(m[1],10):null;}
1966function findDiffRow(line){return document.querySelector('[data-line]') &&
1967 (function(){var rows=document.querySelectorAll('[data-line]');
1968 for(var i=0;i<rows.length;i++){var n=lineNumFromKey(rows[i].getAttribute('data-line')||'');if(n===line)return rows[i];}
1969 return null;
1970 })();}
1971function removePill(sessionId){var old=document.querySelector('[data-presence-sid="'+sessionId+'"]');if(old&&old.parentNode)old.parentNode.removeChild(old);}
1972function placePill(sessionId,username,colour,line,typing){
1973 removePill(sessionId);
1974 if(line==null)return;
1975 var row=findDiffRow(line);if(!row)return;
1976 var pill=document.createElement('span');
1977 pill.className='presence-line-pill'+(typing?' is-typing':'');
1978 pill.setAttribute('data-presence-sid',sessionId);
6fd5915Claude1979 pill.style.background=colour||'#5b6ee8';
25b1ff7Claude1980 pill.textContent=(username||'?').slice(0,12)+(typing?' typing':'');
1981 row.appendChild(pill);
1982}
1983function clearPeer(sessionId){removePill(sessionId);delete peers[sessionId];}
1984
1985// ── Inbound message handler ────────────────────────────────────────────
1986function onMsg(raw){
1987 var d;try{d=JSON.parse(raw);}catch(e){return;}
1988 if(!d||!d.type)return;
1989 if(d.type==='init'){
1990 mySessionId=d.sessionId||null;
1991 peers={};
1992 (d.users||[]).forEach(function(u){
1993 if(u.sessionId===mySessionId)return;
1994 peers[u.sessionId]={username:u.username,colour:u.colour,line:u.line,typing:u.typing};
1995 placePill(u.sessionId,u.username,u.colour,u.line,u.typing);
1996 });
1997 renderBar();
1998 } else if(d.type==='join'){
1999 if(d.user&&d.user.sessionId!==mySessionId){
2000 peers[d.user.sessionId]={username:d.user.username,colour:d.user.colour,line:d.user.line,typing:d.user.typing};
2001 renderBar();
2002 toast(esc(d.user.username)+' joined the review');
2003 }
2004 } else if(d.type==='leave'){
2005 if(d.sessionId&&d.sessionId!==mySessionId){
2006 var name=peers[d.sessionId]&&peers[d.sessionId].username;
2007 clearPeer(d.sessionId);
2008 renderBar();
2009 if(name)toast(esc(name)+' left the review');
2010 }
2011 } else if(d.type==='cursor'){
2012 if(d.sessionId&&d.sessionId!==mySessionId){
2013 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=false;}
2014 placePill(d.sessionId,d.username,d.colour,d.line,false);
2015 }
2016 } else if(d.type==='typing'){
2017 if(d.sessionId&&d.sessionId!==mySessionId){
2018 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=d.typing;}
2019 placePill(d.sessionId,d.username,d.colour,d.line,d.typing);
2020 }
2021 }
2022}
2023
2024// ── Outbound helpers ───────────────────────────────────────────────────
2025function send(obj){try{if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}catch(e){}}
2026
2027// ── Mouse hover on diff rows ───────────────────────────────────────────
2028var hoverTimer=null;
2029document.addEventListener('mouseover',function(ev){
2030 var row=ev.target&&ev.target.closest&&ev.target.closest('[data-line]');
2031 if(!row)return;
2032 if(hoverTimer)clearTimeout(hoverTimer);
2033 hoverTimer=setTimeout(function(){
2034 var key=row.getAttribute('data-line')||'';
2035 var line=lineNumFromKey(key);
2036 if(line!=null)send({type:'cursor',line:line});
2037 },80);
2038});
2039
2040// ── Typing detection in diff comment textareas ─────────────────────────
2041document.addEventListener('focusin',function(ev){
2042 var ta=ev.target;
2043 if(!ta||ta.tagName!=='TEXTAREA')return;
2044 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
2045 var line=lineNumFromKey(row.getAttribute('data-line')||'');
2046 if(line!=null)send({type:'typing',line:line,typing:true});
2047});
2048document.addEventListener('focusout',function(ev){
2049 var ta=ev.target;
2050 if(!ta||ta.tagName!=='TEXTAREA')return;
2051 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
2052 var line=lineNumFromKey(row.getAttribute('data-line')||'');
2053 if(line!=null)send({type:'typing',line:line,typing:false});
2054});
2055
2056// ── WebSocket lifecycle ────────────────────────────────────────────────
2057function connect(){
2058 if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=null;}
2059 try{ws=new WebSocket(url);}catch(e){scheduleReconnect();return;}
2060 ws.onopen=function(){
2061 reconnectDelay=1500;
2062 pingTimer=setInterval(function(){send({type:'ping'});},10000);
2063 };
2064 ws.onmessage=function(ev){onMsg(ev.data);};
2065 ws.onclose=function(){
2066 if(pingTimer){clearInterval(pingTimer);pingTimer=null;}
2067 scheduleReconnect();
2068 };
2069 ws.onerror=function(){try{ws.close();}catch(e){}};
2070}
2071function scheduleReconnect(){
2072 reconnectTimer=setTimeout(function(){connect();},reconnectDelay);
2073 reconnectDelay=Math.min(reconnectDelay*2,30000);
2074}
2075
2076connect();
2077}catch(e){}})();`;
2078}
2079
3c03977Claude2080function LIVE_COEDIT_SCRIPT(prId: string): string {
2081 const idJson = JSON.stringify(prId)
2082 .split("<").join("\\u003C")
2083 .split(">").join("\\u003E")
2084 .split("&").join("\\u0026");
2085 return (
2086 "(function(){try{" +
2087 "if(typeof EventSource==='undefined')return;" +
2088 "var prId=" + idJson + ";" +
2089 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
2090 "var pill=document.getElementById('live-pill');" +
2091 "var avEl=document.getElementById('live-avatars');" +
2092 "var countEl=document.getElementById('live-count');" +
2093 "var sessionId=null;var myColor=null;" +
2094 "var presence={};" + // sessionId -> {color,status,userId,initials}
2095 "var lastApplied={};" + // field -> last server value (for echo suppression)
2096 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
2097 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
2098 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
2099 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
2100 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
2101 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
2102 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
2103 "avEl.innerHTML=html;}}" +
2104 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
2105 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
2106 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
2107 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
2108 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
2109 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
2110 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
2111 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
2112 "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);}" +
2113 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
2114 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
2115 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
2116 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
2117 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
2118 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
2119 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
2120 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
2121 "}catch(e){}" +
2122 "c.style.transform='translate('+x+'px,'+y+'px)';" +
2123 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
2124 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
2125 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
2126 "var es;var delay=1000;" +
2127 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
2128 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
2129 "(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){}});" +
2130 "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){}});" +
2131 "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){}});" +
2132 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
2133 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
2134 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
2135 "var patch=d.patch;if(!patch||!patch.field)return;" +
2136 "var ta=fieldEl(patch.field);if(!ta)return;" +
2137 "if(document.activeElement===ta)return;" + // don't trample local typing
2138 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
2139 "}catch(e){}});" +
2140 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
2141 "}connect();" +
2142 "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){}}" +
2143 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
2144 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
2145 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
2146 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
2147 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
2148 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
2149 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2150 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2151 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2152 "}" +
2153 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
2154 "var live=document.querySelectorAll('[data-live-field]');" +
2155 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
2156 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
2157 "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){}});" +
2158 "}catch(e){}})();"
2159 );
2160}
2161
0074234Claude2162async function resolveRepo(ownerName: string, repoName: string) {
2163 const [owner] = await db
2164 .select()
2165 .from(users)
2166 .where(eq(users.username, ownerName))
2167 .limit(1);
2168 if (!owner) return null;
2169 const [repo] = await db
2170 .select()
2171 .from(repositories)
2172 .where(
2173 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
2174 )
2175 .limit(1);
2176 if (!repo) return null;
2177 return { owner, repo };
2178}
2179
2180// PR Nav helper
2181const PrNav = ({
2182 owner,
2183 repo,
2184 active,
2185}: {
2186 owner: string;
2187 repo: string;
2188 active: "code" | "issues" | "pulls" | "commits";
2189}) => (
bb0f894Claude2190 <TabNav
2191 tabs={[
2192 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
2193 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
2194 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
2195 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
2196 ]}
2197 />
0074234Claude2198);
2199
534f04aClaude2200/**
2201 * Block M3 — pre-merge risk score card. Pure presentational helper.
2202 * Rendered in the conversation tab above the gate checks block. Hidden
2203 * entirely when the PR is closed/merged or there is nothing cached and
2204 * nothing in-flight.
2205 */
2206function PrRiskCard({
2207 risk,
2208 calculating,
2209}: {
2210 risk: PrRiskScore | null;
2211 calculating: boolean;
2212}) {
2213 if (!risk) {
2214 return (
2215 <div
2216 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
2217 >
2218 <strong style="font-size: 13px; color: var(--text)">
2219 Risk score: calculating…
2220 </strong>
2221 <div style="font-size: 12px; margin-top: 4px">
2222 Refresh in a moment to see the pre-merge risk score for this PR.
2223 </div>
2224 </div>
2225 );
2226 }
2227
2228 const palette = riskBandPalette(risk.band);
2229 const label = riskBandLabel(risk.band);
2230
2231 return (
2232 <div
2233 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
2234 >
2235 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
2236 <strong>Risk score:</strong>
2237 <span style={`color:${palette.border};font-weight:600`}>
2238 {palette.icon} {label} ({risk.score}/10)
2239 </span>
2240 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
2241 {risk.commitSha.slice(0, 7)}
2242 </span>
2243 </div>
2244 {risk.aiSummary && (
2245 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
2246 {risk.aiSummary}
2247 </div>
2248 )}
2249 <details style="margin-top:10px">
2250 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
2251 See full signal breakdown
2252 </summary>
2253 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
2254 <li>files changed: {risk.signals.filesChanged}</li>
2255 <li>
2256 lines added/removed: {risk.signals.linesAdded} /{" "}
2257 {risk.signals.linesRemoved}
2258 </li>
2259 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
2260 <li>
2261 schema migration touched:{" "}
2262 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
2263 </li>
2264 <li>
2265 locked / sensitive path touched:{" "}
2266 {risk.signals.lockedPathTouched ? "yes" : "no"}
2267 </li>
2268 <li>
2269 adds new dependency:{" "}
2270 {risk.signals.addsNewDependency ? "yes" : "no"}
2271 </li>
2272 <li>
2273 bumps major dependency:{" "}
2274 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
2275 </li>
2276 <li>
2277 tests added for new code:{" "}
2278 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
2279 </li>
2280 <li>
2281 diff-minus-test ratio:{" "}
2282 {risk.signals.diffMinusTestRatio.toFixed(2)}
2283 </li>
2284 </ul>
2285 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2286 How is this calculated? The score is a transparent sum of
2287 weighted signals — see <code>src/lib/pr-risk.ts</code>
2288 {" "}<code>computePrRiskScore</code>.
2289 </div>
2290 </details>
2291 {calculating && (
2292 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2293 (recomputing for the latest commit — refresh to update)
2294 </div>
2295 )}
2296 </div>
2297 );
2298}
2299
2300function riskBandPalette(band: PrRiskScore["band"]): {
2301 border: string;
2302 icon: string;
2303} {
2304 switch (band) {
2305 case "low":
2306 return { border: "var(--green)", icon: "" };
2307 case "medium":
2308 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
2309 case "high":
2310 return { border: "var(--orange, #db6d28)", icon: "⚠" };
2311 case "critical":
2312 return { border: "var(--red)", icon: "\u{1F6D1}" };
2313 }
2314}
2315
2316function riskBandLabel(band: PrRiskScore["band"]): string {
2317 switch (band) {
2318 case "low":
2319 return "LOW";
2320 case "medium":
2321 return "MEDIUM";
2322 case "high":
2323 return "HIGH";
2324 case "critical":
2325 return "CRITICAL";
2326 }
2327}
2328
09d5f39Claude2329// ---------------------------------------------------------------------------
2330// Merge Impact Analysis — collapsible panel showing affected files and
2331// downstream repos. Shown on open PRs for users with write access.
2332// ---------------------------------------------------------------------------
2333
2334function ImpactPanel({ analysis, owner }: { analysis: ImpactAnalysis; owner: string }) {
2335 const score = analysis.riskScore;
2336 const band = score <= 30 ? "low" : score <= 60 ? "medium" : "high";
2337 const hasAny =
2338 analysis.affectedTestFiles.length > 0 ||
2339 analysis.affectedFiles.length > 0 ||
2340 analysis.downstreamRepos.length > 0;
2341
2342 return (
2343 <div class="impact-panel">
2344 <div
2345 class="impact-header"
2346 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)"
2347 >
2348 <span class={`impact-score score-${band}`}>{score}</span>
2349 <strong>Merge Impact</strong>
2350 <span class="impact-summary">{analysis.riskSummary}</span>
2351 <button class="impact-toggle" type="button" aria-label="Toggle impact panel">
2352
2353 </button>
2354 </div>
2355 <div class="impact-body" hidden>
2356 <div class="impact-section">
2357 <h4>Affected test files ({analysis.affectedTestFiles.length})</h4>
2358 {analysis.affectedTestFiles.length === 0 ? (
2359 <span class="impact-empty">No test files reference the changed files.</span>
2360 ) : (
2361 <ul class="impact-file-list">
2362 {analysis.affectedTestFiles.map((f) => (
2363 <li title={f}>{f}</li>
2364 ))}
2365 </ul>
2366 )}
2367 </div>
2368 <div class="impact-section">
2369 <h4>Affected source files ({analysis.affectedFiles.length})</h4>
2370 {analysis.affectedFiles.length === 0 ? (
2371 <span class="impact-empty">No other source files import the changed files.</span>
2372 ) : (
2373 <ul class="impact-file-list">
2374 {analysis.affectedFiles.map((f) => (
2375 <li title={f}>{f}</li>
2376 ))}
2377 </ul>
2378 )}
2379 </div>
2380 {analysis.downstreamRepos.length > 0 && (
2381 <div class="impact-section impact-downstream">
2382 <h4>Downstream repos ({analysis.downstreamRepos.length})</h4>
2383 <ul class="impact-file-list">
2384 {analysis.downstreamRepos.map((r) => (
2385 <li>
2386 <a
2387 href={`/${r.owner}/${r.repo}`}
2388 style="color:var(--text-link);text-decoration:none"
2389 >
2390 {r.owner}/{r.repo}
2391 </a>
2392 {" "}
2393 <span style="color:var(--text-muted);font-size:11px">
2394 via {r.matchedDependency}
2395 </span>
2396 </li>
2397 ))}
2398 </ul>
2399 </div>
2400 )}
2401 </div>
2402 </div>
2403 );
2404}
2405
422a2d4Claude2406// ---------------------------------------------------------------------------
2407// AI Trio Review — 3-column card grid + disagreement callout.
2408//
2409// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
2410// per run: one per persona (security/correctness/style) plus a top-level
2411// summary. We surface them here as a single grid above the normal
2412// comment stream so reviewers see the verdicts at a glance.
2413// ---------------------------------------------------------------------------
2414
2415const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
2416
2417interface TrioCommentLike {
2418 body: string;
2419}
2420
2421function isTrioComment(body: string | null | undefined): boolean {
2422 if (!body) return false;
2423 return (
2424 body.includes(TRIO_SUMMARY_MARKER) ||
2425 body.includes(TRIO_COMMENT_MARKER.security) ||
2426 body.includes(TRIO_COMMENT_MARKER.correctness) ||
2427 body.includes(TRIO_COMMENT_MARKER.style)
2428 );
2429}
2430
2431function trioPersonaOfComment(body: string): TrioPersona | null {
2432 for (const p of TRIO_PERSONAS) {
2433 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
2434 }
2435 return null;
2436}
2437
2438/**
2439 * Best-effort verdict parse from a persona comment body. The body shape
2440 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
2441 * we only need the "Pass" / "Fail" word from the H2 heading.
2442 */
2443function trioVerdictOfBody(body: string): "pass" | "fail" | null {
2444 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
2445 if (!m) return null;
2446 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
2447}
2448
2449/**
2450 * Parse the disagreement bullet list out of the summary comment so we
2451 * can render it as a polished callout strip. Returns [] when nothing
2452 * matches — the comment author may have edited the marker out.
2453 */
2454function parseDisagreements(summaryBody: string): Array<{
2455 file: string;
2456 failing: string;
2457 passing: string;
2458}> {
2459 const out: Array<{ file: string; failing: string; passing: string }> = [];
2460 // Each disagreement line looks like:
2461 // - `path:42` — security, style say ✗, correctness say ✓
2462 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
2463 let m: RegExpExecArray | null;
2464 while ((m = re.exec(summaryBody)) !== null) {
2465 out.push({
2466 file: m[1].trim(),
2467 failing: m[2].trim().replace(/[,\s]+$/g, ""),
2468 passing: m[3].trim().replace(/[,\s]+$/g, ""),
2469 });
2470 }
2471 return out;
2472}
2473
2474function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
2475 // Find the most recent persona comments + summary. We iterate from
2476 // the end so re-reviews (multiple runs on the same PR) display the
2477 // freshest verdict.
2478 const latest: Partial<Record<TrioPersona, string>> = {};
2479 let summaryBody: string | null = null;
2480 for (let i = comments.length - 1; i >= 0; i--) {
2481 const body = comments[i].body || "";
2482 if (!isTrioComment(body)) continue;
2483 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
2484 summaryBody = body;
2485 continue;
2486 }
2487 const persona = trioPersonaOfComment(body);
2488 if (persona && !latest[persona]) latest[persona] = body;
2489 }
2490 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2491 if (!anyPersona && !summaryBody) return null;
2492
2493 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
2494
2495 return (
67dc4e1Claude2496 <div class="trio-wrap" id="trio-review-section">
422a2d4Claude2497 <div class="trio-header">
2498 <span class="trio-header-dot" aria-hidden="true"></span>
2499 <strong>AI Trio Review</strong>
2500 <span class="trio-header-sub">
2501 Three independent reviewers ran in parallel.
2502 </span>
2503 </div>
2504 <div class="trio-grid">
2505 {TRIO_PERSONAS.map((persona) => {
2506 const body = latest[persona];
2507 const verdict = body ? trioVerdictOfBody(body) : null;
2508 const stateClass =
2509 verdict === "fail"
2510 ? "is-fail"
2511 : verdict === "pass"
2512 ? "is-pass"
2513 : "is-pending";
2514 return (
2515 <div class={`trio-card trio-${persona} ${stateClass}`}>
2516 <div class="trio-card-head">
2517 <span class="trio-card-icon" aria-hidden="true">
2518 {persona === "security"
2519 ? "🛡"
2520 : persona === "correctness"
2521 ? "✓"
2522 : "✎"}
2523 </span>
2524 <strong class="trio-card-title">
2525 {persona[0].toUpperCase() + persona.slice(1)}
2526 </strong>
2527 <span class="trio-card-verdict">
2528 {verdict === "pass"
2529 ? "Pass"
2530 : verdict === "fail"
2531 ? "Fail"
2532 : "Pending"}
2533 </span>
2534 </div>
2535 <div class="trio-card-body">
2536 {body ? (
2537 <MarkdownContent
2538 html={renderMarkdown(stripTrioHeading(body))}
2539 />
2540 ) : (
2541 <span class="trio-card-empty">
2542 Awaiting reviewer output.
2543 </span>
2544 )}
2545 </div>
2546 </div>
2547 );
2548 })}
2549 </div>
2550 {disagreements.length > 0 && (
2551 <div class="trio-disagreement-strip" role="note">
2552 <span class="trio-disagreement-icon" aria-hidden="true">
2553
2554 </span>
2555 <div class="trio-disagreement-body">
2556 <strong>Reviewers disagree — review carefully.</strong>
2557 <ul class="trio-disagreement-list">
2558 {disagreements.map((d) => (
2559 <li>
2560 <code>{d.file}</code> — {d.failing} says ✗,{" "}
2561 {d.passing} says ✓
2562 </li>
2563 ))}
2564 </ul>
2565 </div>
2566 </div>
2567 )}
2568 </div>
2569 );
2570}
2571
2572/**
2573 * Strip the marker comment + first H2 heading from a persona body so
2574 * the card body shows just the findings list (verdict is already in
2575 * the card head). Best-effort — malformed bodies render whole.
2576 */
2577function stripTrioHeading(body: string): string {
2578 return body
2579 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
2580 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
2581 .trim();
2582}
2583
67dc4e1Claude2584/**
2585 * Three small verdict pills rendered inline in the PR header. Each pill
2586 * links to the `#trio-review-section` anchor so clicking scrolls to the
2587 * full card grid. Only shown when `AI_TRIO_REVIEW_ENABLED=1` and at
2588 * least one persona comment exists.
2589 */
2590function TrioVerdictPills({
2591 comments,
2592}: {
2593 comments: TrioCommentLike[];
2594}) {
2595 if (!isTrioReviewEnabled()) return null;
2596
2597 // Find latest persona verdicts (same logic as TrioReviewGrid).
2598 const latest: Partial<Record<TrioPersona, string>> = {};
2599 for (let i = comments.length - 1; i >= 0; i--) {
2600 const body = comments[i].body || "";
2601 if (!isTrioComment(body)) continue;
2602 if (body.includes(TRIO_SUMMARY_MARKER)) continue;
2603 const persona = trioPersonaOfComment(body);
2604 if (persona && !latest[persona]) latest[persona] = body;
2605 }
2606
2607 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2608 if (!anyPersona) return null;
2609
2610 const PERSONA_LABEL: Record<TrioPersona, string> = {
2611 security: "Security",
2612 correctness: "Correctness",
2613 style: "Style",
2614 };
2615 const PERSONA_ICON: Record<TrioPersona, string> = {
2616 security: "🛡",
2617 correctness: "✓",
2618 style: "✎",
2619 };
2620
2621 return (
2622 <span class="trio-pills-wrap" aria-label="AI Trio Review verdicts">
2623 {TRIO_PERSONAS.map((persona) => {
2624 const body = latest[persona];
2625 const verdict = body ? trioVerdictOfBody(body) : null;
2626 const stateClass =
2627 verdict === "pass"
2628 ? "is-pass"
2629 : verdict === "fail"
2630 ? "is-fail"
2631 : "is-pending";
2632 const glyph =
2633 verdict === "pass" ? "✓" : verdict === "fail" ? "✗" : "⟳";
2634 return (
2635 <a
2636 href="#trio-review-section"
2637 class={`trio-pill ${stateClass}`}
2638 title={`AI ${PERSONA_LABEL[persona]} Review — ${verdict === "pass" ? "Pass" : verdict === "fail" ? "Fail" : "Pending"}`}
2639 >
2640 <span aria-hidden="true">{PERSONA_ICON[persona]}</span>
2641 {PERSONA_LABEL[persona]} {glyph}
2642 </a>
2643 );
2644 })}
2645 </span>
2646 );
2647}
2648
0074234Claude2649// List PRs
04f6b7fClaude2650pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2651 const { owner: ownerName, repo: repoName } = c.req.param();
2652 const user = c.get("user");
2653 const state = c.req.query("state") || "open";
d790b49Claude2654 const searchQ = c.req.query("q")?.trim() || "";
80bd7c8Claude2655 const authorFilter = c.req.query("author")?.trim() || "";
f5b9ef5Claude2656 const sortPr = (c.req.query("sort") || "newest").trim();
0074234Claude2657
ea9ed4cClaude2658 // ── Loading skeleton (flag-gated) ──
2659 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
2660 // the user see the page structure before counts + select resolve.
2661 // Behind a flag for now — we don't ship flashes.
2662 if (c.req.query("skeleton") === "1") {
2663 return c.html(
2664 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2665 <RepoHeader owner={ownerName} repo={repoName} />
2666 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2667 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2668 <style
2669 dangerouslySetInnerHTML={{
2670 __html: `
404b398Claude2671 .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; }
2672 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude2673 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
2674 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
2675 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
2676 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
2677 .prs-skel-row { height: 76px; border-radius: 12px; }
2678 `,
2679 }}
2680 />
2681 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
2682 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
2683 <div class="prs-skel-list" aria-hidden="true">
2684 {Array.from({ length: 6 }).map(() => (
2685 <div class="prs-skel prs-skel-row" />
2686 ))}
2687 </div>
2688 <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">
2689 Loading pull requests for {ownerName}/{repoName}…
2690 </span>
2691 </Layout>
2692 );
2693 }
2694
0074234Claude2695 const resolved = await resolveRepo(ownerName, repoName);
2696 if (!resolved) return c.notFound();
2697
6fc53bdClaude2698 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
2699 const stateFilter =
2700 state === "draft"
2701 ? and(
2702 eq(pullRequests.state, "open"),
2703 eq(pullRequests.isDraft, true)
2704 )
2705 : eq(pullRequests.state, state);
2706
0074234Claude2707 const prList = await db
2708 .select({
2709 pr: pullRequests,
2710 author: { username: users.username },
2711 })
2712 .from(pullRequests)
2713 .innerJoin(users, eq(pullRequests.authorId, users.id))
2714 .where(
d790b49Claude2715 and(
2716 eq(pullRequests.repositoryId, resolved.repo.id),
2717 stateFilter,
2718 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
80bd7c8Claude2719 authorFilter ? eq(users.username, authorFilter) : undefined,
d790b49Claude2720 )
0074234Claude2721 )
f5b9ef5Claude2722 .orderBy(
2723 sortPr === "oldest" ? asc(pullRequests.createdAt)
2724 : sortPr === "updated" ? desc(pullRequests.updatedAt)
2725 : desc(pullRequests.createdAt) // newest (default)
2726 );
0074234Claude2727
0369e77Claude2728 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude2729 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude2730 const commentCountMap = new Map<string, number>();
1aef949Claude2731 if (prList.length > 0) {
2732 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude2733 const [reviewRows, commentRows] = await Promise.all([
2734 db
2735 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
2736 .from(prReviews)
2737 .where(inArray(prReviews.pullRequestId, prIds)),
2738 db
2739 .select({
2740 prId: prComments.pullRequestId,
2741 cnt: sql<number>`count(*)::int`,
2742 })
2743 .from(prComments)
2744 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
2745 .groupBy(prComments.pullRequestId),
2746 ]);
1aef949Claude2747 for (const r of reviewRows) {
2748 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
2749 if (r.state === "approved") entry.approved = true;
2750 if (r.state === "changes_requested") entry.changesRequested = true;
2751 reviewMap.set(r.prId, entry);
2752 }
0369e77Claude2753 for (const r of commentRows) {
2754 commentCountMap.set(r.prId, Number(r.cnt));
2755 }
1aef949Claude2756 }
2757
0074234Claude2758 const [counts] = await db
2759 .select({
2760 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude2761 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude2762 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
2763 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
2764 })
2765 .from(pullRequests)
2766 .where(eq(pullRequests.repositoryId, resolved.repo.id));
2767
b078860Claude2768 const openCount = counts?.open ?? 0;
2769 const mergedCount = counts?.merged ?? 0;
2770 const closedCount = counts?.closed ?? 0;
2771 const draftCount = counts?.draft ?? 0;
2772 const allCount = openCount + mergedCount + closedCount;
2773
2774 // "All" is presentational only — the DB query for state='all' matches
2775 // nothing, so we render a friendlier empty state when picked. We do NOT
2776 // change the query logic to keep this commit purely visual.
80bd7c8Claude2777 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude2778 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude2779 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
2780 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
2781 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
2782 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
2783 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude2784 ];
2785 const isAllState = state === "all";
cb5a796Claude2786 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
2787 const prListPendingCount = viewerIsOwnerOnPrList
2788 ? await countPendingForRepo(resolved.repo.id)
2789 : 0;
b078860Claude2790
0074234Claude2791 return c.html(
2792 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2793 <RepoHeader owner={ownerName} repo={repoName} />
2794 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2795 <PendingCommentsBanner
2796 owner={ownerName}
2797 repo={repoName}
2798 count={prListPendingCount}
2799 />
b078860Claude2800 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2801
2802 <div class="prs-hero">
2803 <div class="prs-hero-inner">
2804 <div class="prs-hero-text">
2805 <div class="prs-hero-eyebrow">Pull requests</div>
2806 <h1 class="prs-hero-title">
2807 Review, <span class="gradient-text">merge with AI</span>.
2808 </h1>
2809 <p class="prs-hero-sub">
2810 {openCount === 0 && allCount === 0
2811 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2812 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
2813 </p>
2814 </div>
7a28902Claude2815 <div class="prs-hero-actions">
2816 <a
2817 href={`/${ownerName}/${repoName}/pulls/insights`}
2818 class="prs-cta"
2819 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
2820 >
2821 Insights
2822 </a>
2823 {user && (
b078860Claude2824 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
2825 + New pull request
2826 </a>
7a28902Claude2827 )}
2828 </div>
b078860Claude2829 </div>
2830 </div>
2831
2832 <nav class="prs-tabs" aria-label="Pull request filters">
2833 {tabPills.map((t) => {
2834 const isActive =
2835 state === t.key ||
2836 (t.key === "open" &&
2837 state !== "merged" &&
2838 state !== "closed" &&
2839 state !== "all" &&
2840 state !== "draft");
2841 return (
2842 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2843 <span>{t.label}</span>
2844 <span class="prs-tab-count">{t.count}</span>
2845 </a>
2846 );
2847 })}
2848 </nav>
2849
d790b49Claude2850 <form
2851 method="get"
2852 action={`/${ownerName}/${repoName}/pulls`}
2853 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2854 >
2855 <input type="hidden" name="state" value={state} />
2856 <input
2857 type="search"
2858 name="q"
2859 value={searchQ}
2860 placeholder="Search pull requests…"
2861 class="issues-search-input"
2862 style="flex:1;max-width:380px"
2863 />
80bd7c8Claude2864 <input
2865 type="text"
2866 name="author"
2867 value={authorFilter}
2868 placeholder="Filter by author…"
2869 class="issues-search-input"
2870 style="max-width:200px"
2871 />
d790b49Claude2872 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2873 {(searchQ || authorFilter) && (
d790b49Claude2874 <a
2875 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2876 class="issues-filter-clear"
2877 >
2878 Clear
2879 </a>
2880 )}
2881 </form>
f5b9ef5Claude2882
2883 <div class="prs-sort-row">
2884 <span class="prs-sort-label">Sort:</span>
2885 {(["newest", "oldest", "updated"] as const).map((s) => (
2886 <a
2887 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2888 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2889 >
2890 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2891 </a>
2892 ))}
2893 </div>
2894
0074234Claude2895 {prList.length === 0 ? (
b078860Claude2896 <div class="prs-empty">
ea9ed4cClaude2897 <div class="prs-empty-inner">
2898 <strong>
80bd7c8Claude2899 {searchQ || authorFilter
2900 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2901 : isAllState
2902 ? "Pick a filter above to browse PRs."
2903 : `No ${state} pull requests.`}
ea9ed4cClaude2904 </strong>
2905 <p class="prs-empty-sub">
80bd7c8Claude2906 {searchQ || authorFilter
2907 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2908 : state === "open"
2909 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2910 : isAllState
2911 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2912 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2913 </p>
2914 <div class="prs-empty-cta">
80bd7c8Claude2915 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2916 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2917 + New pull request
2918 </a>
2919 )}
80bd7c8Claude2920 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2921 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2922 View open PRs
2923 </a>
2924 )}
2925 <a href={`/${ownerName}/${repoName}`} class="btn">
2926 Back to code
2927 </a>
2928 </div>
2929 </div>
b078860Claude2930 </div>
0074234Claude2931 ) : (
b078860Claude2932 <div class="prs-list">
2933 {prList.map(({ pr, author }) => {
2934 const stateClass =
2935 pr.state === "open"
2936 ? pr.isDraft
2937 ? "state-draft"
2938 : "state-open"
2939 : pr.state === "merged"
2940 ? "state-merged"
2941 : "state-closed";
2942 const icon =
2943 pr.state === "open"
2944 ? pr.isDraft
2945 ? "◌"
2946 : "○"
2947 : pr.state === "merged"
2948 ? "⮌"
2949 : "✓";
1aef949Claude2950 const rv = reviewMap.get(pr.id);
b078860Claude2951 return (
2952 <a
2953 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2954 class="prs-row"
2955 style="text-decoration:none;color:inherit"
0074234Claude2956 >
b078860Claude2957 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2958 {icon}
0074234Claude2959 </div>
b078860Claude2960 <div class="prs-row-body">
2961 <h3 class="prs-row-title">
2962 <span>{pr.title}</span>
2963 <span class="prs-row-number">#{pr.number}</span>
2964 </h3>
2965 <div class="prs-row-meta">
2966 <span
2967 class="prs-branch-chips"
2968 title={`${pr.headBranch} into ${pr.baseBranch}`}
2969 >
2970 <span class="prs-branch-chip">{pr.headBranch}</span>
2971 <span class="prs-branch-arrow">{"→"}</span>
2972 <span class="prs-branch-chip">{pr.baseBranch}</span>
2973 </span>
2974 <span>
2975 by{" "}
2976 <strong style="color:var(--text)">
2977 {author.username}
2978 </strong>{" "}
2979 {formatRelative(pr.createdAt)}
2980 </span>
2981 <span class="prs-row-tags">
2982 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2983 {pr.state === "merged" && (
2984 <span class="prs-tag is-merged">Merged</span>
2985 )}
1aef949Claude2986 {rv?.approved && !rv.changesRequested && (
2987 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
2988 )}
2989 {rv?.changesRequested && (
2990 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
2991 )}
0369e77Claude2992 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
2993 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
2994 💬 {commentCountMap.get(pr.id)}
2995 </span>
2996 )}
b078860Claude2997 </span>
2998 </div>
0074234Claude2999 </div>
b078860Claude3000 </a>
3001 );
3002 })}
3003 </div>
0074234Claude3004 )}
3005 </Layout>
3006 );
3007});
3008
7a28902Claude3009/* ─────────────────────────────────────────────────────────────────────────
3010 * PR Insights — 90-day analytics for the pull request activity of a repo.
3011 * Route: GET /:owner/:repo/pulls/insights
3012 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
3013 * "insights" is not swallowed by the :number param.
3014 * ───────────────────────────────────────────────────────────────────────── */
3015
3016/** Format a millisecond duration as human-readable string. */
3017function formatMsDuration(ms: number): string {
3018 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
3019 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
3020 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
3021 return `${Math.round(ms / 86_400_000)}d`;
3022}
3023
3024/** Format an ISO week string as "Jan 15". */
3025function formatWeekLabel(isoWeek: string): string {
3026 try {
3027 const d = new Date(isoWeek);
3028 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
3029 } catch {
3030 return isoWeek.slice(5, 10);
3031 }
3032}
3033
3034const PR_INSIGHTS_STYLES = `
3035 .pri-page { padding-bottom: 48px; }
3036 .pri-hero {
3037 position: relative;
3038 margin: 0 0 var(--space-5);
3039 padding: 22px 26px 24px;
3040 background: var(--bg-elevated);
3041 border: 1px solid var(--border);
3042 border-radius: 16px;
3043 overflow: hidden;
3044 }
3045 .pri-hero::before {
3046 content: '';
3047 position: absolute; top: 0; left: 0; right: 0;
3048 height: 2px;
6fd5915Claude3049 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7a28902Claude3050 opacity: 0.7;
3051 pointer-events: none;
3052 }
3053 .pri-hero-eyebrow {
3054 font-size: 12px;
3055 color: var(--text-muted);
3056 text-transform: uppercase;
3057 letter-spacing: 0.08em;
3058 font-weight: 600;
3059 margin-bottom: 8px;
3060 }
3061 .pri-hero-title {
3062 font-family: var(--font-display);
3063 font-size: clamp(26px, 3.4vw, 34px);
3064 font-weight: 800;
3065 letter-spacing: -0.025em;
3066 line-height: 1.06;
3067 margin: 0 0 8px;
3068 color: var(--text-strong);
3069 }
3070 .pri-hero-title .gradient-text {
6fd5915Claude3071 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
7a28902Claude3072 -webkit-background-clip: text;
3073 background-clip: text;
3074 -webkit-text-fill-color: transparent;
3075 color: transparent;
3076 }
3077 .pri-hero-sub {
3078 font-size: 14.5px;
3079 color: var(--text-muted);
3080 margin: 0;
3081 line-height: 1.5;
3082 }
3083 .pri-section { margin-bottom: 32px; }
3084 .pri-section-title {
3085 font-size: 13px;
3086 font-weight: 700;
3087 text-transform: uppercase;
3088 letter-spacing: 0.06em;
3089 color: var(--text-muted);
3090 margin: 0 0 14px;
3091 }
3092 .pri-cards {
3093 display: grid;
3094 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
3095 gap: 12px;
3096 }
3097 .pri-card {
3098 padding: 16px 18px;
3099 background: var(--bg-elevated);
3100 border: 1px solid var(--border);
3101 border-radius: 12px;
3102 }
3103 .pri-card-label {
3104 font-size: 12px;
3105 font-weight: 600;
3106 color: var(--text-muted);
3107 text-transform: uppercase;
3108 letter-spacing: 0.05em;
3109 margin-bottom: 6px;
3110 }
3111 .pri-card-value {
3112 font-size: 28px;
3113 font-weight: 800;
3114 letter-spacing: -0.04em;
3115 color: var(--text-strong);
3116 line-height: 1;
3117 }
3118 .pri-card-sub {
3119 font-size: 12px;
3120 color: var(--text-muted);
3121 margin-top: 4px;
3122 }
3123 .pri-chart {
3124 display: flex;
3125 align-items: flex-end;
3126 gap: 6px;
3127 height: 120px;
3128 background: var(--bg-elevated);
3129 border: 1px solid var(--border);
3130 border-radius: 12px;
3131 padding: 16px 16px 0;
3132 }
3133 .pri-bar-col {
3134 flex: 1;
3135 display: flex;
3136 flex-direction: column;
3137 align-items: center;
3138 justify-content: flex-end;
3139 height: 100%;
3140 gap: 4px;
3141 }
3142 .pri-bar {
3143 width: 100%;
3144 min-height: 4px;
3145 border-radius: 4px 4px 0 0;
6fd5915Claude3146 background: linear-gradient(180deg, #5b6ee8 0%, #5b6ee8 100%);
7a28902Claude3147 transition: opacity 140ms;
3148 }
3149 .pri-bar:hover { opacity: 0.8; }
3150 .pri-bar-label {
3151 font-size: 10px;
3152 color: var(--text-muted);
3153 text-align: center;
3154 padding-bottom: 8px;
3155 white-space: nowrap;
3156 overflow: hidden;
3157 text-overflow: ellipsis;
3158 max-width: 100%;
3159 }
3160 .pri-table {
3161 width: 100%;
3162 border-collapse: collapse;
3163 font-size: 13.5px;
3164 }
3165 .pri-table th {
3166 text-align: left;
3167 font-size: 12px;
3168 font-weight: 600;
3169 text-transform: uppercase;
3170 letter-spacing: 0.05em;
3171 color: var(--text-muted);
3172 padding: 8px 12px;
3173 border-bottom: 1px solid var(--border);
3174 }
3175 .pri-table td {
3176 padding: 10px 12px;
3177 border-bottom: 1px solid var(--border);
3178 color: var(--text);
3179 }
3180 .pri-table tr:last-child td { border-bottom: none; }
3181 .pri-table-wrap {
3182 background: var(--bg-elevated);
3183 border: 1px solid var(--border);
3184 border-radius: 12px;
3185 overflow: hidden;
3186 }
3187 .pri-age-row {
3188 display: flex;
3189 align-items: center;
3190 gap: 12px;
3191 padding: 10px 0;
3192 border-bottom: 1px solid var(--border);
3193 font-size: 13.5px;
3194 }
3195 .pri-age-row:last-child { border-bottom: none; }
3196 .pri-age-label {
3197 flex: 0 0 80px;
3198 color: var(--text-muted);
3199 font-size: 12.5px;
3200 font-weight: 600;
3201 }
3202 .pri-age-bar-wrap {
3203 flex: 1;
3204 height: 8px;
3205 background: var(--bg-secondary);
3206 border-radius: 9999px;
3207 overflow: hidden;
3208 }
3209 .pri-age-bar {
3210 height: 100%;
3211 border-radius: 9999px;
6fd5915Claude3212 background: linear-gradient(90deg, #5b6ee8 0%, #5f8fa0 100%);
7a28902Claude3213 min-width: 4px;
3214 }
3215 .pri-age-count {
3216 flex: 0 0 32px;
3217 text-align: right;
3218 font-weight: 600;
3219 color: var(--text-strong);
3220 font-size: 13px;
3221 }
3222 .pri-sparkline {
3223 display: flex;
3224 align-items: flex-end;
3225 gap: 3px;
3226 height: 40px;
3227 }
3228 .pri-spark-bar {
3229 flex: 1;
3230 min-height: 2px;
3231 border-radius: 2px 2px 0 0;
6fd5915Claude3232 background: var(--accent, #5b6ee8);
7a28902Claude3233 opacity: 0.7;
3234 }
3235 .pri-empty {
3236 color: var(--text-muted);
3237 font-size: 14px;
3238 padding: 24px 0;
3239 text-align: center;
3240 }
3241 @media (max-width: 600px) {
3242 .pri-cards { grid-template-columns: repeat(2, 1fr); }
3243 .pri-hero { padding: 18px 18px 20px; }
3244 }
3245`;
3246
3247pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
3248 const { owner: ownerName, repo: repoName } = c.req.param();
3249 const user = c.get("user");
3250
3251 const resolved = await resolveRepo(ownerName, repoName);
3252 if (!resolved) return c.notFound();
3253
3254 const repoId = resolved.repo.id;
3255 const now = Date.now();
3256
3257 // 1. Merged PRs in last 90 days (avg merge time)
3258 const mergedPRs = await db
3259 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
3260 .from(pullRequests)
3261 .where(and(
3262 eq(pullRequests.repositoryId, repoId),
3263 eq(pullRequests.state, "merged"),
3264 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3265 ));
3266
3267 const avgMergeMs = mergedPRs.length > 0
3268 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
3269 : null;
3270
3271 // 2. PR throughput (last 8 weeks)
3272 const weeklyPRs = await db
3273 .select({
3274 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
3275 count: sql<number>`count(*)::int`,
3276 })
3277 .from(pullRequests)
3278 .where(and(
3279 eq(pullRequests.repositoryId, repoId),
3280 sql`${pullRequests.createdAt} > now() - interval '56 days'`
3281 ))
3282 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
3283 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
3284
3285 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
3286
3287 // 3. PR merge rate (last 90 days)
3288 const [rateCounts] = await db
3289 .select({
3290 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
3291 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
3292 })
3293 .from(pullRequests)
3294 .where(and(
3295 eq(pullRequests.repositoryId, repoId),
3296 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3297 ));
3298
3299 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
3300 const mergeRate = totalResolved > 0
3301 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
3302 : null;
3303
3304 // 4. Top reviewers (last 90 days)
3305 const reviewerCounts = await db
3306 .select({
3307 userId: prReviews.reviewerId,
3308 username: users.username,
3309 count: sql<number>`count(*)::int`,
3310 })
3311 .from(prReviews)
3312 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3313 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
3314 .where(and(
3315 eq(pullRequests.repositoryId, repoId),
3316 sql`${prReviews.createdAt} > now() - interval '90 days'`
3317 ))
3318 .groupBy(prReviews.reviewerId, users.username)
3319 .orderBy(desc(sql`count(*)`))
3320 .limit(5);
3321
3322 // 5. Average reviews per merged PR
3323 const [avgReviewRow] = await db
3324 .select({
3325 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
3326 })
3327 .from(pullRequests)
3328 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3329 .where(and(
3330 eq(pullRequests.repositoryId, repoId),
3331 eq(pullRequests.state, "merged"),
3332 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3333 ));
3334
3335 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
3336 ? Math.round(avgReviewRow.avgReviews * 10) / 10
3337 : null;
3338
3339 // 6. Review turnaround — avg time from PR open to first review
3340 const prsWithReviews = await db
3341 .select({
3342 createdAt: pullRequests.createdAt,
3343 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
3344 })
3345 .from(pullRequests)
3346 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3347 .where(and(
3348 eq(pullRequests.repositoryId, repoId),
3349 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3350 ))
3351 .groupBy(pullRequests.id, pullRequests.createdAt);
3352
3353 const avgReviewTurnaroundMs = prsWithReviews.length > 0
3354 ? prsWithReviews.reduce((s, row) => {
3355 const firstMs = new Date(row.firstReview).getTime();
3356 return s + Math.max(0, firstMs - row.createdAt.getTime());
3357 }, 0) / prsWithReviews.length
3358 : null;
3359
3360 // 7. Open PRs by age bucket
3361 const openPRs = await db
3362 .select({ createdAt: pullRequests.createdAt })
3363 .from(pullRequests)
3364 .where(and(
3365 eq(pullRequests.repositoryId, repoId),
3366 eq(pullRequests.state, "open")
3367 ));
3368
3369 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
3370 for (const { createdAt } of openPRs) {
3371 const ageDays = (now - createdAt.getTime()) / 86_400_000;
3372 if (ageDays < 1) ageBuckets.lt1d++;
3373 else if (ageDays < 3) ageBuckets.d1to3++;
3374 else if (ageDays < 7) ageBuckets.d3to7++;
3375 else if (ageDays < 30) ageBuckets.d7to30++;
3376 else ageBuckets.gt30d++;
3377 }
3378 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
3379
3380 // 8. 7-day merge sparkline
3381 const sparklineRows = await db
3382 .select({
3383 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
3384 count: sql<number>`count(*)::int`,
3385 })
3386 .from(pullRequests)
3387 .where(and(
3388 eq(pullRequests.repositoryId, repoId),
3389 eq(pullRequests.state, "merged"),
3390 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
3391 ))
3392 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
3393 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
3394
3395 const sparkMap = new Map<string, number>();
3396 for (const row of sparklineRows) {
3397 sparkMap.set(row.day.slice(0, 10), row.count);
3398 }
3399 const sparkline: number[] = [];
3400 for (let i = 6; i >= 0; i--) {
3401 const d = new Date(now - i * 86_400_000);
3402 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
3403 }
3404 const maxSpark = Math.max(1, ...sparkline);
3405
3406 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
3407 { label: "< 1 day", key: "lt1d" },
3408 { label: "1–3 days", key: "d1to3" },
3409 { label: "3–7 days", key: "d3to7" },
3410 { label: "7–30 days", key: "d7to30" },
3411 { label: "> 30 days", key: "gt30d" },
3412 ];
3413
3414 return c.html(
3415 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
3416 <RepoHeader owner={ownerName} repo={repoName} />
3417 <PrNav owner={ownerName} repo={repoName} active="pulls" />
3418 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
3419
3420 <div class="pri-page">
3421 {/* Hero */}
3422 <div class="pri-hero">
3423 <div class="pri-hero-eyebrow">Pull requests</div>
3424 <h1 class="pri-hero-title">
3425 PR <span class="gradient-text">Insights</span>
3426 </h1>
3427 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
3428 </div>
3429
3430 {/* Stat cards */}
3431 <div class="pri-section">
3432 <div class="pri-section-title">At a glance</div>
3433 <div class="pri-cards">
3434 <div class="pri-card">
3435 <div class="pri-card-label">Avg merge time</div>
3436 <div class="pri-card-value">
3437 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
3438 </div>
3439 <div class="pri-card-sub">last 90 days</div>
3440 </div>
3441 <div class="pri-card">
3442 <div class="pri-card-label">Total merged</div>
3443 <div class="pri-card-value">{mergedPRs.length}</div>
3444 <div class="pri-card-sub">last 90 days</div>
3445 </div>
3446 <div class="pri-card">
3447 <div class="pri-card-label">Open PRs</div>
3448 <div class="pri-card-value">{openPRs.length}</div>
3449 <div class="pri-card-sub">right now</div>
3450 </div>
3451 <div class="pri-card">
3452 <div class="pri-card-label">Merge rate</div>
3453 <div class="pri-card-value">
3454 {mergeRate != null ? `${mergeRate}%` : "—"}
3455 </div>
3456 <div class="pri-card-sub">merged vs closed</div>
3457 </div>
3458 <div class="pri-card">
3459 <div class="pri-card-label">Avg reviews / PR</div>
3460 <div class="pri-card-value">
3461 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
3462 </div>
3463 <div class="pri-card-sub">merged PRs, 90d</div>
3464 </div>
3465 <div class="pri-card">
3466 <div class="pri-card-label">Top reviewer</div>
3467 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
3468 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
3469 </div>
3470 <div class="pri-card-sub">
3471 {reviewerCounts.length > 0
3472 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
3473 : "no reviews yet"}
3474 </div>
3475 </div>
3476 </div>
3477 </div>
3478
3479 {/* Review turnaround */}
3480 <div class="pri-section">
3481 <div class="pri-section-title">Review turnaround</div>
3482 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
3483 <div class="pri-card">
3484 <div class="pri-card-label">Avg time to first review</div>
3485 <div class="pri-card-value">
3486 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
3487 </div>
3488 <div class="pri-card-sub">
3489 {prsWithReviews.length > 0
3490 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
3491 : "no reviewed PRs in 90d"}
3492 </div>
3493 </div>
3494 </div>
3495 </div>
3496
3497 {/* Weekly throughput bar chart */}
3498 <div class="pri-section">
3499 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
3500 {weeklyPRs.length === 0 ? (
3501 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
3502 ) : (
3503 <div class="pri-chart">
3504 {weeklyPRs.map((w) => (
3505 <div class="pri-bar-col">
3506 <div
3507 class="pri-bar"
3508 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
3509 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
3510 />
3511 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
3512 </div>
3513 ))}
3514 </div>
3515 )}
3516 </div>
3517
3518 {/* 7-day merge sparkline */}
3519 <div class="pri-section">
3520 <div class="pri-section-title">Merges this week (daily)</div>
3521 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
3522 <div class="pri-sparkline">
3523 {sparkline.map((v) => (
3524 <div
3525 class="pri-spark-bar"
3526 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
3527 title={`${v} merge${v === 1 ? "" : "s"}`}
3528 />
3529 ))}
3530 </div>
3531 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
3532 <span>7 days ago</span>
3533 <span>Today</span>
3534 </div>
3535 </div>
3536 </div>
3537
3538 {/* Top reviewers table */}
3539 <div class="pri-section">
3540 <div class="pri-section-title">Top reviewers (last 90 days)</div>
3541 {reviewerCounts.length === 0 ? (
3542 <div class="pri-empty">No reviews posted in the last 90 days.</div>
3543 ) : (
3544 <div class="pri-table-wrap">
3545 <table class="pri-table">
3546 <thead>
3547 <tr>
3548 <th>#</th>
3549 <th>Reviewer</th>
3550 <th>Reviews</th>
3551 </tr>
3552 </thead>
3553 <tbody>
3554 {reviewerCounts.map((r, i) => (
3555 <tr>
3556 <td style="color:var(--text-muted)">{i + 1}</td>
3557 <td>
3558 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
3559 {r.username}
3560 </a>
3561 </td>
3562 <td style="font-weight:600">{r.count}</td>
3563 </tr>
3564 ))}
3565 </tbody>
3566 </table>
3567 </div>
3568 )}
3569 </div>
3570
3571 {/* Open PRs by age */}
3572 <div class="pri-section">
3573 <div class="pri-section-title">Open PRs by age</div>
3574 {openPRs.length === 0 ? (
3575 <div class="pri-empty">No open pull requests.</div>
3576 ) : (
3577 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
3578 {ageBucketDefs.map(({ label, key }) => (
3579 <div class="pri-age-row">
3580 <span class="pri-age-label">{label}</span>
3581 <div class="pri-age-bar-wrap">
3582 <div
3583 class="pri-age-bar"
3584 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
3585 />
3586 </div>
3587 <span class="pri-age-count">{ageBuckets[key]}</span>
3588 </div>
3589 ))}
3590 </div>
3591 )}
3592 </div>
3593
3594 {/* Back link */}
3595 <div>
3596 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
3597 {"←"} Back to pull requests
3598 </a>
3599 </div>
3600 </div>
3601 </Layout>
3602 );
3603});
3604
0074234Claude3605// New PR form
3606pulls.get(
3607 "/:owner/:repo/pulls/new",
3608 softAuth,
3609 requireAuth,
04f6b7fClaude3610 requireRepoAccess("write"),
0074234Claude3611 async (c) => {
3612 const { owner: ownerName, repo: repoName } = c.req.param();
3613 const user = c.get("user")!;
3614 const branches = await listBranches(ownerName, repoName);
3615 const error = c.req.query("error");
3616 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude3617 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude3618
3619 return c.html(
3620 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
3621 <RepoHeader owner={ownerName} repo={repoName} />
3622 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude3623 <Container maxWidth={800}>
3624 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude3625 {error && (
bb0f894Claude3626 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude3627 )}
0316dbbClaude3628 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
3629 <Flex gap={12} align="center" style="margin-bottom: 16px">
3630 <Select name="base">
0074234Claude3631 {branches.map((b) => (
3632 <option value={b} selected={b === defaultBase}>
3633 {b}
3634 </option>
3635 ))}
bb0f894Claude3636 </Select>
3637 <Text muted>&larr;</Text>
3638 <Select name="head">
0074234Claude3639 {branches
3640 .filter((b) => b !== defaultBase)
3641 .concat(defaultBase === branches[0] ? [] : [branches[0]])
3642 .map((b) => (
3643 <option value={b}>{b}</option>
3644 ))}
bb0f894Claude3645 </Select>
3646 </Flex>
3647 <FormGroup>
3648 <Input
0074234Claude3649 name="title"
3650 required
3651 placeholder="Title"
bb0f894Claude3652 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]3653 aria-label="Pull request title"
0074234Claude3654 />
bb0f894Claude3655 </FormGroup>
3656 <FormGroup>
3657 <TextArea
0074234Claude3658 name="body"
81c73c1Claude3659 id="pr-body"
0074234Claude3660 rows={8}
3661 placeholder="Description (Markdown supported)"
bb0f894Claude3662 mono
0074234Claude3663 />
bb0f894Claude3664 </FormGroup>
81c73c1Claude3665 <Flex gap={8} align="center">
3666 <Button type="submit" variant="primary">
3667 Create pull request
3668 </Button>
3669 <button
3670 type="button"
3671 id="ai-suggest-desc"
3672 class="btn"
3673 style="font-weight:500"
3674 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
3675 >
3676 Suggest description with AI
3677 </button>
3678 <span
3679 id="ai-suggest-status"
3680 style="color:var(--text-muted);font-size:13px"
3681 />
3682 </Flex>
bb0f894Claude3683 </Form>
81c73c1Claude3684 <script
3685 dangerouslySetInnerHTML={{
3686 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
3687 }}
3688 />
bb0f894Claude3689 </Container>
0074234Claude3690 </Layout>
3691 );
3692 }
3693);
3694
81c73c1Claude3695// AI-suggested PR description — JSON endpoint driven by the form button.
3696// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
3697// 200; the inline script reads `ok` to decide what to do.
3698pulls.post(
3699 "/:owner/:repo/ai/pr-description",
3700 softAuth,
3701 requireAuth,
3702 requireRepoAccess("write"),
3703 async (c) => {
3704 const { owner: ownerName, repo: repoName } = c.req.param();
3705 if (!isAiAvailable()) {
3706 return c.json({
3707 ok: false,
3708 error: "AI is not available — set ANTHROPIC_API_KEY.",
3709 });
3710 }
3711 const body = await c.req.parseBody();
3712 const title = String(body.title || "").trim();
3713 const baseBranch = String(body.base || "").trim();
3714 const headBranch = String(body.head || "").trim();
3715 if (!baseBranch || !headBranch) {
3716 return c.json({ ok: false, error: "Pick base + head branches first." });
3717 }
3718 if (baseBranch === headBranch) {
3719 return c.json({ ok: false, error: "Base and head must differ." });
3720 }
3721
3722 let diff = "";
3723 try {
3724 const cwd = getRepoPath(ownerName, repoName);
3725 const proc = Bun.spawn(
3726 [
3727 "git",
3728 "diff",
3729 `${baseBranch}...${headBranch}`,
3730 "--",
3731 ],
3732 { cwd, stdout: "pipe", stderr: "pipe" }
3733 );
6ea2109Claude3734 // 30s ceiling — without this a pathological diff (huge binary or
3735 // a corrupt ref) hangs the request indefinitely.
3736 const killer = setTimeout(() => proc.kill(), 30_000);
3737 try {
3738 diff = await new Response(proc.stdout).text();
3739 await proc.exited;
3740 } finally {
3741 clearTimeout(killer);
3742 }
81c73c1Claude3743 } catch {
3744 diff = "";
3745 }
3746 if (!diff.trim()) {
3747 return c.json({
3748 ok: false,
3749 error: "No diff between branches — nothing to summarise.",
3750 });
3751 }
3752
3753 let summary = "";
3754 try {
3755 summary = await generatePrSummary(title || "(untitled)", diff);
3756 } catch (err) {
3757 const msg = err instanceof Error ? err.message : "AI request failed.";
3758 return c.json({ ok: false, error: msg });
3759 }
3760 if (!summary.trim()) {
3761 return c.json({ ok: false, error: "AI returned an empty draft." });
3762 }
3763 return c.json({ ok: true, body: summary });
3764 }
3765);
3766
0074234Claude3767// Create PR
3768pulls.post(
3769 "/:owner/:repo/pulls/new",
3770 softAuth,
3771 requireAuth,
04f6b7fClaude3772 requireRepoAccess("write"),
0074234Claude3773 async (c) => {
3774 const { owner: ownerName, repo: repoName } = c.req.param();
3775 const user = c.get("user")!;
3776 const body = await c.req.parseBody();
3777 const title = String(body.title || "").trim();
3778 const prBody = String(body.body || "").trim();
3779 const baseBranch = String(body.base || "main");
3780 const headBranch = String(body.head || "");
3781
3782 if (!title || !headBranch) {
3783 return c.redirect(
3784 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
3785 );
3786 }
3787
3788 if (baseBranch === headBranch) {
3789 return c.redirect(
3790 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
3791 );
3792 }
3793
3794 const resolved = await resolveRepo(ownerName, repoName);
3795 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3796
6fc53bdClaude3797 const isDraft = String(body.draft || "") === "1";
3798
0074234Claude3799 const [pr] = await db
3800 .insert(pullRequests)
3801 .values({
3802 repositoryId: resolved.repo.id,
3803 authorId: user.id,
3804 title,
3805 body: prBody || null,
3806 baseBranch,
3807 headBranch,
6fc53bdClaude3808 isDraft,
0074234Claude3809 })
3810 .returning();
3811
ec9e3e3Claude3812 // CODEOWNERS — auto-request reviewers based on changed files.
3813 // Fire-and-forget; errors never block PR creation.
3814 (async () => {
3815 try {
3816 const repoDir = getRepoPath(ownerName, repoName);
3817 // Get list of changed files between base and head
3818 const diffProc = Bun.spawn(
3819 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
3820 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3821 );
3822 const rawDiff = await new Response(diffProc.stdout).text();
3823 await diffProc.exited;
3824 const changedFiles = rawDiff.trim().split("\n").filter(Boolean);
3825
3826 if (changedFiles.length > 0) {
3827 // Get CODEOWNERS from the default branch of the repo
3828 const rules = await getCodeownersForRepo(
3829 ownerName,
3830 repoName,
3831 resolved.repo.defaultBranch
3832 );
3833 if (rules.length > 0) {
3834 const ownerUsernames = await reviewersForChangedFiles(
3835 resolved.repo.id,
3836 changedFiles
3837 );
3838 // Filter out the PR author
3839 const filteredOwners = ownerUsernames.filter(
3840 (u) => u !== resolved.owner.username
3841 );
3842
3843 if (filteredOwners.length > 0) {
3844 // Look up user IDs for the owner usernames
3845 const reviewerUsers = await db
3846 .select({ id: users.id, username: users.username })
3847 .from(users)
3848 .where(
3849 inArray(
3850 users.username,
3851 filteredOwners
3852 )
3853 );
3854
3855 // Create review request rows (UNIQUE constraint prevents dupes)
3856 if (reviewerUsers.length > 0) {
3857 await db
3858 .insert(prReviewRequests)
3859 .values(
3860 reviewerUsers.map((u) => ({
3861 prId: pr.id,
3862 reviewerId: u.id,
3863 requestedBy: null as string | null,
3864 }))
3865 )
3866 .onConflictDoNothing();
3867
3868 // Add a PR comment announcing the auto-assigned reviewers
3869 const mentionList = reviewerUsers
3870 .map((u) => `@${u.username}`)
3871 .join(", ");
3872 await db.insert(prComments).values({
3873 pullRequestId: pr.id,
3874 authorId: user.id,
3875 body: `AI: Requested review from ${mentionList} based on CODEOWNERS`,
3876 isAiReview: true,
3877 });
3878 }
3879 }
3880 }
3881 }
3882 } catch (err) {
3883 console.warn("[codeowners] auto-assign failed:", err instanceof Error ? err.message : err);
3884 }
3885 })();
3886
6fc53bdClaude3887 // Skip AI review on drafts — it runs again when the PR is marked ready.
3888 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3889 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3890 (err) => console.error("[ai-review] Failed:", err)
3891 );
3892 }
3893
3cbe3d6Claude3894 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3895 triggerPrTriage({
3896 ownerName,
3897 repoName,
3898 repositoryId: resolved.repo.id,
3899 prId: pr.id,
3900 prAuthorId: user.id,
3901 title,
3902 body: prBody,
3903 baseBranch,
3904 headBranch,
3905 }).catch((err) => console.error("[pr-triage] Failed:", err));
3906
1d4ff60Claude3907 // Chat notifier — fan out to Slack/Discord/Teams.
3908 import("../lib/chat-notifier")
3909 .then((m) =>
3910 m.notifyChatChannels({
3911 ownerUserId: resolved.repo.ownerId,
3912 repositoryId: resolved.repo.id,
3913 event: {
3914 event: "pr.opened",
3915 repo: `${ownerName}/${repoName}`,
3916 title: `#${pr.number} ${title}`,
3917 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3918 body: prBody || undefined,
3919 actor: user.username,
3920 },
3921 })
3922 )
3923 .catch((err) =>
3924 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3925 );
3926
9dd96b9Test User3927 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude3928 import("../lib/auto-merge")
3929 .then((m) => m.tryAutoMergeNow(pr.id))
3930 .catch((err) => {
3931 console.warn(
3932 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
3933 err instanceof Error ? err.message : err
3934 );
3935 });
9dd96b9Test User3936
1df50d5Claude3937 // Migration 0077 — PR preview build. Fire-and-forget; skips when
3938 // PREVIEW_DOMAIN is unset or the repo has no preview_build_command.
3939 // Resolve head SHA asynchronously so we don't block the redirect.
3940 resolveRef(ownerName, repoName, headBranch)
3941 .then((headSha) => {
3942 if (!headSha) return;
3943 return import("../lib/preview-builder").then((m) =>
3944 m.buildPreview(pr.id, resolved.repo.id, headSha)
3945 );
3946 })
3947 .catch(() => {});
3948
0074234Claude3949 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
3950 }
3951);
3952
3953// View single PR
04f6b7fClaude3954pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude3955 const { owner: ownerName, repo: repoName } = c.req.param();
3956 const prNum = parseInt(c.req.param("number"), 10);
3957 const user = c.get("user");
3958 const tab = c.req.query("tab") || "conversation";
3959
3960 const resolved = await resolveRepo(ownerName, repoName);
3961 if (!resolved) return c.notFound();
3962
3963 const [pr] = await db
3964 .select()
3965 .from(pullRequests)
3966 .where(
3967 and(
3968 eq(pullRequests.repositoryId, resolved.repo.id),
3969 eq(pullRequests.number, prNum)
3970 )
3971 )
3972 .limit(1);
3973
3974 if (!pr) return c.notFound();
3975
3976 const [author] = await db
3977 .select()
3978 .from(users)
3979 .where(eq(users.id, pr.authorId))
3980 .limit(1);
3981
cb5a796Claude3982 const allCommentsRaw = await db
0074234Claude3983 .select({
3984 comment: prComments,
cb5a796Claude3985 author: { id: users.id, username: users.username },
0074234Claude3986 })
3987 .from(prComments)
3988 .innerJoin(users, eq(prComments.authorId, users.id))
3989 .where(eq(prComments.pullRequestId, pr.id))
3990 .orderBy(asc(prComments.createdAt));
3991
cb5a796Claude3992 // Filter pending/rejected/spam for non-owner, non-author viewers.
3993 // Owner always sees everything; comment author sees their own pending
3994 // with an "Awaiting approval" badge in the render below.
3995 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
3996 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
3997 if (viewerIsRepoOwner) return true;
3998 if (comment.moderationStatus === "approved") return true;
3999 if (
4000 user &&
4001 cAuthor.id === user.id &&
4002 comment.moderationStatus === "pending"
4003 ) {
4004 return true;
4005 }
4006 return false;
4007 });
4008 const prPendingCount = viewerIsRepoOwner
4009 ? await countPendingForRepo(resolved.repo.id)
4010 : 0;
4011
6fc53bdClaude4012 // Reactions for the PR body + each comment, in parallel.
4013 const [prReactions, ...prCommentReactions] = await Promise.all([
4014 summariseReactions("pr", pr.id, user?.id),
4015 ...comments.map((row) =>
4016 summariseReactions("pr_comment", row.comment.id, user?.id)
4017 ),
4018 ]);
4019
0a67773Claude4020 // Formal reviews (Approve / Request Changes)
4021 const reviewRows = await db
4022 .select({
4023 id: prReviews.id,
4024 state: prReviews.state,
4025 body: prReviews.body,
4026 isAi: prReviews.isAi,
4027 createdAt: prReviews.createdAt,
4028 reviewerUsername: users.username,
4029 reviewerId: prReviews.reviewerId,
4030 })
4031 .from(prReviews)
4032 .innerJoin(users, eq(prReviews.reviewerId, users.id))
4033 .where(eq(prReviews.pullRequestId, pr.id))
4034 .orderBy(asc(prReviews.createdAt));
4035 // Most recent review per reviewer determines the current state
4036 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
4037 for (const r of reviewRows) {
4038 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
4039 }
4040 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
4041 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
4042 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
4043
ec9e3e3Claude4044 // Requested reviewers from CODEOWNERS auto-assign (migration 0077).
4045 const requestedReviewerRows = await db
4046 .select({
4047 reviewerUsername: users.username,
4048 reviewerId: prReviewRequests.reviewerId,
4049 createdAt: prReviewRequests.createdAt,
4050 })
4051 .from(prReviewRequests)
4052 .innerJoin(users, eq(prReviewRequests.reviewerId, users.id))
4053 .where(eq(prReviewRequests.prId, pr.id))
4054 .orderBy(asc(prReviewRequests.createdAt))
4055 .catch(() => [] as { reviewerUsername: string; reviewerId: string; createdAt: Date }[]);
4056
ace34efClaude4057 // Suggested reviewers — best-effort, never throws
4058 let reviewerSuggestions: ReviewerCandidate[] = [];
4059 try {
4060 if (user) {
4061 reviewerSuggestions = await suggestReviewers(
4062 ownerName, repoName, pr.headBranch, pr.baseBranch,
4063 pr.authorId, resolved.repo.id
4064 );
4065 }
4066 } catch {
4067 // silent degradation
4068 }
4069
0074234Claude4070 const canManage =
4071 user &&
4072 (user.id === resolved.owner.id || user.id === pr.authorId);
4073
1d4ff60Claude4074 // Has any previous AI-test-generator run already tagged this PR? Used
4075 // both to hide the "Generate tests with AI" button and to short-circuit
4076 // the explicit POST handler.
4077 const hasAiTestsMarker = comments.some(({ comment }) =>
4078 (comment.body || "").includes(AI_TESTS_MARKER)
4079 );
4080
e883329Claude4081 const error = c.req.query("error");
c3e0c07Claude4082 const info = c.req.query("info");
e883329Claude4083
4084 // Get gate check status for open PRs
4085 let gateChecks: GateCheckResult[] = [];
240c477Claude4086 let ciStatuses: CommitStatus[] = [];
e883329Claude4087 if (pr.state === "open") {
6f1fd83Claude4088 try {
4089 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4090 if (headSha) {
4091 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
4092 const aiApproved = aiComments.length === 0 || aiComments.some(
4093 ({ comment }) => comment.body.includes("**Approved**")
4094 );
4095 const [gateResult, fetchedCiStatuses] = await Promise.all([
4096 runAllGateChecks(
4097 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
4098 ).catch(() => ({ allPassed: false, checks: [] as GateCheckResult[] })),
4099 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
4100 ]);
4101 gateChecks = gateResult.checks;
4102 ciStatuses = fetchedCiStatuses;
4103 }
4104 } catch {
4105 // git repo missing or unreachable — show PR without gate status
e883329Claude4106 }
4107 }
4108
534f04aClaude4109 // Block M3 — pre-merge risk score. Cache-only on the request path so
4110 // the page never waits on Haiku. On a cache miss for an open PR we
4111 // kick off the computation fire-and-forget; the next refresh shows it.
4112 let prRisk: PrRiskScore | null = null;
4113 let prRiskCalculating = false;
4114 if (pr.state === "open") {
4115 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
4116 if (!prRisk) {
4117 prRiskCalculating = true;
a28cedeClaude4118 void computePrRiskForPullRequest(pr.id).catch((err) => {
4119 console.warn(
4120 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
4121 err instanceof Error ? err.message : err
4122 );
4123 });
534f04aClaude4124 }
4125 }
4126
4bbacbeClaude4127 // Migration 0062 — per-branch preview URL. The head branch always
4128 // has a preview row (unless it's the default branch, which never
4129 // happens for an open PR) once it has been pushed at least once.
4130 const preview = await getPreviewForBranch(
4131 (resolved.repo as { id: string }).id,
4132 pr.headBranch
4133 );
4134
0369e77Claude4135 // Branch ahead/behind counts — how many commits head is ahead of base and
4136 // how many commits base has advanced since head branched off.
4137 let branchAhead = 0;
4138 let branchBehind = 0;
4139 if (pr.state === "open") {
4140 try {
4141 const repoDir = getRepoPath(ownerName, repoName);
4142 const [aheadProc, behindProc] = [
4143 Bun.spawn(
4144 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
4145 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4146 ),
4147 Bun.spawn(
4148 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
4149 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4150 ),
4151 ];
4152 const [aheadTxt, behindTxt] = await Promise.all([
4153 new Response(aheadProc.stdout).text(),
4154 new Response(behindProc.stdout).text(),
4155 ]);
4156 await Promise.all([aheadProc.exited, behindProc.exited]);
4157 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
4158 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
4159 } catch { /* non-blocking */ }
4160 }
4161
6d1bbc2Claude4162 // Linked issues — parse closing keywords from PR title+body, look up issues
4163 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
4164 try {
4165 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
4166 const refs = extractClosingRefsMulti([pr.title, pr.body]);
4167 if (refs.length > 0) {
4168 linkedIssues = await db
4169 .select({ number: issues.number, title: issues.title, state: issues.state })
4170 .from(issues)
4171 .where(and(
4172 eq(issues.repositoryId, resolved.repo.id),
4173 inArray(issues.number, refs),
4174 ));
4175 }
4176 } catch { /* non-blocking */ }
4177
4178 // Task list progress — count markdown checkboxes in PR body
4179 let taskTotal = 0;
4180 let taskChecked = 0;
4181 if (pr.body) {
4182 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
4183 taskTotal++;
4184 if (m[1].trim() !== "") taskChecked++;
4185 }
4186 }
4187
74d8c4dClaude4188 // M15 — PR size badge (best-effort, non-blocking)
4189 let prSizeInfo: PrSizeInfo | null = null;
4190 try {
4191 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
4192 } catch { /* swallow — purely cosmetic */ }
4193
09d5f39Claude4194 // Merge impact analysis — only for open PRs with write access (cached, fast)
4195 let impactAnalysis: ImpactAnalysis | null = null;
4196 if (pr.state === "open" && canManage) {
4197 try {
4198 impactAnalysis = await analyzeImpact(resolved.repo.id, pr.id);
4199 } catch { /* non-blocking */ }
4200 }
1d6db4dClaude4201
47a7a0aClaude4202 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude4203 let diffRaw = "";
4204 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude4205 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude4206 if (tab === "files") {
4207 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude4208 // Run the two git diffs in parallel — they're independent reads of
4209 // the same range. Previously sequential, doubling the wall time on
4210 // big PRs (100+ files = 10-30s for no reason).
0074234Claude4211 const proc = Bun.spawn(
4212 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
4213 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4214 );
4215 const statProc = Bun.spawn(
4216 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
4217 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4218 );
6ea2109Claude4219 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
4220 // would otherwise hang the whole request.
4221 const killer = setTimeout(() => {
4222 proc.kill();
4223 statProc.kill();
4224 }, 30_000);
4225 let stat = "";
4226 try {
4227 [diffRaw, stat] = await Promise.all([
4228 new Response(proc.stdout).text(),
4229 new Response(statProc.stdout).text(),
4230 ]);
4231 await Promise.all([proc.exited, statProc.exited]);
4232 } finally {
4233 clearTimeout(killer);
4234 }
0074234Claude4235
4236 diffFiles = stat
4237 .trim()
4238 .split("\n")
4239 .filter(Boolean)
4240 .map((line) => {
4241 const [add, del, filePath] = line.split("\t");
4242 return {
4243 path: filePath,
4244 status: "modified",
4245 additions: add === "-" ? 0 : parseInt(add, 10),
4246 deletions: del === "-" ? 0 : parseInt(del, 10),
4247 patch: "",
4248 };
4249 });
47a7a0aClaude4250
4251 // Fetch inline comments (file+line anchored) for the files tab
4252 const inlineRows = await db
4253 .select({
4254 id: prComments.id,
4255 filePath: prComments.filePath,
4256 lineNumber: prComments.lineNumber,
4257 body: prComments.body,
4258 isAiReview: prComments.isAiReview,
4259 createdAt: prComments.createdAt,
4260 authorUsername: users.username,
4261 })
4262 .from(prComments)
4263 .innerJoin(users, eq(prComments.authorId, users.id))
4264 .where(
4265 and(
4266 eq(prComments.pullRequestId, pr.id),
4267 eq(prComments.moderationStatus, "approved"),
4268 )
4269 )
4270 .orderBy(asc(prComments.createdAt));
4271
4272 diffInlineComments = inlineRows
4273 .filter(r => r.filePath != null && r.lineNumber != null)
4274 .map(r => ({
4275 id: r.id,
4276 filePath: r.filePath!,
4277 lineNumber: r.lineNumber!,
4278 authorUsername: r.authorUsername,
4279 body: renderMarkdown(r.body),
4280 isAiReview: r.isAiReview,
4281 createdAt: r.createdAt.toISOString(),
4282 }));
0074234Claude4283 }
4284
34e63b9Claude4285 // Proactive pattern warning — get changed file paths and check for recurring
4286 // bug patterns. Fire-and-forget safe; returns null on any error or cache miss.
4287 let patternWarning: Pattern | null = null;
4288 try {
4289 const repoDir = getRepoPath(ownerName, repoName);
4290 const nameOnlyProc = Bun.spawn(
4291 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4292 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4293 );
4294 const nameOnlyRaw = await new Response(nameOnlyProc.stdout).text();
4295 await nameOnlyProc.exited;
4296 const prChangedFiles = nameOnlyRaw.trim().split("\n").filter(Boolean);
4297 if (prChangedFiles.length > 0) {
4298 patternWarning = await getPatternWarning(resolved.repo.id, prChangedFiles);
4299 }
4300 } catch {
4301 // Non-blocking — swallow
4302 }
4303
7f992cdClaude4304 // Bus factor warning — flag files dominated by a single author.
4305 let busRiskFiles: BusFactorFile[] = [];
4306 try {
4307 const repoDir2 = getRepoPath(ownerName, repoName);
4308 const bf2Proc = Bun.spawn(
4309 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4310 { cwd: repoDir2, stdout: "pipe", stderr: "pipe" }
4311 );
4312 const bf2Raw = await new Response(bf2Proc.stdout).text();
4313 await bf2Proc.exited;
4314 const bf2Files = bf2Raw.trim().split("\n").filter(Boolean);
4315 if (bf2Files.length > 0) {
4316 busRiskFiles = await getBusFactorWarning(resolved.repo.id, ownerName, repoName, bf2Files);
4317 }
4318 } catch {
4319 // Non-blocking
4320 }
4321
4322 // PR split suggestion — AI recommends sub-PR decomposition for large PRs.
4323 let splitSuggestion: SplitSuggestion | null = null;
4324 try {
4325 splitSuggestion = await suggestPrSplit(
4326 pr.id,
4327 pr.title,
4328 ownerName,
4329 repoName,
4330 pr.baseBranch,
4331 pr.headBranch
4332 );
4333 } catch {
4334 // Non-blocking
4335 }
4336
b078860Claude4337 // ─── Derived visual state ───
4338 const stateKey =
4339 pr.state === "open"
4340 ? pr.isDraft
4341 ? "draft"
4342 : "open"
4343 : pr.state;
4344 const stateLabel =
4345 stateKey === "open"
4346 ? "Open"
4347 : stateKey === "draft"
4348 ? "Draft"
4349 : stateKey === "merged"
4350 ? "Merged"
4351 : "Closed";
4352 const stateIcon =
4353 stateKey === "open"
4354 ? "○"
4355 : stateKey === "draft"
4356 ? "◌"
4357 : stateKey === "merged"
4358 ? "⮌"
4359 : "✓";
4360 const commentCount = comments.length;
4361 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
4362 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
4363 const mergeBlocked =
4364 gateChecks.length > 0 &&
4365 gateChecks.some(
4366 (c) => !c.passed && c.name !== "Merge check"
4367 );
4368
b558f23Claude4369 // Commits tab — list commits included in this PR (base..head range)
4370 let prCommits: GitCommit[] = [];
4371 if (tab === "commits") {
4372 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
4373 }
4374
cc34156Claude4375 // Review context restore — compute BEFORE recording the visit so the
4376 // previous timestamp is available for the delta calculation.
4377 let reviewCtx: ReviewContext | null = null;
4378 if (user) {
4379 reviewCtx = await getReviewContext(pr.id, user.id, {
4380 ownerName,
4381 repoName,
4382 baseBranch: pr.baseBranch,
4383 headBranch: pr.headBranch,
4384 });
4385 // Fire-and-forget: record the visit AFTER computing context
4386 void recordPrVisit(pr.id, user.id);
4387 }
4388
0074234Claude4389 return c.html(
4390 <Layout
4391 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
4392 user={user}
4393 >
4394 <RepoHeader owner={ownerName} repo={repoName} />
4395 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude4396 <PendingCommentsBanner
4397 owner={ownerName}
4398 repo={repoName}
4399 count={prPendingCount}
4400 />
b078860Claude4401 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude4402 <div
4403 id="live-comment-banner"
4404 class="alert"
4405 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
4406 >
4407 <strong class="js-live-count">0</strong> new comment(s) —{" "}
4408 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
4409 reload to view
4410 </a>
4411 </div>
4412 <script
4413 dangerouslySetInnerHTML={{
4414 __html: liveCommentBannerScript({
4415 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
4416 bannerElementId: "live-comment-banner",
4417 }),
4418 }}
4419 />
b078860Claude4420
cc34156Claude4421 {/* Review context restore banner — shown when returning after changes */}
4422 {reviewCtx && (
4423 <div
4424 class="context-restore-banner"
4425 id="review-context"
4426 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"
4427 >
4428 <span class="context-icon" style="font-size:18px;flex-shrink:0;margin-top:2px" aria-hidden="true">{"↩"}</span>
4429 <div style="flex:1;min-width:0">
4430 <strong style="font-size:13.5px;color:var(--text-strong,#111)">Welcome back</strong>
4431 <p style="margin:4px 0 0;font-size:13px;color:var(--text,#333);line-height:1.5">{reviewCtx.summary}</p>
4432 <small style="font-size:11.5px;color:var(--text-muted,#777)">
4433 Last visited {formatRelative(new Date(reviewCtx.lastVisitedAt))}
4434 {reviewCtx.commitsSince > 0 && ` · ${reviewCtx.commitsSince} new commit${reviewCtx.commitsSince === 1 ? "" : "s"}`}
4435 {reviewCtx.newComments > 0 && ` · ${reviewCtx.newComments} new comment${reviewCtx.newComments === 1 ? "" : "s"}`}
4436 </small>
4437 {reviewCtx.suggestedStartLine && (
4438 <p style="margin:6px 0 0;font-size:12px;color:var(--accent,#0070f3)">
4439 Start at: <code style="font-size:11px">{reviewCtx.suggestedStartLine}</code>
4440 </p>
4441 )}
4442 </div>
4443 <button
4444 type="button"
4445 onclick="this.closest('.context-restore-banner').remove()"
4446 style="flex-shrink:0;background:none;border:none;cursor:pointer;font-size:18px;color:var(--text-muted,#777);padding:0;line-height:1"
4447 aria-label="Dismiss"
4448 >
4449 {"×"}
4450 </button>
4451 </div>
4452 )}
4453
b078860Claude4454 <div class="prs-detail-hero">
b558f23Claude4455 <div class="prs-edit-title-wrap">
4456 <h1 class="prs-detail-title" id="pr-title-display">
4457 {pr.title}{" "}
4458 <span class="prs-detail-num">#{pr.number}</span>
4459 </h1>
4460 {canManage && pr.state === "open" && (
4461 <button
4462 type="button"
4463 class="prs-edit-btn"
4464 id="pr-edit-toggle"
4465 onclick={`
4466 document.getElementById('pr-title-display').style.display='none';
4467 document.getElementById('pr-edit-toggle').style.display='none';
4468 document.getElementById('pr-edit-form').style.display='flex';
4469 document.getElementById('pr-title-input').focus();
4470 `}
4471 >
4472 Edit
4473 </button>
4474 )}
4475 </div>
4476 {canManage && pr.state === "open" && (
4477 <form
4478 id="pr-edit-form"
4479 method="post"
4480 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
4481 class="prs-edit-form"
4482 style="display:none"
4483 >
4484 <input
4485 id="pr-title-input"
4486 type="text"
4487 name="title"
4488 value={pr.title}
4489 required
4490 maxlength={256}
4491 placeholder="Pull request title"
4492 />
4493 <div class="prs-edit-actions">
4494 <button type="submit" class="prs-edit-save-btn">Save</button>
4495 <button
4496 type="button"
4497 class="prs-edit-cancel-btn"
4498 onclick={`
4499 document.getElementById('pr-edit-form').style.display='none';
4500 document.getElementById('pr-title-display').style.display='';
4501 document.getElementById('pr-edit-toggle').style.display='';
4502 `}
4503 >
4504 Cancel
4505 </button>
4506 </div>
4507 </form>
4508 )}
b078860Claude4509 <div class="prs-detail-meta">
4510 <span class={`prs-state-pill state-${stateKey}`}>
4511 <span aria-hidden="true">{stateIcon}</span>
4512 <span>{stateLabel}</span>
4513 </span>
74d8c4dClaude4514 {prSizeInfo && (
4515 <span
4516 class="prs-size-badge"
4517 style={`color:${prSizeInfo.color};background:${prSizeInfo.bgColor}`}
4518 title={`${prSizeInfo.linesChanged} lines changed (+${prSizeInfo.added} −${prSizeInfo.deleted})`}
4519 >
4520 {prSizeInfo.label}
4521 </span>
4522 )}
67dc4e1Claude4523 <TrioVerdictPills
4524 comments={comments.map(({ comment }) => comment)}
4525 />
b078860Claude4526 <span>
4527 <strong>{author?.username}</strong> wants to merge
4528 </span>
4529 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
4530 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
4531 <span class="prs-branch-arrow-lg">{"→"}</span>
4532 <span class="prs-branch-pill">{pr.baseBranch}</span>
4533 </span>
0369e77Claude4534 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
4535 <span
4536 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
4537 title={branchBehind > 0
4538 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
4539 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
4540 >
4541 {branchAhead > 0 ? `↑${branchAhead}` : ""}
4542 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
4543 {branchBehind > 0 ? `↓${branchBehind}` : ""}
4544 </span>
4545 )}
b078860Claude4546 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude4547 {taskTotal > 0 && (
4548 <span
4549 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
4550 title={`${taskChecked} of ${taskTotal} tasks completed`}
4551 >
4552 <span class="prs-tasks-progress" aria-hidden="true">
4553 <span
4554 class="prs-tasks-progress-bar"
4555 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
4556 ></span>
4557 </span>
4558 {taskChecked}/{taskTotal} tasks
4559 </span>
4560 )}
4561 {canManage && pr.state === "open" && branchBehind > 0 && (
4562 <form
4563 method="post"
4564 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
4565 class="prs-inline-form"
4566 >
4567 <button
4568 type="submit"
4569 class="prs-update-branch-btn"
4570 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
4571 >
4572 ↑ Update branch
4573 </button>
4574 </form>
4575 )}
3c03977Claude4576 <span
4577 id="live-pill"
4578 class="live-pill"
4579 title="People editing this PR right now"
4580 >
4581 <span class="live-pill-dot" aria-hidden="true"></span>
4582 <span>
4583 Live: <strong id="live-count">0</strong> editing
4584 </span>
4585 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
4586 </span>
4bbacbeClaude4587 {preview && (
4588 <a
4589 class={`preview-prpill is-${preview.status}`}
4590 href={
4591 preview.status === "ready"
4592 ? preview.previewUrl
4593 : `/${ownerName}/${repoName}/previews`
4594 }
4595 target={preview.status === "ready" ? "_blank" : undefined}
4596 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
4597 title={`Preview · ${previewStatusLabel(preview.status)}`}
4598 >
4599 <span class="preview-prpill-dot" aria-hidden="true"></span>
4600 <span>Preview: </span>
4601 <span>{previewStatusLabel(preview.status)}</span>
4602 </a>
4603 )}
b078860Claude4604 {canManage && pr.state === "open" && pr.isDraft && (
4605 <form
4606 method="post"
4607 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4608 class="prs-inline-form prs-detail-actions"
4609 >
4610 <button type="submit" class="prs-merge-ready-btn">
4611 Ready for review
4612 </button>
4613 </form>
4614 )}
4615 </div>
4616 </div>
3c03977Claude4617 <script
4618 dangerouslySetInnerHTML={{
4619 __html: LIVE_COEDIT_SCRIPT(pr.id),
4620 }}
4621 />
829a046Claude4622 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude4623 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude4624 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude4625
25b1ff7Claude4626 {/* Presence styles + bar (shown only on the files tab so cursor pills work) */}
b271465Claude4627 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES + IMPACT_STYLES }} />
25b1ff7Claude4628 {/* Toast container — always present for join/leave toasts */}
4629 <div id="presence-toasts" class="presence-toast-wrap" aria-live="polite" />
4630 {user && (
4631 <>
4632 <div class="presence-bar" id="presence-bar">
4633 <span class="presence-bar-label">Live reviewers</span>
4634 <div class="presence-avatars" id="presence-avatars" />
4635 <span class="presence-count" id="presence-count">Loading…</span>
4636 </div>
4637 <script
4638 dangerouslySetInnerHTML={{
4639 __html: PR_PRESENCE_SCRIPT(ownerName, repoName, pr.number),
4640 }}
4641 />
4642 </>
4643 )}
4644
b078860Claude4645 <nav class="prs-detail-tabs" aria-label="Pull request sections">
4646 <a
4647 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
4648 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
4649 >
4650 Conversation
4651 <span class="prs-detail-tab-count">{commentCount}</span>
4652 </a>
b558f23Claude4653 <a
4654 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
4655 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
4656 >
4657 Commits
4658 {branchAhead > 0 && (
4659 <span class="prs-detail-tab-count">{branchAhead}</span>
4660 )}
4661 </a>
b078860Claude4662 <a
4663 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
4664 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4665 >
4666 Files changed
4667 {diffFiles.length > 0 && (
4668 <span class="prs-detail-tab-count">{diffFiles.length}</span>
4669 )}
4670 </a>
4671 </nav>
4672
34e63b9Claude4673 {/* Proactive pattern warning — shown when a known recurring bug pattern
4674 overlaps with the files changed in this PR. */}
4675 {patternWarning && (
4676 <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">
4677 <span style="font-size:15px;margin-right:6px" aria-hidden="true">⚠️</span>
4678 <strong>Recurring pattern detected: {patternWarning.title}</strong>
4679 <span style="color:var(--fg-muted)">
4680 {" — "}
4681 This area has had {patternWarning.occurrences} similar fix
4682 {patternWarning.occurrences === 1 ? "" : "es"}.
4683 {patternWarning.rootCauseHypothesis && (
4684 <> Root cause may be in <code style="font-size:12px">{patternWarning.suggestedFile}</code>.</>
4685 )}
4686 </span>
4687 </div>
4688 )}
4689
b558f23Claude4690 {tab === "commits" ? (
4691 <div class="prs-commits-list">
4692 {prCommits.length === 0 ? (
4693 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
4694 ) : (
4695 prCommits.map((commit) => (
4696 <div class="prs-commit-row">
4697 <span class="prs-commit-dot" aria-hidden="true"></span>
4698 <div class="prs-commit-body">
4699 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
4700 <div class="prs-commit-meta">
4701 <strong>{commit.author}</strong> committed{" "}
4702 {formatRelative(new Date(commit.date))}
4703 </div>
4704 </div>
4705 <a
4706 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
4707 class="prs-commit-sha"
4708 title="View commit"
4709 >
4710 {commit.sha.slice(0, 7)}
4711 </a>
4712 </div>
4713 ))
4714 )}
4715 </div>
4716 ) : tab === "files" ? (
1d6db4dClaude4717 <>
4718 {/* PR Split Suggestion — shown when PR has >400 changed lines */}
4719 {splitSuggestion && (
4720 <div class="split-suggestion" id="pr-split-banner">
4721 <div class="split-header">
4722 <span class="split-icon" aria-hidden="true">✂️</span>
4723 <strong>This PR may be too large to review effectively</strong>
4724 <span class="split-stat">
4725 {splitSuggestion.totalLines} lines · {splitSuggestion.totalFiles} files
4726 </span>
4727 <button
4728 class="split-toggle"
4729 type="button"
4730 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';"
4731 >
4732 Show split suggestion
4733 </button>
4734 </div>
4735 <div class="split-body" id="pr-split-body" hidden>
4736 <p class="split-intro">
4737 AI suggests splitting into {splitSuggestion.suggestedPrs.length} PRs:
4738 </p>
4739 {splitSuggestion.suggestedPrs.map((sp, i) => (
4740 <div class="split-pr">
4741 <div class="split-pr-num">{i + 1}</div>
4742 <div class="split-pr-body">
4743 <strong>{sp.title}</strong>
4744 <p>{sp.rationale}</p>
4745 <code>{sp.files.join(", ")}</code>
4746 <span class="split-lines">~{sp.estimatedLines} lines</span>
4747 </div>
4748 </div>
4749 ))}
4750 {splitSuggestion.mergeOrder.length > 0 && (
4751 <p class="split-order">
4752 Suggested merge order:{" "}
4753 <strong>{splitSuggestion.mergeOrder.join(" → ")}</strong>
4754 </p>
4755 )}
4756 </div>
4757 </div>
4758 )}
4759
4760 {/* Bus Factor Warning — shown when changed files overlap at-risk files */}
4761 {busRiskFiles.length > 0 && (() => {
4762 const topRisk = busRiskFiles.some((f) => f.risk === "critical")
4763 ? "critical"
4764 : busRiskFiles.some((f) => f.risk === "high")
4765 ? "high"
4766 : "medium";
4767 return (
4768 <div class={`busfactor-panel busfactor-${topRisk}`}>
4769 <span class="busfactor-icon" aria-hidden="true">⚠️</span>
4770 <div class="busfactor-body">
4771 <strong>Knowledge concentration warning</strong>
4772 <p>
4773 {busRiskFiles.length} file{busRiskFiles.length !== 1 ? "s" : ""} in
4774 this PR {busRiskFiles.length !== 1 ? "are" : "is"} primarily
4775 maintained by one person. Consider pairing on this review.
4776 </p>
4777 <ul>
4778 {busRiskFiles.map((f) => (
4779 <li>
4780 <code>{f.path}</code> —{" "}
4781 <strong>{f.primaryAuthorPct}%</strong> by {f.primaryAuthor}
4782 </li>
4783 ))}
4784 </ul>
4785 </div>
4786 </div>
4787 );
4788 })()}
4789
4790 <DiffView
4791 raw={diffRaw}
4792 files={diffFiles}
4793 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
4794 inlineComments={diffInlineComments}
4795 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
4796 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
4797 />
4798 </>
b078860Claude4799 ) : (
4800 <>
4801 {pr.body && (
4802 <CommentBox
4803 author={author?.username ?? "unknown"}
4804 date={pr.createdAt}
4805 body={renderMarkdown(pr.body)}
4806 />
4807 )}
4808
422a2d4Claude4809 {/* Block H — AI trio review (security/correctness/style). When
4810 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
4811 hoisted into a 3-column card grid above the normal comment
4812 stream so reviewers see verdicts at a glance. Disagreements
4813 are surfaced as a yellow callout. */}
4814 <TrioReviewGrid
4815 comments={comments.map(({ comment }) => comment)}
4816 />
4817
15db0e0Claude4818 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude4819 // Skip trio comments — already rendered in TrioReviewGrid above.
4820 if (isTrioComment(comment.body)) return null;
15db0e0Claude4821 const slashCmd = detectSlashCmdComment(comment.body);
4822 if (slashCmd) {
4823 const visible = stripSlashCmdMarker(comment.body);
4824 return (
4825 <div class={`slash-pill slash-cmd-${slashCmd}`}>
4826 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
4827 <span class="slash-pill-actor">
4828 <strong>{commentAuthor.username}</strong>
4829 {" ran "}
4830 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude4831 </span>
15db0e0Claude4832 <span class="slash-pill-time">
4833 {formatRelative(comment.createdAt)}
4834 </span>
4835 <div class="slash-pill-body">
4836 <MarkdownContent html={renderMarkdown(visible)} />
4837 </div>
4838 </div>
4839 );
4840 }
cb5a796Claude4841 const isPending = comment.moderationStatus === "pending";
15db0e0Claude4842 return (
cb5a796Claude4843 <div
4844 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
4845 >
15db0e0Claude4846 <div class="prs-comment-head">
4847 <strong>{commentAuthor.username}</strong>
a7460bfClaude4848 {commentAuthor.username === BOT_USERNAME && (
4849 <span class="prs-bot-badge">&#x1F916; bot</span>
4850 )}
15db0e0Claude4851 {comment.isAiReview && (
4852 <span class="prs-ai-badge">AI Review</span>
4853 )}
cb5a796Claude4854 {isPending && (
4855 <span
4856 class="modq-pending-badge"
4857 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
4858 >
4859 Awaiting approval
4860 </span>
4861 )}
15db0e0Claude4862 <span class="prs-comment-time">
4863 commented {formatRelative(comment.createdAt)}
4864 </span>
4865 {comment.filePath && (
4866 <span class="prs-comment-loc">
4867 {comment.filePath}
4868 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
4869 </span>
4870 )}
4871 </div>
4872 <div class="prs-comment-body">
4873 <MarkdownContent html={renderMarkdown(comment.body)} />
4874 </div>
0074234Claude4875 </div>
15db0e0Claude4876 );
4877 })}
0074234Claude4878
b078860Claude4879 {/* Quick link to the Files changed tab when there's a diff to look at. */}
4880 {pr.state !== "merged" && (
4881 <a
4882 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4883 class="prs-files-card"
4884 >
4885 <span class="prs-files-card-icon" aria-hidden="true">
4886 {"▤"}
4887 </span>
4888 <div class="prs-files-card-text">
4889 <p class="prs-files-card-title">Files changed</p>
4890 <p class="prs-files-card-sub">
4891 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
4892 </p>
e883329Claude4893 </div>
b078860Claude4894 <span class="prs-files-card-cta">View diff {"→"}</span>
4895 </a>
4896 )}
4897
6d1bbc2Claude4898 {linkedIssues.length > 0 && (
4899 <div class="prs-linked-issues">
4900 <div class="prs-linked-issues-head">
4901 <span>Closing issues</span>
4902 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
4903 </div>
4904 {linkedIssues.map((issue) => (
4905 <a
4906 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
4907 class="prs-linked-issue-row"
4908 >
4909 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
4910 {issue.state === "open" ? "○" : "✓"}
4911 </span>
4912 <span class="prs-linked-issue-title">{issue.title}</span>
4913 <span class="prs-linked-issue-num">#{issue.number}</span>
4914 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
4915 {issue.state}
4916 </span>
4917 </a>
4918 ))}
4919 </div>
4920 )}
4921
b078860Claude4922 {error && (
4923 <div
4924 class="auth-error"
4925 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)"
4926 >
4927 {decodeURIComponent(error)}
4928 </div>
4929 )}
4930
4931 {info && (
4932 <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)">
4933 {decodeURIComponent(info)}
4934 </div>
4935 )}
e883329Claude4936
b078860Claude4937 {pr.state === "open" && (prRisk || prRiskCalculating) && (
4938 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
4939 )}
4940
ec9e3e3Claude4941 {/* ─── Requested reviewers (CODEOWNERS auto-assign, migration 0077) ─── */}
4942 {requestedReviewerRows.length > 0 && (
4943 <div class="prs-review-summary" style="margin-top:14px">
4944 <div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-weight:700;margin-bottom:4px">
4945 Review requested
4946 </div>
4947 {requestedReviewerRows.map((rr) => {
4948 const hasReviewed = latestReviewByReviewer.has(rr.reviewerId);
4949 const review = latestReviewByReviewer.get(rr.reviewerId);
4950 const statusIcon = !hasReviewed ? "⏳" : review?.state === "approved" ? "✓" : "✗";
4951 const statusColor = !hasReviewed
4952 ? "var(--text-muted)"
4953 : review?.state === "approved"
4954 ? "#34d399"
4955 : "#f87171";
4956 return (
4957 <div class="prs-review-row" style={`gap:8px`}>
4958 <span class="prs-reviewer-avatar">
4959 {rr.reviewerUsername.slice(0, 1).toUpperCase()}
4960 </span>
4961 <a href={`/${rr.reviewerUsername}`}
4962 style="flex:1;font-size:13px;color:var(--text);font-weight:600;text-decoration:none">
4963 {rr.reviewerUsername}
4964 </a>
4965 <span style={`font-size:12px;font-weight:600;color:${statusColor}`}>
4966 {statusIcon} {!hasReviewed ? "Pending" : review?.state === "approved" ? "Approved" : "Changes requested"}
4967 </span>
4968 </div>
4969 );
4970 })}
4971 </div>
b271465Claude4972 )}
09d5f39Claude4973 {/* ─── Merge Impact Analysis panel ─────────────────────── */}
4974 {impactAnalysis && pr.state === "open" && (
4975 <ImpactPanel analysis={impactAnalysis} owner={ownerName} />
ec9e3e3Claude4976 )}
4977
0a67773Claude4978 {/* ─── Review summary ─────────────────────────────────── */}
4979 {(approvals.length > 0 || changesRequested.length > 0) && (
4980 <div class="prs-review-summary">
4981 {approvals.length > 0 && (
4982 <div class="prs-review-row prs-review-approved">
4983 <span class="prs-review-icon">✓</span>
4984 <span>
4985 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4986 approved this pull request
4987 </span>
4988 </div>
4989 )}
4990 {changesRequested.length > 0 && (
4991 <div class="prs-review-row prs-review-changes">
4992 <span class="prs-review-icon">✗</span>
4993 <span>
4994 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4995 requested changes
4996 </span>
4997 </div>
4998 )}
4999 </div>
5000 )}
5001
ace34efClaude5002 {/* Suggested reviewers */}
5003 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
5004 <div class="prs-review-summary" style="margin-top:12px">
5005 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
5006 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
5007 Suggested reviewers
5008 </span>
5009 {reviewerSuggestions.map((r) => (
5010 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
5011 style="display:flex;align-items:center;gap:8px;width:100%">
5012 <input type="hidden" name="reviewerId" value={r.userId} />
5013 <span class="prs-reviewer-avatar">
5014 {r.username.slice(0, 1).toUpperCase()}
5015 </span>
5016 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
5017 {r.username}
5018 </a>
5019 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
5020 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
5021 Request
5022 </button>
5023 </form>
5024 ))}
5025 </div>
5026 </div>
5027 )}
5028
b078860Claude5029 {pr.state === "open" && gateChecks.length > 0 && (
5030 <div class="prs-gate-card">
5031 <div class="prs-gate-head">
5032 <h3>Gate checks</h3>
5033 <span class="prs-gate-summary">
5034 {gatesAllPassed
5035 ? `All ${gateChecks.length} checks passed`
5036 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
5037 </span>
c3e0c07Claude5038 </div>
b078860Claude5039 {gateChecks.map((check) => {
5040 const isAi = /ai.*review/i.test(check.name);
5041 const isSkip = check.skipped === true;
5042 const statusClass = isSkip
5043 ? "is-skip"
5044 : check.passed
5045 ? "is-pass"
5046 : "is-fail";
5047 const statusGlyph = isSkip
5048 ? "—"
5049 : check.passed
5050 ? "✓"
5051 : "✗";
5052 const statusLabel = isSkip
5053 ? "Skipped"
5054 : check.passed
5055 ? "Passed"
5056 : "Failing";
5057 return (
5058 <div
5059 class="prs-gate-row"
5060 style={
5061 isAi
6fd5915Claude5062 ? "border-left: 3px solid rgba(91,110,232,0.55); padding-left: 15px"
b078860Claude5063 : ""
5064 }
5065 >
5066 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
5067 {statusGlyph}
5068 </span>
5069 <span class="prs-gate-name">
5070 {check.name}
5071 {isAi && (
5072 <span
6fd5915Claude5073 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"
b078860Claude5074 >
5075 AI
5076 </span>
5077 )}
5078 </span>
5079 <span class="prs-gate-details">{check.details}</span>
5080 <span class={`prs-gate-pill ${statusClass}`}>
5081 {statusLabel}
e883329Claude5082 </span>
5083 </div>
b078860Claude5084 );
5085 })}
5086 <div class="prs-gate-footer">
5087 {gatesAllPassed
5088 ? "All checks passed — ready to merge."
5089 : gateChecks.some(
5090 (c) => !c.passed && c.name === "Merge check"
5091 )
5092 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
5093 : "Some checks failed — resolve issues before merging."}
5094 {aiReviewCount > 0 && (
5095 <>
5096 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
5097 </>
5098 )}
5099 </div>
5100 </div>
5101 )}
5102
240c477Claude5103 {pr.state === "open" && ciStatuses.length > 0 && (
5104 <div class="prs-ci-card">
5105 <div class="prs-ci-head">
5106 <h3>CI checks</h3>
5107 <span class="prs-ci-summary">
5108 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
5109 </span>
5110 </div>
5111 {ciStatuses.map((status) => {
5112 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
5113 return (
5114 <div class="prs-ci-row">
5115 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
5116 <span class="prs-ci-context">{status.context}</span>
5117 {status.description && (
5118 <span class="prs-ci-desc">{status.description}</span>
5119 )}
5120 <span class={`prs-ci-pill is-${status.state}`}>
5121 {status.state}
5122 </span>
5123 {status.targetUrl && (
5124 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
5125 )}
5126 </div>
5127 );
5128 })}
5129 </div>
5130 )}
5131
b078860Claude5132 {/* ─── Merge area / state-aware action card ─────────────── */}
5133 {user && pr.state === "open" && (
5134 <div
5135 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
5136 >
5137 <div class="prs-merge-head">
5138 <strong>
5139 {pr.isDraft
5140 ? "Draft — ready for review?"
5141 : mergeBlocked
5142 ? "Merge blocked"
5143 : "Ready to merge"}
5144 </strong>
e883329Claude5145 </div>
b078860Claude5146 <p class="prs-merge-sub">
5147 {pr.isDraft
5148 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
5149 : mergeBlocked
5150 ? "Resolve the failing gate checks above before this PR can land."
5151 : gateChecks.length > 0
5152 ? gatesAllPassed
5153 ? "All gates green. Merge will fast-forward into the base branch."
5154 : "Conflicts will be auto-resolved by GlueCron AI on merge."
5155 : "Run gate checks by refreshing once your branch has a recent commit."}
5156 </p>
5157 <Form
5158 method="post"
5159 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
5160 >
5161 <FormGroup>
3c03977Claude5162 <div class="live-cursor-host" style="position:relative">
5163 <textarea
5164 name="body"
5165 id="pr-comment-body"
5166 data-live-field="comment_new"
6cd2f0eClaude5167 data-md-preview=""
3c03977Claude5168 rows={5}
5169 required
5170 placeholder="Leave a comment... (Markdown supported)"
5171 style="font-family:var(--font-mono);font-size:13px;width:100%"
5172 ></textarea>
5173 </div>
15db0e0Claude5174 <span class="slash-hint" title="Type a slash-command as the first line">
5175 Type <code>/</code> for commands —{" "}
5176 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
09d5f39Claude5177 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>,{" "}
5178 <code>/stage</code>
15db0e0Claude5179 </span>
b078860Claude5180 </FormGroup>
5181 <div class="prs-merge-actions">
5182 <Button type="submit" variant="primary">
5183 Comment
5184 </Button>
0a67773Claude5185 {user && user.id !== pr.authorId && pr.state === "open" && (
5186 <>
5187 <button
5188 type="submit"
5189 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
5190 name="review_state"
5191 value="approved"
5192 class="prs-review-approve-btn"
5193 title="Approve this pull request"
5194 >
5195 ✓ Approve
5196 </button>
5197 <button
5198 type="submit"
5199 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
5200 name="review_state"
5201 value="changes_requested"
5202 class="prs-review-changes-btn"
5203 title="Request changes before merging"
5204 >
5205 ✗ Request changes
5206 </button>
5207 </>
5208 )}
b078860Claude5209 {canManage && (
5210 <>
5211 {pr.isDraft ? (
5212 <button
5213 type="submit"
5214 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
5215 formnovalidate
5216 class="prs-merge-ready-btn"
5217 >
5218 Ready for review
5219 </button>
5220 ) : (
a164a6dClaude5221 <>
5222 <div class="prs-merge-strategy-wrap">
5223 <span class="prs-merge-strategy-label">Strategy</span>
5224 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
5225 <option value="merge">Merge commit</option>
5226 <option value="squash">Squash and merge</option>
5227 <option value="ff">Fast-forward</option>
5228 </select>
5229 </div>
5230 <button
5231 type="submit"
5232 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
5233 formnovalidate
5234 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
5235 title={
5236 mergeBlocked
5237 ? "Failing gate checks must be resolved before this PR can merge."
5238 : "Merge pull request"
5239 }
5240 >
5241 {"✔"} Merge pull request
5242 </button>
5243 </>
b078860Claude5244 )}
5245 {!pr.isDraft && (
5246 <button
0074234Claude5247 type="submit"
b078860Claude5248 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
5249 formnovalidate
5250 class="prs-merge-back-draft"
5251 title="Convert back to draft"
0074234Claude5252 >
b078860Claude5253 Convert to draft
5254 </button>
5255 )}
5256 {isAiReviewEnabled() && (
5257 <button
5258 type="submit"
5259 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
5260 formnovalidate
5261 class="btn"
5262 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
5263 >
5264 Re-run AI review
5265 </button>
5266 )}
1d4ff60Claude5267 {isAiReviewEnabled() && !hasAiTestsMarker && (
5268 <button
5269 type="submit"
5270 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
5271 formnovalidate
5272 class="btn"
5273 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."
5274 >
5275 Generate tests with AI
5276 </button>
5277 )}
b078860Claude5278 <Button
5279 type="submit"
5280 variant="danger"
5281 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
5282 >
5283 Close
5284 </Button>
5285 </>
5286 )}
5287 </div>
5288 </Form>
5289 </div>
5290 )}
5291
5292 {/* Read-only footers for non-open states. */}
5293 {pr.state === "merged" && (
5294 <div class="prs-merge-card is-merged">
5295 <div class="prs-merge-head">
5296 <strong>{"⮌"} Merged</strong>
0074234Claude5297 </div>
b078860Claude5298 <p class="prs-merge-sub">
5299 This pull request was merged into{" "}
5300 <code>{pr.baseBranch}</code>.
5301 </p>
5302 </div>
5303 )}
5304 {pr.state === "closed" && (
5305 <div class="prs-merge-card is-closed">
5306 <div class="prs-merge-head">
5307 <strong>{"✕"} Closed without merging</strong>
5308 </div>
5309 <p class="prs-merge-sub">
5310 This pull request was closed and not merged.
5311 </p>
5312 </div>
5313 )}
5314 </>
5315 )}
641aa42Claude5316 {/* Keyboard hint bar — shown at the bottom of PR pages */}
5317 <div class="kbd-hints" aria-label="Keyboard shortcuts for this pull request">
5318 <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
5319 </div>
5320 <style dangerouslySetInnerHTML={{ __html: `
5321 .kbd-hints {
5322 position: fixed;
5323 bottom: 0;
5324 left: 0;
5325 right: 0;
5326 z-index: 90;
5327 padding: 6px 24px;
5328 background: var(--bg-secondary);
5329 border-top: 1px solid var(--border);
5330 font-size: 12px;
5331 color: var(--text-muted);
5332 display: flex;
5333 align-items: center;
5334 gap: 8px;
5335 flex-wrap: wrap;
5336 }
5337 .kbd-hints kbd {
5338 font-family: var(--font-mono);
5339 font-size: 10px;
5340 background: var(--bg-elevated);
5341 border: 1px solid var(--border);
5342 border-bottom-width: 2px;
5343 border-radius: 4px;
5344 padding: 1px 5px;
5345 color: var(--text);
5346 line-height: 1.5;
5347 }
5348 /* Padding so the page footer doesn't overlap the hint bar */
5349 main { padding-bottom: 40px; }
5350 ` }} />
5351 {/* Repo context commands for command palette */}
5352 <script
5353 id="cmdk-repo-context"
5354 dangerouslySetInnerHTML={{
5355 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
5356 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
5357 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
5358 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
5359 { label: `View commits — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/commits`, kw: "history log" },
5360 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
5361 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
5362 ])};`,
5363 }}
5364 />
5365 {/* PR keyboard shortcuts script */}
5366 <script dangerouslySetInnerHTML={{ __html: `
5367 (function(){
5368 var commentBox = document.querySelector('textarea[name="body"]');
5369 var mergeBtn = document.querySelector('[data-merge-btn], .prs-merge-btn, button[form*="merge"], form[action*="/merge"] button[type="submit"]');
5370 var editBtn = document.getElementById('pr-edit-toggle');
5371 var approveUrl = ${JSON.stringify(`/${ownerName}/${repoName}/pulls/${pr.number}/review`)};
5372
5373 function isTyping(t){
5374 t = t || {};
5375 var tag = (t.tagName || '').toLowerCase();
5376 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
5377 }
5378
5379 document.addEventListener('keydown', function(e){
5380 if (isTyping(e.target)) return;
5381 if (e.metaKey || e.ctrlKey || e.altKey) return;
5382 if (e.key === 'c') {
5383 e.preventDefault();
5384 if (commentBox) { commentBox.focus(); commentBox.scrollIntoView({block:'center'}); }
5385 }
5386 if (e.key === 'e') {
5387 e.preventDefault();
5388 if (editBtn) { editBtn.click(); }
5389 }
5390 if (e.key === 'm') {
5391 e.preventDefault();
5392 var mBtn = document.querySelector('.prs-merge-btn, form[action*="/merge"] button[type="submit"]');
5393 if (mBtn) { mBtn.focus(); mBtn.scrollIntoView({block:'center'}); }
5394 }
5395 if (e.key === 'a') {
5396 e.preventDefault();
5397 // Navigate to approve review page
5398 window.location.href = approveUrl + '?action=approve';
5399 }
5400 if (e.key === 'r') {
5401 e.preventDefault();
5402 window.location.href = approveUrl + '?action=request_changes';
5403 }
5404 if (e.key === 'Escape') {
5405 var focused = document.activeElement;
5406 if (focused) focused.blur();
5407 }
5408 });
5409 })();
5410 ` }} />
0074234Claude5411 </Layout>
5412 );
5413});
5414
6d1bbc2Claude5415// Update branch — merge base into head so the PR branch is up to date.
5416// Uses a git worktree so the bare repo stays clean. Write access required.
5417pulls.post(
5418 "/:owner/:repo/pulls/:number/update-branch",
5419 softAuth,
5420 requireAuth,
5421 requireRepoAccess("write"),
5422 async (c) => {
5423 const { owner: ownerName, repo: repoName } = c.req.param();
5424 const prNum = parseInt(c.req.param("number"), 10);
5425 const user = c.get("user")!;
5426 const resolved = await resolveRepo(ownerName, repoName);
5427 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5428
5429 const [pr] = await db
5430 .select()
5431 .from(pullRequests)
5432 .where(and(
5433 eq(pullRequests.repositoryId, resolved.repo.id),
5434 eq(pullRequests.number, prNum),
5435 ))
5436 .limit(1);
5437 if (!pr || pr.state !== "open") {
5438 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5439 }
5440
5441 const repoDir = getRepoPath(ownerName, repoName);
5442 const wt = `${repoDir}/_update_wt_${Date.now()}`;
5443 const gitEnv = {
5444 ...process.env,
5445 GIT_AUTHOR_NAME: user.displayName || user.username,
5446 GIT_AUTHOR_EMAIL: user.email,
5447 GIT_COMMITTER_NAME: user.displayName || user.username,
5448 GIT_COMMITTER_EMAIL: user.email,
5449 };
5450
5451 const addWt = Bun.spawn(
5452 ["git", "worktree", "add", wt, pr.headBranch],
5453 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5454 );
5455 if (await addWt.exited !== 0) {
5456 return c.redirect(
5457 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
5458 );
5459 }
5460
5461 let ok = false;
5462 try {
5463 const mergeProc = Bun.spawn(
5464 ["git", "merge", "--no-edit", pr.baseBranch],
5465 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
5466 );
5467 if (await mergeProc.exited === 0) {
5468 ok = true;
5469 } else {
5470 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5471 }
5472 } catch {
5473 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5474 }
5475
5476 await Bun.spawn(
5477 ["git", "worktree", "remove", "--force", wt],
5478 { cwd: repoDir }
5479 ).exited.catch(() => {});
5480
5481 if (ok) {
5482 return c.redirect(
5483 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
5484 );
5485 }
5486 return c.redirect(
5487 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
5488 );
5489 }
5490);
5491
b558f23Claude5492// Edit PR title (and optionally body). Owner or author only.
5493pulls.post(
5494 "/:owner/:repo/pulls/:number/edit",
5495 softAuth,
5496 requireAuth,
5497 requireRepoAccess("write"),
5498 async (c) => {
5499 const { owner: ownerName, repo: repoName } = c.req.param();
5500 const prNum = parseInt(c.req.param("number"), 10);
5501 const user = c.get("user")!;
5502 const resolved = await resolveRepo(ownerName, repoName);
5503 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5504
5505 const [pr] = await db
5506 .select()
5507 .from(pullRequests)
5508 .where(and(
5509 eq(pullRequests.repositoryId, resolved.repo.id),
5510 eq(pullRequests.number, prNum),
5511 ))
5512 .limit(1);
5513 if (!pr || pr.state !== "open") {
5514 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5515 }
5516 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
5517 if (!canEdit) {
5518 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5519 }
5520
5521 const body = await c.req.parseBody();
5522 const newTitle = String(body.title || "").trim().slice(0, 256);
5523 if (!newTitle) {
5524 return c.redirect(
5525 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
5526 );
5527 }
5528
5529 await db
5530 .update(pullRequests)
5531 .set({ title: newTitle, updatedAt: new Date() })
5532 .where(eq(pullRequests.id, pr.id));
5533
5534 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
5535 }
5536);
5537
cb5a796Claude5538// Add comment to PR.
5539//
5540// Permission model mirrors `issues.tsx`: any logged-in user with read
5541// access can submit; `decideInitialStatus` routes non-collaborators
5542// through the moderation queue. Slash commands only fire when the
5543// comment is auto-approved — we don't want a banned/pending comment to
5544// silently trigger AI work on the PR.
0074234Claude5545pulls.post(
5546 "/:owner/:repo/pulls/:number/comment",
5547 softAuth,
5548 requireAuth,
cb5a796Claude5549 requireRepoAccess("read"),
0074234Claude5550 async (c) => {
5551 const { owner: ownerName, repo: repoName } = c.req.param();
5552 const prNum = parseInt(c.req.param("number"), 10);
5553 const user = c.get("user")!;
5554 const body = await c.req.parseBody();
5555 const commentBody = String(body.body || "").trim();
47a7a0aClaude5556 const filePathRaw = String(body.file_path || "").trim();
5557 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
5558 const inlineFilePath = filePathRaw || undefined;
5559 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude5560
5561 if (!commentBody) {
5562 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5563 }
5564
5565 const resolved = await resolveRepo(ownerName, repoName);
5566 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5567
5568 const [pr] = await db
5569 .select()
5570 .from(pullRequests)
5571 .where(
5572 and(
5573 eq(pullRequests.repositoryId, resolved.repo.id),
5574 eq(pullRequests.number, prNum)
5575 )
5576 )
5577 .limit(1);
5578
5579 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5580
cb5a796Claude5581 const decision = await decideInitialStatus({
5582 commenterUserId: user.id,
5583 repositoryId: resolved.repo.id,
5584 kind: "pr",
5585 threadId: pr.id,
5586 });
5587
d4ac5c3Claude5588 const [inserted] = await db
5589 .insert(prComments)
5590 .values({
5591 pullRequestId: pr.id,
5592 authorId: user.id,
5593 body: commentBody,
cb5a796Claude5594 moderationStatus: decision.status,
47a7a0aClaude5595 filePath: inlineFilePath,
5596 lineNumber: inlineLineNumber,
d4ac5c3Claude5597 })
5598 .returning();
5599
cb5a796Claude5600 // Live update: only when the comment is actually visible.
5601 if (inserted && decision.status === "approved") {
d4ac5c3Claude5602 try {
5603 const { publish } = await import("../lib/sse");
5604 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
5605 event: "pr-comment",
5606 data: {
5607 pullRequestId: pr.id,
5608 commentId: inserted.id,
5609 authorId: user.id,
5610 authorUsername: user.username,
5611 },
5612 });
5613 } catch {
5614 /* SSE is best-effort */
5615 }
b7ecb14Claude5616 // Notify the PR author — fire-and-forget, never blocks the response.
5617 if (pr.authorId && pr.authorId !== user.id) {
5618 void import("../lib/notify").then(({ createNotification }) =>
5619 createNotification({
5620 userId: pr.authorId,
5621 type: "pr_comment",
5622 title: `New comment on "${pr.title}"`,
5623 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
5624 url: `/${ownerName}/${repoName}/pulls/${prNum}`,
5625 repoId: resolved.repo.id,
5626 })
5627 ).catch(() => { /* never block the response */ });
5628 }
d4ac5c3Claude5629 }
0074234Claude5630
cb5a796Claude5631 if (decision.status === "pending") {
5632 void notifyOwnerOfPendingComment({
5633 repositoryId: resolved.repo.id,
5634 commenterUsername: user.username,
5635 kind: "pr",
5636 threadNumber: prNum,
5637 ownerUsername: ownerName,
5638 repoName,
5639 });
5640 return c.redirect(
5641 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5642 );
5643 }
5644 if (decision.status === "rejected") {
5645 // Silent ban path — same UX as 'pending' so we don't leak the gate.
5646 return c.redirect(
5647 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5648 );
5649 }
5650
15db0e0Claude5651 // Slash-command handoff. We always store the original comment above
5652 // first so free-form text that happens to start with `/` is preserved
5653 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude5654 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude5655 const parsed = parseSlashCommand(commentBody);
5656 if (parsed) {
5657 try {
5658 const result = await executeSlashCommand({
5659 command: parsed.command,
5660 args: parsed.args,
5661 prId: pr.id,
5662 userId: user.id,
5663 repositoryId: resolved.repo.id,
5664 });
5665 await db.insert(prComments).values({
5666 pullRequestId: pr.id,
5667 authorId: user.id,
5668 body: result.body,
5669 });
5670 } catch (err) {
5671 // Defence-in-depth — executeSlashCommand promises not to throw,
5672 // but if it ever does we want the PR thread to know.
5673 await db
5674 .insert(prComments)
5675 .values({
5676 pullRequestId: pr.id,
5677 authorId: user.id,
5678 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
5679 })
5680 .catch(() => {});
5681 }
5682 }
5683
47a7a0aClaude5684 // Inline comments go back to the files tab; conversation comments to the conversation tab
5685 const redirectTab = inlineFilePath ? "?tab=files" : "";
5686 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude5687 }
5688);
5689
b5dd694Claude5690// Apply a suggestion from a PR comment — commits the suggested code to the
5691// head branch on behalf of the logged-in user.
5692pulls.post(
5693 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
5694 softAuth,
5695 requireAuth,
5696 requireRepoAccess("read"),
5697 async (c) => {
5698 const { owner: ownerName, repo: repoName } = c.req.param();
5699 const prNum = parseInt(c.req.param("number"), 10);
5700 const commentId = c.req.param("commentId"); // UUID
5701 const user = c.get("user")!;
5702
5703 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
5704
5705 const resolved = await resolveRepo(ownerName, repoName);
5706 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5707
5708 const [pr] = await db
5709 .select()
5710 .from(pullRequests)
5711 .where(
5712 and(
5713 eq(pullRequests.repositoryId, resolved.repo.id),
5714 eq(pullRequests.number, prNum)
5715 )
5716 )
5717 .limit(1);
5718
5719 if (!pr || pr.state !== "open") {
5720 return c.redirect(`${backUrl}&error=pr_not_open`);
5721 }
5722
5723 // Only PR author or repo owner may apply suggestions.
5724 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
5725 return c.redirect(`${backUrl}&error=forbidden`);
5726 }
5727
5728 // Load the comment.
5729 const [comment] = await db
5730 .select()
5731 .from(prComments)
5732 .where(
5733 and(
5734 eq(prComments.id, commentId),
5735 eq(prComments.pullRequestId, pr.id)
5736 )
5737 )
5738 .limit(1);
5739
5740 if (!comment) {
5741 return c.redirect(`${backUrl}&error=comment_not_found`);
5742 }
5743
5744 // Parse suggestion block from comment body.
5745 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
5746 if (!m) {
5747 return c.redirect(`${backUrl}&error=no_suggestion`);
5748 }
5749 const suggestionCode = m[1];
5750
5751 // Get the commenter's details for the commit message co-author line.
5752 const [commenter] = await db
5753 .select()
5754 .from(users)
5755 .where(eq(users.id, comment.authorId))
5756 .limit(1);
5757
5758 // Fetch current file content from head branch.
5759 if (!comment.filePath) {
5760 return c.redirect(`${backUrl}&error=file_not_found`);
5761 }
5762 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
5763 if (!blob) {
5764 return c.redirect(`${backUrl}&error=file_not_found`);
5765 }
5766
5767 // Apply the patch — replace the target line(s) with suggestion lines.
5768 const lines = blob.content.split('\n');
5769 const lineIdx = (comment.lineNumber ?? 1) - 1;
5770 if (lineIdx < 0 || lineIdx >= lines.length) {
5771 return c.redirect(`${backUrl}&error=line_out_of_range`);
5772 }
5773 const suggestionLines = suggestionCode.split('\n');
5774 lines.splice(lineIdx, 1, ...suggestionLines);
5775 const newContent = lines.join('\n');
5776
5777 // Commit the change.
5778 const coAuthorLine = commenter
5779 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
5780 : "";
5781 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
5782
5783 const result = await createOrUpdateFileOnBranch({
5784 owner: ownerName,
5785 name: repoName,
5786 branch: pr.headBranch,
5787 filePath: comment.filePath,
5788 bytes: new TextEncoder().encode(newContent),
5789 message: commitMessage,
5790 authorName: user.username,
5791 authorEmail: `${user.username}@users.noreply.gluecron.com`,
5792 });
5793
5794 if ("error" in result) {
5795 return c.redirect(`${backUrl}&error=apply_failed`);
5796 }
5797
5798 // Post a follow-up comment noting the suggestion was applied.
5799 await db.insert(prComments).values({
5800 pullRequestId: pr.id,
5801 authorId: user.id,
5802 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
5803 });
5804
5805 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5806 }
5807);
5808
0a67773Claude5809// Formal review — Approve / Request Changes / Comment
5810pulls.post(
5811 "/:owner/:repo/pulls/:number/review",
5812 softAuth,
5813 requireAuth,
5814 requireRepoAccess("read"),
5815 async (c) => {
5816 const { owner: ownerName, repo: repoName } = c.req.param();
5817 const prNum = parseInt(c.req.param("number"), 10);
5818 const user = c.get("user")!;
5819 const body = await c.req.parseBody();
5820 const reviewBody = String(body.body || "").trim();
5821 const reviewState = String(body.review_state || "commented");
5822
5823 const validStates = ["approved", "changes_requested", "commented"];
5824 if (!validStates.includes(reviewState)) {
5825 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5826 }
5827
5828 const resolved = await resolveRepo(ownerName, repoName);
5829 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5830
5831 const [pr] = await db
5832 .select()
5833 .from(pullRequests)
5834 .where(
5835 and(
5836 eq(pullRequests.repositoryId, resolved.repo.id),
5837 eq(pullRequests.number, prNum)
5838 )
5839 )
5840 .limit(1);
5841 if (!pr || pr.state !== "open") {
5842 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5843 }
5844 // Authors can't review their own PR
5845 if (pr.authorId === user.id) {
5846 return c.redirect(
5847 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
5848 );
5849 }
5850
5851 await db.insert(prReviews).values({
5852 pullRequestId: pr.id,
5853 reviewerId: user.id,
5854 state: reviewState,
5855 body: reviewBody || null,
5856 });
5857
5858 const stateLabel =
5859 reviewState === "approved" ? "Approved"
5860 : reviewState === "changes_requested" ? "Changes requested"
5861 : "Commented";
5862 return c.redirect(
5863 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
5864 );
5865 }
5866);
5867
e883329Claude5868// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude5869// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
5870// but we keep it at "write" for v1 so trusted collaborators can ship.
5871// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
5872// surface. Branch-protection rules (evaluated below) are the current mechanism
5873// for locking down merges further on specific branches.
0074234Claude5874pulls.post(
5875 "/:owner/:repo/pulls/:number/merge",
5876 softAuth,
5877 requireAuth,
04f6b7fClaude5878 requireRepoAccess("write"),
0074234Claude5879 async (c) => {
5880 const { owner: ownerName, repo: repoName } = c.req.param();
5881 const prNum = parseInt(c.req.param("number"), 10);
5882 const user = c.get("user")!;
5883
a164a6dClaude5884 // Read merge strategy from form (default: merge commit)
5885 let mergeStrategy = "merge";
5886 try {
5887 const body = await c.req.parseBody();
5888 const s = body.merge_strategy;
5889 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
5890 } catch { /* ignore parse errors — default to merge commit */ }
5891
0074234Claude5892 const resolved = await resolveRepo(ownerName, repoName);
5893 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5894
5895 const [pr] = await db
5896 .select()
5897 .from(pullRequests)
5898 .where(
5899 and(
5900 eq(pullRequests.repositoryId, resolved.repo.id),
5901 eq(pullRequests.number, prNum)
5902 )
5903 )
5904 .limit(1);
5905
5906 if (!pr || pr.state !== "open") {
5907 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5908 }
5909
6fc53bdClaude5910 // Draft PRs cannot be merged — must be marked ready first.
5911 if (pr.isDraft) {
5912 return c.redirect(
5913 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5914 "This PR is a draft. Mark it as ready for review before merging."
5915 )}`
5916 );
5917 }
5918
ec9e3e3Claude5919 // Required reviews check — branch-protection `required_approvals` gate.
5920 // Evaluated before running expensive gate checks so the feedback is fast.
5921 {
5922 const eligibility = await checkMergeEligible(pr.id, resolved.repo.id, pr.baseBranch);
5923 if (!eligibility.eligible && eligibility.reason) {
5924 return c.redirect(
5925 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(eligibility.reason)}`
5926 );
5927 }
5928 }
5929
e883329Claude5930 // Resolve head SHA
5931 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
5932 if (!headSha) {
5933 return c.redirect(
5934 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
5935 );
5936 }
5937
58c39f5Claude5938 // Check if AI review approved this PR.
5939 // The AI summary comment body uses:
5940 // "**AI review:** no blocking issues found." → approved
5941 // "**AI review:** flagged N item(s)..." → not approved
5942 // "severity: blocking" → explicit blocking (future)
5943 // If no AI comments exist yet, treat as approved (gate hasn't run).
e883329Claude5944 const aiComments = await db
5945 .select()
5946 .from(prComments)
5947 .where(
5948 and(
5949 eq(prComments.pullRequestId, pr.id),
5950 eq(prComments.isAiReview, true)
5951 )
5952 );
58c39f5Claude5953 const aiSummaryComment = aiComments.find((c) =>
5954 c.body.includes("<!-- gluecron-ai-review:summary -->")
0074234Claude5955 );
58c39f5Claude5956 const aiApproved =
5957 !aiSummaryComment ||
5958 (aiSummaryComment.body.includes("no blocking issues found") &&
5959 !aiSummaryComment.body.includes("severity: blocking"));
e883329Claude5960
5961 // Run all green gate checks (GateTest + mergeability + AI review)
5962 const gateResult = await runAllGateChecks(
5963 ownerName,
5964 repoName,
5965 pr.baseBranch,
5966 pr.headBranch,
5967 headSha,
5968 aiApproved
0074234Claude5969 );
5970
e883329Claude5971 // If GateTest or AI review failed (hard blocks), reject the merge
5972 const hardFailures = gateResult.checks.filter(
5973 (check) => !check.passed && check.name !== "Merge check"
5974 );
5975 if (hardFailures.length > 0) {
5976 const errorMsg = hardFailures
5977 .map((f) => `${f.name}: ${f.details}`)
5978 .join("; ");
0074234Claude5979 return c.redirect(
e883329Claude5980 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude5981 );
5982 }
5983
1e162a8Claude5984 // D5 — Branch-protection enforcement. Looks up the matching rule for the
5985 // base branch and blocks the merge if requireAiApproval / requireGreenGates
5986 // / requireHumanReview / requiredApprovals are not satisfied. Independent
5987 // of repo-global settings, so owners can lock specific branches down
5988 // further than the repo default.
5989 const protectionRule = await matchProtection(
5990 resolved.repo.id,
5991 pr.baseBranch
5992 );
5993 if (protectionRule) {
5994 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude5995 const required = await listRequiredChecks(protectionRule.id);
5996 const passingNames = required.length > 0
5997 ? await passingCheckNames(resolved.repo.id, headSha)
5998 : [];
5999 const decision = evaluateProtection(
6000 protectionRule,
6001 {
6002 aiApproved,
6003 humanApprovalCount: humanApprovals,
6004 gateResultGreen: hardFailures.length === 0,
6005 hasFailedGates: hardFailures.length > 0,
6006 passingCheckNames: passingNames,
6007 },
6008 required.map((r) => r.checkName)
6009 );
1e162a8Claude6010 if (!decision.allowed) {
6011 return c.redirect(
6012 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6013 decision.reasons.join(" ")
6014 )}`
6015 );
6016 }
6017 }
6018
e883329Claude6019 // Attempt the merge — with auto conflict resolution if needed
6020 const repoDir = getRepoPath(ownerName, repoName);
6021 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
6022 const hasConflicts = mergeCheck && !mergeCheck.passed;
6023
6024 if (hasConflicts && isAiReviewEnabled()) {
6025 // Use Claude to auto-resolve conflicts
6026 const mergeResult = await mergeWithAutoResolve(
6027 ownerName,
6028 repoName,
6029 pr.baseBranch,
6030 pr.headBranch,
6031 `Merge pull request #${pr.number}: ${pr.title}`
6032 );
6033
6034 if (!mergeResult.success) {
6035 return c.redirect(
6036 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
6037 );
6038 }
6039
6040 // Post a comment about the auto-resolution
6041 if (mergeResult.resolvedFiles.length > 0) {
6042 await db.insert(prComments).values({
6043 pullRequestId: pr.id,
6044 authorId: user.id,
6045 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
6046 isAiReview: true,
6047 });
6048 }
6049 } else {
a164a6dClaude6050 // Worktree-based merge: supports merge-commit, squash, and fast-forward
6051 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
6052 const gitEnv = {
6053 ...process.env,
6054 GIT_AUTHOR_NAME: user.displayName || user.username,
6055 GIT_AUTHOR_EMAIL: user.email,
6056 GIT_COMMITTER_NAME: user.displayName || user.username,
6057 GIT_COMMITTER_EMAIL: user.email,
6058 };
6059
6060 // Create linked worktree on the base branch
6061 const addWt = Bun.spawn(
6062 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude6063 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
6064 );
a164a6dClaude6065 if (await addWt.exited !== 0) {
6066 return c.redirect(
6067 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
6068 );
6069 }
6070
6071 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
6072 let mergeOk = false;
6073
6074 try {
6075 if (mergeStrategy === "squash") {
6076 // Squash: stage all changes without committing
6077 const squashProc = Bun.spawn(
6078 ["git", "merge", "--squash", headSha],
6079 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6080 );
6081 if (await squashProc.exited !== 0) {
6082 const errTxt = await new Response(squashProc.stderr).text();
6083 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
6084 }
6085 // Commit the squashed changes
6086 const commitProc = Bun.spawn(
6087 ["git", "commit", "-m", commitMsg],
6088 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6089 );
6090 if (await commitProc.exited !== 0) {
6091 const errTxt = await new Response(commitProc.stderr).text();
6092 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
6093 }
6094 mergeOk = true;
6095 } else if (mergeStrategy === "ff") {
6096 // Fast-forward only — fail if FF is not possible
6097 const ffProc = Bun.spawn(
6098 ["git", "merge", "--ff-only", headSha],
6099 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6100 );
6101 if (await ffProc.exited !== 0) {
6102 const errTxt = await new Response(ffProc.stderr).text();
6103 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
6104 }
6105 mergeOk = true;
6106 } else {
6107 // Default: merge commit (--no-ff always creates a merge commit)
6108 const mergeProc = Bun.spawn(
6109 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
6110 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6111 );
6112 if (await mergeProc.exited !== 0) {
6113 const errTxt = await new Response(mergeProc.stderr).text();
6114 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
6115 }
6116 mergeOk = true;
6117 }
6118 } catch (err) {
6119 // Always clean up the worktree before redirecting
6120 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
6121 const msg = err instanceof Error ? err.message : "Merge failed";
6122 return c.redirect(
6123 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
6124 );
6125 }
6126
6127 // Clean up worktree (changes are now in the bare repo via linked worktree)
6128 await Bun.spawn(
6129 ["git", "worktree", "remove", "--force", wt],
6130 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
6131 ).exited.catch(() => {});
e883329Claude6132
a164a6dClaude6133 if (!mergeOk) {
e883329Claude6134 return c.redirect(
a164a6dClaude6135 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude6136 );
6137 }
6138 }
6139
0074234Claude6140 await db
6141 .update(pullRequests)
6142 .set({
6143 state: "merged",
6144 mergedAt: new Date(),
6145 mergedBy: user.id,
6146 updatedAt: new Date(),
6147 })
6148 .where(eq(pullRequests.id, pr.id));
6149
8809b87Claude6150 // Chat notifier — fan out merge event to Slack/Discord/Teams.
6151 import("../lib/chat-notifier")
6152 .then((m) =>
6153 m.notifyChatChannels({
6154 ownerUserId: resolved.repo.ownerId,
6155 repositoryId: resolved.repo.id,
6156 event: {
6157 event: "pr.merged",
6158 repo: `${ownerName}/${repoName}`,
6159 title: `#${pr.number} ${pr.title}`,
6160 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
6161 actor: user.username,
6162 },
6163 })
6164 )
6165 .catch((err) =>
6166 console.warn(`[chat-notifier] PR merge notify failed:`, err)
6167 );
6168
d62fb36Claude6169 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
6170 // and auto-close each matching open issue with a back-link comment. Bounded
6171 // to the same repo for v1 (cross-repo refs ignored). Failures never block
6172 // the merge redirect.
6173 try {
6174 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
6175 const refs = extractClosingRefsMulti([pr.title, pr.body]);
6176 for (const n of refs) {
6177 const [issue] = await db
6178 .select()
6179 .from(issues)
6180 .where(
6181 and(
6182 eq(issues.repositoryId, resolved.repo.id),
6183 eq(issues.number, n)
6184 )
6185 )
6186 .limit(1);
6187 if (!issue || issue.state !== "open") continue;
6188 await db
6189 .update(issues)
6190 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
6191 .where(eq(issues.id, issue.id));
6192 await db.insert(issueComments).values({
6193 issueId: issue.id,
6194 authorId: user.id,
6195 body: `Closed by pull request #${pr.number}.`,
6196 });
6197 }
6198 } catch {
6199 // Never block the merge on close-keyword failures.
6200 }
6201
0074234Claude6202 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6203 }
6204);
6205
6fc53bdClaude6206// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
6207// hasn't run yet on this PR.
6208pulls.post(
6209 "/:owner/:repo/pulls/:number/ready",
6210 softAuth,
6211 requireAuth,
04f6b7fClaude6212 requireRepoAccess("write"),
6fc53bdClaude6213 async (c) => {
6214 const { owner: ownerName, repo: repoName } = c.req.param();
6215 const prNum = parseInt(c.req.param("number"), 10);
6216 const user = c.get("user")!;
6217
6218 const resolved = await resolveRepo(ownerName, repoName);
6219 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6220
6221 const [pr] = await db
6222 .select()
6223 .from(pullRequests)
6224 .where(
6225 and(
6226 eq(pullRequests.repositoryId, resolved.repo.id),
6227 eq(pullRequests.number, prNum)
6228 )
6229 )
6230 .limit(1);
6231 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6232
6233 // Only the author or repo owner can toggle draft state.
6234 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
6235 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6236 }
6237
6238 if (pr.state === "open" && pr.isDraft) {
6239 await db
6240 .update(pullRequests)
6241 .set({ isDraft: false, updatedAt: new Date() })
6242 .where(eq(pullRequests.id, pr.id));
6243
6244 if (isAiReviewEnabled()) {
6245 triggerAiReview(
6246 ownerName,
6247 repoName,
6248 pr.id,
6249 pr.title,
0316dbbClaude6250 pr.body || "",
6fc53bdClaude6251 pr.baseBranch,
6252 pr.headBranch
6253 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
6254 }
6255 }
6256
6257 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6258 }
6259);
6260
6261// Convert a PR back to draft.
6262pulls.post(
6263 "/:owner/:repo/pulls/:number/draft",
6264 softAuth,
6265 requireAuth,
04f6b7fClaude6266 requireRepoAccess("write"),
6fc53bdClaude6267 async (c) => {
6268 const { owner: ownerName, repo: repoName } = c.req.param();
6269 const prNum = parseInt(c.req.param("number"), 10);
6270 const user = c.get("user")!;
6271
6272 const resolved = await resolveRepo(ownerName, repoName);
6273 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6274
6275 const [pr] = await db
6276 .select()
6277 .from(pullRequests)
6278 .where(
6279 and(
6280 eq(pullRequests.repositoryId, resolved.repo.id),
6281 eq(pullRequests.number, prNum)
6282 )
6283 )
6284 .limit(1);
6285 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6286
6287 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
6288 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6289 }
6290
6291 if (pr.state === "open" && !pr.isDraft) {
6292 await db
6293 .update(pullRequests)
6294 .set({ isDraft: true, updatedAt: new Date() })
6295 .where(eq(pullRequests.id, pr.id));
6296 }
6297
6298 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6299 }
6300);
6301
0074234Claude6302// Close PR
6303pulls.post(
6304 "/:owner/:repo/pulls/:number/close",
6305 softAuth,
6306 requireAuth,
04f6b7fClaude6307 requireRepoAccess("write"),
0074234Claude6308 async (c) => {
6309 const { owner: ownerName, repo: repoName } = c.req.param();
6310 const prNum = parseInt(c.req.param("number"), 10);
6311
6312 const resolved = await resolveRepo(ownerName, repoName);
6313 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6314
6315 await db
6316 .update(pullRequests)
6317 .set({
6318 state: "closed",
6319 closedAt: new Date(),
6320 updatedAt: new Date(),
6321 })
6322 .where(
6323 and(
6324 eq(pullRequests.repositoryId, resolved.repo.id),
6325 eq(pullRequests.number, prNum)
6326 )
6327 );
6328
6329 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6330 }
6331);
6332
c3e0c07Claude6333// Re-run AI review on demand (e.g. after a force-push). Bypasses the
6334// idempotency marker via { force: true }. Write-access only.
6335pulls.post(
6336 "/:owner/:repo/pulls/:number/ai-rereview",
6337 softAuth,
6338 requireAuth,
6339 requireRepoAccess("write"),
6340 async (c) => {
6341 const { owner: ownerName, repo: repoName } = c.req.param();
6342 const prNum = parseInt(c.req.param("number"), 10);
6343 const resolved = await resolveRepo(ownerName, repoName);
6344 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6345
6346 const [pr] = await db
6347 .select()
6348 .from(pullRequests)
6349 .where(
6350 and(
6351 eq(pullRequests.repositoryId, resolved.repo.id),
6352 eq(pullRequests.number, prNum)
6353 )
6354 )
6355 .limit(1);
6356 if (!pr) {
6357 return c.redirect(`/${ownerName}/${repoName}/pulls`);
6358 }
6359
6360 if (!isAiReviewEnabled()) {
6361 return c.redirect(
6362 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6363 "AI review is not configured (ANTHROPIC_API_KEY)."
6364 )}`
6365 );
6366 }
6367
6368 // Fire-and-forget but with { force: true } to bypass the
6369 // already-reviewed marker. The function still never throws.
6370 triggerAiReview(
6371 ownerName,
6372 repoName,
6373 pr.id,
6374 pr.title || "",
6375 pr.body || "",
6376 pr.baseBranch,
6377 pr.headBranch,
6378 { force: true }
a28cedeClaude6379 ).catch((err) => {
6380 console.warn(
6381 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
6382 err instanceof Error ? err.message : err
6383 );
6384 });
c3e0c07Claude6385
6386 return c.redirect(
6387 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6388 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
6389 )}`
6390 );
6391 }
6392);
6393
1d4ff60Claude6394// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
6395// the PR's head branch carrying just the new test files. Write-access only.
6396// Idempotent — if `ai:added-tests` was previously applied we redirect with
6397// an `info` banner instead of re-firing.
6398pulls.post(
6399 "/:owner/:repo/pulls/:number/generate-tests",
6400 softAuth,
6401 requireAuth,
6402 requireRepoAccess("write"),
6403 async (c) => {
6404 const { owner: ownerName, repo: repoName } = c.req.param();
6405 const prNum = parseInt(c.req.param("number"), 10);
6406 const resolved = await resolveRepo(ownerName, repoName);
6407 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6408
6409 const [pr] = await db
6410 .select()
6411 .from(pullRequests)
6412 .where(
6413 and(
6414 eq(pullRequests.repositoryId, resolved.repo.id),
6415 eq(pullRequests.number, prNum)
6416 )
6417 )
6418 .limit(1);
6419 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6420
6421 if (!isAiReviewEnabled()) {
6422 return c.redirect(
6423 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6424 "AI test generation is not configured (ANTHROPIC_API_KEY)."
6425 )}`
6426 );
6427 }
6428
6429 // Fire-and-forget. The lib never throws.
6430 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
6431 .then((res) => {
6432 if (!res.ok) {
6433 console.warn(
6434 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
6435 );
6436 }
6437 })
6438 .catch((err) => {
6439 console.warn(
6440 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
6441 err instanceof Error ? err.message : err
6442 );
6443 });
6444
6445 return c.redirect(
6446 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6447 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
6448 )}`
6449 );
6450 }
6451);
6452
ace34efClaude6453// ─── Request review ───────────────────────────────────────────────────────────
6454pulls.post(
6455 "/:owner/:repo/pulls/:number/request-review",
6456 softAuth,
6457 requireAuth,
6458 requireRepoAccess("write"),
6459 async (c) => {
6460 const { owner: ownerName, repo: repoName } = c.req.param();
6461 const prNum = parseInt(c.req.param("number"), 10);
6462 const user = c.get("user")!;
6463
6464 const resolved = await resolveRepo(ownerName, repoName);
6465 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6466
6467 const [pr] = await db
6468 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
6469 .from(pullRequests)
6470 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
6471 .limit(1);
6472
6473 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6474
6475 const body = await c.req.formData().catch(() => null);
6476 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
6477
6478 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
6479 return c.redirect(
6480 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
6481 );
6482 }
6483
f4abb8eClaude6484 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
6485 // requesting reviews from arbitrary user IDs outside this repository.
6486 const isOwner = reviewerId === resolved.owner.id;
6487 if (!isOwner) {
6488 const [collab] = await db
6489 .select({ id: repoCollaborators.id })
6490 .from(repoCollaborators)
6491 .where(
6492 and(
6493 eq(repoCollaborators.repositoryId, resolved.repo.id),
6494 eq(repoCollaborators.userId, reviewerId),
6495 isNotNull(repoCollaborators.acceptedAt)
6496 )
6497 )
6498 .limit(1);
6499 if (!collab) {
6500 return c.redirect(
6501 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
6502 );
6503 }
6504 }
6505
ace34efClaude6506 const { requestReview } = await import("../lib/reviewer-suggest");
6507 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
6508
6509 const msg = result.ok
6510 ? "Review requested successfully."
6511 : `Failed to request review: ${result.error ?? "unknown error"}`;
6512
6513 return c.redirect(
6514 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
6515 );
6516 }
6517);
6518
25b1ff7Claude6519// ─── WebSocket presence endpoint ─────────────────────────────────────────────
6520//
6521// GET /:owner/:repo/pulls/:number/presence (WebSocket upgrade)
6522//
6523// Unauthenticated connections are rejected with 401. On connect:
6524// → server sends {type:"init", sessionId, users:[...]}
6525// → server broadcasts {type:"join", user} to all other sessions in the room
6526//
6527// Accepted client messages:
6528// {type:"cursor", line: number} — user hovering a diff line
6529// {type:"typing", line: number, typing: bool} — textarea focus/blur
6530// {type:"ping"} — keep-alive (updates lastSeen)
6531//
6532// The WS `data` payload we store on each socket carries everything needed in
6533// the event handlers so no closure tricks are required.
6534
6535pulls.get(
6536 "/:owner/:repo/pulls/:number/presence",
6537 softAuth,
6538 upgradeWebSocket(async (c) => {
6539 const { owner: ownerName, repo: repoName, number: prNumStr } = c.req.param();
6540 const prNum = parseInt(prNumStr ?? "0", 10);
6541 const user = c.get("user");
6542
6543 // Auth check — no anonymous presence
6544 if (!user) {
6545 // upgradeWebSocket doesn't support returning a non-101 directly;
6546 // we return a dummy handler that immediately closes with 4001.
6547 return {
6548 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6549 ws.close(4001, "Unauthorized");
6550 },
6551 onMessage() {},
6552 onClose() {},
6553 };
6554 }
6555
6556 // Resolve repo to get its numeric id for the room key
6557 const resolved = await resolveRepo(ownerName, repoName);
6558 if (!resolved || isNaN(prNum)) {
6559 return {
6560 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6561 ws.close(4004, "Not found");
6562 },
6563 onMessage() {},
6564 onClose() {},
6565 };
6566 }
6567
6568 const prId = `${resolved.repo.id}:${prNum}`;
6569 const sessionId = `${user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
6570
6571 return {
6572 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6573 // Register and join room
6574 registerSocket(prId, sessionId, {
6575 send: (data: string) => ws.send(data),
6576 readyState: ws.readyState,
6577 });
6578 const presenceUser = joinRoom(prId, sessionId, {
6579 userId: user.id,
6580 username: user.username,
6581 });
6582
6583 // Send init snapshot to the new joiner
6584 const currentUsers = getRoomUsers(prId);
6585 ws.send(
6586 JSON.stringify({
6587 type: "init",
6588 sessionId,
6589 users: currentUsers,
6590 })
6591 );
6592
6593 // Broadcast join to all OTHER sessions
6594 broadcastToRoom(
6595 prId,
6596 {
6597 type: "join",
6598 user: { ...presenceUser, sessionId },
6599 },
6600 sessionId
6601 );
6602 },
6603
6604 onMessage(evt: MessageEvent, _ws: import("hono/ws").WSContext) {
6605 let msg: { type: string; line?: number; typing?: boolean };
6606 try {
6607 msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
6608 } catch {
6609 return;
6610 }
6611
6612 if (msg.type === "ping") {
6613 pingSession(prId, sessionId);
6614 return;
6615 }
6616
6617 if (msg.type === "cursor") {
6618 const line = typeof msg.line === "number" ? msg.line : null;
6619 const updated = updatePresence(prId, sessionId, line, false);
6620 if (updated) {
6621 broadcastToRoom(
6622 prId,
6623 {
6624 type: "cursor",
6625 sessionId,
6626 username: updated.username,
6627 colour: updated.colour,
6628 line,
6629 },
6630 sessionId
6631 );
6632 }
6633 return;
6634 }
6635
6636 if (msg.type === "typing") {
6637 const line = typeof msg.line === "number" ? msg.line : null;
6638 const typing = !!msg.typing;
6639 const updated = updatePresence(prId, sessionId, line, typing);
6640 if (updated) {
6641 broadcastToRoom(
6642 prId,
6643 {
6644 type: "typing",
6645 sessionId,
6646 username: updated.username,
6647 colour: updated.colour,
6648 line,
6649 typing,
6650 },
6651 sessionId
6652 );
6653 }
6654 return;
6655 }
6656 },
6657
6658 onClose() {
6659 leaveRoom(prId, sessionId);
6660 unregisterSocket(prId, sessionId);
6661 broadcastToRoom(prId, { type: "leave", sessionId });
6662 },
6663 };
6664 })
6665);
6666
0074234Claude6667export default pulls;