Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

pulls.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

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