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