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