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

pulls.tsx

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

pulls.tsxBlame6907 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,
a2c10c5Claude29 pendingReviews,
30 pendingReviewComments,
0074234Claude31} from "../db/schema";
32import { Layout } from "../views/layout";
ea9ed4cClaude33import { RepoHeader } from "../views/components";
cb5a796Claude34import { PendingCommentsBanner } from "../views/pending-comments-banner";
47a7a0aClaude35import { DiffView, type InlineDiffComment } from "../views/diff-view";
6fc53bdClaude36import { ReactionsBar } from "../views/reactions";
37import { summariseReactions } from "../lib/reactions";
24cf2caClaude38import { loadPrTemplate } from "../lib/templates";
0074234Claude39import { renderMarkdown } from "../lib/markdown";
15db0e0Claude40import {
41 parseSlashCommand,
42 executeSlashCommand,
43 detectSlashCmdComment,
44 stripSlashCmdMarker,
45} from "../lib/pr-slash-commands";
b584e52Claude46import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude47import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude48import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude49import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
0074234Claude50import { softAuth, requireAuth } from "../middleware/auth";
51import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude52import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude53import {
54 decideInitialStatus,
55 notifyOwnerOfPendingComment,
56 countPendingForRepo,
57} from "../lib/comment-moderation";
0316dbbClaude58import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
79ed944Claude59import {
60 TRIO_COMMENT_MARKER,
61 TRIO_SUMMARY_MARKER,
67dc4e1Claude62 isTrioReviewEnabled,
79ed944Claude63 type TrioPersona,
64} from "../lib/ai-review-trio";
1d4ff60Claude65import {
66 generateTestsForPr,
67 AI_TESTS_MARKER,
68} from "../lib/ai-test-generator";
0316dbbClaude69import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude70import { generatePrSummary } from "../lib/ai-generators";
71import { isAiAvailable } from "../lib/ai-client";
cc34156Claude72import { getReviewContext, recordPrVisit, type ReviewContext } from "../lib/review-context";
534f04aClaude73import {
74 computePrRiskForPullRequest,
75 getCachedPrRisk,
76 type PrRiskScore,
77} from "../lib/pr-risk";
0316dbbClaude78import { runAllGateChecks } from "../lib/gate";
79import type { GateCheckResult } from "../lib/gate";
80import {
81 matchProtection,
82 countHumanApprovals,
83 listRequiredChecks,
84 passingCheckNames,
85 evaluateProtection,
86} from "../lib/branch-protection";
87import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude88import {
89 listBranches,
90 getRepoPath,
e883329Claude91 resolveRef,
b5dd694Claude92 getBlob,
93 createOrUpdateFileOnBranch,
b558f23Claude94 commitsBetween,
0074234Claude95} from "../git/repository";
b558f23Claude96import type { GitDiffFile, GitCommit } from "../git/repository";
240c477Claude97import { listStatuses } from "../lib/commit-statuses";
98import type { CommitStatus } from "../db/schema";
0074234Claude99import { html } from "hono/html";
4bbacbeClaude100import {
101 getPreviewForBranch,
102 previewStatusLabel,
103} from "../lib/branch-previews";
1e162a8Claude104import {
bb0f894Claude105 Flex,
106 Container,
107 Badge,
108 Button,
109 LinkButton,
110 Form,
111 FormGroup,
112 Input,
113 TextArea,
114 Select,
115 EmptyState,
116 FilterTabs,
117 TabNav,
118 List,
119 ListItem,
120 Text,
121 Alert,
122 MarkdownContent,
123 CommentBox,
124 formatRelative,
125} from "../views/ui";
0074234Claude126
ace34efClaude127import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
74d8c4dClaude128import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
a7460bfClaude129import { BOT_USERNAME } from "../lib/bot-user";
09d5f39Claude130import { analyzeImpact, type ImpactAnalysis } from "../lib/pr-impact";
131import { getPreviewDir, isPreviewExpired } from "../lib/pr-stage";
7f992cdClaude132import {
133 getCodeownersForRepo,
134 reviewersForChangedFiles,
135} from "../lib/codeowners";
136import { getPatternWarning, type Pattern } from "../lib/pattern-detector";
137import { suggestPrSplit, type SplitSuggestion } from "../lib/pr-splitter";
138import {
139 joinRoom,
140 leaveRoom,
141 broadcastToRoom,
142 registerSocket,
143 unregisterSocket,
144 getRoomUsers,
145 pingSession,
146 updatePresence,
147} from "../lib/pr-presence";
148import { getBusFactorWarning, type BusFactorFile } from "../lib/bus-factor";
149import { checkMergeEligible } from "../lib/branch-rules";
150import { createBunWebSocket } from "hono/bun";
151
152const { upgradeWebSocket, websocket: presenceWebsocket } = createBunWebSocket();
153export { presenceWebsocket };
ace34efClaude154
0074234Claude155const pulls = new Hono<AuthEnv>();
156
b078860Claude157/* ──────────────────────────────────────────────────────────────────────
158 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
159 * into the issue tracker or any other route. Tokens come from layout.tsx
160 * `:root` so light/dark stays consistent if/when light mode lands.
161 * ──────────────────────────────────────────────────────────────────── */
162const PRS_LIST_STYLES = `
163 .prs-hero {
164 position: relative;
165 margin: 0 0 var(--space-5);
166 padding: 22px 26px 24px;
167 background: var(--bg-elevated);
168 border: 1px solid var(--border);
169 border-radius: 16px;
170 overflow: hidden;
171 }
172 .prs-hero::before {
173 content: '';
174 position: absolute; top: 0; left: 0; right: 0;
175 height: 2px;
6fd5915Claude176 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
b078860Claude177 opacity: 0.7;
178 pointer-events: none;
179 }
180 .prs-hero-inner {
181 position: relative;
182 display: flex;
183 justify-content: space-between;
184 align-items: flex-end;
185 gap: 20px;
186 flex-wrap: wrap;
187 }
188 .prs-hero-text { flex: 1; min-width: 280px; }
189 .prs-hero-eyebrow {
190 font-size: 12px;
191 color: var(--text-muted);
192 text-transform: uppercase;
193 letter-spacing: 0.08em;
194 font-weight: 600;
195 margin-bottom: 8px;
196 }
197 .prs-hero-title {
198 font-family: var(--font-display);
199 font-size: clamp(26px, 3.4vw, 34px);
200 font-weight: 800;
201 letter-spacing: -0.025em;
202 line-height: 1.06;
203 margin: 0 0 8px;
204 color: var(--text-strong);
205 }
206 .prs-hero-title .gradient-text {
6fd5915Claude207 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
b078860Claude208 -webkit-background-clip: text;
209 background-clip: text;
210 -webkit-text-fill-color: transparent;
211 color: transparent;
212 }
213 .prs-hero-sub {
214 font-size: 14.5px;
215 color: var(--text-muted);
216 margin: 0;
217 line-height: 1.5;
218 max-width: 620px;
219 }
220 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
221 .prs-cta {
222 display: inline-flex; align-items: center; gap: 6px;
223 padding: 10px 16px;
224 border-radius: 10px;
225 font-size: 13.5px;
226 font-weight: 600;
227 color: #fff;
6fd5915Claude228 background: linear-gradient(135deg, #5b6ee8 0%, #6f5be8 60%, #5f8fa0 140%);
229 border: 1px solid rgba(91,110,232,0.55);
230 box-shadow: 0 6px 18px -8px rgba(91,110,232,0.55);
b078860Claude231 text-decoration: none;
232 transition: transform 120ms ease, box-shadow 160ms ease;
233 }
234 .prs-cta:hover {
235 transform: translateY(-1px);
6fd5915Claude236 box-shadow: 0 10px 22px -6px rgba(91,110,232,0.6);
b078860Claude237 color: #fff;
238 }
239
240 .prs-tabs {
241 display: flex; flex-wrap: wrap; gap: 6px;
242 margin: 0 0 18px;
243 padding: 6px;
244 background: var(--bg-secondary);
245 border: 1px solid var(--border);
246 border-radius: 12px;
247 }
248 .prs-tab {
249 display: inline-flex; align-items: center; gap: 8px;
250 padding: 7px 13px;
251 font-size: 13px;
252 font-weight: 500;
253 color: var(--text-muted);
254 border-radius: 8px;
255 text-decoration: none;
256 transition: background 120ms ease, color 120ms ease;
257 }
258 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
259 .prs-tab.is-active {
260 background: var(--bg-elevated);
261 color: var(--text-strong);
262 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
263 }
264 .prs-tab-count {
265 display: inline-flex; align-items: center; justify-content: center;
266 min-width: 22px; padding: 2px 7px;
267 font-size: 11.5px;
268 font-weight: 600;
269 border-radius: 9999px;
270 background: var(--bg-tertiary);
271 color: var(--text-muted);
272 }
273 .prs-tab.is-active .prs-tab-count {
6fd5915Claude274 background: rgba(91,110,232,0.18);
b078860Claude275 color: var(--text-link);
276 }
277
278 .prs-list { display: flex; flex-direction: column; gap: 10px; }
279 .prs-row {
280 position: relative;
281 display: flex; align-items: flex-start; gap: 14px;
282 padding: 14px 16px;
283 background: var(--bg-elevated);
284 border: 1px solid var(--border);
285 border-radius: 12px;
286 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
287 }
288 .prs-row:hover {
289 transform: translateY(-1px);
290 border-color: var(--border-strong);
291 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
292 }
293 .prs-row-icon {
294 flex: 0 0 auto;
295 width: 26px; height: 26px;
296 display: inline-flex; align-items: center; justify-content: center;
297 border-radius: 9999px;
298 font-size: 13px;
299 margin-top: 2px;
300 }
301 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
6fd5915Claude302 .prs-row-icon.state-merged { color: #5b6ee8; background: rgba(91,110,232,0.16); }
b078860Claude303 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
304 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
305 .prs-row-body { flex: 1; min-width: 0; }
306 .prs-row-title {
307 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
308 font-size: 15px; font-weight: 600;
309 color: var(--text-strong);
310 line-height: 1.35;
311 margin: 0 0 6px;
312 }
313 .prs-row-number {
314 color: var(--text-muted);
315 font-weight: 400;
316 font-size: 14px;
317 }
318 .prs-row-meta {
319 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
320 font-size: 12.5px;
321 color: var(--text-muted);
322 }
323 .prs-branch-chips {
324 display: inline-flex; align-items: center; gap: 6px;
325 font-family: var(--font-mono);
326 font-size: 11.5px;
327 }
328 .prs-branch-chip {
329 padding: 2px 8px;
330 border-radius: 9999px;
331 background: var(--bg-tertiary);
332 border: 1px solid var(--border);
333 color: var(--text);
334 }
335 .prs-branch-arrow {
336 color: var(--text-faint);
337 font-size: 11px;
338 }
339 .prs-row-tags {
340 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
341 margin-left: auto;
342 }
343 .prs-tag {
344 display: inline-flex; align-items: center; gap: 4px;
345 padding: 2px 8px;
346 font-size: 11px;
347 font-weight: 600;
348 border-radius: 9999px;
349 border: 1px solid var(--border);
350 background: var(--bg-secondary);
351 color: var(--text-muted);
352 line-height: 1.6;
353 }
354 .prs-tag.is-draft {
355 color: var(--text-muted);
356 border-color: var(--border-strong);
357 }
358 .prs-tag.is-merged {
359 color: var(--text-link);
6fd5915Claude360 border-color: rgba(91,110,232,0.45);
361 background: rgba(91,110,232,0.10);
b078860Claude362 }
1aef949Claude363 .prs-tag.is-approved {
364 color: #34d399;
365 border-color: rgba(52,211,153,0.40);
366 background: rgba(52,211,153,0.08);
367 }
368 .prs-tag.is-changes {
369 color: #f87171;
370 border-color: rgba(248,113,113,0.40);
371 background: rgba(248,113,113,0.08);
372 }
b078860Claude373
374 .prs-empty {
ea9ed4cClaude375 position: relative;
376 padding: 56px 32px;
b078860Claude377 text-align: center;
378 border: 1px dashed var(--border);
ea9ed4cClaude379 border-radius: 16px;
380 background: var(--bg-elevated);
b078860Claude381 color: var(--text-muted);
ea9ed4cClaude382 overflow: hidden;
b078860Claude383 }
ea9ed4cClaude384 .prs-empty::before {
385 content: '';
386 position: absolute;
387 inset: -40% -20% auto auto;
388 width: 320px; height: 320px;
6fd5915Claude389 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.08) 50%, transparent 75%);
ea9ed4cClaude390 filter: blur(70px);
391 opacity: 0.55;
392 pointer-events: none;
393 animation: prsEmptyOrb 16s ease-in-out infinite;
394 }
395 @keyframes prsEmptyOrb {
396 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
397 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
398 }
399 @media (prefers-reduced-motion: reduce) {
400 .prs-empty::before { animation: none; }
401 }
402 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude403 .prs-empty strong {
404 display: block;
405 color: var(--text-strong);
ea9ed4cClaude406 font-family: var(--font-display);
407 font-size: 22px;
408 font-weight: 700;
409 letter-spacing: -0.018em;
410 margin-bottom: 2px;
411 }
412 .prs-empty-sub {
413 font-size: 14.5px;
414 color: var(--text-muted);
415 line-height: 1.55;
416 max-width: 460px;
417 margin: 0 0 18px;
b078860Claude418 }
ea9ed4cClaude419 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude420
421 @media (max-width: 720px) {
422 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
423 .prs-hero-actions { width: 100%; }
424 .prs-row-tags { margin-left: 0; }
425 }
f1dc7c7Claude426
427 /* Additional mobile rules. Additive only. */
428 @media (max-width: 720px) {
429 .prs-hero { padding: 18px 18px 20px; }
430 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
431 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
432 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
433 .prs-row { padding: 12px 14px; gap: 10px; }
434 .prs-row-icon { width: 24px; height: 24px; }
435 }
f5b9ef5Claude436
437 /* ─── Sort controls (PR list) ─── */
438 .prs-sort-row {
439 display: flex;
440 align-items: center;
441 gap: 6px;
442 margin: 0 0 12px;
443 flex-wrap: wrap;
444 }
445 .prs-sort-label {
446 font-size: 12.5px;
447 color: var(--text-muted);
448 font-weight: 600;
449 margin-right: 2px;
450 }
451 .prs-sort-opt {
452 font-size: 12.5px;
453 color: var(--text-muted);
454 text-decoration: none;
455 padding: 3px 10px;
456 border-radius: 9999px;
457 border: 1px solid transparent;
458 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
459 }
460 .prs-sort-opt:hover {
461 background: var(--bg-hover);
462 color: var(--text);
463 }
464 .prs-sort-opt.is-active {
6fd5915Claude465 background: rgba(91,110,232,0.12);
f5b9ef5Claude466 color: var(--text-link);
6fd5915Claude467 border-color: rgba(91,110,232,0.35);
f5b9ef5Claude468 font-weight: 600;
469 }
b078860Claude470`;
471
472/* ──────────────────────────────────────────────────────────────────────
473 * Inline CSS for the detail page. Same `.prs-*` namespace.
474 * ──────────────────────────────────────────────────────────────────── */
475const PRS_DETAIL_STYLES = `
476 .prs-detail-hero {
477 position: relative;
478 margin: 0 0 var(--space-4);
479 padding: 24px 26px;
480 background: var(--bg-elevated);
481 border: 1px solid var(--border);
482 border-radius: 16px;
483 overflow: hidden;
484 }
485 .prs-detail-hero::before {
486 content: '';
487 position: absolute; top: 0; left: 0; right: 0;
488 height: 2px;
6fd5915Claude489 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
b078860Claude490 opacity: 0.7;
491 pointer-events: none;
492 }
493 .prs-detail-title {
494 font-family: var(--font-display);
495 font-size: clamp(22px, 2.6vw, 28px);
496 font-weight: 700;
497 letter-spacing: -0.022em;
498 line-height: 1.2;
499 color: var(--text-strong);
500 margin: 0 0 12px;
501 }
502 .prs-detail-num {
503 color: var(--text-muted);
504 font-weight: 400;
505 }
506 .prs-state-pill {
507 display: inline-flex; align-items: center; gap: 6px;
508 padding: 6px 12px;
509 border-radius: 9999px;
510 font-size: 12.5px;
511 font-weight: 600;
512 line-height: 1;
513 border: 1px solid transparent;
514 }
515 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
6fd5915Claude516 .prs-state-pill.state-merged { color: #5b6ee8; background: rgba(91,110,232,0.16); border-color: rgba(91,110,232,0.45); }
b078860Claude517 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
518 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
519
74d8c4dClaude520 .prs-size-badge {
521 display: inline-flex;
522 align-items: center;
523 padding: 2px 8px;
524 border-radius: 20px;
525 font-size: 11px;
526 font-weight: 700;
527 letter-spacing: 0.04em;
528 border: 1px solid currentColor;
529 opacity: 0.85;
530 }
531
b078860Claude532 .prs-detail-meta {
533 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
534 font-size: 13px;
535 color: var(--text-muted);
536 }
537 .prs-detail-meta strong { color: var(--text); }
538 .prs-detail-branches {
539 display: inline-flex; align-items: center; gap: 6px;
540 font-family: var(--font-mono);
541 font-size: 12px;
542 }
543 .prs-branch-pill {
544 padding: 3px 9px;
545 border-radius: 9999px;
546 background: var(--bg-tertiary);
547 border: 1px solid var(--border);
548 color: var(--text);
549 }
550 .prs-branch-pill.is-head { color: var(--text-strong); }
551 .prs-branch-arrow-lg {
552 color: var(--accent);
553 font-size: 14px;
554 font-weight: 700;
555 }
0369e77Claude556 .prs-branch-sync {
557 display: inline-flex; align-items: center; gap: 4px;
558 font-size: 11.5px; font-weight: 600;
559 padding: 2px 8px;
560 border-radius: 9999px;
561 border: 1px solid var(--border);
562 background: var(--bg-secondary);
563 color: var(--text-muted);
564 cursor: default;
565 }
566 .prs-branch-sync.is-behind {
567 color: #f87171;
568 border-color: rgba(248,113,113,0.35);
569 background: rgba(248,113,113,0.07);
570 }
571 .prs-branch-sync.is-synced {
572 color: #34d399;
573 border-color: rgba(52,211,153,0.35);
574 background: rgba(52,211,153,0.07);
575 }
b078860Claude576
577 .prs-detail-actions {
578 display: inline-flex; gap: 8px; margin-left: auto;
579 }
580
581 .prs-detail-tabs {
582 display: flex; gap: 4px;
583 margin: 0 0 16px;
584 border-bottom: 1px solid var(--border);
585 }
586 .prs-detail-tab {
587 padding: 10px 14px;
588 font-size: 13.5px;
589 font-weight: 500;
590 color: var(--text-muted);
591 text-decoration: none;
592 border-bottom: 2px solid transparent;
593 transition: color 120ms ease, border-color 120ms ease;
594 margin-bottom: -1px;
595 }
596 .prs-detail-tab:hover { color: var(--text); }
597 .prs-detail-tab.is-active {
598 color: var(--text-strong);
599 border-bottom-color: var(--accent);
600 }
601 .prs-detail-tab-count {
602 display: inline-flex; align-items: center; justify-content: center;
603 min-width: 20px; padding: 0 6px; margin-left: 6px;
604 height: 18px;
605 font-size: 11px;
606 font-weight: 600;
607 border-radius: 9999px;
608 background: var(--bg-tertiary);
609 color: var(--text-muted);
610 }
611
612 /* Gate / check status section */
613 .prs-gate-card {
614 margin-top: 20px;
615 background: var(--bg-elevated);
616 border: 1px solid var(--border);
617 border-radius: 14px;
618 overflow: hidden;
619 }
620 .prs-gate-head {
621 display: flex; align-items: center; gap: 10px;
622 padding: 14px 18px;
623 border-bottom: 1px solid var(--border);
624 }
625 .prs-gate-head h3 {
626 margin: 0;
627 font-size: 14px;
628 font-weight: 600;
629 color: var(--text-strong);
630 }
631 .prs-gate-summary {
632 margin-left: auto;
633 font-size: 12px;
634 color: var(--text-muted);
635 }
636 .prs-gate-row {
637 display: flex; align-items: center; gap: 12px;
638 padding: 12px 18px;
639 border-bottom: 1px solid var(--border-subtle);
640 }
641 .prs-gate-row:last-child { border-bottom: 0; }
642 .prs-gate-icon {
643 flex: 0 0 auto;
644 width: 22px; height: 22px;
645 display: inline-flex; align-items: center; justify-content: center;
646 border-radius: 9999px;
647 font-size: 12px;
648 font-weight: 700;
649 }
650 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
651 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
652 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
653 .prs-gate-name {
654 font-size: 13px;
655 font-weight: 600;
656 color: var(--text);
657 min-width: 140px;
658 }
659 .prs-gate-details {
660 flex: 1; min-width: 0;
661 font-size: 12.5px;
662 color: var(--text-muted);
663 }
664 .prs-gate-pill {
665 flex: 0 0 auto;
666 padding: 3px 10px;
667 border-radius: 9999px;
668 font-size: 11px;
669 font-weight: 600;
670 line-height: 1.5;
671 border: 1px solid transparent;
672 }
673 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
674 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
675 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
676 .prs-gate-footer {
677 padding: 12px 18px;
678 background: var(--bg-secondary);
679 font-size: 12px;
680 color: var(--text-muted);
681 }
682
683 /* Comment cards */
684 .prs-comment {
685 margin-top: 14px;
686 background: var(--bg-elevated);
687 border: 1px solid var(--border);
688 border-radius: 12px;
689 overflow: hidden;
690 }
691 .prs-comment-head {
692 display: flex; align-items: center; gap: 10px;
693 padding: 10px 14px;
694 background: var(--bg-secondary);
695 border-bottom: 1px solid var(--border);
696 font-size: 13px;
697 flex-wrap: wrap;
698 }
699 .prs-comment-head strong { color: var(--text-strong); }
700 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
701 .prs-comment-loc {
702 font-family: var(--font-mono);
703 font-size: 11.5px;
704 color: var(--text-muted);
705 background: var(--bg-tertiary);
706 padding: 2px 8px;
707 border-radius: 6px;
708 }
709 .prs-comment-body { padding: 14px 18px; }
710 .prs-comment.is-ai {
6fd5915Claude711 border-color: rgba(91,110,232,0.45);
712 box-shadow: 0 0 0 1px rgba(91,110,232,0.10), 0 6px 24px -10px rgba(91,110,232,0.30);
b078860Claude713 }
714 .prs-comment.is-ai .prs-comment-head {
6fd5915Claude715 background: linear-gradient(90deg, rgba(91,110,232,0.10), rgba(95,143,160,0.06));
716 border-bottom-color: rgba(91,110,232,0.30);
b078860Claude717 }
718 .prs-ai-badge {
719 display: inline-flex; align-items: center; gap: 4px;
720 padding: 2px 9px;
721 font-size: 10.5px;
722 font-weight: 700;
723 letter-spacing: 0.04em;
724 text-transform: uppercase;
725 color: #fff;
6fd5915Claude726 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 130%);
b078860Claude727 border-radius: 9999px;
728 }
a7460bfClaude729 .prs-bot-badge {
730 display: inline-flex; align-items: center; gap: 3px;
731 padding: 1px 7px;
732 font-size: 10px;
733 font-weight: 600;
734 color: var(--fg-muted);
735 background: var(--bg-elevated);
736 border: 1px solid var(--border);
737 border-radius: 9999px;
738 }
b078860Claude739
740 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
741 .prs-files-card {
742 margin-top: 18px;
743 padding: 14px 18px;
744 display: flex; align-items: center; gap: 14px;
745 background: var(--bg-elevated);
746 border: 1px solid var(--border);
747 border-radius: 12px;
748 text-decoration: none;
749 color: inherit;
750 transition: border-color 120ms ease, transform 140ms ease;
751 }
752 .prs-files-card:hover {
6fd5915Claude753 border-color: rgba(91,110,232,0.45);
b078860Claude754 transform: translateY(-1px);
755 }
756 .prs-files-card-icon {
757 width: 36px; height: 36px;
758 display: inline-flex; align-items: center; justify-content: center;
759 border-radius: 10px;
6fd5915Claude760 background: rgba(91,110,232,0.12);
b078860Claude761 color: var(--text-link);
762 font-size: 18px;
763 }
764 .prs-files-card-text { flex: 1; min-width: 0; }
765 .prs-files-card-title {
766 font-size: 14px;
767 font-weight: 600;
768 color: var(--text-strong);
769 margin: 0 0 2px;
770 }
771 .prs-files-card-sub {
772 font-size: 12.5px;
773 color: var(--text-muted);
774 margin: 0;
775 }
776 .prs-files-card-cta {
777 font-size: 12.5px;
778 color: var(--text-link);
779 font-weight: 600;
780 }
781
782 /* Merge area */
783 .prs-merge-card {
784 position: relative;
785 margin-top: 22px;
786 padding: 18px;
787 background: var(--bg-elevated);
788 border-radius: 14px;
789 overflow: hidden;
790 }
791 .prs-merge-card::before {
792 content: '';
793 position: absolute; inset: 0;
794 padding: 1px;
795 border-radius: 14px;
6fd5915Claude796 background: linear-gradient(135deg, rgba(91,110,232,0.55) 0%, rgba(95,143,160,0.40) 100%);
b078860Claude797 -webkit-mask:
798 linear-gradient(#000 0 0) content-box,
799 linear-gradient(#000 0 0);
800 -webkit-mask-composite: xor;
801 mask-composite: exclude;
802 pointer-events: none;
803 }
804 .prs-merge-card.is-closed::before { background: var(--border-strong); }
6fd5915Claude805 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(91,110,232,0.45), rgba(95,143,160,0.30)); }
b078860Claude806 .prs-merge-head {
807 display: flex; align-items: center; gap: 12px;
808 margin-bottom: 12px;
809 }
810 .prs-merge-head strong {
811 font-family: var(--font-display);
812 font-size: 15px;
813 color: var(--text-strong);
814 font-weight: 700;
815 }
816 .prs-merge-sub {
817 font-size: 13px;
818 color: var(--text-muted);
819 margin: 0 0 12px;
820 }
821 .prs-merge-actions {
822 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
823 }
824 .prs-merge-btn {
825 display: inline-flex; align-items: center; gap: 6px;
826 padding: 9px 16px;
827 border-radius: 10px;
828 font-size: 13.5px;
829 font-weight: 600;
830 color: #fff;
6fd5915Claude831 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #5f8fa0 140%);
b078860Claude832 border: 1px solid rgba(52,211,153,0.55);
833 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
834 cursor: pointer;
835 transition: transform 120ms ease, box-shadow 160ms ease;
836 }
837 .prs-merge-btn:hover {
838 transform: translateY(-1px);
839 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
840 }
841 .prs-merge-btn[disabled],
842 .prs-merge-btn.is-disabled {
843 opacity: 0.55;
844 cursor: not-allowed;
845 transform: none;
846 box-shadow: none;
847 }
848 .prs-merge-ready-btn {
849 display: inline-flex; align-items: center; gap: 6px;
850 padding: 9px 16px;
851 border-radius: 10px;
852 font-size: 13.5px;
853 font-weight: 600;
854 color: #fff;
6fd5915Claude855 background: linear-gradient(135deg, #5b6ee8 0%, #6f5be8 60%, #5f8fa0 140%);
856 border: 1px solid rgba(91,110,232,0.55);
857 box-shadow: 0 6px 18px -8px rgba(91,110,232,0.55);
b078860Claude858 cursor: pointer;
859 transition: transform 120ms ease, box-shadow 160ms ease;
860 }
861 .prs-merge-ready-btn:hover {
862 transform: translateY(-1px);
6fd5915Claude863 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55);
b078860Claude864 }
865 .prs-merge-back-draft {
866 background: none; border: 1px solid var(--border-strong);
867 color: var(--text-muted);
868 padding: 9px 14px; border-radius: 10px;
869 font-size: 13px; cursor: pointer;
870 }
871 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
872
a164a6dClaude873 /* Merge strategy selector */
874 .prs-merge-strategy-wrap {
875 display: inline-flex; align-items: center;
876 background: var(--bg-elevated);
877 border: 1px solid var(--border);
878 border-radius: 10px;
879 overflow: hidden;
880 }
881 .prs-merge-strategy-label {
882 font-size: 11.5px; font-weight: 600;
883 color: var(--text-muted);
884 padding: 0 10px 0 12px;
885 white-space: nowrap;
886 }
887 .prs-merge-strategy-select {
888 background: transparent;
889 border: none;
890 color: var(--text);
891 font-size: 13px;
892 padding: 7px 10px 7px 4px;
893 cursor: pointer;
894 outline: none;
895 appearance: auto;
896 }
6fd5915Claude897 .prs-merge-strategy-select:focus { outline: 2px solid rgba(91,110,232,0.45); }
a164a6dClaude898
0a67773Claude899 /* Review summary banner */
900 .prs-review-summary {
901 display: flex; flex-direction: column; gap: 6px;
902 padding: 12px 16px;
903 background: var(--bg-elevated);
904 border: 1px solid var(--border);
905 border-radius: var(--r-md, 8px);
906 margin-bottom: 12px;
907 }
908 .prs-review-row {
909 display: flex; align-items: center; gap: 10px;
910 font-size: 13px;
911 }
912 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
913 .prs-review-approved .prs-review-icon { color: #34d399; }
914 .prs-review-changes .prs-review-icon { color: #f87171; }
ace34efClaude915 .prs-reviewer-avatar {
916 width: 24px; height: 24px; border-radius: 50%;
917 background: var(--accent); color: #fff;
918 display: flex; align-items: center; justify-content: center;
919 font-size: 11px; font-weight: 700; flex-shrink: 0;
920 }
0a67773Claude921
922 /* Review action buttons */
923 .prs-review-approve-btn {
924 display: inline-flex; align-items: center; gap: 5px;
925 padding: 8px 14px; border-radius: 8px; font-size: 13px;
926 font-weight: 600; cursor: pointer;
927 background: rgba(52,211,153,0.12);
928 color: #34d399;
929 border: 1px solid rgba(52,211,153,0.35);
930 transition: background 120ms;
931 }
932 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
933 .prs-review-changes-btn {
934 display: inline-flex; align-items: center; gap: 5px;
935 padding: 8px 14px; border-radius: 8px; font-size: 13px;
936 font-weight: 600; cursor: pointer;
937 background: rgba(248,113,113,0.10);
938 color: #f87171;
939 border: 1px solid rgba(248,113,113,0.30);
940 transition: background 120ms;
941 }
942 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
943
b078860Claude944 /* Inline form helpers */
945 .prs-inline-form { display: inline-flex; }
946
947 /* Comment composer */
948 .prs-composer { margin-top: 22px; }
949 .prs-composer textarea {
950 border-radius: 12px;
951 }
952
953 @media (max-width: 720px) {
954 .prs-detail-actions { margin-left: 0; }
955 .prs-merge-actions { width: 100%; }
956 .prs-merge-actions > * { flex: 1; min-width: 0; }
957 }
f1dc7c7Claude958
959 /* Additional mobile rules. Additive only. */
960 @media (max-width: 720px) {
961 .prs-detail-hero { padding: 18px; }
962 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
963 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
964 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
965 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
966 .prs-gate-name { min-width: 0; }
967 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
968 .prs-gate-summary { margin-left: 0; }
969 .prs-merge-btn,
970 .prs-merge-ready-btn,
971 .prs-merge-back-draft { min-height: 44px; }
972 .prs-comment-body { padding: 12px 14px; }
973 .prs-comment-head { padding: 10px 12px; }
974 .prs-files-card { padding: 12px 14px; }
975 }
3c03977Claude976
977 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
978 .live-pill {
979 display: inline-flex;
980 align-items: center;
981 gap: 8px;
982 padding: 4px 10px 4px 8px;
983 margin-left: 6px;
984 background: var(--bg-elevated);
985 border: 1px solid var(--border);
986 border-radius: 9999px;
987 font-size: 12px;
988 color: var(--text-muted);
989 line-height: 1;
990 vertical-align: middle;
991 }
992 .live-pill.is-busy { color: var(--text); }
993 .live-pill-dot {
994 width: 8px; height: 8px;
995 border-radius: 9999px;
996 background: #34d399;
997 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
998 animation: live-pulse 1.6s ease-in-out infinite;
999 }
1000 @keyframes live-pulse {
1001 0%, 100% { opacity: 1; }
1002 50% { opacity: 0.55; }
1003 }
1004 .live-avatars {
1005 display: inline-flex;
1006 margin-left: 2px;
1007 }
1008 .live-avatar {
1009 display: inline-flex;
1010 align-items: center;
1011 justify-content: center;
1012 width: 22px; height: 22px;
1013 border-radius: 9999px;
1014 font-size: 10px;
1015 font-weight: 700;
1016 color: #0b1020;
1017 margin-left: -6px;
1018 border: 2px solid var(--bg-elevated);
1019 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
1020 }
1021 .live-avatar:first-child { margin-left: 0; }
1022 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
1023 .live-cursor-host {
1024 position: relative;
1025 }
1026 .live-cursor-overlay {
1027 position: absolute;
1028 inset: 0;
1029 pointer-events: none;
1030 overflow: hidden;
1031 border-radius: inherit;
1032 }
1033 .live-cursor {
1034 position: absolute;
1035 width: 2px;
1036 height: 18px;
1037 border-radius: 2px;
1038 transform: translate(-1px, 0);
1039 transition: transform 80ms linear, opacity 200ms ease;
1040 }
1041 .live-cursor::after {
1042 content: attr(data-label);
1043 position: absolute;
1044 top: -16px;
1045 left: -2px;
1046 font-size: 10px;
1047 line-height: 1;
1048 color: #0b1020;
1049 background: inherit;
1050 padding: 2px 5px;
1051 border-radius: 4px 4px 4px 0;
1052 white-space: nowrap;
1053 font-weight: 600;
1054 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
1055 }
1056 .live-cursor.is-idle { opacity: 0.4; }
1057 .live-edit-tag {
1058 display: inline-block;
1059 margin-left: 6px;
1060 padding: 1px 6px;
1061 font-size: 10px;
1062 font-weight: 600;
1063 letter-spacing: 0.02em;
1064 color: #0b1020;
1065 border-radius: 9999px;
1066 }
15db0e0Claude1067
1068 /* ─── Slash-command pill + composer hint ─── */
1069 .slash-hint {
1070 display: inline-flex;
1071 align-items: center;
1072 gap: 6px;
1073 margin-top: 6px;
1074 padding: 3px 9px;
1075 font-size: 11.5px;
1076 color: var(--text-muted);
1077 background: var(--bg-elevated);
1078 border: 1px dashed var(--border);
1079 border-radius: 9999px;
1080 width: fit-content;
1081 }
1082 .slash-hint code {
1083 background: rgba(110, 168, 255, 0.12);
1084 color: var(--text-strong);
1085 padding: 0 5px;
1086 border-radius: 4px;
1087 font-size: 11px;
1088 }
1089 .slash-pill {
1090 display: grid;
1091 grid-template-columns: auto 1fr auto;
1092 align-items: center;
1093 column-gap: 10px;
1094 row-gap: 6px;
1095 margin: 10px 0;
1096 padding: 10px 14px;
1097 background: linear-gradient(
1098 135deg,
1099 rgba(110, 168, 255, 0.08),
1100 rgba(163, 113, 247, 0.06)
1101 );
1102 border: 1px solid rgba(110, 168, 255, 0.32);
1103 border-left: 3px solid var(--accent, #6ea8ff);
1104 border-radius: var(--radius);
1105 font-size: 13px;
1106 color: var(--text);
1107 }
1108 .slash-pill-icon {
1109 font-size: 14px;
1110 line-height: 1;
1111 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
1112 }
1113 .slash-pill-actor { color: var(--text-muted); }
1114 .slash-pill-actor strong { color: var(--text-strong); }
1115 .slash-pill-cmd {
1116 background: rgba(110, 168, 255, 0.16);
1117 color: var(--text-strong);
1118 padding: 1px 6px;
1119 border-radius: 4px;
1120 font-size: 12.5px;
1121 }
1122 .slash-pill-time {
1123 color: var(--text-muted);
1124 font-size: 12px;
1125 justify-self: end;
1126 }
1127 .slash-pill-body {
1128 grid-column: 1 / -1;
1129 color: var(--text);
1130 font-size: 13px;
1131 line-height: 1.55;
1132 }
1133 .slash-pill-body p:first-child { margin-top: 0; }
1134 .slash-pill-body p:last-child { margin-bottom: 0; }
1135 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
1136 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1137 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
1138 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
6fd5915Claude1139 .slash-pill.slash-cmd-stage { border-left-color: #5f8fa0; }
4bbacbeClaude1140
1141 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
1142 .preview-prpill {
1143 display: inline-flex; align-items: center; gap: 6px;
1144 padding: 3px 10px;
1145 border-radius: 9999px;
1146 font-family: var(--font-mono);
1147 font-size: 11.5px;
1148 font-weight: 600;
1149 background: rgba(255,255,255,0.04);
1150 color: var(--text-muted);
1151 text-decoration: none;
1152 border: 1px solid var(--border);
1153 }
6fd5915Claude1154 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(91,110,232,0.45); }
4bbacbeClaude1155 .preview-prpill .preview-prpill-dot {
1156 width: 7px; height: 7px;
1157 border-radius: 9999px;
1158 background: currentColor;
1159 }
1160 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1161 .preview-prpill.is-building .preview-prpill-dot {
1162 animation: previewPrPulse 1.4s ease-in-out infinite;
1163 }
1164 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1165 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1166 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1167 @keyframes previewPrPulse {
1168 0%, 100% { opacity: 1; }
1169 50% { opacity: 0.4; }
1170 }
79ed944Claude1171
1172 /* ─── AI Trio Review — 3-column verdict cards ─── */
1173 .trio-wrap {
1174 margin-top: 18px;
1175 padding: 16px;
1176 background: var(--bg-elevated);
1177 border: 1px solid var(--border);
1178 border-radius: 14px;
1179 }
1180 .trio-header {
1181 display: flex; align-items: center; gap: 10px;
1182 margin: 0 0 12px;
1183 font-size: 13.5px;
1184 color: var(--text);
1185 }
1186 .trio-header strong { color: var(--text-strong); }
1187 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1188 .trio-header-dot {
1189 width: 8px; height: 8px; border-radius: 9999px;
6fd5915Claude1190 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
1191 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
79ed944Claude1192 }
1193 .trio-grid {
1194 display: grid;
1195 grid-template-columns: repeat(3, minmax(0, 1fr));
1196 gap: 12px;
1197 }
1198 .trio-card {
1199 background: var(--bg-secondary);
1200 border: 1px solid var(--border);
1201 border-radius: 12px;
1202 overflow: hidden;
1203 display: flex; flex-direction: column;
1204 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1205 }
1206 .trio-card-head {
1207 display: flex; align-items: center; gap: 8px;
1208 padding: 10px 12px;
1209 border-bottom: 1px solid var(--border);
1210 background: rgba(255,255,255,0.02);
1211 font-size: 13px;
1212 }
1213 .trio-card-icon {
1214 display: inline-flex; align-items: center; justify-content: center;
1215 width: 22px; height: 22px;
1216 border-radius: 9999px;
1217 font-size: 12px;
1218 background: rgba(255,255,255,0.05);
1219 }
1220 .trio-card-title {
1221 color: var(--text-strong);
1222 font-weight: 600;
1223 letter-spacing: 0.01em;
1224 }
1225 .trio-card-verdict {
1226 margin-left: auto;
1227 font-size: 11px;
1228 font-weight: 700;
1229 letter-spacing: 0.06em;
1230 text-transform: uppercase;
1231 padding: 3px 9px;
1232 border-radius: 9999px;
1233 background: var(--bg-tertiary);
1234 color: var(--text-muted);
1235 border: 1px solid var(--border-strong);
1236 }
1237 .trio-card-body {
1238 padding: 12px 14px;
1239 font-size: 13px;
1240 color: var(--text);
1241 flex: 1;
1242 min-height: 64px;
1243 line-height: 1.55;
1244 }
1245 .trio-card-body p { margin: 0 0 8px; }
1246 .trio-card-body p:last-child { margin-bottom: 0; }
1247 .trio-card-body ul { margin: 0; padding-left: 18px; }
1248 .trio-card-body code {
1249 font-family: var(--font-mono);
1250 font-size: 12px;
1251 background: var(--bg-tertiary);
1252 padding: 1px 6px;
1253 border-radius: 5px;
1254 }
1255 .trio-card-empty {
1256 color: var(--text-muted);
1257 font-style: italic;
1258 font-size: 12.5px;
1259 }
1260
1261 /* Pass state — neutral, no accent. */
1262 .trio-card.is-pass .trio-card-verdict {
1263 color: var(--green);
1264 border-color: rgba(52,211,153,0.35);
1265 background: rgba(52,211,153,0.12);
1266 }
1267
1268 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1269 .trio-card.trio-security.is-fail {
1270 border-color: rgba(248,113,113,0.55);
1271 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1272 }
1273 .trio-card.trio-security.is-fail .trio-card-head {
1274 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1275 border-bottom-color: rgba(248,113,113,0.30);
1276 }
1277 .trio-card.trio-security.is-fail .trio-card-verdict {
1278 color: #fecaca;
1279 border-color: rgba(248,113,113,0.55);
1280 background: rgba(248,113,113,0.20);
1281 }
1282
1283 .trio-card.trio-correctness.is-fail {
1284 border-color: rgba(251,191,36,0.55);
1285 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1286 }
1287 .trio-card.trio-correctness.is-fail .trio-card-head {
1288 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1289 border-bottom-color: rgba(251,191,36,0.30);
1290 }
1291 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1292 color: #fde68a;
1293 border-color: rgba(251,191,36,0.55);
1294 background: rgba(251,191,36,0.20);
1295 }
1296
1297 .trio-card.trio-style.is-fail {
1298 border-color: rgba(96,165,250,0.55);
1299 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1300 }
1301 .trio-card.trio-style.is-fail .trio-card-head {
1302 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1303 border-bottom-color: rgba(96,165,250,0.30);
1304 }
1305 .trio-card.trio-style.is-fail .trio-card-verdict {
1306 color: #bfdbfe;
1307 border-color: rgba(96,165,250,0.55);
1308 background: rgba(96,165,250,0.20);
1309 }
1310
1311 /* Disagreement callout strip — yellow, prominent. */
1312 .trio-disagreement-strip {
1313 display: flex;
1314 gap: 12px;
1315 margin-top: 14px;
1316 padding: 12px 14px;
1317 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1318 border: 1px solid rgba(251,191,36,0.45);
1319 border-radius: 10px;
1320 color: var(--text);
1321 font-size: 13px;
1322 }
1323 .trio-disagreement-icon {
1324 flex: 0 0 auto;
1325 width: 26px; height: 26px;
1326 display: inline-flex; align-items: center; justify-content: center;
1327 border-radius: 9999px;
1328 background: rgba(251,191,36,0.25);
1329 color: #fde68a;
1330 font-size: 14px;
1331 }
1332 .trio-disagreement-body strong {
1333 display: block;
1334 color: #fde68a;
1335 margin: 0 0 4px;
1336 font-weight: 700;
1337 }
1338 .trio-disagreement-list {
1339 margin: 0;
1340 padding-left: 18px;
1341 color: var(--text);
1342 font-size: 12.5px;
1343 line-height: 1.55;
1344 }
1345 .trio-disagreement-list code {
1346 font-family: var(--font-mono);
1347 font-size: 11.5px;
1348 background: var(--bg-tertiary);
1349 padding: 1px 5px;
1350 border-radius: 4px;
1351 }
1352
1353 @media (max-width: 720px) {
1354 .trio-grid { grid-template-columns: 1fr; }
1355 .trio-wrap { padding: 12px; }
1356 }
6d1bbc2Claude1357
1358 /* ─── Task list progress pill ─── */
1359 .prs-tasks-pill {
1360 display: inline-flex; align-items: center; gap: 5px;
1361 font-size: 11.5px; font-weight: 600;
1362 padding: 2px 9px; border-radius: 9999px;
1363 border: 1px solid var(--border);
1364 background: var(--bg-elevated);
1365 color: var(--text-muted);
1366 }
1367 .prs-tasks-pill.is-complete {
1368 color: #34d399;
1369 border-color: rgba(52,211,153,0.40);
1370 background: rgba(52,211,153,0.08);
1371 }
1372 .prs-tasks-progress { display: inline-block; width: 36px; height: 4px; border-radius: 9999px; background: var(--border); overflow: hidden; vertical-align: middle; }
1373 .prs-tasks-progress-bar { height: 100%; border-radius: 9999px; background: #34d399; }
1374
1375 /* ─── Update branch button ─── */
1376 .prs-update-branch-btn {
1377 display: inline-flex; align-items: center; gap: 5px;
1378 padding: 4px 12px; border-radius: 8px; font-size: 12.5px;
1379 font-weight: 600; cursor: pointer;
1380 background: rgba(96,165,250,0.10);
1381 color: #60a5fa;
1382 border: 1px solid rgba(96,165,250,0.30);
1383 transition: background 120ms;
1384 }
1385 .prs-update-branch-btn:hover { background: rgba(96,165,250,0.20); }
1386
1387 /* ─── Linked issues panel ─── */
1388 .prs-linked-issues {
1389 margin-top: 16px;
1390 border: 1px solid var(--border);
1391 border-radius: 12px;
1392 overflow: hidden;
1393 }
1394 .prs-linked-issues-head {
1395 display: flex; align-items: center; justify-content: space-between;
1396 padding: 10px 16px;
1397 background: var(--bg-elevated);
1398 border-bottom: 1px solid var(--border);
1399 font-size: 13px; font-weight: 600; color: var(--text);
1400 }
1401 .prs-linked-issues-count {
1402 font-size: 11px; font-weight: 700;
1403 padding: 1px 7px; border-radius: 9999px;
1404 background: var(--bg-tertiary);
1405 color: var(--text-muted);
1406 }
1407 .prs-linked-issue-row {
1408 display: flex; align-items: center; gap: 10px;
1409 padding: 9px 16px;
1410 border-bottom: 1px solid var(--border);
1411 font-size: 13px;
1412 text-decoration: none; color: inherit;
1413 }
1414 .prs-linked-issue-row:last-child { border-bottom: none; }
1415 .prs-linked-issue-row:hover { background: var(--bg-hover); }
1416 .prs-linked-issue-icon { flex: 0 0 auto; font-size: 14px; }
1417 .prs-linked-issue-icon.is-open { color: #34d399; }
1418 .prs-linked-issue-icon.is-closed { color: #8b949e; }
1419 .prs-linked-issue-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1420 .prs-linked-issue-num { color: var(--text-muted); font-size: 12px; }
1421 .prs-linked-issue-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
1422 .prs-linked-issue-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
1423 .prs-linked-issue-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
b558f23Claude1424
1425 /* ─── Commits tab ─── */
1426 .prs-commits-list { display: flex; flex-direction: column; gap: 0; margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1427 .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; }
1428 .prs-commit-row:last-child { border-bottom: none; }
1429 .prs-commit-row:hover { background: var(--bg-hover); }
1430 .prs-commit-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-top: 6px; }
1431 .prs-commit-body { flex: 1 1 auto; min-width: 0; }
1432 .prs-commit-msg { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
1433 .prs-commit-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
1434 .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; }
1435 .prs-commit-sha:hover { color: var(--accent); }
1436 .prs-commits-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13.5px; }
1437
1438 /* ─── Edit PR title/body ─── */
1439 .prs-edit-title-wrap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1440 .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; }
1441 .prs-edit-btn:hover { color: var(--text); border-color: var(--text-muted); }
1442 .prs-edit-form { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
1443 .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; }
1444 .prs-edit-actions { display: flex; gap: 8px; }
1445 .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; }
1446 .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; }
240c477Claude1447
1448 /* ─── CI status checks ─── */
1449 .prs-ci-card { margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1450 .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); }
1451 .prs-ci-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text); }
1452 .prs-ci-summary { font-size: 12px; color: var(--text-muted); }
1453 .prs-ci-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
1454 .prs-ci-row:last-child { border-bottom: none; }
1455 .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; }
1456 .prs-ci-icon.is-success { background: rgba(52,211,153,0.20); color: #34d399; }
1457 .prs-ci-icon.is-pending { background: rgba(251,191,36,0.20); color: #fbbf24; }
1458 .prs-ci-icon.is-failure, .prs-ci-icon.is-error { background: rgba(248,113,113,0.20); color: #f87171; }
1459 .prs-ci-context { flex: 1 1 auto; font-size: 13px; font-weight: 500; color: var(--text); }
1460 .prs-ci-desc { font-size: 12px; color: var(--text-muted); }
1461 .prs-ci-pill { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 9999px; }
1462 .prs-ci-pill.is-success { color: #34d399; background: rgba(52,211,153,0.10); }
1463 .prs-ci-pill.is-pending { color: #fbbf24; background: rgba(251,191,36,0.10); }
1464 .prs-ci-pill.is-failure, .prs-ci-pill.is-error { color: #f87171; background: rgba(248,113,113,0.10); }
1465 .prs-ci-link { font-size: 12px; color: var(--accent); text-decoration: none; }
1466 .prs-ci-link:hover { text-decoration: underline; }
67dc4e1Claude1467
1468 /* ─── AI Trio verdict pills (header summary) ─── */
1469 .trio-pill {
1470 display: inline-flex; align-items: center; gap: 4px;
1471 padding: 2px 8px;
1472 font-size: 11px;
1473 font-weight: 700;
1474 border-radius: 9999px;
1475 border: 1px solid transparent;
1476 text-decoration: none;
1477 line-height: 1.6;
1478 letter-spacing: 0.01em;
1479 cursor: pointer;
1480 transition: opacity 120ms ease;
1481 }
1482 .trio-pill:hover { opacity: 0.8; }
1483 .trio-pill.is-pass {
1484 color: #34d399;
1485 background: rgba(52,211,153,0.10);
1486 border-color: rgba(52,211,153,0.35);
1487 }
1488 .trio-pill.is-fail {
1489 color: #f87171;
1490 background: rgba(248,113,113,0.10);
1491 border-color: rgba(248,113,113,0.35);
1492 }
1493 .trio-pill.is-pending {
1494 color: var(--text-muted);
1495 background: rgba(255,255,255,0.04);
1496 border-color: var(--border-strong);
1497 }
1498 .trio-pills-wrap {
1499 display: inline-flex; align-items: center; gap: 4px;
1500 }
1d6db4dClaude1501
1502 /* ─── Bus Factor Warning Panel ─── */
1503 .busfactor-panel {
1504 display: flex;
1505 gap: 14px;
1506 align-items: flex-start;
1507 padding: 14px 18px;
1508 margin-bottom: 16px;
1509 border-radius: 12px;
1510 border: 1px solid rgba(245,158,11,0.35);
1511 background: rgba(245,158,11,0.06);
1512 }
1513 .busfactor-critical {
1514 border-color: rgba(239,68,68,0.4);
1515 background: rgba(239,68,68,0.06);
1516 }
1517 .busfactor-high {
1518 border-color: rgba(249,115,22,0.4);
1519 background: rgba(249,115,22,0.06);
1520 }
1521 .busfactor-medium {
1522 border-color: rgba(245,158,11,0.35);
1523 background: rgba(245,158,11,0.06);
1524 }
1525 .busfactor-icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; }
1526 .busfactor-body { flex: 1; min-width: 0; }
1527 .busfactor-body strong { font-size: 14px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1528 .busfactor-body p { font-size: 13px; color: var(--text-muted); margin: 0 0 8px; }
1529 .busfactor-body ul { margin: 0; padding-left: 18px; }
1530 .busfactor-body li { font-size: 12.5px; color: var(--text-muted); margin-bottom: 3px; font-family: var(--font-mono); }
1531 .busfactor-body li strong { font-size: 12.5px; color: var(--text); display: inline; }
1532
1533 /* ─── PR Split Suggestion Panel ─── */
1534 .split-suggestion {
1535 margin-bottom: 16px;
6fd5915Claude1536 border: 1px solid rgba(91,110,232,0.35);
1d6db4dClaude1537 border-radius: 12px;
1538 overflow: hidden;
1539 }
1540 .split-header {
1541 display: flex;
1542 align-items: center;
1543 gap: 10px;
1544 padding: 12px 18px;
6fd5915Claude1545 background: rgba(91,110,232,0.06);
1d6db4dClaude1546 flex-wrap: wrap;
1547 }
1548 .split-icon { font-size: 18px; flex-shrink: 0; }
1549 .split-header strong { font-size: 14px; font-weight: 700; color: var(--text-strong); flex: 1; min-width: 200px; }
1550 .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; }
1551 .split-toggle {
1552 background: none;
6fd5915Claude1553 border: 1px solid rgba(91,110,232,0.45);
1554 color: rgba(91,110,232,0.9);
1d6db4dClaude1555 font-size: 12.5px;
1556 font-weight: 600;
1557 padding: 4px 12px;
1558 border-radius: 8px;
1559 cursor: pointer;
1560 white-space: nowrap;
1561 transition: background 120ms ease;
1562 }
6fd5915Claude1563 .split-toggle:hover { background: rgba(91,110,232,0.1); }
1d6db4dClaude1564 .split-body {
1565 padding: 16px 18px;
6fd5915Claude1566 border-top: 1px solid rgba(91,110,232,0.2);
1d6db4dClaude1567 }
1568 .split-intro { font-size: 13.5px; color: var(--text-muted); margin: 0 0 14px; }
1569 .split-pr {
1570 display: flex;
1571 gap: 14px;
1572 align-items: flex-start;
1573 padding: 12px 0;
1574 border-bottom: 1px solid var(--border);
1575 }
1576 .split-pr:last-of-type { border-bottom: none; }
1577 .split-pr-num {
1578 width: 26px; height: 26px;
1579 border-radius: 50%;
6fd5915Claude1580 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 130%);
1d6db4dClaude1581 color: #fff;
1582 font-size: 12px;
1583 font-weight: 800;
1584 display: inline-flex;
1585 align-items: center;
1586 justify-content: center;
1587 flex-shrink: 0;
1588 margin-top: 2px;
1589 }
1590 .split-pr-body { flex: 1; min-width: 0; }
1591 .split-pr-body strong { font-size: 13.5px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1592 .split-pr-body p { font-size: 12.5px; color: var(--text-muted); margin: 0 0 6px; }
1593 .split-pr-body code { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); word-break: break-all; }
1594 .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; }
1595 .split-order { font-size: 13px; color: var(--text-muted); margin: 14px 0 0; }
1596 .split-order strong { color: var(--text); }
b078860Claude1597`;
1598
25b1ff7Claude1599/* ──────────────────────────────────────────────────────────────────────
1600 * Figma-style collaborative PR presence — styles for the presence bar
1601 * above the diff and the per-line reviewer cursor pills. All scoped
1602 * with `.presence-*` prefix so they never bleed into other views.
1603 * ──────────────────────────────────────────────────────────────────── */
1604const PRESENCE_STYLES = `
1605 .presence-bar {
1606 display: flex;
1607 align-items: center;
1608 gap: 10px;
1609 padding: 8px 14px;
1610 margin: 0 0 10px;
1611 background: var(--bg-elevated);
1612 border: 1px solid var(--border);
1613 border-radius: 10px;
1614 font-size: 12.5px;
1615 color: var(--text-muted);
1616 min-height: 38px;
1617 }
1618 .presence-bar-label {
1619 font-weight: 600;
1620 color: var(--text-muted);
1621 flex-shrink: 0;
1622 }
1623 .presence-avatars {
1624 display: flex;
1625 align-items: center;
1626 gap: 6px;
1627 flex: 1;
1628 flex-wrap: wrap;
1629 }
1630 .presence-avatar {
1631 display: inline-flex;
1632 align-items: center;
1633 gap: 5px;
1634 padding: 3px 8px 3px 4px;
1635 border-radius: 9999px;
1636 font-size: 12px;
1637 font-weight: 600;
1638 color: #fff;
1639 opacity: 0.92;
1640 transition: opacity 200ms;
1641 }
1642 .presence-avatar-dot {
1643 width: 20px; height: 20px;
1644 border-radius: 9999px;
1645 background: rgba(255,255,255,0.22);
1646 display: inline-flex;
1647 align-items: center;
1648 justify-content: center;
1649 font-size: 10px;
1650 font-weight: 700;
1651 flex-shrink: 0;
1652 }
1653 .presence-count {
1654 font-size: 12px;
1655 color: var(--text-faint);
1656 flex-shrink: 0;
1657 }
1658 /* Per-line reviewer cursor pill — injected by JS into .diff-row */
1659 .presence-line-pill {
1660 position: absolute;
1661 right: 6px;
1662 top: 50%;
1663 transform: translateY(-50%);
1664 display: inline-flex;
1665 align-items: center;
1666 gap: 4px;
1667 padding: 1px 7px;
1668 border-radius: 9999px;
1669 font-size: 10.5px;
1670 font-weight: 600;
1671 color: #fff;
1672 pointer-events: none;
1673 white-space: nowrap;
1674 z-index: 10;
1675 opacity: 0.88;
1676 animation: presence-in 160ms ease;
1677 }
1678 @keyframes presence-in {
1679 from { opacity: 0; transform: translateY(-50%) scale(0.85); }
1680 to { opacity: 0.88; transform: translateY(-50%) scale(1); }
1681 }
1682 .presence-line-pill.is-typing::after {
1683 content: '…';
1684 opacity: 0.7;
1685 }
1686 /* diff rows with a cursor pill need relative positioning */
1687 .diff-row { position: relative; }
1688 /* Toast for join/leave events */
1689 .presence-toast-wrap {
1690 position: fixed;
1691 bottom: 24px;
1692 right: 24px;
1693 display: flex;
1694 flex-direction: column;
1695 gap: 8px;
1696 z-index: 9999;
1697 pointer-events: none;
1698 }
1699 .presence-toast {
1700 padding: 8px 14px;
1701 border-radius: 8px;
1702 background: var(--bg-elevated);
1703 border: 1px solid var(--border);
1704 box-shadow: 0 6px 20px -8px rgba(0,0,0,0.55);
1705 font-size: 13px;
1706 color: var(--text);
1707 opacity: 1;
1708 transition: opacity 400ms;
1709 }
1710 .presence-toast.fading { opacity: 0; }
1711`;
b271465Claude1712
1713const IMPACT_STYLES = `
09d5f39Claude1714 /* ─── Merge Impact Analysis panel (.impact-*) ─── */
1715 .impact-panel {
1716 margin-top: 20px;
1717 border: 1px solid var(--border);
1718 border-radius: 12px;
1719 overflow: hidden;
1720 background: var(--bg-elevated);
1721 }
1722 .impact-header {
1723 display: flex;
1724 align-items: center;
1725 gap: 10px;
1726 padding: 12px 16px;
1727 background: var(--bg-elevated);
1728 border-bottom: 1px solid var(--border);
1729 cursor: pointer;
1730 user-select: none;
1731 }
1732 .impact-header:hover { background: var(--bg-hover); }
1733 .impact-score {
1734 display: inline-flex;
1735 align-items: center;
1736 justify-content: center;
1737 min-width: 34px;
1738 height: 24px;
1739 border-radius: 9999px;
1740 font-size: 11.5px;
1741 font-weight: 800;
1742 padding: 0 8px;
1743 letter-spacing: 0.01em;
1744 border: 1.5px solid transparent;
1745 }
1746 .impact-score.score-low {
1747 color: #34d399;
1748 background: rgba(52,211,153,0.12);
1749 border-color: rgba(52,211,153,0.35);
1750 }
1751 .impact-score.score-medium {
1752 color: #fbbf24;
1753 background: rgba(251,191,36,0.12);
1754 border-color: rgba(251,191,36,0.35);
1755 }
1756 .impact-score.score-high {
1757 color: #f87171;
1758 background: rgba(248,113,113,0.12);
1759 border-color: rgba(248,113,113,0.35);
1760 }
1761 .impact-header strong {
1762 font-size: 13.5px;
1763 color: var(--text-strong);
1764 font-weight: 700;
1765 }
1766 .impact-summary {
1767 font-size: 12.5px;
1768 color: var(--text-muted);
1769 flex: 1;
1770 }
1771 .impact-toggle {
1772 background: none;
1773 border: none;
1774 color: var(--text-muted);
1775 font-size: 11px;
1776 cursor: pointer;
1777 padding: 2px 6px;
1778 border-radius: 4px;
1779 transition: color 120ms;
1780 line-height: 1;
1781 }
1782 .impact-toggle:hover { color: var(--text); }
1783 .impact-toggle.is-open { transform: rotate(180deg); }
1784 .impact-body {
1785 padding: 14px 16px;
1786 display: flex;
1787 flex-direction: column;
1788 gap: 14px;
1789 }
1790 .impact-body[hidden] { display: none; }
1791 .impact-section h4 {
1792 margin: 0 0 8px;
1793 font-size: 12.5px;
1794 font-weight: 600;
1795 color: var(--text-muted);
1796 text-transform: uppercase;
1797 letter-spacing: 0.06em;
1798 }
1799 .impact-file-list {
1800 display: flex;
1801 flex-direction: column;
1802 gap: 3px;
1803 margin: 0;
1804 padding: 0;
1805 list-style: none;
1806 }
1807 .impact-file-list li {
1808 font-family: var(--font-mono);
1809 font-size: 12px;
1810 color: var(--text);
1811 padding: 3px 8px;
1812 background: var(--bg-secondary);
1813 border-radius: 5px;
1814 overflow: hidden;
1815 text-overflow: ellipsis;
1816 white-space: nowrap;
1817 }
1818 .impact-downstream .impact-file-list li {
1819 background: rgba(248,113,113,0.06);
1820 border: 1px solid rgba(248,113,113,0.15);
1821 }
1822 .impact-downstream h4 {
1823 color: #f87171;
1824 }
1825 .impact-empty {
1826 font-size: 12.5px;
1827 color: var(--text-muted);
1828 font-style: italic;
1829 }
1830`;
1831
25b1ff7Claude1832
1833
81c73c1Claude1834/**
1835 * Tiny inline JS that drives the "Suggest description with AI" button.
1836 * On click, gathers form values, POSTs JSON to the given endpoint, and
1837 * pipes the response into the #pr-body textarea. All DOM lookups are
1838 * defensive — element absence is a silent no-op.
1839 *
1840 * Built as a string template so it lives next to its server-side caller
1841 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1842 * to avoid </script> breakouts.
1843 */
1844function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1845 const url = JSON.stringify(endpointUrl)
1846 .split("<").join("\\u003C")
1847 .split(">").join("\\u003E")
1848 .split("&").join("\\u0026");
1849 return (
1850 "(function(){try{" +
1851 "var btn=document.getElementById('ai-suggest-desc');" +
1852 "var status=document.getElementById('ai-suggest-status');" +
1853 "var body=document.getElementById('pr-body');" +
1854 "var form=btn&&btn.closest&&btn.closest('form');" +
1855 "if(!btn||!body||!form)return;" +
1856 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1857 "var fd=new FormData(form);" +
1858 "var title=String(fd.get('title')||'').trim();" +
1859 "var base=String(fd.get('base')||'').trim();" +
1860 "var head=String(fd.get('head')||'').trim();" +
1861 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1862 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1863 "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'})" +
1864 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1865 ".then(function(j){btn.disabled=false;" +
1866 "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;}}" +
1867 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1868 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1869 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1870 "});" +
1871 "}catch(e){}})();"
1872 );
1873}
1874
3c03977Claude1875/**
1876 * Live co-editing client. Connects to the per-PR SSE feed and:
1877 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1878 * status colour per user).
1879 * - Renders tinted cursor caret overlays inside #pr-body and every
1880 * `[data-live-field]` element.
1881 * - Broadcasts the local user's cursor position (selectionStart /
1882 * selectionEnd) debounced at 100ms.
1883 * - Broadcasts content patches (`replace` of the whole textarea —
1884 * last-write-wins v1) debounced at 250ms.
1885 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1886 * to the matching local field if untouched.
1887 *
1888 * All endpoint URLs are JSON-escaped via safe replacements so they
1889 * can't break out of the <script> tag.
1890 */
25b1ff7Claude1891
1892/**
1893 * Figma-style collaborative PR presence client (WebSocket).
1894 *
1895 * Connects to `GET /:owner/:repo/pulls/:number/presence` (WebSocket upgrade).
1896 * On connect the server sends `{type:"init", users:[...]}` so the bar renders
1897 * immediately. Subsequent messages from the server drive the presence bar and
1898 * per-line cursor pills in the diff.
1899 *
1900 * Outbound messages:
1901 * {type:"cursor", line: N} — user hovered a diff line
1902 * {type:"typing", line: N, typing: bool} — textarea focus/blur in diff
1903 * {type:"ping"} — keep-alive every 10s
1904 *
1905 * Inbound messages:
1906 * {type:"init", users:[{sessionId,username,colour,line,typing}]}
1907 * {type:"join", user:{sessionId,username,colour,line,typing}}
1908 * {type:"leave", sessionId}
1909 * {type:"cursor", sessionId, username, colour, line}
1910 * {type:"typing", sessionId, username, colour, line, typing}
1911 */
1912function PR_PRESENCE_SCRIPT(owner: string, repo: string, prNum: number): string {
1913 const wsPath = JSON.stringify(`/${owner}/${repo}/pulls/${prNum}/presence`)
1914 .split("<").join("\\u003C")
1915 .split(">").join("\\u003E")
1916 .split("&").join("\\u0026");
1917 return `(function(){
1918try{
1919var wsPath=${wsPath};
1920var proto=location.protocol==='https:'?'wss:':'ws:';
1921var url=proto+'//'+location.host+wsPath;
1922var mySessionId=null;
1923// sessionId -> {username, colour, line, typing}
1924var peers={};
1925var ws=null;
1926var pingTimer=null;
1927var reconnectDelay=1500;
1928var reconnectTimer=null;
1929
1930function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
1931
1932// ── Toast ──────────────────────────────────────────────────────────────
1933var toastWrap=document.getElementById('presence-toasts');
1934function toast(msg){
1935 if(!toastWrap)return;
1936 var t=document.createElement('div');
1937 t.className='presence-toast';
1938 t.textContent=msg;
1939 toastWrap.appendChild(t);
1940 setTimeout(function(){t.classList.add('fading');setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},420);},2500);
1941}
1942
1943// ── Presence bar ───────────────────────────────────────────────────────
1944var avEl=document.getElementById('presence-avatars');
1945var countEl=document.getElementById('presence-count');
1946function renderBar(){
1947 if(!avEl)return;
1948 var ids=Object.keys(peers);
1949 var html='';
1950 for(var i=0;i<ids.length&&i<8;i++){
1951 var p=peers[ids[i]];
1952 var initials=(p.username||'?').slice(0,2).toUpperCase();
1953 html+='<span class="presence-avatar" style="background:'+esc(p.colour)+'" title="'+esc(p.username)+'">';
1954 html+='<span class="presence-avatar-dot">'+esc(initials)+'</span>';
1955 html+=esc(p.username);
1956 html+='</span>';
1957 }
1958 avEl.innerHTML=html;
1959 if(countEl){
1960 var n=ids.length;
1961 countEl.textContent=n===0?'No other reviewers':n===1?'1 reviewer online':n+' reviewers online';
1962 }
1963}
1964
1965// ── Diff cursor pills ──────────────────────────────────────────────────
1966// data-line value is like "12:x:5" or "12:5:x" — pull numeric line only
1967function lineNumFromKey(key){var m=String(key).match(/(\d+)/);return m?parseInt(m[1],10):null;}
1968function findDiffRow(line){return document.querySelector('[data-line]') &&
1969 (function(){var rows=document.querySelectorAll('[data-line]');
1970 for(var i=0;i<rows.length;i++){var n=lineNumFromKey(rows[i].getAttribute('data-line')||'');if(n===line)return rows[i];}
1971 return null;
1972 })();}
1973function removePill(sessionId){var old=document.querySelector('[data-presence-sid="'+sessionId+'"]');if(old&&old.parentNode)old.parentNode.removeChild(old);}
1974function placePill(sessionId,username,colour,line,typing){
1975 removePill(sessionId);
1976 if(line==null)return;
1977 var row=findDiffRow(line);if(!row)return;
1978 var pill=document.createElement('span');
1979 pill.className='presence-line-pill'+(typing?' is-typing':'');
1980 pill.setAttribute('data-presence-sid',sessionId);
6fd5915Claude1981 pill.style.background=colour||'#5b6ee8';
25b1ff7Claude1982 pill.textContent=(username||'?').slice(0,12)+(typing?' typing':'');
1983 row.appendChild(pill);
1984}
1985function clearPeer(sessionId){removePill(sessionId);delete peers[sessionId];}
1986
1987// ── Inbound message handler ────────────────────────────────────────────
1988function onMsg(raw){
1989 var d;try{d=JSON.parse(raw);}catch(e){return;}
1990 if(!d||!d.type)return;
1991 if(d.type==='init'){
1992 mySessionId=d.sessionId||null;
1993 peers={};
1994 (d.users||[]).forEach(function(u){
1995 if(u.sessionId===mySessionId)return;
1996 peers[u.sessionId]={username:u.username,colour:u.colour,line:u.line,typing:u.typing};
1997 placePill(u.sessionId,u.username,u.colour,u.line,u.typing);
1998 });
1999 renderBar();
2000 } else if(d.type==='join'){
2001 if(d.user&&d.user.sessionId!==mySessionId){
2002 peers[d.user.sessionId]={username:d.user.username,colour:d.user.colour,line:d.user.line,typing:d.user.typing};
2003 renderBar();
2004 toast(esc(d.user.username)+' joined the review');
2005 }
2006 } else if(d.type==='leave'){
2007 if(d.sessionId&&d.sessionId!==mySessionId){
2008 var name=peers[d.sessionId]&&peers[d.sessionId].username;
2009 clearPeer(d.sessionId);
2010 renderBar();
2011 if(name)toast(esc(name)+' left the review');
2012 }
2013 } else if(d.type==='cursor'){
2014 if(d.sessionId&&d.sessionId!==mySessionId){
2015 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=false;}
2016 placePill(d.sessionId,d.username,d.colour,d.line,false);
2017 }
2018 } else if(d.type==='typing'){
2019 if(d.sessionId&&d.sessionId!==mySessionId){
2020 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=d.typing;}
2021 placePill(d.sessionId,d.username,d.colour,d.line,d.typing);
2022 }
2023 }
2024}
2025
2026// ── Outbound helpers ───────────────────────────────────────────────────
2027function send(obj){try{if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}catch(e){}}
2028
2029// ── Mouse hover on diff rows ───────────────────────────────────────────
2030var hoverTimer=null;
2031document.addEventListener('mouseover',function(ev){
2032 var row=ev.target&&ev.target.closest&&ev.target.closest('[data-line]');
2033 if(!row)return;
2034 if(hoverTimer)clearTimeout(hoverTimer);
2035 hoverTimer=setTimeout(function(){
2036 var key=row.getAttribute('data-line')||'';
2037 var line=lineNumFromKey(key);
2038 if(line!=null)send({type:'cursor',line:line});
2039 },80);
2040});
2041
2042// ── Typing detection in diff comment textareas ─────────────────────────
2043document.addEventListener('focusin',function(ev){
2044 var ta=ev.target;
2045 if(!ta||ta.tagName!=='TEXTAREA')return;
2046 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
2047 var line=lineNumFromKey(row.getAttribute('data-line')||'');
2048 if(line!=null)send({type:'typing',line:line,typing:true});
2049});
2050document.addEventListener('focusout',function(ev){
2051 var ta=ev.target;
2052 if(!ta||ta.tagName!=='TEXTAREA')return;
2053 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
2054 var line=lineNumFromKey(row.getAttribute('data-line')||'');
2055 if(line!=null)send({type:'typing',line:line,typing:false});
2056});
2057
2058// ── WebSocket lifecycle ────────────────────────────────────────────────
2059function connect(){
2060 if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=null;}
2061 try{ws=new WebSocket(url);}catch(e){scheduleReconnect();return;}
2062 ws.onopen=function(){
2063 reconnectDelay=1500;
2064 pingTimer=setInterval(function(){send({type:'ping'});},10000);
2065 };
2066 ws.onmessage=function(ev){onMsg(ev.data);};
2067 ws.onclose=function(){
2068 if(pingTimer){clearInterval(pingTimer);pingTimer=null;}
2069 scheduleReconnect();
2070 };
2071 ws.onerror=function(){try{ws.close();}catch(e){}};
2072}
2073function scheduleReconnect(){
2074 reconnectTimer=setTimeout(function(){connect();},reconnectDelay);
2075 reconnectDelay=Math.min(reconnectDelay*2,30000);
2076}
2077
2078connect();
2079}catch(e){}})();`;
2080}
2081
3c03977Claude2082function LIVE_COEDIT_SCRIPT(prId: string): string {
2083 const idJson = JSON.stringify(prId)
2084 .split("<").join("\\u003C")
2085 .split(">").join("\\u003E")
2086 .split("&").join("\\u0026");
2087 return (
2088 "(function(){try{" +
2089 "if(typeof EventSource==='undefined')return;" +
2090 "var prId=" + idJson + ";" +
2091 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
2092 "var pill=document.getElementById('live-pill');" +
2093 "var avEl=document.getElementById('live-avatars');" +
2094 "var countEl=document.getElementById('live-count');" +
2095 "var sessionId=null;var myColor=null;" +
2096 "var presence={};" + // sessionId -> {color,status,userId,initials}
2097 "var lastApplied={};" + // field -> last server value (for echo suppression)
2098 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
2099 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
2100 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
2101 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
2102 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
2103 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
2104 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
2105 "avEl.innerHTML=html;}}" +
2106 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
2107 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
2108 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
2109 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
2110 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
2111 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
2112 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
2113 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
2114 "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);}" +
2115 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
2116 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
2117 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
2118 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
2119 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
2120 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
2121 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
2122 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
2123 "}catch(e){}" +
2124 "c.style.transform='translate('+x+'px,'+y+'px)';" +
2125 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
2126 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
2127 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
2128 "var es;var delay=1000;" +
2129 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
2130 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
2131 "(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){}});" +
2132 "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){}});" +
2133 "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){}});" +
2134 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
2135 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
2136 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
2137 "var patch=d.patch;if(!patch||!patch.field)return;" +
2138 "var ta=fieldEl(patch.field);if(!ta)return;" +
2139 "if(document.activeElement===ta)return;" + // don't trample local typing
2140 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
2141 "}catch(e){}});" +
2142 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
2143 "}connect();" +
2144 "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){}}" +
2145 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
2146 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
2147 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
2148 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
2149 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
2150 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
2151 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2152 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2153 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2154 "}" +
2155 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
2156 "var live=document.querySelectorAll('[data-live-field]');" +
2157 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
2158 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
2159 "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){}});" +
2160 "}catch(e){}})();"
2161 );
2162}
2163
0074234Claude2164async function resolveRepo(ownerName: string, repoName: string) {
2165 const [owner] = await db
2166 .select()
2167 .from(users)
2168 .where(eq(users.username, ownerName))
2169 .limit(1);
2170 if (!owner) return null;
2171 const [repo] = await db
2172 .select()
2173 .from(repositories)
2174 .where(
2175 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
2176 )
2177 .limit(1);
2178 if (!repo) return null;
2179 return { owner, repo };
2180}
2181
2182// PR Nav helper
2183const PrNav = ({
2184 owner,
2185 repo,
2186 active,
2187}: {
2188 owner: string;
2189 repo: string;
2190 active: "code" | "issues" | "pulls" | "commits";
2191}) => (
bb0f894Claude2192 <TabNav
2193 tabs={[
2194 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
2195 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
2196 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
2197 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
2198 ]}
2199 />
0074234Claude2200);
2201
534f04aClaude2202/**
2203 * Block M3 — pre-merge risk score card. Pure presentational helper.
2204 * Rendered in the conversation tab above the gate checks block. Hidden
2205 * entirely when the PR is closed/merged or there is nothing cached and
2206 * nothing in-flight.
2207 */
2208function PrRiskCard({
2209 risk,
2210 calculating,
2211}: {
2212 risk: PrRiskScore | null;
2213 calculating: boolean;
2214}) {
2215 if (!risk) {
2216 return (
2217 <div
2218 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
2219 >
2220 <strong style="font-size: 13px; color: var(--text)">
2221 Risk score: calculating…
2222 </strong>
2223 <div style="font-size: 12px; margin-top: 4px">
2224 Refresh in a moment to see the pre-merge risk score for this PR.
2225 </div>
2226 </div>
2227 );
2228 }
2229
2230 const palette = riskBandPalette(risk.band);
2231 const label = riskBandLabel(risk.band);
2232
2233 return (
2234 <div
2235 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
2236 >
2237 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
2238 <strong>Risk score:</strong>
2239 <span style={`color:${palette.border};font-weight:600`}>
2240 {palette.icon} {label} ({risk.score}/10)
2241 </span>
2242 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
2243 {risk.commitSha.slice(0, 7)}
2244 </span>
2245 </div>
2246 {risk.aiSummary && (
2247 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
2248 {risk.aiSummary}
2249 </div>
2250 )}
2251 <details style="margin-top:10px">
2252 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
2253 See full signal breakdown
2254 </summary>
2255 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
2256 <li>files changed: {risk.signals.filesChanged}</li>
2257 <li>
2258 lines added/removed: {risk.signals.linesAdded} /{" "}
2259 {risk.signals.linesRemoved}
2260 </li>
2261 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
2262 <li>
2263 schema migration touched:{" "}
2264 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
2265 </li>
2266 <li>
2267 locked / sensitive path touched:{" "}
2268 {risk.signals.lockedPathTouched ? "yes" : "no"}
2269 </li>
2270 <li>
2271 adds new dependency:{" "}
2272 {risk.signals.addsNewDependency ? "yes" : "no"}
2273 </li>
2274 <li>
2275 bumps major dependency:{" "}
2276 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
2277 </li>
2278 <li>
2279 tests added for new code:{" "}
2280 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
2281 </li>
2282 <li>
2283 diff-minus-test ratio:{" "}
2284 {risk.signals.diffMinusTestRatio.toFixed(2)}
2285 </li>
2286 </ul>
2287 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2288 How is this calculated? The score is a transparent sum of
2289 weighted signals — see <code>src/lib/pr-risk.ts</code>
2290 {" "}<code>computePrRiskScore</code>.
2291 </div>
2292 </details>
2293 {calculating && (
2294 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2295 (recomputing for the latest commit — refresh to update)
2296 </div>
2297 )}
2298 </div>
2299 );
2300}
2301
2302function riskBandPalette(band: PrRiskScore["band"]): {
2303 border: string;
2304 icon: string;
2305} {
2306 switch (band) {
2307 case "low":
2308 return { border: "var(--green)", icon: "" };
2309 case "medium":
2310 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
2311 case "high":
2312 return { border: "var(--orange, #db6d28)", icon: "⚠" };
2313 case "critical":
2314 return { border: "var(--red)", icon: "\u{1F6D1}" };
2315 }
2316}
2317
2318function riskBandLabel(band: PrRiskScore["band"]): string {
2319 switch (band) {
2320 case "low":
2321 return "LOW";
2322 case "medium":
2323 return "MEDIUM";
2324 case "high":
2325 return "HIGH";
2326 case "critical":
2327 return "CRITICAL";
2328 }
2329}
2330
09d5f39Claude2331// ---------------------------------------------------------------------------
2332// Merge Impact Analysis — collapsible panel showing affected files and
2333// downstream repos. Shown on open PRs for users with write access.
2334// ---------------------------------------------------------------------------
2335
2336function ImpactPanel({ analysis, owner }: { analysis: ImpactAnalysis; owner: string }) {
2337 const score = analysis.riskScore;
2338 const band = score <= 30 ? "low" : score <= 60 ? "medium" : "high";
2339 const hasAny =
2340 analysis.affectedTestFiles.length > 0 ||
2341 analysis.affectedFiles.length > 0 ||
2342 analysis.downstreamRepos.length > 0;
2343
2344 return (
2345 <div class="impact-panel">
2346 <div
2347 class="impact-header"
2348 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)"
2349 >
2350 <span class={`impact-score score-${band}`}>{score}</span>
2351 <strong>Merge Impact</strong>
2352 <span class="impact-summary">{analysis.riskSummary}</span>
2353 <button class="impact-toggle" type="button" aria-label="Toggle impact panel">
2354
2355 </button>
2356 </div>
2357 <div class="impact-body" hidden>
2358 <div class="impact-section">
2359 <h4>Affected test files ({analysis.affectedTestFiles.length})</h4>
2360 {analysis.affectedTestFiles.length === 0 ? (
2361 <span class="impact-empty">No test files reference the changed files.</span>
2362 ) : (
2363 <ul class="impact-file-list">
2364 {analysis.affectedTestFiles.map((f) => (
2365 <li title={f}>{f}</li>
2366 ))}
2367 </ul>
2368 )}
2369 </div>
2370 <div class="impact-section">
2371 <h4>Affected source files ({analysis.affectedFiles.length})</h4>
2372 {analysis.affectedFiles.length === 0 ? (
2373 <span class="impact-empty">No other source files import the changed files.</span>
2374 ) : (
2375 <ul class="impact-file-list">
2376 {analysis.affectedFiles.map((f) => (
2377 <li title={f}>{f}</li>
2378 ))}
2379 </ul>
2380 )}
2381 </div>
2382 {analysis.downstreamRepos.length > 0 && (
2383 <div class="impact-section impact-downstream">
2384 <h4>Downstream repos ({analysis.downstreamRepos.length})</h4>
2385 <ul class="impact-file-list">
2386 {analysis.downstreamRepos.map((r) => (
2387 <li>
2388 <a
2389 href={`/${r.owner}/${r.repo}`}
2390 style="color:var(--text-link);text-decoration:none"
2391 >
2392 {r.owner}/{r.repo}
2393 </a>
2394 {" "}
2395 <span style="color:var(--text-muted);font-size:11px">
2396 via {r.matchedDependency}
2397 </span>
2398 </li>
2399 ))}
2400 </ul>
2401 </div>
2402 )}
2403 </div>
2404 </div>
2405 );
2406}
2407
422a2d4Claude2408// ---------------------------------------------------------------------------
2409// AI Trio Review — 3-column card grid + disagreement callout.
2410//
2411// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
2412// per run: one per persona (security/correctness/style) plus a top-level
2413// summary. We surface them here as a single grid above the normal
2414// comment stream so reviewers see the verdicts at a glance.
2415// ---------------------------------------------------------------------------
2416
2417const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
2418
2419interface TrioCommentLike {
2420 body: string;
2421}
2422
2423function isTrioComment(body: string | null | undefined): boolean {
2424 if (!body) return false;
2425 return (
2426 body.includes(TRIO_SUMMARY_MARKER) ||
2427 body.includes(TRIO_COMMENT_MARKER.security) ||
2428 body.includes(TRIO_COMMENT_MARKER.correctness) ||
2429 body.includes(TRIO_COMMENT_MARKER.style)
2430 );
2431}
2432
2433function trioPersonaOfComment(body: string): TrioPersona | null {
2434 for (const p of TRIO_PERSONAS) {
2435 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
2436 }
2437 return null;
2438}
2439
2440/**
2441 * Best-effort verdict parse from a persona comment body. The body shape
2442 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
2443 * we only need the "Pass" / "Fail" word from the H2 heading.
2444 */
2445function trioVerdictOfBody(body: string): "pass" | "fail" | null {
2446 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
2447 if (!m) return null;
2448 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
2449}
2450
2451/**
2452 * Parse the disagreement bullet list out of the summary comment so we
2453 * can render it as a polished callout strip. Returns [] when nothing
2454 * matches — the comment author may have edited the marker out.
2455 */
2456function parseDisagreements(summaryBody: string): Array<{
2457 file: string;
2458 failing: string;
2459 passing: string;
2460}> {
2461 const out: Array<{ file: string; failing: string; passing: string }> = [];
2462 // Each disagreement line looks like:
2463 // - `path:42` — security, style say ✗, correctness say ✓
2464 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
2465 let m: RegExpExecArray | null;
2466 while ((m = re.exec(summaryBody)) !== null) {
2467 out.push({
2468 file: m[1].trim(),
2469 failing: m[2].trim().replace(/[,\s]+$/g, ""),
2470 passing: m[3].trim().replace(/[,\s]+$/g, ""),
2471 });
2472 }
2473 return out;
2474}
2475
2476function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
2477 // Find the most recent persona comments + summary. We iterate from
2478 // the end so re-reviews (multiple runs on the same PR) display the
2479 // freshest verdict.
2480 const latest: Partial<Record<TrioPersona, string>> = {};
2481 let summaryBody: string | null = null;
2482 for (let i = comments.length - 1; i >= 0; i--) {
2483 const body = comments[i].body || "";
2484 if (!isTrioComment(body)) continue;
2485 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
2486 summaryBody = body;
2487 continue;
2488 }
2489 const persona = trioPersonaOfComment(body);
2490 if (persona && !latest[persona]) latest[persona] = body;
2491 }
2492 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2493 if (!anyPersona && !summaryBody) return null;
2494
2495 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
2496
2497 return (
67dc4e1Claude2498 <div class="trio-wrap" id="trio-review-section">
422a2d4Claude2499 <div class="trio-header">
2500 <span class="trio-header-dot" aria-hidden="true"></span>
2501 <strong>AI Trio Review</strong>
2502 <span class="trio-header-sub">
2503 Three independent reviewers ran in parallel.
2504 </span>
2505 </div>
2506 <div class="trio-grid">
2507 {TRIO_PERSONAS.map((persona) => {
2508 const body = latest[persona];
2509 const verdict = body ? trioVerdictOfBody(body) : null;
2510 const stateClass =
2511 verdict === "fail"
2512 ? "is-fail"
2513 : verdict === "pass"
2514 ? "is-pass"
2515 : "is-pending";
2516 return (
2517 <div class={`trio-card trio-${persona} ${stateClass}`}>
2518 <div class="trio-card-head">
2519 <span class="trio-card-icon" aria-hidden="true">
2520 {persona === "security"
2521 ? "🛡"
2522 : persona === "correctness"
2523 ? "✓"
2524 : "✎"}
2525 </span>
2526 <strong class="trio-card-title">
2527 {persona[0].toUpperCase() + persona.slice(1)}
2528 </strong>
2529 <span class="trio-card-verdict">
2530 {verdict === "pass"
2531 ? "Pass"
2532 : verdict === "fail"
2533 ? "Fail"
2534 : "Pending"}
2535 </span>
2536 </div>
2537 <div class="trio-card-body">
2538 {body ? (
2539 <MarkdownContent
2540 html={renderMarkdown(stripTrioHeading(body))}
2541 />
2542 ) : (
2543 <span class="trio-card-empty">
2544 Awaiting reviewer output.
2545 </span>
2546 )}
2547 </div>
2548 </div>
2549 );
2550 })}
2551 </div>
2552 {disagreements.length > 0 && (
2553 <div class="trio-disagreement-strip" role="note">
2554 <span class="trio-disagreement-icon" aria-hidden="true">
2555
2556 </span>
2557 <div class="trio-disagreement-body">
2558 <strong>Reviewers disagree — review carefully.</strong>
2559 <ul class="trio-disagreement-list">
2560 {disagreements.map((d) => (
2561 <li>
2562 <code>{d.file}</code> — {d.failing} says ✗,{" "}
2563 {d.passing} says ✓
2564 </li>
2565 ))}
2566 </ul>
2567 </div>
2568 </div>
2569 )}
2570 </div>
2571 );
2572}
2573
2574/**
2575 * Strip the marker comment + first H2 heading from a persona body so
2576 * the card body shows just the findings list (verdict is already in
2577 * the card head). Best-effort — malformed bodies render whole.
2578 */
2579function stripTrioHeading(body: string): string {
2580 return body
2581 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
2582 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
2583 .trim();
2584}
2585
67dc4e1Claude2586/**
2587 * Three small verdict pills rendered inline in the PR header. Each pill
2588 * links to the `#trio-review-section` anchor so clicking scrolls to the
2589 * full card grid. Only shown when `AI_TRIO_REVIEW_ENABLED=1` and at
2590 * least one persona comment exists.
2591 */
2592function TrioVerdictPills({
2593 comments,
2594}: {
2595 comments: TrioCommentLike[];
2596}) {
2597 if (!isTrioReviewEnabled()) return null;
2598
2599 // Find latest persona verdicts (same logic as TrioReviewGrid).
2600 const latest: Partial<Record<TrioPersona, string>> = {};
2601 for (let i = comments.length - 1; i >= 0; i--) {
2602 const body = comments[i].body || "";
2603 if (!isTrioComment(body)) continue;
2604 if (body.includes(TRIO_SUMMARY_MARKER)) continue;
2605 const persona = trioPersonaOfComment(body);
2606 if (persona && !latest[persona]) latest[persona] = body;
2607 }
2608
2609 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2610 if (!anyPersona) return null;
2611
2612 const PERSONA_LABEL: Record<TrioPersona, string> = {
2613 security: "Security",
2614 correctness: "Correctness",
2615 style: "Style",
2616 };
2617 const PERSONA_ICON: Record<TrioPersona, string> = {
2618 security: "🛡",
2619 correctness: "✓",
2620 style: "✎",
2621 };
2622
2623 return (
2624 <span class="trio-pills-wrap" aria-label="AI Trio Review verdicts">
2625 {TRIO_PERSONAS.map((persona) => {
2626 const body = latest[persona];
2627 const verdict = body ? trioVerdictOfBody(body) : null;
2628 const stateClass =
2629 verdict === "pass"
2630 ? "is-pass"
2631 : verdict === "fail"
2632 ? "is-fail"
2633 : "is-pending";
2634 const glyph =
2635 verdict === "pass" ? "✓" : verdict === "fail" ? "✗" : "⟳";
2636 return (
2637 <a
2638 href="#trio-review-section"
2639 class={`trio-pill ${stateClass}`}
2640 title={`AI ${PERSONA_LABEL[persona]} Review — ${verdict === "pass" ? "Pass" : verdict === "fail" ? "Fail" : "Pending"}`}
2641 >
2642 <span aria-hidden="true">{PERSONA_ICON[persona]}</span>
2643 {PERSONA_LABEL[persona]} {glyph}
2644 </a>
2645 );
2646 })}
2647 </span>
2648 );
2649}
2650
0074234Claude2651// List PRs
04f6b7fClaude2652pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2653 const { owner: ownerName, repo: repoName } = c.req.param();
2654 const user = c.get("user");
2655 const state = c.req.query("state") || "open";
d790b49Claude2656 const searchQ = c.req.query("q")?.trim() || "";
80bd7c8Claude2657 const authorFilter = c.req.query("author")?.trim() || "";
f5b9ef5Claude2658 const sortPr = (c.req.query("sort") || "newest").trim();
0074234Claude2659
ea9ed4cClaude2660 // ── Loading skeleton (flag-gated) ──
2661 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
2662 // the user see the page structure before counts + select resolve.
2663 // Behind a flag for now — we don't ship flashes.
2664 if (c.req.query("skeleton") === "1") {
2665 return c.html(
2666 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2667 <RepoHeader owner={ownerName} repo={repoName} />
2668 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2669 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2670 <style
2671 dangerouslySetInnerHTML={{
2672 __html: `
404b398Claude2673 .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; }
2674 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude2675 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
2676 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
2677 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
2678 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
2679 .prs-skel-row { height: 76px; border-radius: 12px; }
2680 `,
2681 }}
2682 />
2683 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
2684 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
2685 <div class="prs-skel-list" aria-hidden="true">
2686 {Array.from({ length: 6 }).map(() => (
2687 <div class="prs-skel prs-skel-row" />
2688 ))}
2689 </div>
2690 <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">
2691 Loading pull requests for {ownerName}/{repoName}…
2692 </span>
2693 </Layout>
2694 );
2695 }
2696
0074234Claude2697 const resolved = await resolveRepo(ownerName, repoName);
2698 if (!resolved) return c.notFound();
2699
6fc53bdClaude2700 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
2701 const stateFilter =
2702 state === "draft"
2703 ? and(
2704 eq(pullRequests.state, "open"),
2705 eq(pullRequests.isDraft, true)
2706 )
2707 : eq(pullRequests.state, state);
2708
0074234Claude2709 const prList = await db
2710 .select({
2711 pr: pullRequests,
2712 author: { username: users.username },
2713 })
2714 .from(pullRequests)
2715 .innerJoin(users, eq(pullRequests.authorId, users.id))
2716 .where(
d790b49Claude2717 and(
2718 eq(pullRequests.repositoryId, resolved.repo.id),
2719 stateFilter,
2720 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
80bd7c8Claude2721 authorFilter ? eq(users.username, authorFilter) : undefined,
d790b49Claude2722 )
0074234Claude2723 )
f5b9ef5Claude2724 .orderBy(
2725 sortPr === "oldest" ? asc(pullRequests.createdAt)
2726 : sortPr === "updated" ? desc(pullRequests.updatedAt)
2727 : desc(pullRequests.createdAt) // newest (default)
2728 );
0074234Claude2729
0369e77Claude2730 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude2731 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude2732 const commentCountMap = new Map<string, number>();
1aef949Claude2733 if (prList.length > 0) {
2734 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude2735 const [reviewRows, commentRows] = await Promise.all([
2736 db
2737 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
2738 .from(prReviews)
2739 .where(inArray(prReviews.pullRequestId, prIds)),
2740 db
2741 .select({
2742 prId: prComments.pullRequestId,
2743 cnt: sql<number>`count(*)::int`,
2744 })
2745 .from(prComments)
2746 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
2747 .groupBy(prComments.pullRequestId),
2748 ]);
1aef949Claude2749 for (const r of reviewRows) {
2750 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
2751 if (r.state === "approved") entry.approved = true;
2752 if (r.state === "changes_requested") entry.changesRequested = true;
2753 reviewMap.set(r.prId, entry);
2754 }
0369e77Claude2755 for (const r of commentRows) {
2756 commentCountMap.set(r.prId, Number(r.cnt));
2757 }
1aef949Claude2758 }
2759
0074234Claude2760 const [counts] = await db
2761 .select({
2762 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude2763 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude2764 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
2765 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
2766 })
2767 .from(pullRequests)
2768 .where(eq(pullRequests.repositoryId, resolved.repo.id));
2769
b078860Claude2770 const openCount = counts?.open ?? 0;
2771 const mergedCount = counts?.merged ?? 0;
2772 const closedCount = counts?.closed ?? 0;
2773 const draftCount = counts?.draft ?? 0;
2774 const allCount = openCount + mergedCount + closedCount;
2775
2776 // "All" is presentational only — the DB query for state='all' matches
2777 // nothing, so we render a friendlier empty state when picked. We do NOT
2778 // change the query logic to keep this commit purely visual.
80bd7c8Claude2779 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude2780 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude2781 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
2782 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
2783 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
2784 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
2785 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude2786 ];
2787 const isAllState = state === "all";
cb5a796Claude2788 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
2789 const prListPendingCount = viewerIsOwnerOnPrList
2790 ? await countPendingForRepo(resolved.repo.id)
2791 : 0;
b078860Claude2792
0074234Claude2793 return c.html(
2794 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2795 <RepoHeader owner={ownerName} repo={repoName} />
2796 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2797 <PendingCommentsBanner
2798 owner={ownerName}
2799 repo={repoName}
2800 count={prListPendingCount}
2801 />
b078860Claude2802 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2803
2804 <div class="prs-hero">
2805 <div class="prs-hero-inner">
2806 <div class="prs-hero-text">
2807 <div class="prs-hero-eyebrow">Pull requests</div>
2808 <h1 class="prs-hero-title">
2809 Review, <span class="gradient-text">merge with AI</span>.
2810 </h1>
2811 <p class="prs-hero-sub">
2812 {openCount === 0 && allCount === 0
2813 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2814 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
2815 </p>
2816 </div>
7a28902Claude2817 <div class="prs-hero-actions">
2818 <a
2819 href={`/${ownerName}/${repoName}/pulls/insights`}
2820 class="prs-cta"
2821 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
2822 >
2823 Insights
2824 </a>
2825 {user && (
b078860Claude2826 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
2827 + New pull request
2828 </a>
7a28902Claude2829 )}
2830 </div>
b078860Claude2831 </div>
2832 </div>
2833
2834 <nav class="prs-tabs" aria-label="Pull request filters">
2835 {tabPills.map((t) => {
2836 const isActive =
2837 state === t.key ||
2838 (t.key === "open" &&
2839 state !== "merged" &&
2840 state !== "closed" &&
2841 state !== "all" &&
2842 state !== "draft");
2843 return (
2844 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2845 <span>{t.label}</span>
2846 <span class="prs-tab-count">{t.count}</span>
2847 </a>
2848 );
2849 })}
2850 </nav>
2851
d790b49Claude2852 <form
2853 method="get"
2854 action={`/${ownerName}/${repoName}/pulls`}
2855 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2856 >
2857 <input type="hidden" name="state" value={state} />
2858 <input
2859 type="search"
2860 name="q"
2861 value={searchQ}
2862 placeholder="Search pull requests…"
2863 class="issues-search-input"
2864 style="flex:1;max-width:380px"
2865 />
80bd7c8Claude2866 <input
2867 type="text"
2868 name="author"
2869 value={authorFilter}
2870 placeholder="Filter by author…"
2871 class="issues-search-input"
2872 style="max-width:200px"
2873 />
d790b49Claude2874 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2875 {(searchQ || authorFilter) && (
d790b49Claude2876 <a
2877 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2878 class="issues-filter-clear"
2879 >
2880 Clear
2881 </a>
2882 )}
2883 </form>
f5b9ef5Claude2884
2885 <div class="prs-sort-row">
2886 <span class="prs-sort-label">Sort:</span>
2887 {(["newest", "oldest", "updated"] as const).map((s) => (
2888 <a
2889 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2890 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2891 >
2892 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2893 </a>
2894 ))}
2895 </div>
2896
0074234Claude2897 {prList.length === 0 ? (
b078860Claude2898 <div class="prs-empty">
ea9ed4cClaude2899 <div class="prs-empty-inner">
2900 <strong>
80bd7c8Claude2901 {searchQ || authorFilter
2902 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2903 : isAllState
2904 ? "Pick a filter above to browse PRs."
2905 : `No ${state} pull requests.`}
ea9ed4cClaude2906 </strong>
2907 <p class="prs-empty-sub">
80bd7c8Claude2908 {searchQ || authorFilter
2909 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2910 : state === "open"
2911 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2912 : isAllState
2913 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2914 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2915 </p>
2916 <div class="prs-empty-cta">
80bd7c8Claude2917 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2918 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2919 + New pull request
2920 </a>
2921 )}
80bd7c8Claude2922 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2923 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2924 View open PRs
2925 </a>
2926 )}
2927 <a href={`/${ownerName}/${repoName}`} class="btn">
2928 Back to code
2929 </a>
2930 </div>
2931 </div>
b078860Claude2932 </div>
0074234Claude2933 ) : (
b078860Claude2934 <div class="prs-list">
2935 {prList.map(({ pr, author }) => {
2936 const stateClass =
2937 pr.state === "open"
2938 ? pr.isDraft
2939 ? "state-draft"
2940 : "state-open"
2941 : pr.state === "merged"
2942 ? "state-merged"
2943 : "state-closed";
2944 const icon =
2945 pr.state === "open"
2946 ? pr.isDraft
2947 ? "◌"
2948 : "○"
2949 : pr.state === "merged"
2950 ? "⮌"
2951 : "✓";
1aef949Claude2952 const rv = reviewMap.get(pr.id);
b078860Claude2953 return (
2954 <a
2955 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2956 class="prs-row"
2957 style="text-decoration:none;color:inherit"
0074234Claude2958 >
b078860Claude2959 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2960 {icon}
0074234Claude2961 </div>
b078860Claude2962 <div class="prs-row-body">
2963 <h3 class="prs-row-title">
2964 <span>{pr.title}</span>
2965 <span class="prs-row-number">#{pr.number}</span>
2966 </h3>
2967 <div class="prs-row-meta">
2968 <span
2969 class="prs-branch-chips"
2970 title={`${pr.headBranch} into ${pr.baseBranch}`}
2971 >
2972 <span class="prs-branch-chip">{pr.headBranch}</span>
2973 <span class="prs-branch-arrow">{"→"}</span>
2974 <span class="prs-branch-chip">{pr.baseBranch}</span>
2975 </span>
2976 <span>
2977 by{" "}
2978 <strong style="color:var(--text)">
2979 {author.username}
2980 </strong>{" "}
2981 {formatRelative(pr.createdAt)}
2982 </span>
2983 <span class="prs-row-tags">
2984 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2985 {pr.state === "merged" && (
2986 <span class="prs-tag is-merged">Merged</span>
2987 )}
1aef949Claude2988 {rv?.approved && !rv.changesRequested && (
2989 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
2990 )}
2991 {rv?.changesRequested && (
2992 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
2993 )}
0369e77Claude2994 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
2995 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
2996 💬 {commentCountMap.get(pr.id)}
2997 </span>
2998 )}
b078860Claude2999 </span>
3000 </div>
0074234Claude3001 </div>
b078860Claude3002 </a>
3003 );
3004 })}
3005 </div>
0074234Claude3006 )}
3007 </Layout>
3008 );
3009});
3010
7a28902Claude3011/* ─────────────────────────────────────────────────────────────────────────
3012 * PR Insights — 90-day analytics for the pull request activity of a repo.
3013 * Route: GET /:owner/:repo/pulls/insights
3014 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
3015 * "insights" is not swallowed by the :number param.
3016 * ───────────────────────────────────────────────────────────────────────── */
3017
3018/** Format a millisecond duration as human-readable string. */
3019function formatMsDuration(ms: number): string {
3020 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
3021 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
3022 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
3023 return `${Math.round(ms / 86_400_000)}d`;
3024}
3025
3026/** Format an ISO week string as "Jan 15". */
3027function formatWeekLabel(isoWeek: string): string {
3028 try {
3029 const d = new Date(isoWeek);
3030 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
3031 } catch {
3032 return isoWeek.slice(5, 10);
3033 }
3034}
3035
3036const PR_INSIGHTS_STYLES = `
3037 .pri-page { padding-bottom: 48px; }
3038 .pri-hero {
3039 position: relative;
3040 margin: 0 0 var(--space-5);
3041 padding: 22px 26px 24px;
3042 background: var(--bg-elevated);
3043 border: 1px solid var(--border);
3044 border-radius: 16px;
3045 overflow: hidden;
3046 }
3047 .pri-hero::before {
3048 content: '';
3049 position: absolute; top: 0; left: 0; right: 0;
3050 height: 2px;
6fd5915Claude3051 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7a28902Claude3052 opacity: 0.7;
3053 pointer-events: none;
3054 }
3055 .pri-hero-eyebrow {
3056 font-size: 12px;
3057 color: var(--text-muted);
3058 text-transform: uppercase;
3059 letter-spacing: 0.08em;
3060 font-weight: 600;
3061 margin-bottom: 8px;
3062 }
3063 .pri-hero-title {
3064 font-family: var(--font-display);
3065 font-size: clamp(26px, 3.4vw, 34px);
3066 font-weight: 800;
3067 letter-spacing: -0.025em;
3068 line-height: 1.06;
3069 margin: 0 0 8px;
3070 color: var(--text-strong);
3071 }
3072 .pri-hero-title .gradient-text {
6fd5915Claude3073 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
7a28902Claude3074 -webkit-background-clip: text;
3075 background-clip: text;
3076 -webkit-text-fill-color: transparent;
3077 color: transparent;
3078 }
3079 .pri-hero-sub {
3080 font-size: 14.5px;
3081 color: var(--text-muted);
3082 margin: 0;
3083 line-height: 1.5;
3084 }
3085 .pri-section { margin-bottom: 32px; }
3086 .pri-section-title {
3087 font-size: 13px;
3088 font-weight: 700;
3089 text-transform: uppercase;
3090 letter-spacing: 0.06em;
3091 color: var(--text-muted);
3092 margin: 0 0 14px;
3093 }
3094 .pri-cards {
3095 display: grid;
3096 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
3097 gap: 12px;
3098 }
3099 .pri-card {
3100 padding: 16px 18px;
3101 background: var(--bg-elevated);
3102 border: 1px solid var(--border);
3103 border-radius: 12px;
3104 }
3105 .pri-card-label {
3106 font-size: 12px;
3107 font-weight: 600;
3108 color: var(--text-muted);
3109 text-transform: uppercase;
3110 letter-spacing: 0.05em;
3111 margin-bottom: 6px;
3112 }
3113 .pri-card-value {
3114 font-size: 28px;
3115 font-weight: 800;
3116 letter-spacing: -0.04em;
3117 color: var(--text-strong);
3118 line-height: 1;
3119 }
3120 .pri-card-sub {
3121 font-size: 12px;
3122 color: var(--text-muted);
3123 margin-top: 4px;
3124 }
3125 .pri-chart {
3126 display: flex;
3127 align-items: flex-end;
3128 gap: 6px;
3129 height: 120px;
3130 background: var(--bg-elevated);
3131 border: 1px solid var(--border);
3132 border-radius: 12px;
3133 padding: 16px 16px 0;
3134 }
3135 .pri-bar-col {
3136 flex: 1;
3137 display: flex;
3138 flex-direction: column;
3139 align-items: center;
3140 justify-content: flex-end;
3141 height: 100%;
3142 gap: 4px;
3143 }
3144 .pri-bar {
3145 width: 100%;
3146 min-height: 4px;
3147 border-radius: 4px 4px 0 0;
6fd5915Claude3148 background: linear-gradient(180deg, #5b6ee8 0%, #5b6ee8 100%);
7a28902Claude3149 transition: opacity 140ms;
3150 }
3151 .pri-bar:hover { opacity: 0.8; }
3152 .pri-bar-label {
3153 font-size: 10px;
3154 color: var(--text-muted);
3155 text-align: center;
3156 padding-bottom: 8px;
3157 white-space: nowrap;
3158 overflow: hidden;
3159 text-overflow: ellipsis;
3160 max-width: 100%;
3161 }
3162 .pri-table {
3163 width: 100%;
3164 border-collapse: collapse;
3165 font-size: 13.5px;
3166 }
3167 .pri-table th {
3168 text-align: left;
3169 font-size: 12px;
3170 font-weight: 600;
3171 text-transform: uppercase;
3172 letter-spacing: 0.05em;
3173 color: var(--text-muted);
3174 padding: 8px 12px;
3175 border-bottom: 1px solid var(--border);
3176 }
3177 .pri-table td {
3178 padding: 10px 12px;
3179 border-bottom: 1px solid var(--border);
3180 color: var(--text);
3181 }
3182 .pri-table tr:last-child td { border-bottom: none; }
3183 .pri-table-wrap {
3184 background: var(--bg-elevated);
3185 border: 1px solid var(--border);
3186 border-radius: 12px;
3187 overflow: hidden;
3188 }
3189 .pri-age-row {
3190 display: flex;
3191 align-items: center;
3192 gap: 12px;
3193 padding: 10px 0;
3194 border-bottom: 1px solid var(--border);
3195 font-size: 13.5px;
3196 }
3197 .pri-age-row:last-child { border-bottom: none; }
3198 .pri-age-label {
3199 flex: 0 0 80px;
3200 color: var(--text-muted);
3201 font-size: 12.5px;
3202 font-weight: 600;
3203 }
3204 .pri-age-bar-wrap {
3205 flex: 1;
3206 height: 8px;
3207 background: var(--bg-secondary);
3208 border-radius: 9999px;
3209 overflow: hidden;
3210 }
3211 .pri-age-bar {
3212 height: 100%;
3213 border-radius: 9999px;
6fd5915Claude3214 background: linear-gradient(90deg, #5b6ee8 0%, #5f8fa0 100%);
7a28902Claude3215 min-width: 4px;
3216 }
3217 .pri-age-count {
3218 flex: 0 0 32px;
3219 text-align: right;
3220 font-weight: 600;
3221 color: var(--text-strong);
3222 font-size: 13px;
3223 }
3224 .pri-sparkline {
3225 display: flex;
3226 align-items: flex-end;
3227 gap: 3px;
3228 height: 40px;
3229 }
3230 .pri-spark-bar {
3231 flex: 1;
3232 min-height: 2px;
3233 border-radius: 2px 2px 0 0;
6fd5915Claude3234 background: var(--accent, #5b6ee8);
7a28902Claude3235 opacity: 0.7;
3236 }
3237 .pri-empty {
3238 color: var(--text-muted);
3239 font-size: 14px;
3240 padding: 24px 0;
3241 text-align: center;
3242 }
3243 @media (max-width: 600px) {
3244 .pri-cards { grid-template-columns: repeat(2, 1fr); }
3245 .pri-hero { padding: 18px 18px 20px; }
3246 }
3247`;
3248
3249pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
3250 const { owner: ownerName, repo: repoName } = c.req.param();
3251 const user = c.get("user");
3252
3253 const resolved = await resolveRepo(ownerName, repoName);
3254 if (!resolved) return c.notFound();
3255
3256 const repoId = resolved.repo.id;
3257 const now = Date.now();
3258
3259 // 1. Merged PRs in last 90 days (avg merge time)
3260 const mergedPRs = await db
3261 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
3262 .from(pullRequests)
3263 .where(and(
3264 eq(pullRequests.repositoryId, repoId),
3265 eq(pullRequests.state, "merged"),
3266 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3267 ));
3268
3269 const avgMergeMs = mergedPRs.length > 0
3270 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
3271 : null;
3272
3273 // 2. PR throughput (last 8 weeks)
3274 const weeklyPRs = await db
3275 .select({
3276 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
3277 count: sql<number>`count(*)::int`,
3278 })
3279 .from(pullRequests)
3280 .where(and(
3281 eq(pullRequests.repositoryId, repoId),
3282 sql`${pullRequests.createdAt} > now() - interval '56 days'`
3283 ))
3284 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
3285 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
3286
3287 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
3288
3289 // 3. PR merge rate (last 90 days)
3290 const [rateCounts] = await db
3291 .select({
3292 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
3293 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
3294 })
3295 .from(pullRequests)
3296 .where(and(
3297 eq(pullRequests.repositoryId, repoId),
3298 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3299 ));
3300
3301 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
3302 const mergeRate = totalResolved > 0
3303 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
3304 : null;
3305
3306 // 4. Top reviewers (last 90 days)
3307 const reviewerCounts = await db
3308 .select({
3309 userId: prReviews.reviewerId,
3310 username: users.username,
3311 count: sql<number>`count(*)::int`,
3312 })
3313 .from(prReviews)
3314 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3315 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
3316 .where(and(
3317 eq(pullRequests.repositoryId, repoId),
3318 sql`${prReviews.createdAt} > now() - interval '90 days'`
3319 ))
3320 .groupBy(prReviews.reviewerId, users.username)
3321 .orderBy(desc(sql`count(*)`))
3322 .limit(5);
3323
3324 // 5. Average reviews per merged PR
3325 const [avgReviewRow] = await db
3326 .select({
3327 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
3328 })
3329 .from(pullRequests)
3330 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3331 .where(and(
3332 eq(pullRequests.repositoryId, repoId),
3333 eq(pullRequests.state, "merged"),
3334 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3335 ));
3336
3337 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
3338 ? Math.round(avgReviewRow.avgReviews * 10) / 10
3339 : null;
3340
3341 // 6. Review turnaround — avg time from PR open to first review
3342 const prsWithReviews = await db
3343 .select({
3344 createdAt: pullRequests.createdAt,
3345 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
3346 })
3347 .from(pullRequests)
3348 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3349 .where(and(
3350 eq(pullRequests.repositoryId, repoId),
3351 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3352 ))
3353 .groupBy(pullRequests.id, pullRequests.createdAt);
3354
3355 const avgReviewTurnaroundMs = prsWithReviews.length > 0
3356 ? prsWithReviews.reduce((s, row) => {
3357 const firstMs = new Date(row.firstReview).getTime();
3358 return s + Math.max(0, firstMs - row.createdAt.getTime());
3359 }, 0) / prsWithReviews.length
3360 : null;
3361
3362 // 7. Open PRs by age bucket
3363 const openPRs = await db
3364 .select({ createdAt: pullRequests.createdAt })
3365 .from(pullRequests)
3366 .where(and(
3367 eq(pullRequests.repositoryId, repoId),
3368 eq(pullRequests.state, "open")
3369 ));
3370
3371 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
3372 for (const { createdAt } of openPRs) {
3373 const ageDays = (now - createdAt.getTime()) / 86_400_000;
3374 if (ageDays < 1) ageBuckets.lt1d++;
3375 else if (ageDays < 3) ageBuckets.d1to3++;
3376 else if (ageDays < 7) ageBuckets.d3to7++;
3377 else if (ageDays < 30) ageBuckets.d7to30++;
3378 else ageBuckets.gt30d++;
3379 }
3380 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
3381
3382 // 8. 7-day merge sparkline
3383 const sparklineRows = await db
3384 .select({
3385 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
3386 count: sql<number>`count(*)::int`,
3387 })
3388 .from(pullRequests)
3389 .where(and(
3390 eq(pullRequests.repositoryId, repoId),
3391 eq(pullRequests.state, "merged"),
3392 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
3393 ))
3394 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
3395 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
3396
3397 const sparkMap = new Map<string, number>();
3398 for (const row of sparklineRows) {
3399 sparkMap.set(row.day.slice(0, 10), row.count);
3400 }
3401 const sparkline: number[] = [];
3402 for (let i = 6; i >= 0; i--) {
3403 const d = new Date(now - i * 86_400_000);
3404 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
3405 }
3406 const maxSpark = Math.max(1, ...sparkline);
3407
3408 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
3409 { label: "< 1 day", key: "lt1d" },
3410 { label: "1–3 days", key: "d1to3" },
3411 { label: "3–7 days", key: "d3to7" },
3412 { label: "7–30 days", key: "d7to30" },
3413 { label: "> 30 days", key: "gt30d" },
3414 ];
3415
3416 return c.html(
3417 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
3418 <RepoHeader owner={ownerName} repo={repoName} />
3419 <PrNav owner={ownerName} repo={repoName} active="pulls" />
3420 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
3421
3422 <div class="pri-page">
3423 {/* Hero */}
3424 <div class="pri-hero">
3425 <div class="pri-hero-eyebrow">Pull requests</div>
3426 <h1 class="pri-hero-title">
3427 PR <span class="gradient-text">Insights</span>
3428 </h1>
3429 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
3430 </div>
3431
3432 {/* Stat cards */}
3433 <div class="pri-section">
3434 <div class="pri-section-title">At a glance</div>
3435 <div class="pri-cards">
3436 <div class="pri-card">
3437 <div class="pri-card-label">Avg merge time</div>
3438 <div class="pri-card-value">
3439 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
3440 </div>
3441 <div class="pri-card-sub">last 90 days</div>
3442 </div>
3443 <div class="pri-card">
3444 <div class="pri-card-label">Total merged</div>
3445 <div class="pri-card-value">{mergedPRs.length}</div>
3446 <div class="pri-card-sub">last 90 days</div>
3447 </div>
3448 <div class="pri-card">
3449 <div class="pri-card-label">Open PRs</div>
3450 <div class="pri-card-value">{openPRs.length}</div>
3451 <div class="pri-card-sub">right now</div>
3452 </div>
3453 <div class="pri-card">
3454 <div class="pri-card-label">Merge rate</div>
3455 <div class="pri-card-value">
3456 {mergeRate != null ? `${mergeRate}%` : "—"}
3457 </div>
3458 <div class="pri-card-sub">merged vs closed</div>
3459 </div>
3460 <div class="pri-card">
3461 <div class="pri-card-label">Avg reviews / PR</div>
3462 <div class="pri-card-value">
3463 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
3464 </div>
3465 <div class="pri-card-sub">merged PRs, 90d</div>
3466 </div>
3467 <div class="pri-card">
3468 <div class="pri-card-label">Top reviewer</div>
3469 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
3470 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
3471 </div>
3472 <div class="pri-card-sub">
3473 {reviewerCounts.length > 0
3474 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
3475 : "no reviews yet"}
3476 </div>
3477 </div>
3478 </div>
3479 </div>
3480
3481 {/* Review turnaround */}
3482 <div class="pri-section">
3483 <div class="pri-section-title">Review turnaround</div>
3484 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
3485 <div class="pri-card">
3486 <div class="pri-card-label">Avg time to first review</div>
3487 <div class="pri-card-value">
3488 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
3489 </div>
3490 <div class="pri-card-sub">
3491 {prsWithReviews.length > 0
3492 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
3493 : "no reviewed PRs in 90d"}
3494 </div>
3495 </div>
3496 </div>
3497 </div>
3498
3499 {/* Weekly throughput bar chart */}
3500 <div class="pri-section">
3501 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
3502 {weeklyPRs.length === 0 ? (
3503 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
3504 ) : (
3505 <div class="pri-chart">
3506 {weeklyPRs.map((w) => (
3507 <div class="pri-bar-col">
3508 <div
3509 class="pri-bar"
3510 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
3511 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
3512 />
3513 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
3514 </div>
3515 ))}
3516 </div>
3517 )}
3518 </div>
3519
3520 {/* 7-day merge sparkline */}
3521 <div class="pri-section">
3522 <div class="pri-section-title">Merges this week (daily)</div>
3523 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
3524 <div class="pri-sparkline">
3525 {sparkline.map((v) => (
3526 <div
3527 class="pri-spark-bar"
3528 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
3529 title={`${v} merge${v === 1 ? "" : "s"}`}
3530 />
3531 ))}
3532 </div>
3533 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
3534 <span>7 days ago</span>
3535 <span>Today</span>
3536 </div>
3537 </div>
3538 </div>
3539
3540 {/* Top reviewers table */}
3541 <div class="pri-section">
3542 <div class="pri-section-title">Top reviewers (last 90 days)</div>
3543 {reviewerCounts.length === 0 ? (
3544 <div class="pri-empty">No reviews posted in the last 90 days.</div>
3545 ) : (
3546 <div class="pri-table-wrap">
3547 <table class="pri-table">
3548 <thead>
3549 <tr>
3550 <th>#</th>
3551 <th>Reviewer</th>
3552 <th>Reviews</th>
3553 </tr>
3554 </thead>
3555 <tbody>
3556 {reviewerCounts.map((r, i) => (
3557 <tr>
3558 <td style="color:var(--text-muted)">{i + 1}</td>
3559 <td>
3560 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
3561 {r.username}
3562 </a>
3563 </td>
3564 <td style="font-weight:600">{r.count}</td>
3565 </tr>
3566 ))}
3567 </tbody>
3568 </table>
3569 </div>
3570 )}
3571 </div>
3572
3573 {/* Open PRs by age */}
3574 <div class="pri-section">
3575 <div class="pri-section-title">Open PRs by age</div>
3576 {openPRs.length === 0 ? (
3577 <div class="pri-empty">No open pull requests.</div>
3578 ) : (
3579 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
3580 {ageBucketDefs.map(({ label, key }) => (
3581 <div class="pri-age-row">
3582 <span class="pri-age-label">{label}</span>
3583 <div class="pri-age-bar-wrap">
3584 <div
3585 class="pri-age-bar"
3586 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
3587 />
3588 </div>
3589 <span class="pri-age-count">{ageBuckets[key]}</span>
3590 </div>
3591 ))}
3592 </div>
3593 )}
3594 </div>
3595
3596 {/* Back link */}
3597 <div>
3598 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
3599 {"←"} Back to pull requests
3600 </a>
3601 </div>
3602 </div>
3603 </Layout>
3604 );
3605});
3606
0074234Claude3607// New PR form
3608pulls.get(
3609 "/:owner/:repo/pulls/new",
3610 softAuth,
3611 requireAuth,
04f6b7fClaude3612 requireRepoAccess("write"),
0074234Claude3613 async (c) => {
3614 const { owner: ownerName, repo: repoName } = c.req.param();
3615 const user = c.get("user")!;
3616 const branches = await listBranches(ownerName, repoName);
3617 const error = c.req.query("error");
3618 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude3619 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude3620
3621 return c.html(
3622 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
3623 <RepoHeader owner={ownerName} repo={repoName} />
3624 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude3625 <Container maxWidth={800}>
3626 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude3627 {error && (
bb0f894Claude3628 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude3629 )}
0316dbbClaude3630 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
3631 <Flex gap={12} align="center" style="margin-bottom: 16px">
3632 <Select name="base">
0074234Claude3633 {branches.map((b) => (
3634 <option value={b} selected={b === defaultBase}>
3635 {b}
3636 </option>
3637 ))}
bb0f894Claude3638 </Select>
3639 <Text muted>&larr;</Text>
3640 <Select name="head">
0074234Claude3641 {branches
3642 .filter((b) => b !== defaultBase)
3643 .concat(defaultBase === branches[0] ? [] : [branches[0]])
3644 .map((b) => (
3645 <option value={b}>{b}</option>
3646 ))}
bb0f894Claude3647 </Select>
3648 </Flex>
3649 <FormGroup>
3650 <Input
0074234Claude3651 name="title"
3652 required
3653 placeholder="Title"
bb0f894Claude3654 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]3655 aria-label="Pull request title"
0074234Claude3656 />
bb0f894Claude3657 </FormGroup>
3658 <FormGroup>
3659 <TextArea
0074234Claude3660 name="body"
81c73c1Claude3661 id="pr-body"
0074234Claude3662 rows={8}
3663 placeholder="Description (Markdown supported)"
bb0f894Claude3664 mono
0074234Claude3665 />
bb0f894Claude3666 </FormGroup>
81c73c1Claude3667 <Flex gap={8} align="center">
3668 <Button type="submit" variant="primary">
3669 Create pull request
3670 </Button>
3671 <button
3672 type="button"
3673 id="ai-suggest-desc"
3674 class="btn"
3675 style="font-weight:500"
3676 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
3677 >
3678 Suggest description with AI
3679 </button>
3680 <span
3681 id="ai-suggest-status"
3682 style="color:var(--text-muted);font-size:13px"
3683 />
3684 </Flex>
bb0f894Claude3685 </Form>
81c73c1Claude3686 <script
3687 dangerouslySetInnerHTML={{
3688 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
3689 }}
3690 />
bb0f894Claude3691 </Container>
0074234Claude3692 </Layout>
3693 );
3694 }
3695);
3696
81c73c1Claude3697// AI-suggested PR description — JSON endpoint driven by the form button.
3698// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
3699// 200; the inline script reads `ok` to decide what to do.
3700pulls.post(
3701 "/:owner/:repo/ai/pr-description",
3702 softAuth,
3703 requireAuth,
3704 requireRepoAccess("write"),
3705 async (c) => {
3706 const { owner: ownerName, repo: repoName } = c.req.param();
3707 if (!isAiAvailable()) {
3708 return c.json({
3709 ok: false,
3710 error: "AI is not available — set ANTHROPIC_API_KEY.",
3711 });
3712 }
3713 const body = await c.req.parseBody();
3714 const title = String(body.title || "").trim();
3715 const baseBranch = String(body.base || "").trim();
3716 const headBranch = String(body.head || "").trim();
3717 if (!baseBranch || !headBranch) {
3718 return c.json({ ok: false, error: "Pick base + head branches first." });
3719 }
3720 if (baseBranch === headBranch) {
3721 return c.json({ ok: false, error: "Base and head must differ." });
3722 }
3723
3724 let diff = "";
3725 try {
3726 const cwd = getRepoPath(ownerName, repoName);
3727 const proc = Bun.spawn(
3728 [
3729 "git",
3730 "diff",
3731 `${baseBranch}...${headBranch}`,
3732 "--",
3733 ],
3734 { cwd, stdout: "pipe", stderr: "pipe" }
3735 );
6ea2109Claude3736 // 30s ceiling — without this a pathological diff (huge binary or
3737 // a corrupt ref) hangs the request indefinitely.
3738 const killer = setTimeout(() => proc.kill(), 30_000);
3739 try {
3740 diff = await new Response(proc.stdout).text();
3741 await proc.exited;
3742 } finally {
3743 clearTimeout(killer);
3744 }
81c73c1Claude3745 } catch {
3746 diff = "";
3747 }
3748 if (!diff.trim()) {
3749 return c.json({
3750 ok: false,
3751 error: "No diff between branches — nothing to summarise.",
3752 });
3753 }
3754
3755 let summary = "";
3756 try {
3757 summary = await generatePrSummary(title || "(untitled)", diff);
3758 } catch (err) {
3759 const msg = err instanceof Error ? err.message : "AI request failed.";
3760 return c.json({ ok: false, error: msg });
3761 }
3762 if (!summary.trim()) {
3763 return c.json({ ok: false, error: "AI returned an empty draft." });
3764 }
3765 return c.json({ ok: true, body: summary });
3766 }
3767);
3768
0074234Claude3769// Create PR
3770pulls.post(
3771 "/:owner/:repo/pulls/new",
3772 softAuth,
3773 requireAuth,
04f6b7fClaude3774 requireRepoAccess("write"),
0074234Claude3775 async (c) => {
3776 const { owner: ownerName, repo: repoName } = c.req.param();
3777 const user = c.get("user")!;
3778 const body = await c.req.parseBody();
3779 const title = String(body.title || "").trim();
3780 const prBody = String(body.body || "").trim();
3781 const baseBranch = String(body.base || "main");
3782 const headBranch = String(body.head || "");
3783
3784 if (!title || !headBranch) {
3785 return c.redirect(
3786 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
3787 );
3788 }
3789
3790 if (baseBranch === headBranch) {
3791 return c.redirect(
3792 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
3793 );
3794 }
3795
3796 const resolved = await resolveRepo(ownerName, repoName);
3797 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3798
6fc53bdClaude3799 const isDraft = String(body.draft || "") === "1";
3800
0074234Claude3801 const [pr] = await db
3802 .insert(pullRequests)
3803 .values({
3804 repositoryId: resolved.repo.id,
3805 authorId: user.id,
3806 title,
3807 body: prBody || null,
3808 baseBranch,
3809 headBranch,
6fc53bdClaude3810 isDraft,
0074234Claude3811 })
3812 .returning();
3813
ec9e3e3Claude3814 // CODEOWNERS — auto-request reviewers based on changed files.
3815 // Fire-and-forget; errors never block PR creation.
3816 (async () => {
3817 try {
3818 const repoDir = getRepoPath(ownerName, repoName);
3819 // Get list of changed files between base and head
3820 const diffProc = Bun.spawn(
3821 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
3822 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3823 );
3824 const rawDiff = await new Response(diffProc.stdout).text();
3825 await diffProc.exited;
3826 const changedFiles = rawDiff.trim().split("\n").filter(Boolean);
3827
3828 if (changedFiles.length > 0) {
3829 // Get CODEOWNERS from the default branch of the repo
3830 const rules = await getCodeownersForRepo(
3831 ownerName,
3832 repoName,
3833 resolved.repo.defaultBranch
3834 );
3835 if (rules.length > 0) {
3836 const ownerUsernames = await reviewersForChangedFiles(
3837 resolved.repo.id,
3838 changedFiles
3839 );
3840 // Filter out the PR author
3841 const filteredOwners = ownerUsernames.filter(
3842 (u) => u !== resolved.owner.username
3843 );
3844
3845 if (filteredOwners.length > 0) {
3846 // Look up user IDs for the owner usernames
3847 const reviewerUsers = await db
3848 .select({ id: users.id, username: users.username })
3849 .from(users)
3850 .where(
3851 inArray(
3852 users.username,
3853 filteredOwners
3854 )
3855 );
3856
3857 // Create review request rows (UNIQUE constraint prevents dupes)
3858 if (reviewerUsers.length > 0) {
3859 await db
3860 .insert(prReviewRequests)
3861 .values(
3862 reviewerUsers.map((u) => ({
3863 prId: pr.id,
3864 reviewerId: u.id,
3865 requestedBy: null as string | null,
3866 }))
3867 )
3868 .onConflictDoNothing();
3869
3870 // Add a PR comment announcing the auto-assigned reviewers
3871 const mentionList = reviewerUsers
3872 .map((u) => `@${u.username}`)
3873 .join(", ");
3874 await db.insert(prComments).values({
3875 pullRequestId: pr.id,
3876 authorId: user.id,
3877 body: `AI: Requested review from ${mentionList} based on CODEOWNERS`,
3878 isAiReview: true,
3879 });
3880 }
3881 }
3882 }
3883 }
3884 } catch (err) {
3885 console.warn("[codeowners] auto-assign failed:", err instanceof Error ? err.message : err);
3886 }
3887 })();
3888
6fc53bdClaude3889 // Skip AI review on drafts — it runs again when the PR is marked ready.
3890 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3891 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3892 (err) => console.error("[ai-review] Failed:", err)
3893 );
3894 }
3895
3cbe3d6Claude3896 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3897 triggerPrTriage({
3898 ownerName,
3899 repoName,
3900 repositoryId: resolved.repo.id,
3901 prId: pr.id,
3902 prAuthorId: user.id,
3903 title,
3904 body: prBody,
3905 baseBranch,
3906 headBranch,
3907 }).catch((err) => console.error("[pr-triage] Failed:", err));
3908
1d4ff60Claude3909 // Chat notifier — fan out to Slack/Discord/Teams.
3910 import("../lib/chat-notifier")
3911 .then((m) =>
3912 m.notifyChatChannels({
3913 ownerUserId: resolved.repo.ownerId,
3914 repositoryId: resolved.repo.id,
3915 event: {
3916 event: "pr.opened",
3917 repo: `${ownerName}/${repoName}`,
3918 title: `#${pr.number} ${title}`,
3919 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3920 body: prBody || undefined,
3921 actor: user.username,
3922 },
3923 })
3924 )
3925 .catch((err) =>
3926 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3927 );
3928
9dd96b9Test User3929 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude3930 import("../lib/auto-merge")
3931 .then((m) => m.tryAutoMergeNow(pr.id))
3932 .catch((err) => {
3933 console.warn(
3934 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
3935 err instanceof Error ? err.message : err
3936 );
3937 });
9dd96b9Test User3938
1df50d5Claude3939 // Migration 0077 — PR preview build. Fire-and-forget; skips when
3940 // PREVIEW_DOMAIN is unset or the repo has no preview_build_command.
3941 // Resolve head SHA asynchronously so we don't block the redirect.
3942 resolveRef(ownerName, repoName, headBranch)
3943 .then((headSha) => {
3944 if (!headSha) return;
3945 return import("../lib/preview-builder").then((m) =>
3946 m.buildPreview(pr.id, resolved.repo.id, headSha)
3947 );
3948 })
3949 .catch(() => {});
3950
0074234Claude3951 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
3952 }
3953);
3954
3955// View single PR
04f6b7fClaude3956pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude3957 const { owner: ownerName, repo: repoName } = c.req.param();
3958 const prNum = parseInt(c.req.param("number"), 10);
3959 const user = c.get("user");
3960 const tab = c.req.query("tab") || "conversation";
a2c10c5Claude3961 const isSplit = c.req.query("diffview") === "split";
3962 const pendingCount = parseInt(c.req.query("pending") || "0", 10) || 0;
0074234Claude3963
3964 const resolved = await resolveRepo(ownerName, repoName);
3965 if (!resolved) return c.notFound();
3966
3967 const [pr] = await db
3968 .select()
3969 .from(pullRequests)
3970 .where(
3971 and(
3972 eq(pullRequests.repositoryId, resolved.repo.id),
3973 eq(pullRequests.number, prNum)
3974 )
3975 )
3976 .limit(1);
3977
3978 if (!pr) return c.notFound();
3979
3980 const [author] = await db
3981 .select()
3982 .from(users)
3983 .where(eq(users.id, pr.authorId))
3984 .limit(1);
3985
cb5a796Claude3986 const allCommentsRaw = await db
0074234Claude3987 .select({
3988 comment: prComments,
cb5a796Claude3989 author: { id: users.id, username: users.username },
0074234Claude3990 })
3991 .from(prComments)
3992 .innerJoin(users, eq(prComments.authorId, users.id))
3993 .where(eq(prComments.pullRequestId, pr.id))
3994 .orderBy(asc(prComments.createdAt));
3995
cb5a796Claude3996 // Filter pending/rejected/spam for non-owner, non-author viewers.
3997 // Owner always sees everything; comment author sees their own pending
3998 // with an "Awaiting approval" badge in the render below.
3999 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
4000 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
4001 if (viewerIsRepoOwner) return true;
4002 if (comment.moderationStatus === "approved") return true;
4003 if (
4004 user &&
4005 cAuthor.id === user.id &&
4006 comment.moderationStatus === "pending"
4007 ) {
4008 return true;
4009 }
4010 return false;
4011 });
4012 const prPendingCount = viewerIsRepoOwner
4013 ? await countPendingForRepo(resolved.repo.id)
4014 : 0;
4015
6fc53bdClaude4016 // Reactions for the PR body + each comment, in parallel.
4017 const [prReactions, ...prCommentReactions] = await Promise.all([
4018 summariseReactions("pr", pr.id, user?.id),
4019 ...comments.map((row) =>
4020 summariseReactions("pr_comment", row.comment.id, user?.id)
4021 ),
4022 ]);
4023
0a67773Claude4024 // Formal reviews (Approve / Request Changes)
4025 const reviewRows = await db
4026 .select({
4027 id: prReviews.id,
4028 state: prReviews.state,
4029 body: prReviews.body,
4030 isAi: prReviews.isAi,
4031 createdAt: prReviews.createdAt,
4032 reviewerUsername: users.username,
4033 reviewerId: prReviews.reviewerId,
4034 })
4035 .from(prReviews)
4036 .innerJoin(users, eq(prReviews.reviewerId, users.id))
4037 .where(eq(prReviews.pullRequestId, pr.id))
4038 .orderBy(asc(prReviews.createdAt));
4039 // Most recent review per reviewer determines the current state
4040 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
4041 for (const r of reviewRows) {
4042 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
4043 }
4044 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
4045 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
4046 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
4047
ec9e3e3Claude4048 // Requested reviewers from CODEOWNERS auto-assign (migration 0077).
4049 const requestedReviewerRows = await db
4050 .select({
4051 reviewerUsername: users.username,
4052 reviewerId: prReviewRequests.reviewerId,
4053 createdAt: prReviewRequests.createdAt,
4054 })
4055 .from(prReviewRequests)
4056 .innerJoin(users, eq(prReviewRequests.reviewerId, users.id))
4057 .where(eq(prReviewRequests.prId, pr.id))
4058 .orderBy(asc(prReviewRequests.createdAt))
4059 .catch(() => [] as { reviewerUsername: string; reviewerId: string; createdAt: Date }[]);
4060
ace34efClaude4061 // Suggested reviewers — best-effort, never throws
4062 let reviewerSuggestions: ReviewerCandidate[] = [];
4063 try {
4064 if (user) {
4065 reviewerSuggestions = await suggestReviewers(
4066 ownerName, repoName, pr.headBranch, pr.baseBranch,
4067 pr.authorId, resolved.repo.id
4068 );
4069 }
4070 } catch {
4071 // silent degradation
4072 }
4073
0074234Claude4074 const canManage =
4075 user &&
4076 (user.id === resolved.owner.id || user.id === pr.authorId);
4077
1d4ff60Claude4078 // Has any previous AI-test-generator run already tagged this PR? Used
4079 // both to hide the "Generate tests with AI" button and to short-circuit
4080 // the explicit POST handler.
4081 const hasAiTestsMarker = comments.some(({ comment }) =>
4082 (comment.body || "").includes(AI_TESTS_MARKER)
4083 );
4084
e883329Claude4085 const error = c.req.query("error");
c3e0c07Claude4086 const info = c.req.query("info");
e883329Claude4087
4088 // Get gate check status for open PRs
4089 let gateChecks: GateCheckResult[] = [];
240c477Claude4090 let ciStatuses: CommitStatus[] = [];
e883329Claude4091 if (pr.state === "open") {
6f1fd83Claude4092 try {
4093 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4094 if (headSha) {
4095 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
4096 const aiApproved = aiComments.length === 0 || aiComments.some(
4097 ({ comment }) => comment.body.includes("**Approved**")
4098 );
4099 const [gateResult, fetchedCiStatuses] = await Promise.all([
4100 runAllGateChecks(
4101 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
4102 ).catch(() => ({ allPassed: false, checks: [] as GateCheckResult[] })),
4103 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
4104 ]);
4105 gateChecks = gateResult.checks;
4106 ciStatuses = fetchedCiStatuses;
4107 }
4108 } catch {
4109 // git repo missing or unreachable — show PR without gate status
e883329Claude4110 }
4111 }
4112
534f04aClaude4113 // Block M3 — pre-merge risk score. Cache-only on the request path so
4114 // the page never waits on Haiku. On a cache miss for an open PR we
4115 // kick off the computation fire-and-forget; the next refresh shows it.
4116 let prRisk: PrRiskScore | null = null;
4117 let prRiskCalculating = false;
4118 if (pr.state === "open") {
4119 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
4120 if (!prRisk) {
4121 prRiskCalculating = true;
a28cedeClaude4122 void computePrRiskForPullRequest(pr.id).catch((err) => {
4123 console.warn(
4124 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
4125 err instanceof Error ? err.message : err
4126 );
4127 });
534f04aClaude4128 }
4129 }
4130
4bbacbeClaude4131 // Migration 0062 — per-branch preview URL. The head branch always
4132 // has a preview row (unless it's the default branch, which never
4133 // happens for an open PR) once it has been pushed at least once.
4134 const preview = await getPreviewForBranch(
4135 (resolved.repo as { id: string }).id,
4136 pr.headBranch
4137 );
4138
0369e77Claude4139 // Branch ahead/behind counts — how many commits head is ahead of base and
4140 // how many commits base has advanced since head branched off.
4141 let branchAhead = 0;
4142 let branchBehind = 0;
4143 if (pr.state === "open") {
4144 try {
4145 const repoDir = getRepoPath(ownerName, repoName);
4146 const [aheadProc, behindProc] = [
4147 Bun.spawn(
4148 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
4149 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4150 ),
4151 Bun.spawn(
4152 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
4153 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4154 ),
4155 ];
4156 const [aheadTxt, behindTxt] = await Promise.all([
4157 new Response(aheadProc.stdout).text(),
4158 new Response(behindProc.stdout).text(),
4159 ]);
4160 await Promise.all([aheadProc.exited, behindProc.exited]);
4161 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
4162 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
4163 } catch { /* non-blocking */ }
4164 }
4165
6d1bbc2Claude4166 // Linked issues — parse closing keywords from PR title+body, look up issues
4167 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
4168 try {
4169 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
4170 const refs = extractClosingRefsMulti([pr.title, pr.body]);
4171 if (refs.length > 0) {
4172 linkedIssues = await db
4173 .select({ number: issues.number, title: issues.title, state: issues.state })
4174 .from(issues)
4175 .where(and(
4176 eq(issues.repositoryId, resolved.repo.id),
4177 inArray(issues.number, refs),
4178 ));
4179 }
4180 } catch { /* non-blocking */ }
4181
4182 // Task list progress — count markdown checkboxes in PR body
4183 let taskTotal = 0;
4184 let taskChecked = 0;
4185 if (pr.body) {
4186 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
4187 taskTotal++;
4188 if (m[1].trim() !== "") taskChecked++;
4189 }
4190 }
4191
74d8c4dClaude4192 // M15 — PR size badge (best-effort, non-blocking)
4193 let prSizeInfo: PrSizeInfo | null = null;
4194 try {
4195 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
4196 } catch { /* swallow — purely cosmetic */ }
4197
09d5f39Claude4198 // Merge impact analysis — only for open PRs with write access (cached, fast)
4199 let impactAnalysis: ImpactAnalysis | null = null;
4200 if (pr.state === "open" && canManage) {
4201 try {
4202 impactAnalysis = await analyzeImpact(resolved.repo.id, pr.id);
4203 } catch { /* non-blocking */ }
4204 }
1d6db4dClaude4205
47a7a0aClaude4206 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude4207 let diffRaw = "";
4208 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude4209 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude4210 if (tab === "files") {
4211 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude4212 // Run the two git diffs in parallel — they're independent reads of
4213 // the same range. Previously sequential, doubling the wall time on
4214 // big PRs (100+ files = 10-30s for no reason).
0074234Claude4215 const proc = Bun.spawn(
4216 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
4217 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4218 );
4219 const statProc = Bun.spawn(
4220 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
4221 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4222 );
6ea2109Claude4223 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
4224 // would otherwise hang the whole request.
4225 const killer = setTimeout(() => {
4226 proc.kill();
4227 statProc.kill();
4228 }, 30_000);
4229 let stat = "";
4230 try {
4231 [diffRaw, stat] = await Promise.all([
4232 new Response(proc.stdout).text(),
4233 new Response(statProc.stdout).text(),
4234 ]);
4235 await Promise.all([proc.exited, statProc.exited]);
4236 } finally {
4237 clearTimeout(killer);
4238 }
0074234Claude4239
4240 diffFiles = stat
4241 .trim()
4242 .split("\n")
4243 .filter(Boolean)
4244 .map((line) => {
4245 const [add, del, filePath] = line.split("\t");
4246 return {
4247 path: filePath,
4248 status: "modified",
4249 additions: add === "-" ? 0 : parseInt(add, 10),
4250 deletions: del === "-" ? 0 : parseInt(del, 10),
4251 patch: "",
4252 };
4253 });
47a7a0aClaude4254
4255 // Fetch inline comments (file+line anchored) for the files tab
4256 const inlineRows = await db
4257 .select({
4258 id: prComments.id,
4259 filePath: prComments.filePath,
4260 lineNumber: prComments.lineNumber,
4261 body: prComments.body,
4262 isAiReview: prComments.isAiReview,
4263 createdAt: prComments.createdAt,
4264 authorUsername: users.username,
4265 })
4266 .from(prComments)
4267 .innerJoin(users, eq(prComments.authorId, users.id))
4268 .where(
4269 and(
4270 eq(prComments.pullRequestId, pr.id),
4271 eq(prComments.moderationStatus, "approved"),
4272 )
4273 )
4274 .orderBy(asc(prComments.createdAt));
4275
4276 diffInlineComments = inlineRows
4277 .filter(r => r.filePath != null && r.lineNumber != null)
4278 .map(r => ({
4279 id: r.id,
4280 filePath: r.filePath!,
4281 lineNumber: r.lineNumber!,
4282 authorUsername: r.authorUsername,
4283 body: renderMarkdown(r.body),
4284 isAiReview: r.isAiReview,
4285 createdAt: r.createdAt.toISOString(),
4286 }));
0074234Claude4287 }
4288
34e63b9Claude4289 // Proactive pattern warning — get changed file paths and check for recurring
4290 // bug patterns. Fire-and-forget safe; returns null on any error or cache miss.
4291 let patternWarning: Pattern | null = null;
4292 try {
4293 const repoDir = getRepoPath(ownerName, repoName);
4294 const nameOnlyProc = Bun.spawn(
4295 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4296 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4297 );
4298 const nameOnlyRaw = await new Response(nameOnlyProc.stdout).text();
4299 await nameOnlyProc.exited;
4300 const prChangedFiles = nameOnlyRaw.trim().split("\n").filter(Boolean);
4301 if (prChangedFiles.length > 0) {
4302 patternWarning = await getPatternWarning(resolved.repo.id, prChangedFiles);
4303 }
4304 } catch {
4305 // Non-blocking — swallow
4306 }
4307
7f992cdClaude4308 // Bus factor warning — flag files dominated by a single author.
4309 let busRiskFiles: BusFactorFile[] = [];
4310 try {
4311 const repoDir2 = getRepoPath(ownerName, repoName);
4312 const bf2Proc = Bun.spawn(
4313 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4314 { cwd: repoDir2, stdout: "pipe", stderr: "pipe" }
4315 );
4316 const bf2Raw = await new Response(bf2Proc.stdout).text();
4317 await bf2Proc.exited;
4318 const bf2Files = bf2Raw.trim().split("\n").filter(Boolean);
4319 if (bf2Files.length > 0) {
4320 busRiskFiles = await getBusFactorWarning(resolved.repo.id, ownerName, repoName, bf2Files);
4321 }
4322 } catch {
4323 // Non-blocking
4324 }
4325
4326 // PR split suggestion — AI recommends sub-PR decomposition for large PRs.
4327 let splitSuggestion: SplitSuggestion | null = null;
4328 try {
4329 splitSuggestion = await suggestPrSplit(
4330 pr.id,
4331 pr.title,
4332 ownerName,
4333 repoName,
4334 pr.baseBranch,
4335 pr.headBranch
4336 );
4337 } catch {
4338 // Non-blocking
4339 }
4340
b078860Claude4341 // ─── Derived visual state ───
4342 const stateKey =
4343 pr.state === "open"
4344 ? pr.isDraft
4345 ? "draft"
4346 : "open"
4347 : pr.state;
4348 const stateLabel =
4349 stateKey === "open"
4350 ? "Open"
4351 : stateKey === "draft"
4352 ? "Draft"
4353 : stateKey === "merged"
4354 ? "Merged"
4355 : "Closed";
4356 const stateIcon =
4357 stateKey === "open"
4358 ? "○"
4359 : stateKey === "draft"
4360 ? "◌"
4361 : stateKey === "merged"
4362 ? "⮌"
4363 : "✓";
4364 const commentCount = comments.length;
4365 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
4366 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
4367 const mergeBlocked =
4368 gateChecks.length > 0 &&
4369 gateChecks.some(
4370 (c) => !c.passed && c.name !== "Merge check"
4371 );
4372
b558f23Claude4373 // Commits tab — list commits included in this PR (base..head range)
4374 let prCommits: GitCommit[] = [];
4375 if (tab === "commits") {
4376 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
4377 }
4378
cc34156Claude4379 // Review context restore — compute BEFORE recording the visit so the
4380 // previous timestamp is available for the delta calculation.
4381 let reviewCtx: ReviewContext | null = null;
4382 if (user) {
4383 reviewCtx = await getReviewContext(pr.id, user.id, {
4384 ownerName,
4385 repoName,
4386 baseBranch: pr.baseBranch,
4387 headBranch: pr.headBranch,
4388 });
4389 // Fire-and-forget: record the visit AFTER computing context
4390 void recordPrVisit(pr.id, user.id);
4391 }
4392
0074234Claude4393 return c.html(
4394 <Layout
4395 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
4396 user={user}
4397 >
4398 <RepoHeader owner={ownerName} repo={repoName} />
4399 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude4400 <PendingCommentsBanner
4401 owner={ownerName}
4402 repo={repoName}
4403 count={prPendingCount}
4404 />
b078860Claude4405 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude4406 <div
4407 id="live-comment-banner"
4408 class="alert"
4409 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
4410 >
4411 <strong class="js-live-count">0</strong> new comment(s) —{" "}
4412 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
4413 reload to view
4414 </a>
4415 </div>
4416 <script
4417 dangerouslySetInnerHTML={{
4418 __html: liveCommentBannerScript({
4419 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
4420 bannerElementId: "live-comment-banner",
4421 }),
4422 }}
4423 />
b078860Claude4424
cc34156Claude4425 {/* Review context restore banner — shown when returning after changes */}
4426 {reviewCtx && (
4427 <div
4428 class="context-restore-banner"
4429 id="review-context"
4430 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"
4431 >
4432 <span class="context-icon" style="font-size:18px;flex-shrink:0;margin-top:2px" aria-hidden="true">{"↩"}</span>
4433 <div style="flex:1;min-width:0">
4434 <strong style="font-size:13.5px;color:var(--text-strong,#111)">Welcome back</strong>
4435 <p style="margin:4px 0 0;font-size:13px;color:var(--text,#333);line-height:1.5">{reviewCtx.summary}</p>
4436 <small style="font-size:11.5px;color:var(--text-muted,#777)">
4437 Last visited {formatRelative(new Date(reviewCtx.lastVisitedAt))}
4438 {reviewCtx.commitsSince > 0 && ` · ${reviewCtx.commitsSince} new commit${reviewCtx.commitsSince === 1 ? "" : "s"}`}
4439 {reviewCtx.newComments > 0 && ` · ${reviewCtx.newComments} new comment${reviewCtx.newComments === 1 ? "" : "s"}`}
4440 </small>
4441 {reviewCtx.suggestedStartLine && (
4442 <p style="margin:6px 0 0;font-size:12px;color:var(--accent,#0070f3)">
4443 Start at: <code style="font-size:11px">{reviewCtx.suggestedStartLine}</code>
4444 </p>
4445 )}
4446 </div>
4447 <button
4448 type="button"
4449 onclick="this.closest('.context-restore-banner').remove()"
4450 style="flex-shrink:0;background:none;border:none;cursor:pointer;font-size:18px;color:var(--text-muted,#777);padding:0;line-height:1"
4451 aria-label="Dismiss"
4452 >
4453 {"×"}
4454 </button>
4455 </div>
4456 )}
4457
b078860Claude4458 <div class="prs-detail-hero">
b558f23Claude4459 <div class="prs-edit-title-wrap">
4460 <h1 class="prs-detail-title" id="pr-title-display">
4461 {pr.title}{" "}
4462 <span class="prs-detail-num">#{pr.number}</span>
4463 </h1>
4464 {canManage && pr.state === "open" && (
4465 <button
4466 type="button"
4467 class="prs-edit-btn"
4468 id="pr-edit-toggle"
4469 onclick={`
4470 document.getElementById('pr-title-display').style.display='none';
4471 document.getElementById('pr-edit-toggle').style.display='none';
4472 document.getElementById('pr-edit-form').style.display='flex';
4473 document.getElementById('pr-title-input').focus();
4474 `}
4475 >
4476 Edit
4477 </button>
4478 )}
4479 </div>
4480 {canManage && pr.state === "open" && (
4481 <form
4482 id="pr-edit-form"
4483 method="post"
4484 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
4485 class="prs-edit-form"
4486 style="display:none"
4487 >
4488 <input
4489 id="pr-title-input"
4490 type="text"
4491 name="title"
4492 value={pr.title}
4493 required
4494 maxlength={256}
4495 placeholder="Pull request title"
4496 />
4497 <div class="prs-edit-actions">
4498 <button type="submit" class="prs-edit-save-btn">Save</button>
4499 <button
4500 type="button"
4501 class="prs-edit-cancel-btn"
4502 onclick={`
4503 document.getElementById('pr-edit-form').style.display='none';
4504 document.getElementById('pr-title-display').style.display='';
4505 document.getElementById('pr-edit-toggle').style.display='';
4506 `}
4507 >
4508 Cancel
4509 </button>
4510 </div>
4511 </form>
4512 )}
b078860Claude4513 <div class="prs-detail-meta">
4514 <span class={`prs-state-pill state-${stateKey}`}>
4515 <span aria-hidden="true">{stateIcon}</span>
4516 <span>{stateLabel}</span>
4517 </span>
74d8c4dClaude4518 {prSizeInfo && (
4519 <span
4520 class="prs-size-badge"
4521 style={`color:${prSizeInfo.color};background:${prSizeInfo.bgColor}`}
4522 title={`${prSizeInfo.linesChanged} lines changed (+${prSizeInfo.added} −${prSizeInfo.deleted})`}
4523 >
4524 {prSizeInfo.label}
4525 </span>
4526 )}
67dc4e1Claude4527 <TrioVerdictPills
4528 comments={comments.map(({ comment }) => comment)}
4529 />
b078860Claude4530 <span>
4531 <strong>{author?.username}</strong> wants to merge
4532 </span>
4533 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
4534 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
4535 <span class="prs-branch-arrow-lg">{"→"}</span>
4536 <span class="prs-branch-pill">{pr.baseBranch}</span>
4537 </span>
0369e77Claude4538 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
4539 <span
4540 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
4541 title={branchBehind > 0
4542 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
4543 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
4544 >
4545 {branchAhead > 0 ? `↑${branchAhead}` : ""}
4546 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
4547 {branchBehind > 0 ? `↓${branchBehind}` : ""}
4548 </span>
4549 )}
b078860Claude4550 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude4551 {taskTotal > 0 && (
4552 <span
4553 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
4554 title={`${taskChecked} of ${taskTotal} tasks completed`}
4555 >
4556 <span class="prs-tasks-progress" aria-hidden="true">
4557 <span
4558 class="prs-tasks-progress-bar"
4559 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
4560 ></span>
4561 </span>
4562 {taskChecked}/{taskTotal} tasks
4563 </span>
4564 )}
4565 {canManage && pr.state === "open" && branchBehind > 0 && (
4566 <form
4567 method="post"
4568 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
4569 class="prs-inline-form"
4570 >
4571 <button
4572 type="submit"
4573 class="prs-update-branch-btn"
4574 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
4575 >
4576 ↑ Update branch
4577 </button>
4578 </form>
4579 )}
3c03977Claude4580 <span
4581 id="live-pill"
4582 class="live-pill"
4583 title="People editing this PR right now"
4584 >
4585 <span class="live-pill-dot" aria-hidden="true"></span>
4586 <span>
4587 Live: <strong id="live-count">0</strong> editing
4588 </span>
4589 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
4590 </span>
4bbacbeClaude4591 {preview && (
4592 <a
4593 class={`preview-prpill is-${preview.status}`}
4594 href={
4595 preview.status === "ready"
4596 ? preview.previewUrl
4597 : `/${ownerName}/${repoName}/previews`
4598 }
4599 target={preview.status === "ready" ? "_blank" : undefined}
4600 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
4601 title={`Preview · ${previewStatusLabel(preview.status)}`}
4602 >
4603 <span class="preview-prpill-dot" aria-hidden="true"></span>
4604 <span>Preview: </span>
4605 <span>{previewStatusLabel(preview.status)}</span>
4606 </a>
4607 )}
b078860Claude4608 {canManage && pr.state === "open" && pr.isDraft && (
4609 <form
4610 method="post"
4611 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4612 class="prs-inline-form prs-detail-actions"
4613 >
4614 <button type="submit" class="prs-merge-ready-btn">
4615 Ready for review
4616 </button>
4617 </form>
4618 )}
4619 </div>
4620 </div>
3c03977Claude4621 <script
4622 dangerouslySetInnerHTML={{
4623 __html: LIVE_COEDIT_SCRIPT(pr.id),
4624 }}
4625 />
829a046Claude4626 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude4627 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude4628 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude4629
25b1ff7Claude4630 {/* Presence styles + bar (shown only on the files tab so cursor pills work) */}
b271465Claude4631 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES + IMPACT_STYLES }} />
25b1ff7Claude4632 {/* Toast container — always present for join/leave toasts */}
4633 <div id="presence-toasts" class="presence-toast-wrap" aria-live="polite" />
4634 {user && (
4635 <>
4636 <div class="presence-bar" id="presence-bar">
4637 <span class="presence-bar-label">Live reviewers</span>
4638 <div class="presence-avatars" id="presence-avatars" />
4639 <span class="presence-count" id="presence-count">Loading…</span>
4640 </div>
4641 <script
4642 dangerouslySetInnerHTML={{
4643 __html: PR_PRESENCE_SCRIPT(ownerName, repoName, pr.number),
4644 }}
4645 />
4646 </>
4647 )}
4648
b078860Claude4649 <nav class="prs-detail-tabs" aria-label="Pull request sections">
4650 <a
4651 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
4652 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
4653 >
4654 Conversation
4655 <span class="prs-detail-tab-count">{commentCount}</span>
4656 </a>
b558f23Claude4657 <a
4658 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
4659 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
4660 >
4661 Commits
4662 {branchAhead > 0 && (
4663 <span class="prs-detail-tab-count">{branchAhead}</span>
4664 )}
4665 </a>
b078860Claude4666 <a
4667 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
4668 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4669 >
4670 Files changed
4671 {diffFiles.length > 0 && (
4672 <span class="prs-detail-tab-count">{diffFiles.length}</span>
4673 )}
4674 </a>
4675 </nav>
4676
34e63b9Claude4677 {/* Proactive pattern warning — shown when a known recurring bug pattern
4678 overlaps with the files changed in this PR. */}
4679 {patternWarning && (
4680 <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">
4681 <span style="font-size:15px;margin-right:6px" aria-hidden="true">⚠️</span>
4682 <strong>Recurring pattern detected: {patternWarning.title}</strong>
4683 <span style="color:var(--fg-muted)">
4684 {" — "}
4685 This area has had {patternWarning.occurrences} similar fix
4686 {patternWarning.occurrences === 1 ? "" : "es"}.
4687 {patternWarning.rootCauseHypothesis && (
4688 <> Root cause may be in <code style="font-size:12px">{patternWarning.suggestedFile}</code>.</>
4689 )}
4690 </span>
4691 </div>
4692 )}
4693
b558f23Claude4694 {tab === "commits" ? (
4695 <div class="prs-commits-list">
4696 {prCommits.length === 0 ? (
4697 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
4698 ) : (
4699 prCommits.map((commit) => (
4700 <div class="prs-commit-row">
4701 <span class="prs-commit-dot" aria-hidden="true"></span>
4702 <div class="prs-commit-body">
4703 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
4704 <div class="prs-commit-meta">
4705 <strong>{commit.author}</strong> committed{" "}
4706 {formatRelative(new Date(commit.date))}
4707 </div>
4708 </div>
4709 <a
4710 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
4711 class="prs-commit-sha"
4712 title="View commit"
4713 >
4714 {commit.sha.slice(0, 7)}
4715 </a>
4716 </div>
4717 ))
4718 )}
4719 </div>
4720 ) : tab === "files" ? (
1d6db4dClaude4721 <>
4722 {/* PR Split Suggestion — shown when PR has >400 changed lines */}
4723 {splitSuggestion && (
4724 <div class="split-suggestion" id="pr-split-banner">
4725 <div class="split-header">
4726 <span class="split-icon" aria-hidden="true">✂️</span>
4727 <strong>This PR may be too large to review effectively</strong>
4728 <span class="split-stat">
4729 {splitSuggestion.totalLines} lines · {splitSuggestion.totalFiles} files
4730 </span>
4731 <button
4732 class="split-toggle"
4733 type="button"
4734 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';"
4735 >
4736 Show split suggestion
4737 </button>
4738 </div>
4739 <div class="split-body" id="pr-split-body" hidden>
4740 <p class="split-intro">
4741 AI suggests splitting into {splitSuggestion.suggestedPrs.length} PRs:
4742 </p>
4743 {splitSuggestion.suggestedPrs.map((sp, i) => (
4744 <div class="split-pr">
4745 <div class="split-pr-num">{i + 1}</div>
4746 <div class="split-pr-body">
4747 <strong>{sp.title}</strong>
4748 <p>{sp.rationale}</p>
4749 <code>{sp.files.join(", ")}</code>
4750 <span class="split-lines">~{sp.estimatedLines} lines</span>
4751 </div>
4752 </div>
4753 ))}
4754 {splitSuggestion.mergeOrder.length > 0 && (
4755 <p class="split-order">
4756 Suggested merge order:{" "}
4757 <strong>{splitSuggestion.mergeOrder.join(" → ")}</strong>
4758 </p>
4759 )}
4760 </div>
4761 </div>
4762 )}
4763
4764 {/* Bus Factor Warning — shown when changed files overlap at-risk files */}
4765 {busRiskFiles.length > 0 && (() => {
4766 const topRisk = busRiskFiles.some((f) => f.risk === "critical")
4767 ? "critical"
4768 : busRiskFiles.some((f) => f.risk === "high")
4769 ? "high"
4770 : "medium";
4771 return (
4772 <div class={`busfactor-panel busfactor-${topRisk}`}>
4773 <span class="busfactor-icon" aria-hidden="true">⚠️</span>
4774 <div class="busfactor-body">
4775 <strong>Knowledge concentration warning</strong>
4776 <p>
4777 {busRiskFiles.length} file{busRiskFiles.length !== 1 ? "s" : ""} in
4778 this PR {busRiskFiles.length !== 1 ? "are" : "is"} primarily
4779 maintained by one person. Consider pairing on this review.
4780 </p>
4781 <ul>
4782 {busRiskFiles.map((f) => (
4783 <li>
4784 <code>{f.path}</code> —{" "}
4785 <strong>{f.primaryAuthorPct}%</strong> by {f.primaryAuthor}
4786 </li>
4787 ))}
4788 </ul>
4789 </div>
4790 </div>
4791 );
4792 })()}
4793
4794 <DiffView
4795 raw={diffRaw}
4796 files={diffFiles}
4797 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
4798 inlineComments={diffInlineComments}
4799 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
4800 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
a2c10c5Claude4801 pendingReviewUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/review/pending/add` : undefined}
4802 submitReviewUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/review/submit` : undefined}
4803 isSplit={isSplit}
4804 pendingCount={pendingCount}
4805 owner={ownerName}
4806 repo={repoName}
4807 prNumber={pr.number}
1d6db4dClaude4808 />
4809 </>
b078860Claude4810 ) : (
4811 <>
4812 {pr.body && (
4813 <CommentBox
4814 author={author?.username ?? "unknown"}
4815 date={pr.createdAt}
4816 body={renderMarkdown(pr.body)}
4817 />
4818 )}
4819
422a2d4Claude4820 {/* Block H — AI trio review (security/correctness/style). When
4821 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
4822 hoisted into a 3-column card grid above the normal comment
4823 stream so reviewers see verdicts at a glance. Disagreements
4824 are surfaced as a yellow callout. */}
4825 <TrioReviewGrid
4826 comments={comments.map(({ comment }) => comment)}
4827 />
4828
15db0e0Claude4829 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude4830 // Skip trio comments — already rendered in TrioReviewGrid above.
4831 if (isTrioComment(comment.body)) return null;
15db0e0Claude4832 const slashCmd = detectSlashCmdComment(comment.body);
4833 if (slashCmd) {
4834 const visible = stripSlashCmdMarker(comment.body);
4835 return (
4836 <div class={`slash-pill slash-cmd-${slashCmd}`}>
4837 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
4838 <span class="slash-pill-actor">
4839 <strong>{commentAuthor.username}</strong>
4840 {" ran "}
4841 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude4842 </span>
15db0e0Claude4843 <span class="slash-pill-time">
4844 {formatRelative(comment.createdAt)}
4845 </span>
4846 <div class="slash-pill-body">
4847 <MarkdownContent html={renderMarkdown(visible)} />
4848 </div>
4849 </div>
4850 );
4851 }
cb5a796Claude4852 const isPending = comment.moderationStatus === "pending";
15db0e0Claude4853 return (
cb5a796Claude4854 <div
4855 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
4856 >
15db0e0Claude4857 <div class="prs-comment-head">
4858 <strong>{commentAuthor.username}</strong>
a7460bfClaude4859 {commentAuthor.username === BOT_USERNAME && (
4860 <span class="prs-bot-badge">&#x1F916; bot</span>
4861 )}
15db0e0Claude4862 {comment.isAiReview && (
4863 <span class="prs-ai-badge">AI Review</span>
4864 )}
cb5a796Claude4865 {isPending && (
4866 <span
4867 class="modq-pending-badge"
4868 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
4869 >
4870 Awaiting approval
4871 </span>
4872 )}
15db0e0Claude4873 <span class="prs-comment-time">
4874 commented {formatRelative(comment.createdAt)}
4875 </span>
4876 {comment.filePath && (
4877 <span class="prs-comment-loc">
4878 {comment.filePath}
4879 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
4880 </span>
4881 )}
4882 </div>
4883 <div class="prs-comment-body">
4884 <MarkdownContent html={renderMarkdown(comment.body)} />
4885 </div>
0074234Claude4886 </div>
15db0e0Claude4887 );
4888 })}
0074234Claude4889
b078860Claude4890 {/* Quick link to the Files changed tab when there's a diff to look at. */}
4891 {pr.state !== "merged" && (
4892 <a
4893 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4894 class="prs-files-card"
4895 >
4896 <span class="prs-files-card-icon" aria-hidden="true">
4897 {"▤"}
4898 </span>
4899 <div class="prs-files-card-text">
4900 <p class="prs-files-card-title">Files changed</p>
4901 <p class="prs-files-card-sub">
4902 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
4903 </p>
e883329Claude4904 </div>
b078860Claude4905 <span class="prs-files-card-cta">View diff {"→"}</span>
4906 </a>
4907 )}
4908
6d1bbc2Claude4909 {linkedIssues.length > 0 && (
4910 <div class="prs-linked-issues">
4911 <div class="prs-linked-issues-head">
4912 <span>Closing issues</span>
4913 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
4914 </div>
4915 {linkedIssues.map((issue) => (
4916 <a
4917 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
4918 class="prs-linked-issue-row"
4919 >
4920 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
4921 {issue.state === "open" ? "○" : "✓"}
4922 </span>
4923 <span class="prs-linked-issue-title">{issue.title}</span>
4924 <span class="prs-linked-issue-num">#{issue.number}</span>
4925 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
4926 {issue.state}
4927 </span>
4928 </a>
4929 ))}
4930 </div>
4931 )}
4932
b078860Claude4933 {error && (
4934 <div
4935 class="auth-error"
4936 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)"
4937 >
4938 {decodeURIComponent(error)}
4939 </div>
4940 )}
4941
4942 {info && (
4943 <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)">
4944 {decodeURIComponent(info)}
4945 </div>
4946 )}
e883329Claude4947
b078860Claude4948 {pr.state === "open" && (prRisk || prRiskCalculating) && (
4949 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
4950 )}
4951
ec9e3e3Claude4952 {/* ─── Requested reviewers (CODEOWNERS auto-assign, migration 0077) ─── */}
4953 {requestedReviewerRows.length > 0 && (
4954 <div class="prs-review-summary" style="margin-top:14px">
4955 <div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-weight:700;margin-bottom:4px">
4956 Review requested
4957 </div>
4958 {requestedReviewerRows.map((rr) => {
4959 const hasReviewed = latestReviewByReviewer.has(rr.reviewerId);
4960 const review = latestReviewByReviewer.get(rr.reviewerId);
4961 const statusIcon = !hasReviewed ? "⏳" : review?.state === "approved" ? "✓" : "✗";
4962 const statusColor = !hasReviewed
4963 ? "var(--text-muted)"
4964 : review?.state === "approved"
4965 ? "#34d399"
4966 : "#f87171";
4967 return (
4968 <div class="prs-review-row" style={`gap:8px`}>
4969 <span class="prs-reviewer-avatar">
4970 {rr.reviewerUsername.slice(0, 1).toUpperCase()}
4971 </span>
4972 <a href={`/${rr.reviewerUsername}`}
4973 style="flex:1;font-size:13px;color:var(--text);font-weight:600;text-decoration:none">
4974 {rr.reviewerUsername}
4975 </a>
4976 <span style={`font-size:12px;font-weight:600;color:${statusColor}`}>
4977 {statusIcon} {!hasReviewed ? "Pending" : review?.state === "approved" ? "Approved" : "Changes requested"}
4978 </span>
4979 </div>
4980 );
4981 })}
4982 </div>
b271465Claude4983 )}
09d5f39Claude4984 {/* ─── Merge Impact Analysis panel ─────────────────────── */}
4985 {impactAnalysis && pr.state === "open" && (
4986 <ImpactPanel analysis={impactAnalysis} owner={ownerName} />
ec9e3e3Claude4987 )}
4988
0a67773Claude4989 {/* ─── Review summary ─────────────────────────────────── */}
4990 {(approvals.length > 0 || changesRequested.length > 0) && (
4991 <div class="prs-review-summary">
4992 {approvals.length > 0 && (
4993 <div class="prs-review-row prs-review-approved">
4994 <span class="prs-review-icon">✓</span>
4995 <span>
4996 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4997 approved this pull request
4998 </span>
4999 </div>
5000 )}
5001 {changesRequested.length > 0 && (
5002 <div class="prs-review-row prs-review-changes">
5003 <span class="prs-review-icon">✗</span>
5004 <span>
5005 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
5006 requested changes
5007 </span>
5008 </div>
5009 )}
5010 </div>
5011 )}
5012
ace34efClaude5013 {/* Suggested reviewers */}
5014 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
5015 <div class="prs-review-summary" style="margin-top:12px">
5016 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
5017 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
5018 Suggested reviewers
5019 </span>
5020 {reviewerSuggestions.map((r) => (
5021 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
5022 style="display:flex;align-items:center;gap:8px;width:100%">
5023 <input type="hidden" name="reviewerId" value={r.userId} />
5024 <span class="prs-reviewer-avatar">
5025 {r.username.slice(0, 1).toUpperCase()}
5026 </span>
5027 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
5028 {r.username}
5029 </a>
5030 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
5031 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
5032 Request
5033 </button>
5034 </form>
5035 ))}
5036 </div>
5037 </div>
5038 )}
5039
b078860Claude5040 {pr.state === "open" && gateChecks.length > 0 && (
5041 <div class="prs-gate-card">
5042 <div class="prs-gate-head">
5043 <h3>Gate checks</h3>
5044 <span class="prs-gate-summary">
5045 {gatesAllPassed
5046 ? `All ${gateChecks.length} checks passed`
5047 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
5048 </span>
c3e0c07Claude5049 </div>
b078860Claude5050 {gateChecks.map((check) => {
5051 const isAi = /ai.*review/i.test(check.name);
5052 const isSkip = check.skipped === true;
5053 const statusClass = isSkip
5054 ? "is-skip"
5055 : check.passed
5056 ? "is-pass"
5057 : "is-fail";
5058 const statusGlyph = isSkip
5059 ? "—"
5060 : check.passed
5061 ? "✓"
5062 : "✗";
5063 const statusLabel = isSkip
5064 ? "Skipped"
5065 : check.passed
5066 ? "Passed"
5067 : "Failing";
5068 return (
5069 <div
5070 class="prs-gate-row"
5071 style={
5072 isAi
6fd5915Claude5073 ? "border-left: 3px solid rgba(91,110,232,0.55); padding-left: 15px"
b078860Claude5074 : ""
5075 }
5076 >
5077 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
5078 {statusGlyph}
5079 </span>
5080 <span class="prs-gate-name">
5081 {check.name}
5082 {isAi && (
5083 <span
6fd5915Claude5084 style="margin-left:8px;display:inline-flex;align-items:center;gap:4px;padding:1px 7px;font-size:10px;font-weight:700;letter-spacing:0.04em;text-transform:uppercase;color:#fff;background:linear-gradient(135deg,#5b6ee8 0%,#5f8fa0 130%);border-radius:9999px;vertical-align:middle"
b078860Claude5085 >
5086 AI
5087 </span>
5088 )}
5089 </span>
5090 <span class="prs-gate-details">{check.details}</span>
5091 <span class={`prs-gate-pill ${statusClass}`}>
5092 {statusLabel}
e883329Claude5093 </span>
5094 </div>
b078860Claude5095 );
5096 })}
5097 <div class="prs-gate-footer">
5098 {gatesAllPassed
5099 ? "All checks passed — ready to merge."
5100 : gateChecks.some(
5101 (c) => !c.passed && c.name === "Merge check"
5102 )
5103 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
5104 : "Some checks failed — resolve issues before merging."}
5105 {aiReviewCount > 0 && (
5106 <>
5107 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
5108 </>
5109 )}
5110 </div>
5111 </div>
5112 )}
5113
240c477Claude5114 {pr.state === "open" && ciStatuses.length > 0 && (
5115 <div class="prs-ci-card">
5116 <div class="prs-ci-head">
5117 <h3>CI checks</h3>
5118 <span class="prs-ci-summary">
5119 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
5120 </span>
5121 </div>
5122 {ciStatuses.map((status) => {
5123 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
5124 return (
5125 <div class="prs-ci-row">
5126 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
5127 <span class="prs-ci-context">{status.context}</span>
5128 {status.description && (
5129 <span class="prs-ci-desc">{status.description}</span>
5130 )}
5131 <span class={`prs-ci-pill is-${status.state}`}>
5132 {status.state}
5133 </span>
5134 {status.targetUrl && (
5135 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
5136 )}
5137 </div>
5138 );
5139 })}
5140 </div>
5141 )}
5142
b078860Claude5143 {/* ─── Merge area / state-aware action card ─────────────── */}
5144 {user && pr.state === "open" && (
5145 <div
5146 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
5147 >
5148 <div class="prs-merge-head">
5149 <strong>
5150 {pr.isDraft
5151 ? "Draft — ready for review?"
5152 : mergeBlocked
5153 ? "Merge blocked"
5154 : "Ready to merge"}
5155 </strong>
e883329Claude5156 </div>
b078860Claude5157 <p class="prs-merge-sub">
5158 {pr.isDraft
5159 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
5160 : mergeBlocked
5161 ? "Resolve the failing gate checks above before this PR can land."
5162 : gateChecks.length > 0
5163 ? gatesAllPassed
5164 ? "All gates green. Merge will fast-forward into the base branch."
5165 : "Conflicts will be auto-resolved by GlueCron AI on merge."
5166 : "Run gate checks by refreshing once your branch has a recent commit."}
5167 </p>
5168 <Form
5169 method="post"
5170 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
5171 >
5172 <FormGroup>
3c03977Claude5173 <div class="live-cursor-host" style="position:relative">
5174 <textarea
5175 name="body"
5176 id="pr-comment-body"
5177 data-live-field="comment_new"
6cd2f0eClaude5178 data-md-preview=""
3c03977Claude5179 rows={5}
5180 required
5181 placeholder="Leave a comment... (Markdown supported)"
5182 style="font-family:var(--font-mono);font-size:13px;width:100%"
5183 ></textarea>
5184 </div>
15db0e0Claude5185 <span class="slash-hint" title="Type a slash-command as the first line">
5186 Type <code>/</code> for commands —{" "}
5187 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
09d5f39Claude5188 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>,{" "}
5189 <code>/stage</code>
15db0e0Claude5190 </span>
b078860Claude5191 </FormGroup>
5192 <div class="prs-merge-actions">
5193 <Button type="submit" variant="primary">
5194 Comment
5195 </Button>
0a67773Claude5196 {user && user.id !== pr.authorId && pr.state === "open" && (
5197 <>
5198 <button
5199 type="submit"
5200 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
5201 name="review_state"
5202 value="approved"
5203 class="prs-review-approve-btn"
5204 title="Approve this pull request"
5205 >
5206 ✓ Approve
5207 </button>
5208 <button
5209 type="submit"
5210 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
5211 name="review_state"
5212 value="changes_requested"
5213 class="prs-review-changes-btn"
5214 title="Request changes before merging"
5215 >
5216 ✗ Request changes
5217 </button>
5218 </>
5219 )}
b078860Claude5220 {canManage && (
5221 <>
5222 {pr.isDraft ? (
5223 <button
5224 type="submit"
5225 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
5226 formnovalidate
5227 class="prs-merge-ready-btn"
5228 >
5229 Ready for review
5230 </button>
5231 ) : (
a164a6dClaude5232 <>
5233 <div class="prs-merge-strategy-wrap">
5234 <span class="prs-merge-strategy-label">Strategy</span>
5235 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
5236 <option value="merge">Merge commit</option>
5237 <option value="squash">Squash and merge</option>
5238 <option value="ff">Fast-forward</option>
5239 </select>
5240 </div>
5241 <button
5242 type="submit"
5243 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
5244 formnovalidate
5245 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
5246 title={
5247 mergeBlocked
5248 ? "Failing gate checks must be resolved before this PR can merge."
5249 : "Merge pull request"
5250 }
5251 >
5252 {"✔"} Merge pull request
5253 </button>
5254 </>
b078860Claude5255 )}
5256 {!pr.isDraft && (
5257 <button
0074234Claude5258 type="submit"
b078860Claude5259 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
5260 formnovalidate
5261 class="prs-merge-back-draft"
5262 title="Convert back to draft"
0074234Claude5263 >
b078860Claude5264 Convert to draft
5265 </button>
5266 )}
5267 {isAiReviewEnabled() && (
5268 <button
5269 type="submit"
5270 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
5271 formnovalidate
5272 class="btn"
5273 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
5274 >
5275 Re-run AI review
5276 </button>
5277 )}
1d4ff60Claude5278 {isAiReviewEnabled() && !hasAiTestsMarker && (
5279 <button
5280 type="submit"
5281 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
5282 formnovalidate
5283 class="btn"
5284 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."
5285 >
5286 Generate tests with AI
5287 </button>
5288 )}
b078860Claude5289 <Button
5290 type="submit"
5291 variant="danger"
5292 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
5293 >
5294 Close
5295 </Button>
5296 </>
5297 )}
5298 </div>
5299 </Form>
5300 </div>
5301 )}
5302
5303 {/* Read-only footers for non-open states. */}
5304 {pr.state === "merged" && (
5305 <div class="prs-merge-card is-merged">
5306 <div class="prs-merge-head">
5307 <strong>{"⮌"} Merged</strong>
0074234Claude5308 </div>
b078860Claude5309 <p class="prs-merge-sub">
5310 This pull request was merged into{" "}
5311 <code>{pr.baseBranch}</code>.
5312 </p>
5313 </div>
5314 )}
5315 {pr.state === "closed" && (
5316 <div class="prs-merge-card is-closed">
5317 <div class="prs-merge-head">
5318 <strong>{"✕"} Closed without merging</strong>
5319 </div>
5320 <p class="prs-merge-sub">
5321 This pull request was closed and not merged.
5322 </p>
5323 </div>
5324 )}
5325 </>
5326 )}
641aa42Claude5327 {/* Keyboard hint bar — shown at the bottom of PR pages */}
5328 <div class="kbd-hints" aria-label="Keyboard shortcuts for this pull request">
5329 <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
5330 </div>
5331 <style dangerouslySetInnerHTML={{ __html: `
5332 .kbd-hints {
5333 position: fixed;
5334 bottom: 0;
5335 left: 0;
5336 right: 0;
5337 z-index: 90;
5338 padding: 6px 24px;
5339 background: var(--bg-secondary);
5340 border-top: 1px solid var(--border);
5341 font-size: 12px;
5342 color: var(--text-muted);
5343 display: flex;
5344 align-items: center;
5345 gap: 8px;
5346 flex-wrap: wrap;
5347 }
5348 .kbd-hints kbd {
5349 font-family: var(--font-mono);
5350 font-size: 10px;
5351 background: var(--bg-elevated);
5352 border: 1px solid var(--border);
5353 border-bottom-width: 2px;
5354 border-radius: 4px;
5355 padding: 1px 5px;
5356 color: var(--text);
5357 line-height: 1.5;
5358 }
5359 /* Padding so the page footer doesn't overlap the hint bar */
5360 main { padding-bottom: 40px; }
5361 ` }} />
5362 {/* Repo context commands for command palette */}
5363 <script
5364 id="cmdk-repo-context"
5365 dangerouslySetInnerHTML={{
5366 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
5367 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
5368 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
5369 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
5370 { label: `View commits — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/commits`, kw: "history log" },
5371 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
5372 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
5373 ])};`,
5374 }}
5375 />
5376 {/* PR keyboard shortcuts script */}
5377 <script dangerouslySetInnerHTML={{ __html: `
5378 (function(){
5379 var commentBox = document.querySelector('textarea[name="body"]');
5380 var mergeBtn = document.querySelector('[data-merge-btn], .prs-merge-btn, button[form*="merge"], form[action*="/merge"] button[type="submit"]');
5381 var editBtn = document.getElementById('pr-edit-toggle');
5382 var approveUrl = ${JSON.stringify(`/${ownerName}/${repoName}/pulls/${pr.number}/review`)};
5383
5384 function isTyping(t){
5385 t = t || {};
5386 var tag = (t.tagName || '').toLowerCase();
5387 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
5388 }
5389
5390 document.addEventListener('keydown', function(e){
5391 if (isTyping(e.target)) return;
5392 if (e.metaKey || e.ctrlKey || e.altKey) return;
5393 if (e.key === 'c') {
5394 e.preventDefault();
5395 if (commentBox) { commentBox.focus(); commentBox.scrollIntoView({block:'center'}); }
5396 }
5397 if (e.key === 'e') {
5398 e.preventDefault();
5399 if (editBtn) { editBtn.click(); }
5400 }
5401 if (e.key === 'm') {
5402 e.preventDefault();
5403 var mBtn = document.querySelector('.prs-merge-btn, form[action*="/merge"] button[type="submit"]');
5404 if (mBtn) { mBtn.focus(); mBtn.scrollIntoView({block:'center'}); }
5405 }
5406 if (e.key === 'a') {
5407 e.preventDefault();
5408 // Navigate to approve review page
5409 window.location.href = approveUrl + '?action=approve';
5410 }
5411 if (e.key === 'r') {
5412 e.preventDefault();
5413 window.location.href = approveUrl + '?action=request_changes';
5414 }
5415 if (e.key === 'Escape') {
5416 var focused = document.activeElement;
5417 if (focused) focused.blur();
5418 }
5419 });
5420 })();
5421 ` }} />
0074234Claude5422 </Layout>
5423 );
5424});
5425
6d1bbc2Claude5426// Update branch — merge base into head so the PR branch is up to date.
5427// Uses a git worktree so the bare repo stays clean. Write access required.
5428pulls.post(
5429 "/:owner/:repo/pulls/:number/update-branch",
5430 softAuth,
5431 requireAuth,
5432 requireRepoAccess("write"),
5433 async (c) => {
5434 const { owner: ownerName, repo: repoName } = c.req.param();
5435 const prNum = parseInt(c.req.param("number"), 10);
5436 const user = c.get("user")!;
5437 const resolved = await resolveRepo(ownerName, repoName);
5438 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5439
5440 const [pr] = await db
5441 .select()
5442 .from(pullRequests)
5443 .where(and(
5444 eq(pullRequests.repositoryId, resolved.repo.id),
5445 eq(pullRequests.number, prNum),
5446 ))
5447 .limit(1);
5448 if (!pr || pr.state !== "open") {
5449 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5450 }
5451
5452 const repoDir = getRepoPath(ownerName, repoName);
5453 const wt = `${repoDir}/_update_wt_${Date.now()}`;
5454 const gitEnv = {
5455 ...process.env,
5456 GIT_AUTHOR_NAME: user.displayName || user.username,
5457 GIT_AUTHOR_EMAIL: user.email,
5458 GIT_COMMITTER_NAME: user.displayName || user.username,
5459 GIT_COMMITTER_EMAIL: user.email,
5460 };
5461
5462 const addWt = Bun.spawn(
5463 ["git", "worktree", "add", wt, pr.headBranch],
5464 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5465 );
5466 if (await addWt.exited !== 0) {
5467 return c.redirect(
5468 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
5469 );
5470 }
5471
5472 let ok = false;
5473 try {
5474 const mergeProc = Bun.spawn(
5475 ["git", "merge", "--no-edit", pr.baseBranch],
5476 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
5477 );
5478 if (await mergeProc.exited === 0) {
5479 ok = true;
5480 } else {
5481 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5482 }
5483 } catch {
5484 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5485 }
5486
5487 await Bun.spawn(
5488 ["git", "worktree", "remove", "--force", wt],
5489 { cwd: repoDir }
5490 ).exited.catch(() => {});
5491
5492 if (ok) {
5493 return c.redirect(
5494 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
5495 );
5496 }
5497 return c.redirect(
5498 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
5499 );
5500 }
5501);
5502
b558f23Claude5503// Edit PR title (and optionally body). Owner or author only.
5504pulls.post(
5505 "/:owner/:repo/pulls/:number/edit",
5506 softAuth,
5507 requireAuth,
5508 requireRepoAccess("write"),
5509 async (c) => {
5510 const { owner: ownerName, repo: repoName } = c.req.param();
5511 const prNum = parseInt(c.req.param("number"), 10);
5512 const user = c.get("user")!;
5513 const resolved = await resolveRepo(ownerName, repoName);
5514 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5515
5516 const [pr] = await db
5517 .select()
5518 .from(pullRequests)
5519 .where(and(
5520 eq(pullRequests.repositoryId, resolved.repo.id),
5521 eq(pullRequests.number, prNum),
5522 ))
5523 .limit(1);
5524 if (!pr || pr.state !== "open") {
5525 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5526 }
5527 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
5528 if (!canEdit) {
5529 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5530 }
5531
5532 const body = await c.req.parseBody();
5533 const newTitle = String(body.title || "").trim().slice(0, 256);
5534 if (!newTitle) {
5535 return c.redirect(
5536 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
5537 );
5538 }
5539
5540 await db
5541 .update(pullRequests)
5542 .set({ title: newTitle, updatedAt: new Date() })
5543 .where(eq(pullRequests.id, pr.id));
5544
5545 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
5546 }
5547);
5548
cb5a796Claude5549// Add comment to PR.
5550//
5551// Permission model mirrors `issues.tsx`: any logged-in user with read
5552// access can submit; `decideInitialStatus` routes non-collaborators
5553// through the moderation queue. Slash commands only fire when the
5554// comment is auto-approved — we don't want a banned/pending comment to
5555// silently trigger AI work on the PR.
0074234Claude5556pulls.post(
5557 "/:owner/:repo/pulls/:number/comment",
5558 softAuth,
5559 requireAuth,
cb5a796Claude5560 requireRepoAccess("read"),
0074234Claude5561 async (c) => {
5562 const { owner: ownerName, repo: repoName } = c.req.param();
5563 const prNum = parseInt(c.req.param("number"), 10);
5564 const user = c.get("user")!;
5565 const body = await c.req.parseBody();
5566 const commentBody = String(body.body || "").trim();
47a7a0aClaude5567 const filePathRaw = String(body.file_path || "").trim();
5568 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
5569 const inlineFilePath = filePathRaw || undefined;
5570 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude5571
5572 if (!commentBody) {
5573 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5574 }
5575
5576 const resolved = await resolveRepo(ownerName, repoName);
5577 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5578
5579 const [pr] = await db
5580 .select()
5581 .from(pullRequests)
5582 .where(
5583 and(
5584 eq(pullRequests.repositoryId, resolved.repo.id),
5585 eq(pullRequests.number, prNum)
5586 )
5587 )
5588 .limit(1);
5589
5590 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5591
cb5a796Claude5592 const decision = await decideInitialStatus({
5593 commenterUserId: user.id,
5594 repositoryId: resolved.repo.id,
5595 kind: "pr",
5596 threadId: pr.id,
5597 });
5598
d4ac5c3Claude5599 const [inserted] = await db
5600 .insert(prComments)
5601 .values({
5602 pullRequestId: pr.id,
5603 authorId: user.id,
5604 body: commentBody,
cb5a796Claude5605 moderationStatus: decision.status,
47a7a0aClaude5606 filePath: inlineFilePath,
5607 lineNumber: inlineLineNumber,
d4ac5c3Claude5608 })
5609 .returning();
5610
cb5a796Claude5611 // Live update: only when the comment is actually visible.
5612 if (inserted && decision.status === "approved") {
d4ac5c3Claude5613 try {
5614 const { publish } = await import("../lib/sse");
5615 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
5616 event: "pr-comment",
5617 data: {
5618 pullRequestId: pr.id,
5619 commentId: inserted.id,
5620 authorId: user.id,
5621 authorUsername: user.username,
5622 },
5623 });
5624 } catch {
5625 /* SSE is best-effort */
5626 }
b7ecb14Claude5627 // Notify the PR author — fire-and-forget, never blocks the response.
5628 if (pr.authorId && pr.authorId !== user.id) {
5629 void import("../lib/notify").then(({ createNotification }) =>
5630 createNotification({
5631 userId: pr.authorId,
5632 type: "pr_comment",
5633 title: `New comment on "${pr.title}"`,
5634 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
5635 url: `/${ownerName}/${repoName}/pulls/${prNum}`,
5636 repoId: resolved.repo.id,
5637 })
5638 ).catch(() => { /* never block the response */ });
5639 }
d4ac5c3Claude5640 }
0074234Claude5641
cb5a796Claude5642 if (decision.status === "pending") {
5643 void notifyOwnerOfPendingComment({
5644 repositoryId: resolved.repo.id,
5645 commenterUsername: user.username,
5646 kind: "pr",
5647 threadNumber: prNum,
5648 ownerUsername: ownerName,
5649 repoName,
5650 });
5651 return c.redirect(
5652 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5653 );
5654 }
5655 if (decision.status === "rejected") {
5656 // Silent ban path — same UX as 'pending' so we don't leak the gate.
5657 return c.redirect(
5658 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5659 );
5660 }
5661
15db0e0Claude5662 // Slash-command handoff. We always store the original comment above
5663 // first so free-form text that happens to start with `/` is preserved
5664 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude5665 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude5666 const parsed = parseSlashCommand(commentBody);
5667 if (parsed) {
5668 try {
5669 const result = await executeSlashCommand({
5670 command: parsed.command,
5671 args: parsed.args,
5672 prId: pr.id,
5673 userId: user.id,
5674 repositoryId: resolved.repo.id,
5675 });
5676 await db.insert(prComments).values({
5677 pullRequestId: pr.id,
5678 authorId: user.id,
5679 body: result.body,
5680 });
5681 } catch (err) {
5682 // Defence-in-depth — executeSlashCommand promises not to throw,
5683 // but if it ever does we want the PR thread to know.
5684 await db
5685 .insert(prComments)
5686 .values({
5687 pullRequestId: pr.id,
5688 authorId: user.id,
5689 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
5690 })
5691 .catch(() => {});
5692 }
5693 }
5694
47a7a0aClaude5695 // Inline comments go back to the files tab; conversation comments to the conversation tab
5696 const redirectTab = inlineFilePath ? "?tab=files" : "";
5697 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude5698 }
5699);
5700
a2c10c5Claude5701// ─── Batched PR review workflow ─────────────────────────────────────────────
5702// Reviewers can stage multiple inline comments in a pending_reviews session,
5703// then submit them all at once as an Approve / Request Changes / Comment review.
5704
5705// GET /:owner/:repo/pulls/:number/review/pending
5706// Returns {count, comments} for the current user's pending review session.
5707pulls.get(
5708 "/:owner/:repo/pulls/:number/review/pending",
5709 softAuth,
5710 requireAuth,
5711 requireRepoAccess("read"),
5712 async (c) => {
5713 const { owner: ownerName, repo: repoName } = c.req.param();
5714 const prNum = parseInt(c.req.param("number"), 10);
5715 const user = c.get("user")!;
5716
5717 const resolved = await resolveRepo(ownerName, repoName);
5718 if (!resolved) return c.json({ count: 0, comments: [] });
5719
5720 const [pr] = await db
5721 .select()
5722 .from(pullRequests)
5723 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5724 .limit(1);
5725 if (!pr) return c.json({ count: 0, comments: [] });
5726
5727 const [review] = await db
5728 .select()
5729 .from(pendingReviews)
5730 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5731 .limit(1);
5732
5733 if (!review) return c.json({ count: 0, comments: [] });
5734
5735 const comments = await db
5736 .select({ id: pendingReviewComments.id, filePath: pendingReviewComments.filePath, lineNumber: pendingReviewComments.lineNumber, body: pendingReviewComments.body })
5737 .from(pendingReviewComments)
5738 .where(eq(pendingReviewComments.reviewId, review.id))
5739 .orderBy(asc(pendingReviewComments.createdAt));
5740
5741 return c.json({ count: comments.length, comments });
5742 }
5743);
5744
5745// POST /:owner/:repo/pulls/:number/review/pending/add
5746// Adds a comment to the current user's pending review (creates session if needed).
5747pulls.post(
5748 "/:owner/:repo/pulls/:number/review/pending/add",
5749 softAuth,
5750 requireAuth,
5751 requireRepoAccess("read"),
5752 async (c) => {
5753 const { owner: ownerName, repo: repoName } = c.req.param();
5754 const prNum = parseInt(c.req.param("number"), 10);
5755 const user = c.get("user")!;
5756 const body = await c.req.parseBody();
5757 const filePath = String(body.filePath || "").trim();
5758 const lineNumber = parseInt(String(body.lineNumber || ""), 10);
5759 const commentBody = String(body.body || "").trim();
5760
5761 if (!filePath || !Number.isFinite(lineNumber) || lineNumber <= 0 || !commentBody) {
5762 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5763 }
5764
5765 const resolved = await resolveRepo(ownerName, repoName);
5766 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5767
5768 const [pr] = await db
5769 .select()
5770 .from(pullRequests)
5771 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5772 .limit(1);
5773 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5774
5775 // Upsert the pending_reviews session row
5776 let [review] = await db
5777 .select()
5778 .from(pendingReviews)
5779 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5780 .limit(1);
5781
5782 if (!review) {
5783 [review] = await db
5784 .insert(pendingReviews)
5785 .values({ prId: pr.id, authorId: user.id })
5786 .onConflictDoNothing()
5787 .returning();
5788 // If onConflictDoNothing returned nothing (race), fetch again
5789 if (!review) {
5790 [review] = await db
5791 .select()
5792 .from(pendingReviews)
5793 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5794 .limit(1);
5795 }
5796 }
5797
5798 if (!review) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5799
5800 await db.insert(pendingReviewComments).values({
5801 reviewId: review.id,
5802 filePath,
5803 lineNumber,
5804 body: commentBody,
5805 });
5806
5807 // Count total pending comments for this review
5808 const countRows = await db
5809 .select({ id: pendingReviewComments.id })
5810 .from(pendingReviewComments)
5811 .where(eq(pendingReviewComments.reviewId, review.id));
5812
5813 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files&pending=${countRows.length}`);
5814 }
5815);
5816
5817// POST /:owner/:repo/pulls/:number/review/pending/:commentId/delete
5818// Removes a single comment from the pending review session.
5819pulls.post(
5820 "/:owner/:repo/pulls/:number/review/pending/:commentId/delete",
5821 softAuth,
5822 requireAuth,
5823 requireRepoAccess("read"),
5824 async (c) => {
5825 const { owner: ownerName, repo: repoName, commentId } = c.req.param();
5826 const prNum = parseInt(c.req.param("number"), 10);
5827 const user = c.get("user")!;
5828
5829 const resolved = await resolveRepo(ownerName, repoName);
5830 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5831
5832 const [pr] = await db
5833 .select()
5834 .from(pullRequests)
5835 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5836 .limit(1);
5837 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5838
5839 // Verify ownership via the review session
5840 const [review] = await db
5841 .select()
5842 .from(pendingReviews)
5843 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5844 .limit(1);
5845
5846 if (review) {
5847 await db
5848 .delete(pendingReviewComments)
5849 .where(and(eq(pendingReviewComments.id, commentId), eq(pendingReviewComments.reviewId, review.id)));
5850 }
5851
5852 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5853 }
5854);
5855
5856// POST /:owner/:repo/pulls/:number/review/submit
5857// Submits the pending review: posts all staged comments + a formal review state.
5858pulls.post(
5859 "/:owner/:repo/pulls/:number/review/submit",
5860 softAuth,
5861 requireAuth,
5862 requireRepoAccess("read"),
5863 async (c) => {
5864 const { owner: ownerName, repo: repoName } = c.req.param();
5865 const prNum = parseInt(c.req.param("number"), 10);
5866 const user = c.get("user")!;
5867 const body = await c.req.parseBody();
5868 const reviewBody = String(body.reviewBody || "").trim();
5869 const reviewStateRaw = String(body.reviewState || "comment");
5870
5871 const reviewState =
5872 reviewStateRaw === "approve"
5873 ? "approved"
5874 : reviewStateRaw === "request_changes"
5875 ? "changes_requested"
5876 : "commented";
5877
5878 const resolved = await resolveRepo(ownerName, repoName);
5879 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5880
5881 const [pr] = await db
5882 .select()
5883 .from(pullRequests)
5884 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5885 .limit(1);
5886 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5887
5888 const [review] = await db
5889 .select()
5890 .from(pendingReviews)
5891 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5892 .limit(1);
5893
5894 if (review) {
5895 const pendingComments = await db
5896 .select()
5897 .from(pendingReviewComments)
5898 .where(eq(pendingReviewComments.reviewId, review.id))
5899 .orderBy(asc(pendingReviewComments.createdAt));
5900
5901 // Post each pending comment as a real pr_comment
5902 for (const pc of pendingComments) {
5903 await db.insert(prComments).values({
5904 pullRequestId: pr.id,
5905 authorId: user.id,
5906 body: pc.body,
5907 filePath: pc.filePath,
5908 lineNumber: pc.lineNumber,
5909 isAiReview: false,
5910 });
5911 }
5912
5913 // Delete the pending review session (cascades to comments)
5914 await db.delete(pendingReviews).where(eq(pendingReviews.id, review.id));
5915 }
5916
5917 // Insert the formal review record
5918 await db.insert(prReviews).values({
5919 pullRequestId: pr.id,
5920 reviewerId: user.id,
5921 state: reviewState,
5922 body: reviewBody || null,
5923 isAi: false,
5924 });
5925
5926 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=conversation`);
5927 }
5928);
5929
b5dd694Claude5930// Apply a suggestion from a PR comment — commits the suggested code to the
5931// head branch on behalf of the logged-in user.
5932pulls.post(
5933 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
5934 softAuth,
5935 requireAuth,
5936 requireRepoAccess("read"),
5937 async (c) => {
5938 const { owner: ownerName, repo: repoName } = c.req.param();
5939 const prNum = parseInt(c.req.param("number"), 10);
5940 const commentId = c.req.param("commentId"); // UUID
5941 const user = c.get("user")!;
5942
5943 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
5944
5945 const resolved = await resolveRepo(ownerName, repoName);
5946 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5947
5948 const [pr] = await db
5949 .select()
5950 .from(pullRequests)
5951 .where(
5952 and(
5953 eq(pullRequests.repositoryId, resolved.repo.id),
5954 eq(pullRequests.number, prNum)
5955 )
5956 )
5957 .limit(1);
5958
5959 if (!pr || pr.state !== "open") {
5960 return c.redirect(`${backUrl}&error=pr_not_open`);
5961 }
5962
5963 // Only PR author or repo owner may apply suggestions.
5964 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
5965 return c.redirect(`${backUrl}&error=forbidden`);
5966 }
5967
5968 // Load the comment.
5969 const [comment] = await db
5970 .select()
5971 .from(prComments)
5972 .where(
5973 and(
5974 eq(prComments.id, commentId),
5975 eq(prComments.pullRequestId, pr.id)
5976 )
5977 )
5978 .limit(1);
5979
5980 if (!comment) {
5981 return c.redirect(`${backUrl}&error=comment_not_found`);
5982 }
5983
5984 // Parse suggestion block from comment body.
5985 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
5986 if (!m) {
5987 return c.redirect(`${backUrl}&error=no_suggestion`);
5988 }
5989 const suggestionCode = m[1];
5990
5991 // Get the commenter's details for the commit message co-author line.
5992 const [commenter] = await db
5993 .select()
5994 .from(users)
5995 .where(eq(users.id, comment.authorId))
5996 .limit(1);
5997
5998 // Fetch current file content from head branch.
5999 if (!comment.filePath) {
6000 return c.redirect(`${backUrl}&error=file_not_found`);
6001 }
6002 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
6003 if (!blob) {
6004 return c.redirect(`${backUrl}&error=file_not_found`);
6005 }
6006
6007 // Apply the patch — replace the target line(s) with suggestion lines.
6008 const lines = blob.content.split('\n');
6009 const lineIdx = (comment.lineNumber ?? 1) - 1;
6010 if (lineIdx < 0 || lineIdx >= lines.length) {
6011 return c.redirect(`${backUrl}&error=line_out_of_range`);
6012 }
6013 const suggestionLines = suggestionCode.split('\n');
6014 lines.splice(lineIdx, 1, ...suggestionLines);
6015 const newContent = lines.join('\n');
6016
6017 // Commit the change.
6018 const coAuthorLine = commenter
6019 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
6020 : "";
6021 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
6022
6023 const result = await createOrUpdateFileOnBranch({
6024 owner: ownerName,
6025 name: repoName,
6026 branch: pr.headBranch,
6027 filePath: comment.filePath,
6028 bytes: new TextEncoder().encode(newContent),
6029 message: commitMessage,
6030 authorName: user.username,
6031 authorEmail: `${user.username}@users.noreply.gluecron.com`,
6032 });
6033
6034 if ("error" in result) {
6035 return c.redirect(`${backUrl}&error=apply_failed`);
6036 }
6037
6038 // Post a follow-up comment noting the suggestion was applied.
6039 await db.insert(prComments).values({
6040 pullRequestId: pr.id,
6041 authorId: user.id,
6042 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
6043 });
6044
6045 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
6046 }
6047);
6048
0a67773Claude6049// Formal review — Approve / Request Changes / Comment
6050pulls.post(
6051 "/:owner/:repo/pulls/:number/review",
6052 softAuth,
6053 requireAuth,
6054 requireRepoAccess("read"),
6055 async (c) => {
6056 const { owner: ownerName, repo: repoName } = c.req.param();
6057 const prNum = parseInt(c.req.param("number"), 10);
6058 const user = c.get("user")!;
6059 const body = await c.req.parseBody();
6060 const reviewBody = String(body.body || "").trim();
6061 const reviewState = String(body.review_state || "commented");
6062
6063 const validStates = ["approved", "changes_requested", "commented"];
6064 if (!validStates.includes(reviewState)) {
6065 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6066 }
6067
6068 const resolved = await resolveRepo(ownerName, repoName);
6069 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6070
6071 const [pr] = await db
6072 .select()
6073 .from(pullRequests)
6074 .where(
6075 and(
6076 eq(pullRequests.repositoryId, resolved.repo.id),
6077 eq(pullRequests.number, prNum)
6078 )
6079 )
6080 .limit(1);
6081 if (!pr || pr.state !== "open") {
6082 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6083 }
6084 // Authors can't review their own PR
6085 if (pr.authorId === user.id) {
6086 return c.redirect(
6087 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
6088 );
6089 }
6090
6091 await db.insert(prReviews).values({
6092 pullRequestId: pr.id,
6093 reviewerId: user.id,
6094 state: reviewState,
6095 body: reviewBody || null,
6096 });
6097
6098 const stateLabel =
6099 reviewState === "approved" ? "Approved"
6100 : reviewState === "changes_requested" ? "Changes requested"
6101 : "Commented";
6102 return c.redirect(
6103 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
6104 );
6105 }
6106);
6107
e883329Claude6108// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude6109// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
6110// but we keep it at "write" for v1 so trusted collaborators can ship.
6111// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
6112// surface. Branch-protection rules (evaluated below) are the current mechanism
6113// for locking down merges further on specific branches.
0074234Claude6114pulls.post(
6115 "/:owner/:repo/pulls/:number/merge",
6116 softAuth,
6117 requireAuth,
04f6b7fClaude6118 requireRepoAccess("write"),
0074234Claude6119 async (c) => {
6120 const { owner: ownerName, repo: repoName } = c.req.param();
6121 const prNum = parseInt(c.req.param("number"), 10);
6122 const user = c.get("user")!;
6123
a164a6dClaude6124 // Read merge strategy from form (default: merge commit)
6125 let mergeStrategy = "merge";
6126 try {
6127 const body = await c.req.parseBody();
6128 const s = body.merge_strategy;
6129 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
6130 } catch { /* ignore parse errors — default to merge commit */ }
6131
0074234Claude6132 const resolved = await resolveRepo(ownerName, repoName);
6133 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6134
6135 const [pr] = await db
6136 .select()
6137 .from(pullRequests)
6138 .where(
6139 and(
6140 eq(pullRequests.repositoryId, resolved.repo.id),
6141 eq(pullRequests.number, prNum)
6142 )
6143 )
6144 .limit(1);
6145
6146 if (!pr || pr.state !== "open") {
6147 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6148 }
6149
6fc53bdClaude6150 // Draft PRs cannot be merged — must be marked ready first.
6151 if (pr.isDraft) {
6152 return c.redirect(
6153 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6154 "This PR is a draft. Mark it as ready for review before merging."
6155 )}`
6156 );
6157 }
6158
ec9e3e3Claude6159 // Required reviews check — branch-protection `required_approvals` gate.
6160 // Evaluated before running expensive gate checks so the feedback is fast.
6161 {
6162 const eligibility = await checkMergeEligible(pr.id, resolved.repo.id, pr.baseBranch);
6163 if (!eligibility.eligible && eligibility.reason) {
6164 return c.redirect(
6165 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(eligibility.reason)}`
6166 );
6167 }
6168 }
6169
e883329Claude6170 // Resolve head SHA
6171 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
6172 if (!headSha) {
6173 return c.redirect(
6174 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
6175 );
6176 }
6177
58c39f5Claude6178 // Check if AI review approved this PR.
6179 // The AI summary comment body uses:
6180 // "**AI review:** no blocking issues found." → approved
6181 // "**AI review:** flagged N item(s)..." → not approved
6182 // "severity: blocking" → explicit blocking (future)
6183 // If no AI comments exist yet, treat as approved (gate hasn't run).
e883329Claude6184 const aiComments = await db
6185 .select()
6186 .from(prComments)
6187 .where(
6188 and(
6189 eq(prComments.pullRequestId, pr.id),
6190 eq(prComments.isAiReview, true)
6191 )
6192 );
58c39f5Claude6193 const aiSummaryComment = aiComments.find((c) =>
6194 c.body.includes("<!-- gluecron-ai-review:summary -->")
0074234Claude6195 );
58c39f5Claude6196 const aiApproved =
6197 !aiSummaryComment ||
6198 (aiSummaryComment.body.includes("no blocking issues found") &&
6199 !aiSummaryComment.body.includes("severity: blocking"));
e883329Claude6200
6201 // Run all green gate checks (GateTest + mergeability + AI review)
6202 const gateResult = await runAllGateChecks(
6203 ownerName,
6204 repoName,
6205 pr.baseBranch,
6206 pr.headBranch,
6207 headSha,
6208 aiApproved
0074234Claude6209 );
6210
e883329Claude6211 // If GateTest or AI review failed (hard blocks), reject the merge
6212 const hardFailures = gateResult.checks.filter(
6213 (check) => !check.passed && check.name !== "Merge check"
6214 );
6215 if (hardFailures.length > 0) {
6216 const errorMsg = hardFailures
6217 .map((f) => `${f.name}: ${f.details}`)
6218 .join("; ");
0074234Claude6219 return c.redirect(
e883329Claude6220 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude6221 );
6222 }
6223
1e162a8Claude6224 // D5 — Branch-protection enforcement. Looks up the matching rule for the
6225 // base branch and blocks the merge if requireAiApproval / requireGreenGates
6226 // / requireHumanReview / requiredApprovals are not satisfied. Independent
6227 // of repo-global settings, so owners can lock specific branches down
6228 // further than the repo default.
6229 const protectionRule = await matchProtection(
6230 resolved.repo.id,
6231 pr.baseBranch
6232 );
6233 if (protectionRule) {
6234 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude6235 const required = await listRequiredChecks(protectionRule.id);
6236 const passingNames = required.length > 0
6237 ? await passingCheckNames(resolved.repo.id, headSha)
6238 : [];
6239 const decision = evaluateProtection(
6240 protectionRule,
6241 {
6242 aiApproved,
6243 humanApprovalCount: humanApprovals,
6244 gateResultGreen: hardFailures.length === 0,
6245 hasFailedGates: hardFailures.length > 0,
6246 passingCheckNames: passingNames,
6247 },
6248 required.map((r) => r.checkName)
6249 );
1e162a8Claude6250 if (!decision.allowed) {
6251 return c.redirect(
6252 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6253 decision.reasons.join(" ")
6254 )}`
6255 );
6256 }
6257 }
6258
e883329Claude6259 // Attempt the merge — with auto conflict resolution if needed
6260 const repoDir = getRepoPath(ownerName, repoName);
6261 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
6262 const hasConflicts = mergeCheck && !mergeCheck.passed;
6263
6264 if (hasConflicts && isAiReviewEnabled()) {
6265 // Use Claude to auto-resolve conflicts
6266 const mergeResult = await mergeWithAutoResolve(
6267 ownerName,
6268 repoName,
6269 pr.baseBranch,
6270 pr.headBranch,
6271 `Merge pull request #${pr.number}: ${pr.title}`
6272 );
6273
6274 if (!mergeResult.success) {
6275 return c.redirect(
6276 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
6277 );
6278 }
6279
6280 // Post a comment about the auto-resolution
6281 if (mergeResult.resolvedFiles.length > 0) {
6282 await db.insert(prComments).values({
6283 pullRequestId: pr.id,
6284 authorId: user.id,
6285 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
6286 isAiReview: true,
6287 });
6288 }
6289 } else {
a164a6dClaude6290 // Worktree-based merge: supports merge-commit, squash, and fast-forward
6291 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
6292 const gitEnv = {
6293 ...process.env,
6294 GIT_AUTHOR_NAME: user.displayName || user.username,
6295 GIT_AUTHOR_EMAIL: user.email,
6296 GIT_COMMITTER_NAME: user.displayName || user.username,
6297 GIT_COMMITTER_EMAIL: user.email,
6298 };
6299
6300 // Create linked worktree on the base branch
6301 const addWt = Bun.spawn(
6302 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude6303 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
6304 );
a164a6dClaude6305 if (await addWt.exited !== 0) {
6306 return c.redirect(
6307 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
6308 );
6309 }
6310
6311 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
6312 let mergeOk = false;
6313
6314 try {
6315 if (mergeStrategy === "squash") {
6316 // Squash: stage all changes without committing
6317 const squashProc = Bun.spawn(
6318 ["git", "merge", "--squash", headSha],
6319 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6320 );
6321 if (await squashProc.exited !== 0) {
6322 const errTxt = await new Response(squashProc.stderr).text();
6323 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
6324 }
6325 // Commit the squashed changes
6326 const commitProc = Bun.spawn(
6327 ["git", "commit", "-m", commitMsg],
6328 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6329 );
6330 if (await commitProc.exited !== 0) {
6331 const errTxt = await new Response(commitProc.stderr).text();
6332 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
6333 }
6334 mergeOk = true;
6335 } else if (mergeStrategy === "ff") {
6336 // Fast-forward only — fail if FF is not possible
6337 const ffProc = Bun.spawn(
6338 ["git", "merge", "--ff-only", headSha],
6339 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6340 );
6341 if (await ffProc.exited !== 0) {
6342 const errTxt = await new Response(ffProc.stderr).text();
6343 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
6344 }
6345 mergeOk = true;
6346 } else {
6347 // Default: merge commit (--no-ff always creates a merge commit)
6348 const mergeProc = Bun.spawn(
6349 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
6350 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6351 );
6352 if (await mergeProc.exited !== 0) {
6353 const errTxt = await new Response(mergeProc.stderr).text();
6354 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
6355 }
6356 mergeOk = true;
6357 }
6358 } catch (err) {
6359 // Always clean up the worktree before redirecting
6360 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
6361 const msg = err instanceof Error ? err.message : "Merge failed";
6362 return c.redirect(
6363 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
6364 );
6365 }
6366
6367 // Clean up worktree (changes are now in the bare repo via linked worktree)
6368 await Bun.spawn(
6369 ["git", "worktree", "remove", "--force", wt],
6370 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
6371 ).exited.catch(() => {});
e883329Claude6372
a164a6dClaude6373 if (!mergeOk) {
e883329Claude6374 return c.redirect(
a164a6dClaude6375 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude6376 );
6377 }
6378 }
6379
0074234Claude6380 await db
6381 .update(pullRequests)
6382 .set({
6383 state: "merged",
6384 mergedAt: new Date(),
6385 mergedBy: user.id,
6386 updatedAt: new Date(),
6387 })
6388 .where(eq(pullRequests.id, pr.id));
6389
8809b87Claude6390 // Chat notifier — fan out merge event to Slack/Discord/Teams.
6391 import("../lib/chat-notifier")
6392 .then((m) =>
6393 m.notifyChatChannels({
6394 ownerUserId: resolved.repo.ownerId,
6395 repositoryId: resolved.repo.id,
6396 event: {
6397 event: "pr.merged",
6398 repo: `${ownerName}/${repoName}`,
6399 title: `#${pr.number} ${pr.title}`,
6400 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
6401 actor: user.username,
6402 },
6403 })
6404 )
6405 .catch((err) =>
6406 console.warn(`[chat-notifier] PR merge notify failed:`, err)
6407 );
6408
d62fb36Claude6409 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
6410 // and auto-close each matching open issue with a back-link comment. Bounded
6411 // to the same repo for v1 (cross-repo refs ignored). Failures never block
6412 // the merge redirect.
6413 try {
6414 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
6415 const refs = extractClosingRefsMulti([pr.title, pr.body]);
6416 for (const n of refs) {
6417 const [issue] = await db
6418 .select()
6419 .from(issues)
6420 .where(
6421 and(
6422 eq(issues.repositoryId, resolved.repo.id),
6423 eq(issues.number, n)
6424 )
6425 )
6426 .limit(1);
6427 if (!issue || issue.state !== "open") continue;
6428 await db
6429 .update(issues)
6430 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
6431 .where(eq(issues.id, issue.id));
6432 await db.insert(issueComments).values({
6433 issueId: issue.id,
6434 authorId: user.id,
6435 body: `Closed by pull request #${pr.number}.`,
6436 });
6437 }
6438 } catch {
6439 // Never block the merge on close-keyword failures.
6440 }
6441
0074234Claude6442 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6443 }
6444);
6445
6fc53bdClaude6446// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
6447// hasn't run yet on this PR.
6448pulls.post(
6449 "/:owner/:repo/pulls/:number/ready",
6450 softAuth,
6451 requireAuth,
04f6b7fClaude6452 requireRepoAccess("write"),
6fc53bdClaude6453 async (c) => {
6454 const { owner: ownerName, repo: repoName } = c.req.param();
6455 const prNum = parseInt(c.req.param("number"), 10);
6456 const user = c.get("user")!;
6457
6458 const resolved = await resolveRepo(ownerName, repoName);
6459 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6460
6461 const [pr] = await db
6462 .select()
6463 .from(pullRequests)
6464 .where(
6465 and(
6466 eq(pullRequests.repositoryId, resolved.repo.id),
6467 eq(pullRequests.number, prNum)
6468 )
6469 )
6470 .limit(1);
6471 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6472
6473 // Only the author or repo owner can toggle draft state.
6474 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
6475 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6476 }
6477
6478 if (pr.state === "open" && pr.isDraft) {
6479 await db
6480 .update(pullRequests)
6481 .set({ isDraft: false, updatedAt: new Date() })
6482 .where(eq(pullRequests.id, pr.id));
6483
6484 if (isAiReviewEnabled()) {
6485 triggerAiReview(
6486 ownerName,
6487 repoName,
6488 pr.id,
6489 pr.title,
0316dbbClaude6490 pr.body || "",
6fc53bdClaude6491 pr.baseBranch,
6492 pr.headBranch
6493 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
6494 }
6495 }
6496
6497 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6498 }
6499);
6500
6501// Convert a PR back to draft.
6502pulls.post(
6503 "/:owner/:repo/pulls/:number/draft",
6504 softAuth,
6505 requireAuth,
04f6b7fClaude6506 requireRepoAccess("write"),
6fc53bdClaude6507 async (c) => {
6508 const { owner: ownerName, repo: repoName } = c.req.param();
6509 const prNum = parseInt(c.req.param("number"), 10);
6510 const user = c.get("user")!;
6511
6512 const resolved = await resolveRepo(ownerName, repoName);
6513 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6514
6515 const [pr] = await db
6516 .select()
6517 .from(pullRequests)
6518 .where(
6519 and(
6520 eq(pullRequests.repositoryId, resolved.repo.id),
6521 eq(pullRequests.number, prNum)
6522 )
6523 )
6524 .limit(1);
6525 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6526
6527 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
6528 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6529 }
6530
6531 if (pr.state === "open" && !pr.isDraft) {
6532 await db
6533 .update(pullRequests)
6534 .set({ isDraft: true, updatedAt: new Date() })
6535 .where(eq(pullRequests.id, pr.id));
6536 }
6537
6538 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6539 }
6540);
6541
0074234Claude6542// Close PR
6543pulls.post(
6544 "/:owner/:repo/pulls/:number/close",
6545 softAuth,
6546 requireAuth,
04f6b7fClaude6547 requireRepoAccess("write"),
0074234Claude6548 async (c) => {
6549 const { owner: ownerName, repo: repoName } = c.req.param();
6550 const prNum = parseInt(c.req.param("number"), 10);
6551
6552 const resolved = await resolveRepo(ownerName, repoName);
6553 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6554
6555 await db
6556 .update(pullRequests)
6557 .set({
6558 state: "closed",
6559 closedAt: new Date(),
6560 updatedAt: new Date(),
6561 })
6562 .where(
6563 and(
6564 eq(pullRequests.repositoryId, resolved.repo.id),
6565 eq(pullRequests.number, prNum)
6566 )
6567 );
6568
6569 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6570 }
6571);
6572
c3e0c07Claude6573// Re-run AI review on demand (e.g. after a force-push). Bypasses the
6574// idempotency marker via { force: true }. Write-access only.
6575pulls.post(
6576 "/:owner/:repo/pulls/:number/ai-rereview",
6577 softAuth,
6578 requireAuth,
6579 requireRepoAccess("write"),
6580 async (c) => {
6581 const { owner: ownerName, repo: repoName } = c.req.param();
6582 const prNum = parseInt(c.req.param("number"), 10);
6583 const resolved = await resolveRepo(ownerName, repoName);
6584 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6585
6586 const [pr] = await db
6587 .select()
6588 .from(pullRequests)
6589 .where(
6590 and(
6591 eq(pullRequests.repositoryId, resolved.repo.id),
6592 eq(pullRequests.number, prNum)
6593 )
6594 )
6595 .limit(1);
6596 if (!pr) {
6597 return c.redirect(`/${ownerName}/${repoName}/pulls`);
6598 }
6599
6600 if (!isAiReviewEnabled()) {
6601 return c.redirect(
6602 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6603 "AI review is not configured (ANTHROPIC_API_KEY)."
6604 )}`
6605 );
6606 }
6607
6608 // Fire-and-forget but with { force: true } to bypass the
6609 // already-reviewed marker. The function still never throws.
6610 triggerAiReview(
6611 ownerName,
6612 repoName,
6613 pr.id,
6614 pr.title || "",
6615 pr.body || "",
6616 pr.baseBranch,
6617 pr.headBranch,
6618 { force: true }
a28cedeClaude6619 ).catch((err) => {
6620 console.warn(
6621 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
6622 err instanceof Error ? err.message : err
6623 );
6624 });
c3e0c07Claude6625
6626 return c.redirect(
6627 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6628 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
6629 )}`
6630 );
6631 }
6632);
6633
1d4ff60Claude6634// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
6635// the PR's head branch carrying just the new test files. Write-access only.
6636// Idempotent — if `ai:added-tests` was previously applied we redirect with
6637// an `info` banner instead of re-firing.
6638pulls.post(
6639 "/:owner/:repo/pulls/:number/generate-tests",
6640 softAuth,
6641 requireAuth,
6642 requireRepoAccess("write"),
6643 async (c) => {
6644 const { owner: ownerName, repo: repoName } = c.req.param();
6645 const prNum = parseInt(c.req.param("number"), 10);
6646 const resolved = await resolveRepo(ownerName, repoName);
6647 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6648
6649 const [pr] = await db
6650 .select()
6651 .from(pullRequests)
6652 .where(
6653 and(
6654 eq(pullRequests.repositoryId, resolved.repo.id),
6655 eq(pullRequests.number, prNum)
6656 )
6657 )
6658 .limit(1);
6659 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6660
6661 if (!isAiReviewEnabled()) {
6662 return c.redirect(
6663 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6664 "AI test generation is not configured (ANTHROPIC_API_KEY)."
6665 )}`
6666 );
6667 }
6668
6669 // Fire-and-forget. The lib never throws.
6670 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
6671 .then((res) => {
6672 if (!res.ok) {
6673 console.warn(
6674 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
6675 );
6676 }
6677 })
6678 .catch((err) => {
6679 console.warn(
6680 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
6681 err instanceof Error ? err.message : err
6682 );
6683 });
6684
6685 return c.redirect(
6686 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6687 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
6688 )}`
6689 );
6690 }
6691);
6692
ace34efClaude6693// ─── Request review ───────────────────────────────────────────────────────────
6694pulls.post(
6695 "/:owner/:repo/pulls/:number/request-review",
6696 softAuth,
6697 requireAuth,
6698 requireRepoAccess("write"),
6699 async (c) => {
6700 const { owner: ownerName, repo: repoName } = c.req.param();
6701 const prNum = parseInt(c.req.param("number"), 10);
6702 const user = c.get("user")!;
6703
6704 const resolved = await resolveRepo(ownerName, repoName);
6705 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6706
6707 const [pr] = await db
6708 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
6709 .from(pullRequests)
6710 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
6711 .limit(1);
6712
6713 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6714
6715 const body = await c.req.formData().catch(() => null);
6716 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
6717
6718 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
6719 return c.redirect(
6720 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
6721 );
6722 }
6723
f4abb8eClaude6724 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
6725 // requesting reviews from arbitrary user IDs outside this repository.
6726 const isOwner = reviewerId === resolved.owner.id;
6727 if (!isOwner) {
6728 const [collab] = await db
6729 .select({ id: repoCollaborators.id })
6730 .from(repoCollaborators)
6731 .where(
6732 and(
6733 eq(repoCollaborators.repositoryId, resolved.repo.id),
6734 eq(repoCollaborators.userId, reviewerId),
6735 isNotNull(repoCollaborators.acceptedAt)
6736 )
6737 )
6738 .limit(1);
6739 if (!collab) {
6740 return c.redirect(
6741 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
6742 );
6743 }
6744 }
6745
ace34efClaude6746 const { requestReview } = await import("../lib/reviewer-suggest");
6747 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
6748
6749 const msg = result.ok
6750 ? "Review requested successfully."
6751 : `Failed to request review: ${result.error ?? "unknown error"}`;
6752
6753 return c.redirect(
6754 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
6755 );
6756 }
6757);
6758
25b1ff7Claude6759// ─── WebSocket presence endpoint ─────────────────────────────────────────────
6760//
6761// GET /:owner/:repo/pulls/:number/presence (WebSocket upgrade)
6762//
6763// Unauthenticated connections are rejected with 401. On connect:
6764// → server sends {type:"init", sessionId, users:[...]}
6765// → server broadcasts {type:"join", user} to all other sessions in the room
6766//
6767// Accepted client messages:
6768// {type:"cursor", line: number} — user hovering a diff line
6769// {type:"typing", line: number, typing: bool} — textarea focus/blur
6770// {type:"ping"} — keep-alive (updates lastSeen)
6771//
6772// The WS `data` payload we store on each socket carries everything needed in
6773// the event handlers so no closure tricks are required.
6774
6775pulls.get(
6776 "/:owner/:repo/pulls/:number/presence",
6777 softAuth,
6778 upgradeWebSocket(async (c) => {
6779 const { owner: ownerName, repo: repoName, number: prNumStr } = c.req.param();
6780 const prNum = parseInt(prNumStr ?? "0", 10);
6781 const user = c.get("user");
6782
6783 // Auth check — no anonymous presence
6784 if (!user) {
6785 // upgradeWebSocket doesn't support returning a non-101 directly;
6786 // we return a dummy handler that immediately closes with 4001.
6787 return {
6788 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6789 ws.close(4001, "Unauthorized");
6790 },
6791 onMessage() {},
6792 onClose() {},
6793 };
6794 }
6795
6796 // Resolve repo to get its numeric id for the room key
6797 const resolved = await resolveRepo(ownerName, repoName);
6798 if (!resolved || isNaN(prNum)) {
6799 return {
6800 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6801 ws.close(4004, "Not found");
6802 },
6803 onMessage() {},
6804 onClose() {},
6805 };
6806 }
6807
6808 const prId = `${resolved.repo.id}:${prNum}`;
6809 const sessionId = `${user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
6810
6811 return {
6812 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6813 // Register and join room
6814 registerSocket(prId, sessionId, {
6815 send: (data: string) => ws.send(data),
6816 readyState: ws.readyState,
6817 });
6818 const presenceUser = joinRoom(prId, sessionId, {
6819 userId: user.id,
6820 username: user.username,
6821 });
6822
6823 // Send init snapshot to the new joiner
6824 const currentUsers = getRoomUsers(prId);
6825 ws.send(
6826 JSON.stringify({
6827 type: "init",
6828 sessionId,
6829 users: currentUsers,
6830 })
6831 );
6832
6833 // Broadcast join to all OTHER sessions
6834 broadcastToRoom(
6835 prId,
6836 {
6837 type: "join",
6838 user: { ...presenceUser, sessionId },
6839 },
6840 sessionId
6841 );
6842 },
6843
6844 onMessage(evt: MessageEvent, _ws: import("hono/ws").WSContext) {
6845 let msg: { type: string; line?: number; typing?: boolean };
6846 try {
6847 msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
6848 } catch {
6849 return;
6850 }
6851
6852 if (msg.type === "ping") {
6853 pingSession(prId, sessionId);
6854 return;
6855 }
6856
6857 if (msg.type === "cursor") {
6858 const line = typeof msg.line === "number" ? msg.line : null;
6859 const updated = updatePresence(prId, sessionId, line, false);
6860 if (updated) {
6861 broadcastToRoom(
6862 prId,
6863 {
6864 type: "cursor",
6865 sessionId,
6866 username: updated.username,
6867 colour: updated.colour,
6868 line,
6869 },
6870 sessionId
6871 );
6872 }
6873 return;
6874 }
6875
6876 if (msg.type === "typing") {
6877 const line = typeof msg.line === "number" ? msg.line : null;
6878 const typing = !!msg.typing;
6879 const updated = updatePresence(prId, sessionId, line, typing);
6880 if (updated) {
6881 broadcastToRoom(
6882 prId,
6883 {
6884 type: "typing",
6885 sessionId,
6886 username: updated.username,
6887 colour: updated.colour,
6888 line,
6889 typing,
6890 },
6891 sessionId
6892 );
6893 }
6894 return;
6895 }
6896 },
6897
6898 onClose() {
6899 leaveRoom(prId, sessionId);
6900 unregisterSocket(prId, sessionId);
6901 broadcastToRoom(prId, { type: "leave", sessionId });
6902 },
6903 };
6904 })
6905);
6906
0074234Claude6907export default pulls;