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