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