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