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.tsxBlame6935 lines · 5 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";
f6730d0ccantynz-alt33import { RepoHeader, RepoNav, PageHeader, Button as GxButton, Badge as GxBadge, sharedComponentStyles } 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";
c166384ccantynz-alt58import { isAiReviewEnabled, triggerAiReview, isAiReviewApproved } 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";
a74f4edccanty labs71import { fireWebhooks } from "./webhooks";
81c73c1Claude72import { generatePrSummary } from "../lib/ai-generators";
73import { isAiAvailable } from "../lib/ai-client";
cc34156Claude74import { getReviewContext, recordPrVisit, type ReviewContext } from "../lib/review-context";
534f04aClaude75import {
76 computePrRiskForPullRequest,
77 getCachedPrRisk,
78 type PrRiskScore,
79} from "../lib/pr-risk";
0316dbbClaude80import { runAllGateChecks } from "../lib/gate";
81import type { GateCheckResult } from "../lib/gate";
82import {
83 matchProtection,
84 countHumanApprovals,
85 listRequiredChecks,
86 passingCheckNames,
87 evaluateProtection,
88} from "../lib/branch-protection";
89import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude90import {
91 listBranches,
92 getRepoPath,
e883329Claude93 resolveRef,
b5dd694Claude94 getBlob,
95 createOrUpdateFileOnBranch,
b558f23Claude96 commitsBetween,
0074234Claude97} from "../git/repository";
b558f23Claude98import type { GitDiffFile, GitCommit } from "../git/repository";
240c477Claude99import { listStatuses } from "../lib/commit-statuses";
100import type { CommitStatus } from "../db/schema";
0074234Claude101import { html } from "hono/html";
4bbacbeClaude102import {
103 getPreviewForBranch,
104 previewStatusLabel,
105} from "../lib/branch-previews";
1e162a8Claude106import {
bb0f894Claude107 Flex,
108 Container,
109 Badge,
110 Button,
111 LinkButton,
112 Form,
113 FormGroup,
114 Input,
115 TextArea,
116 Select,
117 EmptyState,
118 FilterTabs,
119 TabNav,
120 List,
121 ListItem,
122 Text,
123 Alert,
124 MarkdownContent,
125 CommentBox,
126 formatRelative,
127} from "../views/ui";
0074234Claude128
ace34efClaude129import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
74d8c4dClaude130import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
a7460bfClaude131import { BOT_USERNAME } from "../lib/bot-user";
09d5f39Claude132import { analyzeImpact, type ImpactAnalysis } from "../lib/pr-impact";
133import { getPreviewDir, isPreviewExpired } from "../lib/pr-stage";
7f992cdClaude134import {
135 getCodeownersForRepo,
136 reviewersForChangedFiles,
91b054eccanty labs137 requiredOwnersApproved,
7f992cdClaude138} from "../lib/codeowners";
91b054eccanty labs139import { enqueuePullRequestWorkflows } from "../lib/pr-workflow-sync";
7f992cdClaude140import { getPatternWarning, type Pattern } from "../lib/pattern-detector";
141import { suggestPrSplit, type SplitSuggestion } from "../lib/pr-splitter";
142import {
143 joinRoom,
144 leaveRoom,
145 broadcastToRoom,
146 registerSocket,
147 unregisterSocket,
148 getRoomUsers,
149 pingSession,
150 updatePresence,
151} from "../lib/pr-presence";
152import { getBusFactorWarning, type BusFactorFile } from "../lib/bus-factor";
153import { checkMergeEligible } from "../lib/branch-rules";
154import { createBunWebSocket } from "hono/bun";
155
156const { upgradeWebSocket, websocket: presenceWebsocket } = createBunWebSocket();
157export { presenceWebsocket };
ace34efClaude158
0074234Claude159const pulls = new Hono<AuthEnv>();
160
b078860Claude161/* ──────────────────────────────────────────────────────────────────────
162 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
163 * into the issue tracker or any other route. Tokens come from layout.tsx
164 * `:root` so light/dark stays consistent if/when light mode lands.
165 * ──────────────────────────────────────────────────────────────────── */
166const PRS_LIST_STYLES = `
167 .prs-tabs {
168 display: flex; flex-wrap: wrap; gap: 6px;
169 margin: 0 0 18px;
170 padding: 6px;
171 background: var(--bg-secondary);
172 border: 1px solid var(--border);
173 border-radius: 12px;
174 }
175 .prs-tab {
176 display: inline-flex; align-items: center; gap: 8px;
177 padding: 7px 13px;
178 font-size: 13px;
179 font-weight: 500;
180 color: var(--text-muted);
181 border-radius: 8px;
182 text-decoration: none;
183 transition: background 120ms ease, color 120ms ease;
184 }
185 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
186 .prs-tab.is-active {
187 background: var(--bg-elevated);
188 color: var(--text-strong);
189 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
190 }
191 .prs-tab-count {
192 display: inline-flex; align-items: center; justify-content: center;
193 min-width: 22px; padding: 2px 7px;
194 font-size: 11.5px;
195 font-weight: 600;
196 border-radius: 9999px;
197 background: var(--bg-tertiary);
198 color: var(--text-muted);
199 }
200 .prs-tab.is-active .prs-tab-count {
6fd5915Claude201 background: rgba(91,110,232,0.18);
b078860Claude202 color: var(--text-link);
203 }
204
205 .prs-list { display: flex; flex-direction: column; gap: 10px; }
206 .prs-row {
207 position: relative;
208 display: flex; align-items: flex-start; gap: 14px;
209 padding: 14px 16px;
210 background: var(--bg-elevated);
211 border: 1px solid var(--border);
212 border-radius: 12px;
213 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
214 }
215 .prs-row:hover {
216 transform: translateY(-1px);
217 border-color: var(--border-strong);
218 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
219 }
220 .prs-row-icon {
221 flex: 0 0 auto;
222 width: 26px; height: 26px;
223 display: inline-flex; align-items: center; justify-content: center;
224 border-radius: 9999px;
225 font-size: 13px;
226 margin-top: 2px;
227 }
228 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
e589f77ccantynz-alt229 .prs-row-icon.state-merged { color: var(--accent); background: rgba(91,110,232,0.16); }
b078860Claude230 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
231 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
232 .prs-row-body { flex: 1; min-width: 0; }
233 .prs-row-title {
234 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
235 font-size: 15px; font-weight: 600;
236 color: var(--text-strong);
237 line-height: 1.35;
238 margin: 0 0 6px;
239 }
240 .prs-row-number {
241 color: var(--text-muted);
242 font-weight: 400;
243 font-size: 14px;
244 }
245 .prs-row-meta {
246 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
247 font-size: 12.5px;
248 color: var(--text-muted);
249 }
250 .prs-branch-chips {
251 display: inline-flex; align-items: center; gap: 6px;
252 font-family: var(--font-mono);
253 font-size: 11.5px;
254 }
255 .prs-branch-chip {
256 padding: 2px 8px;
257 border-radius: 9999px;
258 background: var(--bg-tertiary);
259 border: 1px solid var(--border);
260 color: var(--text);
261 }
262 .prs-branch-arrow {
263 color: var(--text-faint);
264 font-size: 11px;
265 }
266 .prs-row-tags {
267 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
268 margin-left: auto;
269 }
270 .prs-tag {
271 display: inline-flex; align-items: center; gap: 4px;
272 padding: 2px 8px;
273 font-size: 11px;
274 font-weight: 600;
275 border-radius: 9999px;
276 border: 1px solid var(--border);
277 background: var(--bg-secondary);
278 color: var(--text-muted);
279 line-height: 1.6;
280 }
281 .prs-tag.is-draft {
282 color: var(--text-muted);
283 border-color: var(--border-strong);
284 }
285 .prs-tag.is-merged {
286 color: var(--text-link);
6fd5915Claude287 border-color: rgba(91,110,232,0.45);
288 background: rgba(91,110,232,0.10);
b078860Claude289 }
1aef949Claude290 .prs-tag.is-approved {
e589f77ccantynz-alt291 color: var(--green);
1aef949Claude292 border-color: rgba(52,211,153,0.40);
293 background: rgba(52,211,153,0.08);
294 }
295 .prs-tag.is-changes {
e589f77ccantynz-alt296 color: var(--red);
1aef949Claude297 border-color: rgba(248,113,113,0.40);
298 background: rgba(248,113,113,0.08);
299 }
2c61840Claude300 .prs-tag.is-ai {
301 color: #a78bfa;
302 border-color: rgba(167,139,250,0.40);
303 background: rgba(167,139,250,0.08);
304 }
b078860Claude305
306 .prs-empty {
ea9ed4cClaude307 position: relative;
308 padding: 56px 32px;
b078860Claude309 text-align: center;
310 border: 1px dashed var(--border);
ea9ed4cClaude311 border-radius: 16px;
312 background: var(--bg-elevated);
b078860Claude313 color: var(--text-muted);
ea9ed4cClaude314 overflow: hidden;
b078860Claude315 }
ea9ed4cClaude316 .prs-empty::before {
317 content: '';
318 position: absolute;
319 inset: -40% -20% auto auto;
320 width: 320px; height: 320px;
6fd5915Claude321 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.08) 50%, transparent 75%);
ea9ed4cClaude322 filter: blur(70px);
323 opacity: 0.55;
324 pointer-events: none;
325 animation: prsEmptyOrb 16s ease-in-out infinite;
326 }
327 @keyframes prsEmptyOrb {
328 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
329 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
330 }
331 @media (prefers-reduced-motion: reduce) {
332 .prs-empty::before { animation: none; }
333 }
334 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude335 .prs-empty strong {
336 display: block;
337 color: var(--text-strong);
ea9ed4cClaude338 font-family: var(--font-display);
339 font-size: 22px;
340 font-weight: 700;
341 letter-spacing: -0.018em;
342 margin-bottom: 2px;
343 }
344 .prs-empty-sub {
345 font-size: 14.5px;
346 color: var(--text-muted);
347 line-height: 1.55;
348 max-width: 460px;
349 margin: 0 0 18px;
b078860Claude350 }
ea9ed4cClaude351 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude352
353 @media (max-width: 720px) {
354 .prs-row-tags { margin-left: 0; }
355 }
f1dc7c7Claude356
357 /* Additional mobile rules. Additive only. */
358 @media (max-width: 720px) {
359 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
360 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
361 .prs-row { padding: 12px 14px; gap: 10px; }
362 .prs-row-icon { width: 24px; height: 24px; }
363 }
f5b9ef5Claude364
365 /* ─── Sort controls (PR list) ─── */
366 .prs-sort-row {
367 display: flex;
368 align-items: center;
369 gap: 6px;
370 margin: 0 0 12px;
371 flex-wrap: wrap;
372 }
373 .prs-sort-label {
374 font-size: 12.5px;
375 color: var(--text-muted);
376 font-weight: 600;
377 margin-right: 2px;
378 }
379 .prs-sort-opt {
380 font-size: 12.5px;
381 color: var(--text-muted);
382 text-decoration: none;
383 padding: 3px 10px;
384 border-radius: 9999px;
385 border: 1px solid transparent;
386 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
387 }
388 .prs-sort-opt:hover {
389 background: var(--bg-hover);
390 color: var(--text);
391 }
392 .prs-sort-opt.is-active {
6fd5915Claude393 background: rgba(91,110,232,0.12);
f5b9ef5Claude394 color: var(--text-link);
6fd5915Claude395 border-color: rgba(91,110,232,0.35);
f5b9ef5Claude396 font-weight: 600;
397 }
b078860Claude398`;
399
400/* ──────────────────────────────────────────────────────────────────────
401 * Inline CSS for the detail page. Same `.prs-*` namespace.
402 * ──────────────────────────────────────────────────────────────────── */
403const PRS_DETAIL_STYLES = `
404 .prs-detail-hero {
405 position: relative;
406 margin: 0 0 var(--space-4);
407 padding: 24px 26px;
408 background: var(--bg-elevated);
409 border: 1px solid var(--border);
410 border-radius: 16px;
411 overflow: hidden;
412 }
413 .prs-detail-hero::before {
414 content: '';
415 position: absolute; top: 0; left: 0; right: 0;
416 height: 2px;
6fd5915Claude417 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
b078860Claude418 opacity: 0.7;
419 pointer-events: none;
420 }
421 .prs-detail-title {
422 font-family: var(--font-display);
423 font-size: clamp(22px, 2.6vw, 28px);
424 font-weight: 700;
425 letter-spacing: -0.022em;
426 line-height: 1.2;
427 color: var(--text-strong);
428 margin: 0 0 12px;
429 }
430 .prs-detail-num {
431 color: var(--text-muted);
432 font-weight: 400;
433 }
74d8c4dClaude434 .prs-size-badge {
435 display: inline-flex;
436 align-items: center;
437 padding: 2px 8px;
438 border-radius: 20px;
439 font-size: 11px;
440 font-weight: 700;
441 letter-spacing: 0.04em;
442 border: 1px solid currentColor;
443 opacity: 0.85;
444 }
445
b078860Claude446 .prs-detail-meta {
447 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
448 font-size: 13px;
449 color: var(--text-muted);
450 }
451 .prs-detail-meta strong { color: var(--text); }
452 .prs-detail-branches {
453 display: inline-flex; align-items: center; gap: 6px;
454 font-family: var(--font-mono);
455 font-size: 12px;
456 }
457 .prs-branch-pill {
458 padding: 3px 9px;
459 border-radius: 9999px;
460 background: var(--bg-tertiary);
461 border: 1px solid var(--border);
462 color: var(--text);
463 }
464 .prs-branch-pill.is-head { color: var(--text-strong); }
465 .prs-branch-arrow-lg {
466 color: var(--accent);
467 font-size: 14px;
468 font-weight: 700;
469 }
0369e77Claude470 .prs-branch-sync {
471 display: inline-flex; align-items: center; gap: 4px;
472 font-size: 11.5px; font-weight: 600;
473 padding: 2px 8px;
474 border-radius: 9999px;
475 border: 1px solid var(--border);
476 background: var(--bg-secondary);
477 color: var(--text-muted);
478 cursor: default;
479 }
480 .prs-branch-sync.is-behind {
e589f77ccantynz-alt481 color: var(--red);
0369e77Claude482 border-color: rgba(248,113,113,0.35);
483 background: rgba(248,113,113,0.07);
484 }
485 .prs-branch-sync.is-synced {
e589f77ccantynz-alt486 color: var(--green);
0369e77Claude487 border-color: rgba(52,211,153,0.35);
488 background: rgba(52,211,153,0.07);
489 }
b078860Claude490
491 .prs-detail-actions {
492 display: inline-flex; gap: 8px; margin-left: auto;
493 }
494
495 .prs-detail-tabs {
496 display: flex; gap: 4px;
497 margin: 0 0 16px;
498 border-bottom: 1px solid var(--border);
499 }
500 .prs-detail-tab {
501 padding: 10px 14px;
502 font-size: 13.5px;
503 font-weight: 500;
504 color: var(--text-muted);
505 text-decoration: none;
506 border-bottom: 2px solid transparent;
507 transition: color 120ms ease, border-color 120ms ease;
508 margin-bottom: -1px;
509 }
510 .prs-detail-tab:hover { color: var(--text); }
511 .prs-detail-tab.is-active {
512 color: var(--text-strong);
513 border-bottom-color: var(--accent);
514 }
515 .prs-detail-tab-count {
516 display: inline-flex; align-items: center; justify-content: center;
517 min-width: 20px; padding: 0 6px; margin-left: 6px;
518 height: 18px;
519 font-size: 11px;
520 font-weight: 600;
521 border-radius: 9999px;
522 background: var(--bg-tertiary);
523 color: var(--text-muted);
524 }
525
526 /* Gate / check status section */
527 .prs-gate-card {
528 margin-top: 20px;
529 background: var(--bg-elevated);
530 border: 1px solid var(--border);
531 border-radius: 14px;
532 overflow: hidden;
533 }
534 .prs-gate-head {
535 display: flex; align-items: center; gap: 10px;
536 padding: 14px 18px;
537 border-bottom: 1px solid var(--border);
538 }
539 .prs-gate-head h3 {
540 margin: 0;
541 font-size: 14px;
542 font-weight: 600;
543 color: var(--text-strong);
544 }
545 .prs-gate-summary {
546 margin-left: auto;
547 font-size: 12px;
548 color: var(--text-muted);
549 }
550 .prs-gate-row {
551 display: flex; align-items: center; gap: 12px;
552 padding: 12px 18px;
553 border-bottom: 1px solid var(--border-subtle);
554 }
555 .prs-gate-row:last-child { border-bottom: 0; }
556 .prs-gate-icon {
557 flex: 0 0 auto;
558 width: 22px; height: 22px;
559 display: inline-flex; align-items: center; justify-content: center;
560 border-radius: 9999px;
561 font-size: 12px;
562 font-weight: 700;
563 }
564 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
565 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
566 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
567 .prs-gate-name {
568 font-size: 13px;
569 font-weight: 600;
570 color: var(--text);
571 min-width: 140px;
572 }
573 .prs-gate-details {
574 flex: 1; min-width: 0;
575 font-size: 12.5px;
576 color: var(--text-muted);
577 }
578 .prs-gate-pill {
579 flex: 0 0 auto;
580 padding: 3px 10px;
581 border-radius: 9999px;
582 font-size: 11px;
583 font-weight: 600;
584 line-height: 1.5;
585 border: 1px solid transparent;
586 }
587 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
588 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
589 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
590 .prs-gate-footer {
591 padding: 12px 18px;
592 background: var(--bg-secondary);
593 font-size: 12px;
594 color: var(--text-muted);
595 }
596
597 /* Comment cards */
598 .prs-comment {
599 margin-top: 14px;
600 background: var(--bg-elevated);
601 border: 1px solid var(--border);
602 border-radius: 12px;
603 overflow: hidden;
604 }
605 .prs-comment-head {
606 display: flex; align-items: center; gap: 10px;
607 padding: 10px 14px;
608 background: var(--bg-secondary);
609 border-bottom: 1px solid var(--border);
610 font-size: 13px;
611 flex-wrap: wrap;
612 }
613 .prs-comment-head strong { color: var(--text-strong); }
614 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
615 .prs-comment-loc {
616 font-family: var(--font-mono);
617 font-size: 11.5px;
618 color: var(--text-muted);
619 background: var(--bg-tertiary);
620 padding: 2px 8px;
621 border-radius: 6px;
622 }
623 .prs-comment-body { padding: 14px 18px; }
624 .prs-comment.is-ai {
6fd5915Claude625 border-color: rgba(91,110,232,0.45);
626 box-shadow: 0 0 0 1px rgba(91,110,232,0.10), 0 6px 24px -10px rgba(91,110,232,0.30);
b078860Claude627 }
628 .prs-comment.is-ai .prs-comment-head {
6fd5915Claude629 background: linear-gradient(90deg, rgba(91,110,232,0.10), rgba(95,143,160,0.06));
630 border-bottom-color: rgba(91,110,232,0.30);
b078860Claude631 }
632 .prs-ai-badge {
633 display: inline-flex; align-items: center; gap: 4px;
634 padding: 2px 9px;
635 font-size: 10.5px;
636 font-weight: 700;
637 letter-spacing: 0.04em;
638 text-transform: uppercase;
639 color: #fff;
6fd5915Claude640 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 130%);
b078860Claude641 border-radius: 9999px;
642 }
a7460bfClaude643 .prs-bot-badge {
644 display: inline-flex; align-items: center; gap: 3px;
645 padding: 1px 7px;
646 font-size: 10px;
647 font-weight: 600;
648 color: var(--fg-muted);
649 background: var(--bg-elevated);
650 border: 1px solid var(--border);
651 border-radius: 9999px;
652 }
b078860Claude653
654 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
655 .prs-files-card {
656 margin-top: 18px;
657 padding: 14px 18px;
658 display: flex; align-items: center; gap: 14px;
659 background: var(--bg-elevated);
660 border: 1px solid var(--border);
661 border-radius: 12px;
662 text-decoration: none;
663 color: inherit;
664 transition: border-color 120ms ease, transform 140ms ease;
665 }
666 .prs-files-card:hover {
6fd5915Claude667 border-color: rgba(91,110,232,0.45);
b078860Claude668 transform: translateY(-1px);
669 }
670 .prs-files-card-icon {
671 width: 36px; height: 36px;
672 display: inline-flex; align-items: center; justify-content: center;
673 border-radius: 10px;
6fd5915Claude674 background: rgba(91,110,232,0.12);
b078860Claude675 color: var(--text-link);
676 font-size: 18px;
677 }
678 .prs-files-card-text { flex: 1; min-width: 0; }
679 .prs-files-card-title {
680 font-size: 14px;
681 font-weight: 600;
682 color: var(--text-strong);
683 margin: 0 0 2px;
684 }
685 .prs-files-card-sub {
686 font-size: 12.5px;
687 color: var(--text-muted);
688 margin: 0;
689 }
690 .prs-files-card-cta {
691 font-size: 12.5px;
692 color: var(--text-link);
693 font-weight: 600;
694 }
695
696 /* Merge area */
697 .prs-merge-card {
698 position: relative;
699 margin-top: 22px;
700 padding: 18px;
701 background: var(--bg-elevated);
702 border-radius: 14px;
703 overflow: hidden;
704 }
705 .prs-merge-card::before {
706 content: '';
707 position: absolute; inset: 0;
708 padding: 1px;
709 border-radius: 14px;
6fd5915Claude710 background: linear-gradient(135deg, rgba(91,110,232,0.55) 0%, rgba(95,143,160,0.40) 100%);
b078860Claude711 -webkit-mask:
712 linear-gradient(#000 0 0) content-box,
713 linear-gradient(#000 0 0);
714 -webkit-mask-composite: xor;
715 mask-composite: exclude;
716 pointer-events: none;
717 }
718 .prs-merge-card.is-closed::before { background: var(--border-strong); }
6fd5915Claude719 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(91,110,232,0.45), rgba(95,143,160,0.30)); }
b078860Claude720 .prs-merge-head {
721 display: flex; align-items: center; gap: 12px;
722 margin-bottom: 12px;
723 }
724 .prs-merge-head strong {
725 font-family: var(--font-display);
726 font-size: 15px;
727 color: var(--text-strong);
728 font-weight: 700;
729 }
730 .prs-merge-sub {
731 font-size: 13px;
732 color: var(--text-muted);
733 margin: 0 0 12px;
734 }
735 .prs-merge-actions {
736 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
737 }
738 .prs-merge-btn {
739 display: inline-flex; align-items: center; gap: 6px;
740 padding: 9px 16px;
741 border-radius: 10px;
742 font-size: 13.5px;
743 font-weight: 600;
744 color: #fff;
6fd5915Claude745 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #5f8fa0 140%);
b078860Claude746 border: 1px solid rgba(52,211,153,0.55);
747 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
748 cursor: pointer;
749 transition: transform 120ms ease, box-shadow 160ms ease;
750 }
751 .prs-merge-btn:hover {
752 transform: translateY(-1px);
753 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
754 }
755 .prs-merge-btn[disabled],
756 .prs-merge-btn.is-disabled {
757 opacity: 0.55;
758 cursor: not-allowed;
759 transform: none;
760 box-shadow: none;
761 }
762 .prs-merge-ready-btn {
763 display: inline-flex; align-items: center; gap: 6px;
764 padding: 9px 16px;
765 border-radius: 10px;
766 font-size: 13.5px;
767 font-weight: 600;
768 color: #fff;
6fd5915Claude769 background: linear-gradient(135deg, #5b6ee8 0%, #6f5be8 60%, #5f8fa0 140%);
770 border: 1px solid rgba(91,110,232,0.55);
771 box-shadow: 0 6px 18px -8px rgba(91,110,232,0.55);
b078860Claude772 cursor: pointer;
773 transition: transform 120ms ease, box-shadow 160ms ease;
774 }
775 .prs-merge-ready-btn:hover {
776 transform: translateY(-1px);
6fd5915Claude777 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55);
b078860Claude778 }
779 .prs-merge-back-draft {
780 background: none; border: 1px solid var(--border-strong);
781 color: var(--text-muted);
782 padding: 9px 14px; border-radius: 10px;
783 font-size: 13px; cursor: pointer;
784 }
785 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
786
a164a6dClaude787 /* Merge strategy selector */
788 .prs-merge-strategy-wrap {
789 display: inline-flex; align-items: center;
790 background: var(--bg-elevated);
791 border: 1px solid var(--border);
792 border-radius: 10px;
793 overflow: hidden;
794 }
795 .prs-merge-strategy-label {
796 font-size: 11.5px; font-weight: 600;
797 color: var(--text-muted);
798 padding: 0 10px 0 12px;
799 white-space: nowrap;
800 }
801 .prs-merge-strategy-select {
802 background: transparent;
803 border: none;
804 color: var(--text);
805 font-size: 13px;
806 padding: 7px 10px 7px 4px;
807 cursor: pointer;
808 outline: none;
809 appearance: auto;
810 }
6fd5915Claude811 .prs-merge-strategy-select:focus { outline: 2px solid rgba(91,110,232,0.45); }
a164a6dClaude812
0a67773Claude813 /* Review summary banner */
814 .prs-review-summary {
815 display: flex; flex-direction: column; gap: 6px;
816 padding: 12px 16px;
817 background: var(--bg-elevated);
818 border: 1px solid var(--border);
819 border-radius: var(--r-md, 8px);
820 margin-bottom: 12px;
821 }
822 .prs-review-row {
823 display: flex; align-items: center; gap: 10px;
824 font-size: 13px;
825 }
826 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
e589f77ccantynz-alt827 .prs-review-approved .prs-review-icon { color: var(--green); }
828 .prs-review-changes .prs-review-icon { color: var(--red); }
ace34efClaude829 .prs-reviewer-avatar {
830 width: 24px; height: 24px; border-radius: 50%;
831 background: var(--accent); color: #fff;
832 display: flex; align-items: center; justify-content: center;
833 font-size: 11px; font-weight: 700; flex-shrink: 0;
834 }
0a67773Claude835
836 /* Review action buttons */
837 .prs-review-approve-btn {
838 display: inline-flex; align-items: center; gap: 5px;
839 padding: 8px 14px; border-radius: 8px; font-size: 13px;
840 font-weight: 600; cursor: pointer;
841 background: rgba(52,211,153,0.12);
e589f77ccantynz-alt842 color: var(--green);
0a67773Claude843 border: 1px solid rgba(52,211,153,0.35);
844 transition: background 120ms;
845 }
846 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
847 .prs-review-changes-btn {
848 display: inline-flex; align-items: center; gap: 5px;
849 padding: 8px 14px; border-radius: 8px; font-size: 13px;
850 font-weight: 600; cursor: pointer;
851 background: rgba(248,113,113,0.10);
e589f77ccantynz-alt852 color: var(--red);
0a67773Claude853 border: 1px solid rgba(248,113,113,0.30);
854 transition: background 120ms;
855 }
856 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
857
b078860Claude858 /* Inline form helpers */
859 .prs-inline-form { display: inline-flex; }
860
861 /* Comment composer */
862 .prs-composer { margin-top: 22px; }
863 .prs-composer textarea {
864 border-radius: 12px;
865 }
866
867 @media (max-width: 720px) {
868 .prs-detail-actions { margin-left: 0; }
869 .prs-merge-actions { width: 100%; }
870 .prs-merge-actions > * { flex: 1; min-width: 0; }
871 }
f1dc7c7Claude872
873 /* Additional mobile rules. Additive only. */
874 @media (max-width: 720px) {
875 .prs-detail-hero { padding: 18px; }
876 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
877 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
878 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
879 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
880 .prs-gate-name { min-width: 0; }
881 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
882 .prs-gate-summary { margin-left: 0; }
883 .prs-merge-btn,
884 .prs-merge-ready-btn,
885 .prs-merge-back-draft { min-height: 44px; }
886 .prs-comment-body { padding: 12px 14px; }
887 .prs-comment-head { padding: 10px 12px; }
888 .prs-files-card { padding: 12px 14px; }
889 }
3c03977Claude890
891 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
892 .live-pill {
893 display: inline-flex;
894 align-items: center;
895 gap: 8px;
896 padding: 4px 10px 4px 8px;
897 margin-left: 6px;
898 background: var(--bg-elevated);
899 border: 1px solid var(--border);
900 border-radius: 9999px;
901 font-size: 12px;
902 color: var(--text-muted);
903 line-height: 1;
904 vertical-align: middle;
905 }
906 .live-pill.is-busy { color: var(--text); }
907 .live-pill-dot {
908 width: 8px; height: 8px;
909 border-radius: 9999px;
e589f77ccantynz-alt910 background: var(--green);
3c03977Claude911 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
912 animation: live-pulse 1.6s ease-in-out infinite;
913 }
914 @keyframes live-pulse {
915 0%, 100% { opacity: 1; }
916 50% { opacity: 0.55; }
917 }
918 .live-avatars {
919 display: inline-flex;
920 margin-left: 2px;
921 }
922 .live-avatar {
923 display: inline-flex;
924 align-items: center;
925 justify-content: center;
926 width: 22px; height: 22px;
927 border-radius: 9999px;
928 font-size: 10px;
929 font-weight: 700;
930 color: #0b1020;
931 margin-left: -6px;
932 border: 2px solid var(--bg-elevated);
933 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
934 }
935 .live-avatar:first-child { margin-left: 0; }
936 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
937 .live-cursor-host {
938 position: relative;
939 }
940 .live-cursor-overlay {
941 position: absolute;
942 inset: 0;
943 pointer-events: none;
944 overflow: hidden;
945 border-radius: inherit;
946 }
947 .live-cursor {
948 position: absolute;
949 width: 2px;
950 height: 18px;
951 border-radius: 2px;
952 transform: translate(-1px, 0);
953 transition: transform 80ms linear, opacity 200ms ease;
954 }
955 .live-cursor::after {
956 content: attr(data-label);
957 position: absolute;
958 top: -16px;
959 left: -2px;
960 font-size: 10px;
961 line-height: 1;
962 color: #0b1020;
963 background: inherit;
964 padding: 2px 5px;
965 border-radius: 4px 4px 4px 0;
966 white-space: nowrap;
967 font-weight: 600;
968 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
969 }
970 .live-cursor.is-idle { opacity: 0.4; }
971 .live-edit-tag {
972 display: inline-block;
973 margin-left: 6px;
974 padding: 1px 6px;
975 font-size: 10px;
976 font-weight: 600;
977 letter-spacing: 0.02em;
978 color: #0b1020;
979 border-radius: 9999px;
980 }
15db0e0Claude981
982 /* ─── Slash-command pill + composer hint ─── */
983 .slash-hint {
984 display: inline-flex;
985 align-items: center;
986 gap: 6px;
987 margin-top: 6px;
988 padding: 3px 9px;
989 font-size: 11.5px;
990 color: var(--text-muted);
991 background: var(--bg-elevated);
992 border: 1px dashed var(--border);
993 border-radius: 9999px;
994 width: fit-content;
995 }
996 .slash-hint code {
997 background: rgba(110, 168, 255, 0.12);
998 color: var(--text-strong);
999 padding: 0 5px;
1000 border-radius: 4px;
1001 font-size: 11px;
1002 }
1003 .slash-pill {
1004 display: grid;
1005 grid-template-columns: auto 1fr auto;
1006 align-items: center;
1007 column-gap: 10px;
1008 row-gap: 6px;
1009 margin: 10px 0;
1010 padding: 10px 14px;
1011 background: linear-gradient(
1012 135deg,
1013 rgba(110, 168, 255, 0.08),
1014 rgba(163, 113, 247, 0.06)
1015 );
1016 border: 1px solid rgba(110, 168, 255, 0.32);
1017 border-left: 3px solid var(--accent, #6ea8ff);
1018 border-radius: var(--radius);
1019 font-size: 13px;
1020 color: var(--text);
1021 }
1022 .slash-pill-icon {
1023 font-size: 14px;
1024 line-height: 1;
1025 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
1026 }
1027 .slash-pill-actor { color: var(--text-muted); }
1028 .slash-pill-actor strong { color: var(--text-strong); }
1029 .slash-pill-cmd {
1030 background: rgba(110, 168, 255, 0.16);
1031 color: var(--text-strong);
1032 padding: 1px 6px;
1033 border-radius: 4px;
1034 font-size: 12.5px;
1035 }
1036 .slash-pill-time {
1037 color: var(--text-muted);
1038 font-size: 12px;
1039 justify-self: end;
1040 }
1041 .slash-pill-body {
1042 grid-column: 1 / -1;
1043 color: var(--text);
1044 font-size: 13px;
1045 line-height: 1.55;
1046 }
1047 .slash-pill-body p:first-child { margin-top: 0; }
1048 .slash-pill-body p:last-child { margin-bottom: 0; }
e589f77ccantynz-alt1049 .slash-pill.slash-cmd-merge { border-left-color: var(--green); }
15db0e0Claude1050 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1051 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
e589f77ccantynz-alt1052 .slash-pill.slash-cmd-lgtm { border-left-color: var(--green); }
6fd5915Claude1053 .slash-pill.slash-cmd-stage { border-left-color: #5f8fa0; }
4bbacbeClaude1054
1055 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
1056 .preview-prpill {
1057 display: inline-flex; align-items: center; gap: 6px;
1058 padding: 3px 10px;
1059 border-radius: 9999px;
1060 font-family: var(--font-mono);
1061 font-size: 11.5px;
1062 font-weight: 600;
1063 background: rgba(255,255,255,0.04);
1064 color: var(--text-muted);
1065 text-decoration: none;
1066 border: 1px solid var(--border);
1067 }
6fd5915Claude1068 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(91,110,232,0.45); }
4bbacbeClaude1069 .preview-prpill .preview-prpill-dot {
1070 width: 7px; height: 7px;
1071 border-radius: 9999px;
1072 background: currentColor;
1073 }
1074 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1075 .preview-prpill.is-building .preview-prpill-dot {
1076 animation: previewPrPulse 1.4s ease-in-out infinite;
1077 }
e589f77ccantynz-alt1078 .preview-prpill.is-ready { color: var(--green); border-color: rgba(52,211,153,0.30); }
4bbacbeClaude1079 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1080 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1081 @keyframes previewPrPulse {
1082 0%, 100% { opacity: 1; }
1083 50% { opacity: 0.4; }
1084 }
79ed944Claude1085
1086 /* ─── AI Trio Review — 3-column verdict cards ─── */
1087 .trio-wrap {
1088 margin-top: 18px;
1089 padding: 16px;
1090 background: var(--bg-elevated);
1091 border: 1px solid var(--border);
1092 border-radius: 14px;
1093 }
1094 .trio-header {
1095 display: flex; align-items: center; gap: 10px;
1096 margin: 0 0 12px;
1097 font-size: 13.5px;
1098 color: var(--text);
1099 }
1100 .trio-header strong { color: var(--text-strong); }
1101 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1102 .trio-header-dot {
1103 width: 8px; height: 8px; border-radius: 9999px;
6fd5915Claude1104 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
1105 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
79ed944Claude1106 }
1107 .trio-grid {
1108 display: grid;
1109 grid-template-columns: repeat(3, minmax(0, 1fr));
1110 gap: 12px;
1111 }
1112 .trio-card {
1113 background: var(--bg-secondary);
1114 border: 1px solid var(--border);
1115 border-radius: 12px;
1116 overflow: hidden;
1117 display: flex; flex-direction: column;
1118 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1119 }
1120 .trio-card-head {
1121 display: flex; align-items: center; gap: 8px;
1122 padding: 10px 12px;
1123 border-bottom: 1px solid var(--border);
1124 background: rgba(255,255,255,0.02);
1125 font-size: 13px;
1126 }
1127 .trio-card-icon {
1128 display: inline-flex; align-items: center; justify-content: center;
1129 width: 22px; height: 22px;
1130 border-radius: 9999px;
1131 font-size: 12px;
1132 background: rgba(255,255,255,0.05);
1133 }
1134 .trio-card-title {
1135 color: var(--text-strong);
1136 font-weight: 600;
1137 letter-spacing: 0.01em;
1138 }
1139 .trio-card-verdict {
1140 margin-left: auto;
1141 font-size: 11px;
1142 font-weight: 700;
1143 letter-spacing: 0.06em;
1144 text-transform: uppercase;
1145 padding: 3px 9px;
1146 border-radius: 9999px;
1147 background: var(--bg-tertiary);
1148 color: var(--text-muted);
1149 border: 1px solid var(--border-strong);
1150 }
1151 .trio-card-body {
1152 padding: 12px 14px;
1153 font-size: 13px;
1154 color: var(--text);
1155 flex: 1;
1156 min-height: 64px;
1157 line-height: 1.55;
1158 }
1159 .trio-card-body p { margin: 0 0 8px; }
1160 .trio-card-body p:last-child { margin-bottom: 0; }
1161 .trio-card-body ul { margin: 0; padding-left: 18px; }
1162 .trio-card-body code {
1163 font-family: var(--font-mono);
1164 font-size: 12px;
1165 background: var(--bg-tertiary);
1166 padding: 1px 6px;
1167 border-radius: 5px;
1168 }
1169 .trio-card-empty {
1170 color: var(--text-muted);
1171 font-style: italic;
1172 font-size: 12.5px;
1173 }
1174
1175 /* Pass state — neutral, no accent. */
1176 .trio-card.is-pass .trio-card-verdict {
1177 color: var(--green);
1178 border-color: rgba(52,211,153,0.35);
1179 background: rgba(52,211,153,0.12);
1180 }
1181
1182 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1183 .trio-card.trio-security.is-fail {
1184 border-color: rgba(248,113,113,0.55);
1185 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1186 }
1187 .trio-card.trio-security.is-fail .trio-card-head {
1188 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1189 border-bottom-color: rgba(248,113,113,0.30);
1190 }
1191 .trio-card.trio-security.is-fail .trio-card-verdict {
1192 color: #fecaca;
1193 border-color: rgba(248,113,113,0.55);
1194 background: rgba(248,113,113,0.20);
1195 }
1196
1197 .trio-card.trio-correctness.is-fail {
1198 border-color: rgba(251,191,36,0.55);
1199 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1200 }
1201 .trio-card.trio-correctness.is-fail .trio-card-head {
1202 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1203 border-bottom-color: rgba(251,191,36,0.30);
1204 }
1205 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1206 color: #fde68a;
1207 border-color: rgba(251,191,36,0.55);
1208 background: rgba(251,191,36,0.20);
1209 }
1210
1211 .trio-card.trio-style.is-fail {
1212 border-color: rgba(96,165,250,0.55);
1213 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1214 }
1215 .trio-card.trio-style.is-fail .trio-card-head {
1216 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1217 border-bottom-color: rgba(96,165,250,0.30);
1218 }
1219 .trio-card.trio-style.is-fail .trio-card-verdict {
1220 color: #bfdbfe;
1221 border-color: rgba(96,165,250,0.55);
1222 background: rgba(96,165,250,0.20);
1223 }
1224
1225 /* Disagreement callout strip — yellow, prominent. */
1226 .trio-disagreement-strip {
1227 display: flex;
1228 gap: 12px;
1229 margin-top: 14px;
1230 padding: 12px 14px;
1231 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1232 border: 1px solid rgba(251,191,36,0.45);
1233 border-radius: 10px;
1234 color: var(--text);
1235 font-size: 13px;
1236 }
1237 .trio-disagreement-icon {
1238 flex: 0 0 auto;
1239 width: 26px; height: 26px;
1240 display: inline-flex; align-items: center; justify-content: center;
1241 border-radius: 9999px;
1242 background: rgba(251,191,36,0.25);
1243 color: #fde68a;
1244 font-size: 14px;
1245 }
1246 .trio-disagreement-body strong {
1247 display: block;
1248 color: #fde68a;
1249 margin: 0 0 4px;
1250 font-weight: 700;
1251 }
1252 .trio-disagreement-list {
1253 margin: 0;
1254 padding-left: 18px;
1255 color: var(--text);
1256 font-size: 12.5px;
1257 line-height: 1.55;
1258 }
1259 .trio-disagreement-list code {
1260 font-family: var(--font-mono);
1261 font-size: 11.5px;
1262 background: var(--bg-tertiary);
1263 padding: 1px 5px;
1264 border-radius: 4px;
1265 }
1266
1267 @media (max-width: 720px) {
1268 .trio-grid { grid-template-columns: 1fr; }
1269 .trio-wrap { padding: 12px; }
1270 }
6d1bbc2Claude1271
1272 /* ─── Task list progress pill ─── */
1273 .prs-tasks-pill {
1274 display: inline-flex; align-items: center; gap: 5px;
1275 font-size: 11.5px; font-weight: 600;
1276 padding: 2px 9px; border-radius: 9999px;
1277 border: 1px solid var(--border);
1278 background: var(--bg-elevated);
1279 color: var(--text-muted);
1280 }
1281 .prs-tasks-pill.is-complete {
e589f77ccantynz-alt1282 color: var(--green);
6d1bbc2Claude1283 border-color: rgba(52,211,153,0.40);
1284 background: rgba(52,211,153,0.08);
1285 }
1286 .prs-tasks-progress { display: inline-block; width: 36px; height: 4px; border-radius: 9999px; background: var(--border); overflow: hidden; vertical-align: middle; }
e589f77ccantynz-alt1287 .prs-tasks-progress-bar { height: 100%; border-radius: 9999px; background: var(--green); }
6d1bbc2Claude1288
1289 /* ─── Update branch button ─── */
1290 .prs-update-branch-btn {
1291 display: inline-flex; align-items: center; gap: 5px;
1292 padding: 4px 12px; border-radius: 8px; font-size: 12.5px;
1293 font-weight: 600; cursor: pointer;
1294 background: rgba(96,165,250,0.10);
1295 color: #60a5fa;
1296 border: 1px solid rgba(96,165,250,0.30);
1297 transition: background 120ms;
1298 }
1299 .prs-update-branch-btn:hover { background: rgba(96,165,250,0.20); }
1300
1301 /* ─── Linked issues panel ─── */
1302 .prs-linked-issues {
1303 margin-top: 16px;
1304 border: 1px solid var(--border);
1305 border-radius: 12px;
1306 overflow: hidden;
1307 }
1308 .prs-linked-issues-head {
1309 display: flex; align-items: center; justify-content: space-between;
1310 padding: 10px 16px;
1311 background: var(--bg-elevated);
1312 border-bottom: 1px solid var(--border);
1313 font-size: 13px; font-weight: 600; color: var(--text);
1314 }
1315 .prs-linked-issues-count {
1316 font-size: 11px; font-weight: 700;
1317 padding: 1px 7px; border-radius: 9999px;
1318 background: var(--bg-tertiary);
1319 color: var(--text-muted);
1320 }
1321 .prs-linked-issue-row {
1322 display: flex; align-items: center; gap: 10px;
1323 padding: 9px 16px;
1324 border-bottom: 1px solid var(--border);
1325 font-size: 13px;
1326 text-decoration: none; color: inherit;
1327 }
1328 .prs-linked-issue-row:last-child { border-bottom: none; }
1329 .prs-linked-issue-row:hover { background: var(--bg-hover); }
1330 .prs-linked-issue-icon { flex: 0 0 auto; font-size: 14px; }
e589f77ccantynz-alt1331 .prs-linked-issue-icon.is-open { color: var(--green); }
6d1bbc2Claude1332 .prs-linked-issue-icon.is-closed { color: #8b949e; }
1333 .prs-linked-issue-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1334 .prs-linked-issue-num { color: var(--text-muted); font-size: 12px; }
1335 .prs-linked-issue-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
e589f77ccantynz-alt1336 .prs-linked-issue-state.is-open { color: var(--green); background: rgba(52,211,153,0.10); }
6d1bbc2Claude1337 .prs-linked-issue-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
b558f23Claude1338
1339 /* ─── Commits tab ─── */
1340 .prs-commits-list { display: flex; flex-direction: column; gap: 0; margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1341 .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; }
1342 .prs-commit-row:last-child { border-bottom: none; }
1343 .prs-commit-row:hover { background: var(--bg-hover); }
1344 .prs-commit-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-top: 6px; }
1345 .prs-commit-body { flex: 1 1 auto; min-width: 0; }
1346 .prs-commit-msg { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
1347 .prs-commit-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
1348 .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; }
1349 .prs-commit-sha:hover { color: var(--accent); }
1350 .prs-commits-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13.5px; }
1351
1352 /* ─── Edit PR title/body ─── */
1353 .prs-edit-title-wrap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1354 .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; }
1355 .prs-edit-btn:hover { color: var(--text); border-color: var(--text-muted); }
1356 .prs-edit-form { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
1357 .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; }
1358 .prs-edit-actions { display: flex; gap: 8px; }
1359 .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; }
1360 .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; }
240c477Claude1361
1362 /* ─── CI status checks ─── */
1363 .prs-ci-card { margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1364 .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); }
1365 .prs-ci-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text); }
1366 .prs-ci-summary { font-size: 12px; color: var(--text-muted); }
1367 .prs-ci-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
1368 .prs-ci-row:last-child { border-bottom: none; }
1369 .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; }
e589f77ccantynz-alt1370 .prs-ci-icon.is-success { background: rgba(52,211,153,0.20); color: var(--green); }
1371 .prs-ci-icon.is-pending { background: rgba(251,191,36,0.20); color: var(--yellow); }
1372 .prs-ci-icon.is-failure, .prs-ci-icon.is-error { background: rgba(248,113,113,0.20); color: var(--red); }
240c477Claude1373 .prs-ci-context { flex: 1 1 auto; font-size: 13px; font-weight: 500; color: var(--text); }
1374 .prs-ci-desc { font-size: 12px; color: var(--text-muted); }
1375 .prs-ci-pill { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 9999px; }
e589f77ccantynz-alt1376 .prs-ci-pill.is-success { color: var(--green); background: rgba(52,211,153,0.10); }
1377 .prs-ci-pill.is-pending { color: var(--yellow); background: rgba(251,191,36,0.10); }
1378 .prs-ci-pill.is-failure, .prs-ci-pill.is-error { color: var(--red); background: rgba(248,113,113,0.10); }
240c477Claude1379 .prs-ci-link { font-size: 12px; color: var(--accent); text-decoration: none; }
1380 .prs-ci-link:hover { text-decoration: underline; }
67dc4e1Claude1381
1382 /* ─── AI Trio verdict pills (header summary) ─── */
1383 .trio-pill {
1384 display: inline-flex; align-items: center; gap: 4px;
1385 padding: 2px 8px;
1386 font-size: 11px;
1387 font-weight: 700;
1388 border-radius: 9999px;
1389 border: 1px solid transparent;
1390 text-decoration: none;
1391 line-height: 1.6;
1392 letter-spacing: 0.01em;
1393 cursor: pointer;
1394 transition: opacity 120ms ease;
1395 }
1396 .trio-pill:hover { opacity: 0.8; }
1397 .trio-pill.is-pass {
e589f77ccantynz-alt1398 color: var(--green);
67dc4e1Claude1399 background: rgba(52,211,153,0.10);
1400 border-color: rgba(52,211,153,0.35);
1401 }
1402 .trio-pill.is-fail {
e589f77ccantynz-alt1403 color: var(--red);
67dc4e1Claude1404 background: rgba(248,113,113,0.10);
1405 border-color: rgba(248,113,113,0.35);
1406 }
1407 .trio-pill.is-pending {
1408 color: var(--text-muted);
1409 background: rgba(255,255,255,0.04);
1410 border-color: var(--border-strong);
1411 }
1412 .trio-pills-wrap {
1413 display: inline-flex; align-items: center; gap: 4px;
1414 }
1d6db4dClaude1415
1416 /* ─── Bus Factor Warning Panel ─── */
1417 .busfactor-panel {
1418 display: flex;
1419 gap: 14px;
1420 align-items: flex-start;
1421 padding: 14px 18px;
1422 margin-bottom: 16px;
1423 border-radius: 12px;
1424 border: 1px solid rgba(245,158,11,0.35);
1425 background: rgba(245,158,11,0.06);
1426 }
1427 .busfactor-critical {
1428 border-color: rgba(239,68,68,0.4);
1429 background: rgba(239,68,68,0.06);
1430 }
1431 .busfactor-high {
1432 border-color: rgba(249,115,22,0.4);
1433 background: rgba(249,115,22,0.06);
1434 }
1435 .busfactor-medium {
1436 border-color: rgba(245,158,11,0.35);
1437 background: rgba(245,158,11,0.06);
1438 }
1439 .busfactor-icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; }
1440 .busfactor-body { flex: 1; min-width: 0; }
1441 .busfactor-body strong { font-size: 14px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1442 .busfactor-body p { font-size: 13px; color: var(--text-muted); margin: 0 0 8px; }
1443 .busfactor-body ul { margin: 0; padding-left: 18px; }
1444 .busfactor-body li { font-size: 12.5px; color: var(--text-muted); margin-bottom: 3px; font-family: var(--font-mono); }
1445 .busfactor-body li strong { font-size: 12.5px; color: var(--text); display: inline; }
1446
1447 /* ─── PR Split Suggestion Panel ─── */
1448 .split-suggestion {
1449 margin-bottom: 16px;
6fd5915Claude1450 border: 1px solid rgba(91,110,232,0.35);
1d6db4dClaude1451 border-radius: 12px;
1452 overflow: hidden;
1453 }
1454 .split-header {
1455 display: flex;
1456 align-items: center;
1457 gap: 10px;
1458 padding: 12px 18px;
6fd5915Claude1459 background: rgba(91,110,232,0.06);
1d6db4dClaude1460 flex-wrap: wrap;
1461 }
1462 .split-icon { font-size: 18px; flex-shrink: 0; }
1463 .split-header strong { font-size: 14px; font-weight: 700; color: var(--text-strong); flex: 1; min-width: 200px; }
1464 .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; }
1465 .split-toggle {
1466 background: none;
6fd5915Claude1467 border: 1px solid rgba(91,110,232,0.45);
1468 color: rgba(91,110,232,0.9);
1d6db4dClaude1469 font-size: 12.5px;
1470 font-weight: 600;
1471 padding: 4px 12px;
1472 border-radius: 8px;
1473 cursor: pointer;
1474 white-space: nowrap;
1475 transition: background 120ms ease;
1476 }
6fd5915Claude1477 .split-toggle:hover { background: rgba(91,110,232,0.1); }
1d6db4dClaude1478 .split-body {
1479 padding: 16px 18px;
6fd5915Claude1480 border-top: 1px solid rgba(91,110,232,0.2);
1d6db4dClaude1481 }
1482 .split-intro { font-size: 13.5px; color: var(--text-muted); margin: 0 0 14px; }
1483 .split-pr {
1484 display: flex;
1485 gap: 14px;
1486 align-items: flex-start;
1487 padding: 12px 0;
1488 border-bottom: 1px solid var(--border);
1489 }
1490 .split-pr:last-of-type { border-bottom: none; }
1491 .split-pr-num {
1492 width: 26px; height: 26px;
1493 border-radius: 50%;
6fd5915Claude1494 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 130%);
1d6db4dClaude1495 color: #fff;
1496 font-size: 12px;
1497 font-weight: 800;
1498 display: inline-flex;
1499 align-items: center;
1500 justify-content: center;
1501 flex-shrink: 0;
1502 margin-top: 2px;
1503 }
1504 .split-pr-body { flex: 1; min-width: 0; }
1505 .split-pr-body strong { font-size: 13.5px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1506 .split-pr-body p { font-size: 12.5px; color: var(--text-muted); margin: 0 0 6px; }
1507 .split-pr-body code { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); word-break: break-all; }
1508 .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; }
1509 .split-order { font-size: 13px; color: var(--text-muted); margin: 14px 0 0; }
1510 .split-order strong { color: var(--text); }
b078860Claude1511`;
1512
25b1ff7Claude1513/* ──────────────────────────────────────────────────────────────────────
1514 * Figma-style collaborative PR presence — styles for the presence bar
1515 * above the diff and the per-line reviewer cursor pills. All scoped
1516 * with `.presence-*` prefix so they never bleed into other views.
1517 * ──────────────────────────────────────────────────────────────────── */
1518const PRESENCE_STYLES = `
1519 .presence-bar {
1520 display: flex;
1521 align-items: center;
1522 gap: 10px;
1523 padding: 8px 14px;
1524 margin: 0 0 10px;
1525 background: var(--bg-elevated);
1526 border: 1px solid var(--border);
1527 border-radius: 10px;
1528 font-size: 12.5px;
1529 color: var(--text-muted);
1530 min-height: 38px;
1531 }
1532 .presence-bar-label {
1533 font-weight: 600;
1534 color: var(--text-muted);
1535 flex-shrink: 0;
1536 }
1537 .presence-avatars {
1538 display: flex;
1539 align-items: center;
1540 gap: 6px;
1541 flex: 1;
1542 flex-wrap: wrap;
1543 }
1544 .presence-avatar {
1545 display: inline-flex;
1546 align-items: center;
1547 gap: 5px;
1548 padding: 3px 8px 3px 4px;
1549 border-radius: 9999px;
1550 font-size: 12px;
1551 font-weight: 600;
1552 color: #fff;
1553 opacity: 0.92;
1554 transition: opacity 200ms;
1555 }
1556 .presence-avatar-dot {
1557 width: 20px; height: 20px;
1558 border-radius: 9999px;
1559 background: rgba(255,255,255,0.22);
1560 display: inline-flex;
1561 align-items: center;
1562 justify-content: center;
1563 font-size: 10px;
1564 font-weight: 700;
1565 flex-shrink: 0;
1566 }
1567 .presence-count {
1568 font-size: 12px;
1569 color: var(--text-faint);
1570 flex-shrink: 0;
1571 }
1572 /* Per-line reviewer cursor pill — injected by JS into .diff-row */
1573 .presence-line-pill {
1574 position: absolute;
1575 right: 6px;
1576 top: 50%;
1577 transform: translateY(-50%);
1578 display: inline-flex;
1579 align-items: center;
1580 gap: 4px;
1581 padding: 1px 7px;
1582 border-radius: 9999px;
1583 font-size: 10.5px;
1584 font-weight: 600;
1585 color: #fff;
1586 pointer-events: none;
1587 white-space: nowrap;
1588 z-index: 10;
1589 opacity: 0.88;
1590 animation: presence-in 160ms ease;
1591 }
1592 @keyframes presence-in {
1593 from { opacity: 0; transform: translateY(-50%) scale(0.85); }
1594 to { opacity: 0.88; transform: translateY(-50%) scale(1); }
1595 }
1596 .presence-line-pill.is-typing::after {
1597 content: '…';
1598 opacity: 0.7;
1599 }
1600 /* diff rows with a cursor pill need relative positioning */
1601 .diff-row { position: relative; }
1602 /* Toast for join/leave events */
1603 .presence-toast-wrap {
1604 position: fixed;
1605 bottom: 24px;
1606 right: 24px;
1607 display: flex;
1608 flex-direction: column;
1609 gap: 8px;
1610 z-index: 9999;
1611 pointer-events: none;
1612 }
1613 .presence-toast {
1614 padding: 8px 14px;
1615 border-radius: 8px;
1616 background: var(--bg-elevated);
1617 border: 1px solid var(--border);
1618 box-shadow: 0 6px 20px -8px rgba(0,0,0,0.55);
1619 font-size: 13px;
1620 color: var(--text);
1621 opacity: 1;
1622 transition: opacity 400ms;
1623 }
1624 .presence-toast.fading { opacity: 0; }
1625`;
b271465Claude1626
1627const IMPACT_STYLES = `
09d5f39Claude1628 /* ─── Merge Impact Analysis panel (.impact-*) ─── */
1629 .impact-panel {
1630 margin-top: 20px;
1631 border: 1px solid var(--border);
1632 border-radius: 12px;
1633 overflow: hidden;
1634 background: var(--bg-elevated);
1635 }
1636 .impact-header {
1637 display: flex;
1638 align-items: center;
1639 gap: 10px;
1640 padding: 12px 16px;
1641 background: var(--bg-elevated);
1642 border-bottom: 1px solid var(--border);
1643 cursor: pointer;
1644 user-select: none;
1645 }
1646 .impact-header:hover { background: var(--bg-hover); }
1647 .impact-score {
1648 display: inline-flex;
1649 align-items: center;
1650 justify-content: center;
1651 min-width: 34px;
1652 height: 24px;
1653 border-radius: 9999px;
1654 font-size: 11.5px;
1655 font-weight: 800;
1656 padding: 0 8px;
1657 letter-spacing: 0.01em;
1658 border: 1.5px solid transparent;
1659 }
1660 .impact-score.score-low {
e589f77ccantynz-alt1661 color: var(--green);
09d5f39Claude1662 background: rgba(52,211,153,0.12);
1663 border-color: rgba(52,211,153,0.35);
1664 }
1665 .impact-score.score-medium {
e589f77ccantynz-alt1666 color: var(--yellow);
09d5f39Claude1667 background: rgba(251,191,36,0.12);
1668 border-color: rgba(251,191,36,0.35);
1669 }
1670 .impact-score.score-high {
e589f77ccantynz-alt1671 color: var(--red);
09d5f39Claude1672 background: rgba(248,113,113,0.12);
1673 border-color: rgba(248,113,113,0.35);
1674 }
1675 .impact-header strong {
1676 font-size: 13.5px;
1677 color: var(--text-strong);
1678 font-weight: 700;
1679 }
1680 .impact-summary {
1681 font-size: 12.5px;
1682 color: var(--text-muted);
1683 flex: 1;
1684 }
1685 .impact-toggle {
1686 background: none;
1687 border: none;
1688 color: var(--text-muted);
1689 font-size: 11px;
1690 cursor: pointer;
1691 padding: 2px 6px;
1692 border-radius: 4px;
1693 transition: color 120ms;
1694 line-height: 1;
1695 }
1696 .impact-toggle:hover { color: var(--text); }
1697 .impact-toggle.is-open { transform: rotate(180deg); }
1698 .impact-body {
1699 padding: 14px 16px;
1700 display: flex;
1701 flex-direction: column;
1702 gap: 14px;
1703 }
1704 .impact-body[hidden] { display: none; }
1705 .impact-section h4 {
1706 margin: 0 0 8px;
1707 font-size: 12.5px;
1708 font-weight: 600;
1709 color: var(--text-muted);
1710 text-transform: uppercase;
1711 letter-spacing: 0.06em;
1712 }
1713 .impact-file-list {
1714 display: flex;
1715 flex-direction: column;
1716 gap: 3px;
1717 margin: 0;
1718 padding: 0;
1719 list-style: none;
1720 }
1721 .impact-file-list li {
1722 font-family: var(--font-mono);
1723 font-size: 12px;
1724 color: var(--text);
1725 padding: 3px 8px;
1726 background: var(--bg-secondary);
1727 border-radius: 5px;
1728 overflow: hidden;
1729 text-overflow: ellipsis;
1730 white-space: nowrap;
1731 }
1732 .impact-downstream .impact-file-list li {
1733 background: rgba(248,113,113,0.06);
1734 border: 1px solid rgba(248,113,113,0.15);
1735 }
1736 .impact-downstream h4 {
e589f77ccantynz-alt1737 color: var(--red);
09d5f39Claude1738 }
1739 .impact-empty {
1740 font-size: 12.5px;
1741 color: var(--text-muted);
1742 font-style: italic;
1743 }
1744`;
1745
25b1ff7Claude1746
1747
81c73c1Claude1748/**
1749 * Tiny inline JS that drives the "Suggest description with AI" button.
1750 * On click, gathers form values, POSTs JSON to the given endpoint, and
1751 * pipes the response into the #pr-body textarea. All DOM lookups are
1752 * defensive — element absence is a silent no-op.
1753 *
1754 * Built as a string template so it lives next to its server-side caller
1755 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1756 * to avoid </script> breakouts.
1757 */
1758function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1759 const url = JSON.stringify(endpointUrl)
1760 .split("<").join("\\u003C")
1761 .split(">").join("\\u003E")
1762 .split("&").join("\\u0026");
1763 return (
1764 "(function(){try{" +
1765 "var btn=document.getElementById('ai-suggest-desc');" +
1766 "var status=document.getElementById('ai-suggest-status');" +
1767 "var body=document.getElementById('pr-body');" +
1768 "var form=btn&&btn.closest&&btn.closest('form');" +
1769 "if(!btn||!body||!form)return;" +
1770 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1771 "var fd=new FormData(form);" +
1772 "var title=String(fd.get('title')||'').trim();" +
1773 "var base=String(fd.get('base')||'').trim();" +
1774 "var head=String(fd.get('head')||'').trim();" +
1775 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1776 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1777 "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'})" +
1778 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1779 ".then(function(j){btn.disabled=false;" +
1780 "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;}}" +
1781 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1782 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1783 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1784 "});" +
1785 "}catch(e){}})();"
1786 );
1787}
1788
3c03977Claude1789/**
1790 * Live co-editing client. Connects to the per-PR SSE feed and:
1791 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1792 * status colour per user).
1793 * - Renders tinted cursor caret overlays inside #pr-body and every
1794 * `[data-live-field]` element.
1795 * - Broadcasts the local user's cursor position (selectionStart /
1796 * selectionEnd) debounced at 100ms.
1797 * - Broadcasts content patches (`replace` of the whole textarea —
1798 * last-write-wins v1) debounced at 250ms.
1799 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1800 * to the matching local field if untouched.
1801 *
1802 * All endpoint URLs are JSON-escaped via safe replacements so they
1803 * can't break out of the <script> tag.
1804 */
25b1ff7Claude1805
1806/**
1807 * Figma-style collaborative PR presence client (WebSocket).
1808 *
1809 * Connects to `GET /:owner/:repo/pulls/:number/presence` (WebSocket upgrade).
1810 * On connect the server sends `{type:"init", users:[...]}` so the bar renders
1811 * immediately. Subsequent messages from the server drive the presence bar and
1812 * per-line cursor pills in the diff.
1813 *
1814 * Outbound messages:
1815 * {type:"cursor", line: N} — user hovered a diff line
1816 * {type:"typing", line: N, typing: bool} — textarea focus/blur in diff
1817 * {type:"ping"} — keep-alive every 10s
1818 *
1819 * Inbound messages:
1820 * {type:"init", users:[{sessionId,username,colour,line,typing}]}
1821 * {type:"join", user:{sessionId,username,colour,line,typing}}
1822 * {type:"leave", sessionId}
1823 * {type:"cursor", sessionId, username, colour, line}
1824 * {type:"typing", sessionId, username, colour, line, typing}
1825 */
1826function PR_PRESENCE_SCRIPT(owner: string, repo: string, prNum: number): string {
1827 const wsPath = JSON.stringify(`/${owner}/${repo}/pulls/${prNum}/presence`)
1828 .split("<").join("\\u003C")
1829 .split(">").join("\\u003E")
1830 .split("&").join("\\u0026");
1831 return `(function(){
1832try{
1833var wsPath=${wsPath};
1834var proto=location.protocol==='https:'?'wss:':'ws:';
1835var url=proto+'//'+location.host+wsPath;
1836var mySessionId=null;
1837// sessionId -> {username, colour, line, typing}
1838var peers={};
1839var ws=null;
1840var pingTimer=null;
1841var reconnectDelay=1500;
1842var reconnectTimer=null;
1843
1844function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
1845
1846// ── Toast ──────────────────────────────────────────────────────────────
1847var toastWrap=document.getElementById('presence-toasts');
1848function toast(msg){
1849 if(!toastWrap)return;
1850 var t=document.createElement('div');
1851 t.className='presence-toast';
1852 t.textContent=msg;
1853 toastWrap.appendChild(t);
1854 setTimeout(function(){t.classList.add('fading');setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},420);},2500);
1855}
1856
1857// ── Presence bar ───────────────────────────────────────────────────────
1858var avEl=document.getElementById('presence-avatars');
1859var countEl=document.getElementById('presence-count');
1860function renderBar(){
1861 if(!avEl)return;
1862 var ids=Object.keys(peers);
1863 var html='';
1864 for(var i=0;i<ids.length&&i<8;i++){
1865 var p=peers[ids[i]];
1866 var initials=(p.username||'?').slice(0,2).toUpperCase();
1867 html+='<span class="presence-avatar" style="background:'+esc(p.colour)+'" title="'+esc(p.username)+'">';
1868 html+='<span class="presence-avatar-dot">'+esc(initials)+'</span>';
1869 html+=esc(p.username);
1870 html+='</span>';
1871 }
1872 avEl.innerHTML=html;
1873 if(countEl){
1874 var n=ids.length;
1875 countEl.textContent=n===0?'No other reviewers':n===1?'1 reviewer online':n+' reviewers online';
1876 }
1877}
1878
1879// ── Diff cursor pills ──────────────────────────────────────────────────
1880// data-line value is like "12:x:5" or "12:5:x" — pull numeric line only
1881function lineNumFromKey(key){var m=String(key).match(/(\d+)/);return m?parseInt(m[1],10):null;}
1882function findDiffRow(line){return document.querySelector('[data-line]') &&
1883 (function(){var rows=document.querySelectorAll('[data-line]');
1884 for(var i=0;i<rows.length;i++){var n=lineNumFromKey(rows[i].getAttribute('data-line')||'');if(n===line)return rows[i];}
1885 return null;
1886 })();}
1887function removePill(sessionId){var old=document.querySelector('[data-presence-sid="'+sessionId+'"]');if(old&&old.parentNode)old.parentNode.removeChild(old);}
1888function placePill(sessionId,username,colour,line,typing){
1889 removePill(sessionId);
1890 if(line==null)return;
1891 var row=findDiffRow(line);if(!row)return;
1892 var pill=document.createElement('span');
1893 pill.className='presence-line-pill'+(typing?' is-typing':'');
1894 pill.setAttribute('data-presence-sid',sessionId);
e589f77ccantynz-alt1895 pill.style.background=colour||'var(--accent)';
25b1ff7Claude1896 pill.textContent=(username||'?').slice(0,12)+(typing?' typing':'');
1897 row.appendChild(pill);
1898}
1899function clearPeer(sessionId){removePill(sessionId);delete peers[sessionId];}
1900
1901// ── Inbound message handler ────────────────────────────────────────────
1902function onMsg(raw){
1903 var d;try{d=JSON.parse(raw);}catch(e){return;}
1904 if(!d||!d.type)return;
1905 if(d.type==='init'){
1906 mySessionId=d.sessionId||null;
1907 peers={};
1908 (d.users||[]).forEach(function(u){
1909 if(u.sessionId===mySessionId)return;
1910 peers[u.sessionId]={username:u.username,colour:u.colour,line:u.line,typing:u.typing};
1911 placePill(u.sessionId,u.username,u.colour,u.line,u.typing);
1912 });
1913 renderBar();
1914 } else if(d.type==='join'){
1915 if(d.user&&d.user.sessionId!==mySessionId){
1916 peers[d.user.sessionId]={username:d.user.username,colour:d.user.colour,line:d.user.line,typing:d.user.typing};
1917 renderBar();
1918 toast(esc(d.user.username)+' joined the review');
1919 }
1920 } else if(d.type==='leave'){
1921 if(d.sessionId&&d.sessionId!==mySessionId){
1922 var name=peers[d.sessionId]&&peers[d.sessionId].username;
1923 clearPeer(d.sessionId);
1924 renderBar();
1925 if(name)toast(esc(name)+' left the review');
1926 }
1927 } else if(d.type==='cursor'){
1928 if(d.sessionId&&d.sessionId!==mySessionId){
1929 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=false;}
1930 placePill(d.sessionId,d.username,d.colour,d.line,false);
1931 }
1932 } else if(d.type==='typing'){
1933 if(d.sessionId&&d.sessionId!==mySessionId){
1934 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=d.typing;}
1935 placePill(d.sessionId,d.username,d.colour,d.line,d.typing);
1936 }
1937 }
1938}
1939
1940// ── Outbound helpers ───────────────────────────────────────────────────
1941function send(obj){try{if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}catch(e){}}
1942
1943// ── Mouse hover on diff rows ───────────────────────────────────────────
1944var hoverTimer=null;
1945document.addEventListener('mouseover',function(ev){
1946 var row=ev.target&&ev.target.closest&&ev.target.closest('[data-line]');
1947 if(!row)return;
1948 if(hoverTimer)clearTimeout(hoverTimer);
1949 hoverTimer=setTimeout(function(){
1950 var key=row.getAttribute('data-line')||'';
1951 var line=lineNumFromKey(key);
1952 if(line!=null)send({type:'cursor',line:line});
1953 },80);
1954});
1955
1956// ── Typing detection in diff comment textareas ─────────────────────────
1957document.addEventListener('focusin',function(ev){
1958 var ta=ev.target;
1959 if(!ta||ta.tagName!=='TEXTAREA')return;
1960 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1961 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1962 if(line!=null)send({type:'typing',line:line,typing:true});
1963});
1964document.addEventListener('focusout',function(ev){
1965 var ta=ev.target;
1966 if(!ta||ta.tagName!=='TEXTAREA')return;
1967 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1968 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1969 if(line!=null)send({type:'typing',line:line,typing:false});
1970});
1971
1972// ── WebSocket lifecycle ────────────────────────────────────────────────
1973function connect(){
1974 if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=null;}
1975 try{ws=new WebSocket(url);}catch(e){scheduleReconnect();return;}
1976 ws.onopen=function(){
1977 reconnectDelay=1500;
1978 pingTimer=setInterval(function(){send({type:'ping'});},10000);
1979 };
1980 ws.onmessage=function(ev){onMsg(ev.data);};
1981 ws.onclose=function(){
1982 if(pingTimer){clearInterval(pingTimer);pingTimer=null;}
1983 scheduleReconnect();
1984 };
1985 ws.onerror=function(){try{ws.close();}catch(e){}};
1986}
1987function scheduleReconnect(){
1988 reconnectTimer=setTimeout(function(){connect();},reconnectDelay);
1989 reconnectDelay=Math.min(reconnectDelay*2,30000);
1990}
1991
1992connect();
1993}catch(e){}})();`;
1994}
1995
3c03977Claude1996function LIVE_COEDIT_SCRIPT(prId: string): string {
1997 const idJson = JSON.stringify(prId)
1998 .split("<").join("\\u003C")
1999 .split(">").join("\\u003E")
2000 .split("&").join("\\u0026");
2001 return (
2002 "(function(){try{" +
2003 "if(typeof EventSource==='undefined')return;" +
2004 "var prId=" + idJson + ";" +
2005 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
2006 "var pill=document.getElementById('live-pill');" +
2007 "var avEl=document.getElementById('live-avatars');" +
2008 "var countEl=document.getElementById('live-count');" +
2009 "var sessionId=null;var myColor=null;" +
2010 "var presence={};" + // sessionId -> {color,status,userId,initials}
2011 "var lastApplied={};" + // field -> last server value (for echo suppression)
2012 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
2013 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
2014 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
2015 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
2016 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
2017 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
2018 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
2019 "avEl.innerHTML=html;}}" +
2020 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
2021 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
2022 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
2023 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
2024 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
2025 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
2026 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
2027 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
2028 "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);}" +
2029 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
2030 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
2031 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
2032 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
2033 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
2034 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
2035 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
2036 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
2037 "}catch(e){}" +
2038 "c.style.transform='translate('+x+'px,'+y+'px)';" +
2039 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
2040 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
2041 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
2042 "var es;var delay=1000;" +
2043 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
2044 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
2045 "(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){}});" +
2046 "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){}});" +
2047 "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){}});" +
2048 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
2049 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
2050 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
2051 "var patch=d.patch;if(!patch||!patch.field)return;" +
2052 "var ta=fieldEl(patch.field);if(!ta)return;" +
2053 "if(document.activeElement===ta)return;" + // don't trample local typing
2054 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
2055 "}catch(e){}});" +
2056 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
2057 "}connect();" +
2058 "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){}}" +
2059 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
2060 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
2061 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
2062 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
2063 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
2064 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
2065 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2066 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2067 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2068 "}" +
2069 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
2070 "var live=document.querySelectorAll('[data-live-field]');" +
2071 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
2072 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
2073 "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){}});" +
2074 "}catch(e){}})();"
2075 );
2076}
2077
0074234Claude2078async function resolveRepo(ownerName: string, repoName: string) {
2079 const [owner] = await db
2080 .select()
2081 .from(users)
2082 .where(eq(users.username, ownerName))
2083 .limit(1);
2084 if (!owner) return null;
2085 const [repo] = await db
2086 .select()
2087 .from(repositories)
2088 .where(
2089 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
2090 )
2091 .limit(1);
2092 if (!repo) return null;
2093 return { owner, repo };
2094}
2095
2096// PR Nav helper
621081eccantynz-alt2097// Thin wrapper over the shared RepoNav so the PR pages render the ONE
2098// canonical repo nav instead of a stripped 4-tab bar. Owner-directed nav
2099// unification this session.
0074234Claude2100const PrNav = ({
2101 owner,
2102 repo,
2103 active,
2104}: {
2105 owner: string;
2106 repo: string;
2107 active: "code" | "issues" | "pulls" | "commits";
621081eccantynz-alt2108}) => <RepoNav owner={owner} repo={repo} active={active} />;
0074234Claude2109
534f04aClaude2110/**
2111 * Block M3 — pre-merge risk score card. Pure presentational helper.
2112 * Rendered in the conversation tab above the gate checks block. Hidden
2113 * entirely when the PR is closed/merged or there is nothing cached and
2114 * nothing in-flight.
2115 */
2116function PrRiskCard({
2117 risk,
2118 calculating,
2119}: {
2120 risk: PrRiskScore | null;
2121 calculating: boolean;
2122}) {
2123 if (!risk) {
2124 return (
2125 <div
2126 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
2127 >
2128 <strong style="font-size: 13px; color: var(--text)">
2129 Risk score: calculating…
2130 </strong>
2131 <div style="font-size: 12px; margin-top: 4px">
2132 Refresh in a moment to see the pre-merge risk score for this PR.
2133 </div>
2134 </div>
2135 );
2136 }
2137
2138 const palette = riskBandPalette(risk.band);
2139 const label = riskBandLabel(risk.band);
2140
2141 return (
2142 <div
2143 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
2144 >
2145 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
2146 <strong>Risk score:</strong>
2147 <span style={`color:${palette.border};font-weight:600`}>
2148 {palette.icon} {label} ({risk.score}/10)
2149 </span>
2150 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
2151 {risk.commitSha.slice(0, 7)}
2152 </span>
2153 </div>
2154 {risk.aiSummary && (
2155 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
2156 {risk.aiSummary}
2157 </div>
2158 )}
2159 <details style="margin-top:10px">
2160 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
2161 See full signal breakdown
2162 </summary>
2163 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
2164 <li>files changed: {risk.signals.filesChanged}</li>
2165 <li>
2166 lines added/removed: {risk.signals.linesAdded} /{" "}
2167 {risk.signals.linesRemoved}
2168 </li>
2169 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
2170 <li>
2171 schema migration touched:{" "}
2172 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
2173 </li>
2174 <li>
2175 locked / sensitive path touched:{" "}
2176 {risk.signals.lockedPathTouched ? "yes" : "no"}
2177 </li>
2178 <li>
2179 adds new dependency:{" "}
2180 {risk.signals.addsNewDependency ? "yes" : "no"}
2181 </li>
2182 <li>
2183 bumps major dependency:{" "}
2184 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
2185 </li>
2186 <li>
2187 tests added for new code:{" "}
2188 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
2189 </li>
2190 <li>
2191 diff-minus-test ratio:{" "}
2192 {risk.signals.diffMinusTestRatio.toFixed(2)}
2193 </li>
2194 </ul>
2195 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2196 How is this calculated? The score is a transparent sum of
2197 weighted signals — see <code>src/lib/pr-risk.ts</code>
2198 {" "}<code>computePrRiskScore</code>.
2199 </div>
2200 </details>
2201 {calculating && (
2202 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2203 (recomputing for the latest commit — refresh to update)
2204 </div>
2205 )}
2206 </div>
2207 );
2208}
2209
2210function riskBandPalette(band: PrRiskScore["band"]): {
2211 border: string;
2212 icon: string;
2213} {
2214 switch (band) {
2215 case "low":
2216 return { border: "var(--green)", icon: "" };
2217 case "medium":
2218 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
2219 case "high":
2220 return { border: "var(--orange, #db6d28)", icon: "⚠" };
2221 case "critical":
2222 return { border: "var(--red)", icon: "\u{1F6D1}" };
2223 }
2224}
2225
2226function riskBandLabel(band: PrRiskScore["band"]): string {
2227 switch (band) {
2228 case "low":
2229 return "LOW";
2230 case "medium":
2231 return "MEDIUM";
2232 case "high":
2233 return "HIGH";
2234 case "critical":
2235 return "CRITICAL";
2236 }
2237}
2238
09d5f39Claude2239// ---------------------------------------------------------------------------
2240// Merge Impact Analysis — collapsible panel showing affected files and
2241// downstream repos. Shown on open PRs for users with write access.
2242// ---------------------------------------------------------------------------
2243
2244function ImpactPanel({ analysis, owner }: { analysis: ImpactAnalysis; owner: string }) {
2245 const score = analysis.riskScore;
2246 const band = score <= 30 ? "low" : score <= 60 ? "medium" : "high";
2247 const hasAny =
2248 analysis.affectedTestFiles.length > 0 ||
2249 analysis.affectedFiles.length > 0 ||
2250 analysis.downstreamRepos.length > 0;
2251
2252 return (
2253 <div class="impact-panel">
2254 <div
2255 class="impact-header"
2256 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)"
2257 >
2258 <span class={`impact-score score-${band}`}>{score}</span>
2259 <strong>Merge Impact</strong>
2260 <span class="impact-summary">{analysis.riskSummary}</span>
2261 <button class="impact-toggle" type="button" aria-label="Toggle impact panel">
2262
2263 </button>
2264 </div>
2265 <div class="impact-body" hidden>
2266 <div class="impact-section">
2267 <h4>Affected test files ({analysis.affectedTestFiles.length})</h4>
2268 {analysis.affectedTestFiles.length === 0 ? (
2269 <span class="impact-empty">No test files reference the changed files.</span>
2270 ) : (
2271 <ul class="impact-file-list">
2272 {analysis.affectedTestFiles.map((f) => (
2273 <li title={f}>{f}</li>
2274 ))}
2275 </ul>
2276 )}
2277 </div>
2278 <div class="impact-section">
2279 <h4>Affected source files ({analysis.affectedFiles.length})</h4>
2280 {analysis.affectedFiles.length === 0 ? (
2281 <span class="impact-empty">No other source files import the changed files.</span>
2282 ) : (
2283 <ul class="impact-file-list">
2284 {analysis.affectedFiles.map((f) => (
2285 <li title={f}>{f}</li>
2286 ))}
2287 </ul>
2288 )}
2289 </div>
2290 {analysis.downstreamRepos.length > 0 && (
2291 <div class="impact-section impact-downstream">
2292 <h4>Downstream repos ({analysis.downstreamRepos.length})</h4>
2293 <ul class="impact-file-list">
2294 {analysis.downstreamRepos.map((r) => (
2295 <li>
2296 <a
2297 href={`/${r.owner}/${r.repo}`}
2298 style="color:var(--text-link);text-decoration:none"
2299 >
2300 {r.owner}/{r.repo}
2301 </a>
2302 {" "}
2303 <span style="color:var(--text-muted);font-size:11px">
2304 via {r.matchedDependency}
2305 </span>
2306 </li>
2307 ))}
2308 </ul>
2309 </div>
2310 )}
2311 </div>
2312 </div>
2313 );
2314}
2315
422a2d4Claude2316// ---------------------------------------------------------------------------
2317// AI Trio Review — 3-column card grid + disagreement callout.
2318//
2319// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
2320// per run: one per persona (security/correctness/style) plus a top-level
2321// summary. We surface them here as a single grid above the normal
2322// comment stream so reviewers see the verdicts at a glance.
2323// ---------------------------------------------------------------------------
2324
2325const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
2326
2327interface TrioCommentLike {
2328 body: string;
2329}
2330
2331function isTrioComment(body: string | null | undefined): boolean {
2332 if (!body) return false;
2333 return (
2334 body.includes(TRIO_SUMMARY_MARKER) ||
2335 body.includes(TRIO_COMMENT_MARKER.security) ||
2336 body.includes(TRIO_COMMENT_MARKER.correctness) ||
2337 body.includes(TRIO_COMMENT_MARKER.style)
2338 );
2339}
2340
2341function trioPersonaOfComment(body: string): TrioPersona | null {
2342 for (const p of TRIO_PERSONAS) {
2343 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
2344 }
2345 return null;
2346}
2347
2348/**
2349 * Best-effort verdict parse from a persona comment body. The body shape
2350 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
2351 * we only need the "Pass" / "Fail" word from the H2 heading.
2352 */
2353function trioVerdictOfBody(body: string): "pass" | "fail" | null {
2354 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
2355 if (!m) return null;
2356 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
2357}
2358
2359/**
2360 * Parse the disagreement bullet list out of the summary comment so we
2361 * can render it as a polished callout strip. Returns [] when nothing
2362 * matches — the comment author may have edited the marker out.
2363 */
2364function parseDisagreements(summaryBody: string): Array<{
2365 file: string;
2366 failing: string;
2367 passing: string;
2368}> {
2369 const out: Array<{ file: string; failing: string; passing: string }> = [];
2370 // Each disagreement line looks like:
2371 // - `path:42` — security, style say ✗, correctness say ✓
2372 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
2373 let m: RegExpExecArray | null;
2374 while ((m = re.exec(summaryBody)) !== null) {
2375 out.push({
2376 file: m[1].trim(),
2377 failing: m[2].trim().replace(/[,\s]+$/g, ""),
2378 passing: m[3].trim().replace(/[,\s]+$/g, ""),
2379 });
2380 }
2381 return out;
2382}
2383
2384function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
2385 // Find the most recent persona comments + summary. We iterate from
2386 // the end so re-reviews (multiple runs on the same PR) display the
2387 // freshest verdict.
2388 const latest: Partial<Record<TrioPersona, string>> = {};
2389 let summaryBody: string | null = null;
2390 for (let i = comments.length - 1; i >= 0; i--) {
2391 const body = comments[i].body || "";
2392 if (!isTrioComment(body)) continue;
2393 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
2394 summaryBody = body;
2395 continue;
2396 }
2397 const persona = trioPersonaOfComment(body);
2398 if (persona && !latest[persona]) latest[persona] = body;
2399 }
2400 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2401 if (!anyPersona && !summaryBody) return null;
2402
2403 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
2404
2405 return (
67dc4e1Claude2406 <div class="trio-wrap" id="trio-review-section">
422a2d4Claude2407 <div class="trio-header">
2408 <span class="trio-header-dot" aria-hidden="true"></span>
2409 <strong>AI Trio Review</strong>
2410 <span class="trio-header-sub">
2411 Three independent reviewers ran in parallel.
2412 </span>
2413 </div>
2414 <div class="trio-grid">
2415 {TRIO_PERSONAS.map((persona) => {
2416 const body = latest[persona];
2417 const verdict = body ? trioVerdictOfBody(body) : null;
2418 const stateClass =
2419 verdict === "fail"
2420 ? "is-fail"
2421 : verdict === "pass"
2422 ? "is-pass"
2423 : "is-pending";
2424 return (
2425 <div class={`trio-card trio-${persona} ${stateClass}`}>
2426 <div class="trio-card-head">
2427 <span class="trio-card-icon" aria-hidden="true">
2428 {persona === "security"
2429 ? "🛡"
2430 : persona === "correctness"
2431 ? "✓"
2432 : "✎"}
2433 </span>
2434 <strong class="trio-card-title">
2435 {persona[0].toUpperCase() + persona.slice(1)}
2436 </strong>
2437 <span class="trio-card-verdict">
2438 {verdict === "pass"
2439 ? "Pass"
2440 : verdict === "fail"
2441 ? "Fail"
2442 : "Pending"}
2443 </span>
2444 </div>
2445 <div class="trio-card-body">
2446 {body ? (
2447 <MarkdownContent
2448 html={renderMarkdown(stripTrioHeading(body))}
2449 />
2450 ) : (
2451 <span class="trio-card-empty">
2452 Awaiting reviewer output.
2453 </span>
2454 )}
2455 </div>
2456 </div>
2457 );
2458 })}
2459 </div>
2460 {disagreements.length > 0 && (
2461 <div class="trio-disagreement-strip" role="note">
2462 <span class="trio-disagreement-icon" aria-hidden="true">
2463
2464 </span>
2465 <div class="trio-disagreement-body">
2466 <strong>Reviewers disagree — review carefully.</strong>
2467 <ul class="trio-disagreement-list">
2468 {disagreements.map((d) => (
2469 <li>
2470 <code>{d.file}</code> — {d.failing} says ✗,{" "}
2471 {d.passing} says ✓
2472 </li>
2473 ))}
2474 </ul>
2475 </div>
2476 </div>
2477 )}
2478 </div>
2479 );
2480}
2481
2482/**
2483 * Strip the marker comment + first H2 heading from a persona body so
2484 * the card body shows just the findings list (verdict is already in
2485 * the card head). Best-effort — malformed bodies render whole.
2486 */
2487function stripTrioHeading(body: string): string {
2488 return body
2489 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
2490 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
2491 .trim();
2492}
2493
67dc4e1Claude2494/**
2495 * Three small verdict pills rendered inline in the PR header. Each pill
2496 * links to the `#trio-review-section` anchor so clicking scrolls to the
2497 * full card grid. Only shown when `AI_TRIO_REVIEW_ENABLED=1` and at
2498 * least one persona comment exists.
2499 */
2500function TrioVerdictPills({
2501 comments,
2502}: {
2503 comments: TrioCommentLike[];
2504}) {
2505 if (!isTrioReviewEnabled()) return null;
2506
2507 // Find latest persona verdicts (same logic as TrioReviewGrid).
2508 const latest: Partial<Record<TrioPersona, string>> = {};
2509 for (let i = comments.length - 1; i >= 0; i--) {
2510 const body = comments[i].body || "";
2511 if (!isTrioComment(body)) continue;
2512 if (body.includes(TRIO_SUMMARY_MARKER)) continue;
2513 const persona = trioPersonaOfComment(body);
2514 if (persona && !latest[persona]) latest[persona] = body;
2515 }
2516
2517 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2518 if (!anyPersona) return null;
2519
2520 const PERSONA_LABEL: Record<TrioPersona, string> = {
2521 security: "Security",
2522 correctness: "Correctness",
2523 style: "Style",
2524 };
2525 const PERSONA_ICON: Record<TrioPersona, string> = {
2526 security: "🛡",
2527 correctness: "✓",
2528 style: "✎",
2529 };
2530
2531 return (
2532 <span class="trio-pills-wrap" aria-label="AI Trio Review verdicts">
2533 {TRIO_PERSONAS.map((persona) => {
2534 const body = latest[persona];
2535 const verdict = body ? trioVerdictOfBody(body) : null;
2536 const stateClass =
2537 verdict === "pass"
2538 ? "is-pass"
2539 : verdict === "fail"
2540 ? "is-fail"
2541 : "is-pending";
2542 const glyph =
2543 verdict === "pass" ? "✓" : verdict === "fail" ? "✗" : "⟳";
2544 return (
2545 <a
2546 href="#trio-review-section"
2547 class={`trio-pill ${stateClass}`}
2548 title={`AI ${PERSONA_LABEL[persona]} Review — ${verdict === "pass" ? "Pass" : verdict === "fail" ? "Fail" : "Pending"}`}
2549 >
2550 <span aria-hidden="true">{PERSONA_ICON[persona]}</span>
2551 {PERSONA_LABEL[persona]} {glyph}
2552 </a>
2553 );
2554 })}
2555 </span>
2556 );
2557}
2558
2c61840Claude2559// Detect AI-generated PRs from body markers and branch naming conventions.
2560// No DB column required — pattern-matched at render time.
2561function isAiGeneratedPr(body: string | null, headBranch: string): boolean {
2562 const AI_BRANCH_PREFIXES = ["claude/", "copilot/", "ai/", "bot/", "gluecron-ai/", "devin/"];
2563 if (AI_BRANCH_PREFIXES.some((p) => headBranch.startsWith(p))) return true;
2564 if (!body) return false;
2565 const AI_BODY_MARKERS = [
2566 "🤖 Generated with",
2567 "Co-Authored-By: Claude",
2568 "Claude-Session:",
2569 "gluecron:ai-generated",
2570 "AI-generated",
2571 "generated by claude",
2572 "generated with claude code",
2573 "copilot workspace",
2574 ];
2575 const lower = body.toLowerCase();
2576 return AI_BODY_MARKERS.some((m) => lower.includes(m.toLowerCase()));
2577}
2578
0074234Claude2579// List PRs
04f6b7fClaude2580pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2581 const { owner: ownerName, repo: repoName } = c.req.param();
2582 const user = c.get("user");
2583 const state = c.req.query("state") || "open";
d790b49Claude2584 const searchQ = c.req.query("q")?.trim() || "";
80bd7c8Claude2585 const authorFilter = c.req.query("author")?.trim() || "";
f5b9ef5Claude2586 const sortPr = (c.req.query("sort") || "newest").trim();
0074234Claude2587
ea9ed4cClaude2588 // ── Loading skeleton (flag-gated) ──
2589 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
2590 // the user see the page structure before counts + select resolve.
2591 // Behind a flag for now — we don't ship flashes.
2592 if (c.req.query("skeleton") === "1") {
2593 return c.html(
2594 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2595 <RepoHeader owner={ownerName} repo={repoName} />
2596 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2597 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2598 <style
2599 dangerouslySetInnerHTML={{
2600 __html: `
404b398Claude2601 .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; }
2602 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude2603 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
2604 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
2605 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
2606 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
2607 .prs-skel-row { height: 76px; border-radius: 12px; }
2608 `,
2609 }}
2610 />
2611 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
2612 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
2613 <div class="prs-skel-list" aria-hidden="true">
2614 {Array.from({ length: 6 }).map(() => (
2615 <div class="prs-skel prs-skel-row" />
2616 ))}
2617 </div>
2618 <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">
2619 Loading pull requests for {ownerName}/{repoName}…
2620 </span>
2621 </Layout>
2622 );
2623 }
2624
0074234Claude2625 const resolved = await resolveRepo(ownerName, repoName);
2626 if (!resolved) return c.notFound();
2627
6fc53bdClaude2628 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
2629 const stateFilter =
2630 state === "draft"
2631 ? and(
2632 eq(pullRequests.state, "open"),
2633 eq(pullRequests.isDraft, true)
2634 )
2635 : eq(pullRequests.state, state);
2636
0074234Claude2637 const prList = await db
2638 .select({
2639 pr: pullRequests,
2640 author: { username: users.username },
2641 })
2642 .from(pullRequests)
2643 .innerJoin(users, eq(pullRequests.authorId, users.id))
2644 .where(
d790b49Claude2645 and(
2646 eq(pullRequests.repositoryId, resolved.repo.id),
2647 stateFilter,
2648 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
80bd7c8Claude2649 authorFilter ? eq(users.username, authorFilter) : undefined,
d790b49Claude2650 )
0074234Claude2651 )
f5b9ef5Claude2652 .orderBy(
2653 sortPr === "oldest" ? asc(pullRequests.createdAt)
2654 : sortPr === "updated" ? desc(pullRequests.updatedAt)
2655 : desc(pullRequests.createdAt) // newest (default)
2656 );
0074234Claude2657
0369e77Claude2658 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude2659 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude2660 const commentCountMap = new Map<string, number>();
1aef949Claude2661 if (prList.length > 0) {
2662 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude2663 const [reviewRows, commentRows] = await Promise.all([
2664 db
2665 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
2666 .from(prReviews)
2667 .where(inArray(prReviews.pullRequestId, prIds)),
2668 db
2669 .select({
2670 prId: prComments.pullRequestId,
2671 cnt: sql<number>`count(*)::int`,
2672 })
2673 .from(prComments)
2674 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
2675 .groupBy(prComments.pullRequestId),
2676 ]);
1aef949Claude2677 for (const r of reviewRows) {
2678 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
2679 if (r.state === "approved") entry.approved = true;
2680 if (r.state === "changes_requested") entry.changesRequested = true;
2681 reviewMap.set(r.prId, entry);
2682 }
0369e77Claude2683 for (const r of commentRows) {
2684 commentCountMap.set(r.prId, Number(r.cnt));
2685 }
1aef949Claude2686 }
2687
0074234Claude2688 const [counts] = await db
2689 .select({
2690 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude2691 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude2692 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
2693 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
2694 })
2695 .from(pullRequests)
2696 .where(eq(pullRequests.repositoryId, resolved.repo.id));
2697
8102dd4ccantynz-alt2698 // `sql<number>` is a compile-time cast only: Postgres count() returns
2699 // bigint, which the driver hands back as a STRING. Without Number() the
2700 // "All" pill renders "0"+"0"+"0" = "000", and `openCount === 0` is never
2701 // true so the empty state never shows. Coerce at the boundary.
2702 const openCount = Number(counts?.open ?? 0);
2703 const mergedCount = Number(counts?.merged ?? 0);
2704 const closedCount = Number(counts?.closed ?? 0);
2705 const draftCount = Number(counts?.draft ?? 0);
b078860Claude2706 const allCount = openCount + mergedCount + closedCount;
2707
2708 // "All" is presentational only — the DB query for state='all' matches
2709 // nothing, so we render a friendlier empty state when picked. We do NOT
2710 // change the query logic to keep this commit purely visual.
80bd7c8Claude2711 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude2712 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude2713 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
2714 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
2715 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
2716 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
2717 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude2718 ];
2719 const isAllState = state === "all";
cb5a796Claude2720 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
2721 const prListPendingCount = viewerIsOwnerOnPrList
2722 ? await countPendingForRepo(resolved.repo.id)
2723 : 0;
b078860Claude2724
0074234Claude2725 return c.html(
2726 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2727 <RepoHeader owner={ownerName} repo={repoName} />
2728 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2729 <PendingCommentsBanner
2730 owner={ownerName}
2731 repo={repoName}
2732 count={prListPendingCount}
2733 />
b078860Claude2734 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
f6730d0ccantynz-alt2735 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
2736
2737 <PageHeader
2738 eyebrow="Pull requests"
2739 title="Review, merge with AI."
2740 lede={
2741 openCount === 0 && allCount === 0
2742 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2743 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`
2744 }
2745 actions={
2746 <>
2747 <GxButton href={`/${ownerName}/${repoName}/pulls/insights`} variant="secondary">
7a28902Claude2748 Insights
f6730d0ccantynz-alt2749 </GxButton>
7a28902Claude2750 {user && (
f6730d0ccantynz-alt2751 <GxButton href={`/${ownerName}/${repoName}/pulls/new`} variant="primary">
b078860Claude2752 + New pull request
f6730d0ccantynz-alt2753 </GxButton>
7a28902Claude2754 )}
f6730d0ccantynz-alt2755 </>
2756 }
2757 />
b078860Claude2758
2759 <nav class="prs-tabs" aria-label="Pull request filters">
2760 {tabPills.map((t) => {
2761 const isActive =
2762 state === t.key ||
2763 (t.key === "open" &&
2764 state !== "merged" &&
2765 state !== "closed" &&
2766 state !== "all" &&
2767 state !== "draft");
2768 return (
2769 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2770 <span>{t.label}</span>
2771 <span class="prs-tab-count">{t.count}</span>
2772 </a>
2773 );
2774 })}
2775 </nav>
2776
d790b49Claude2777 <form
2778 method="get"
2779 action={`/${ownerName}/${repoName}/pulls`}
2780 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2781 >
2782 <input type="hidden" name="state" value={state} />
2783 <input
2784 type="search"
2785 name="q"
2786 value={searchQ}
2787 placeholder="Search pull requests…"
2788 class="issues-search-input"
2789 style="flex:1;max-width:380px"
2790 />
80bd7c8Claude2791 <input
2792 type="text"
2793 name="author"
2794 value={authorFilter}
2795 placeholder="Filter by author…"
2796 class="issues-search-input"
2797 style="max-width:200px"
2798 />
d790b49Claude2799 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2800 {(searchQ || authorFilter) && (
d790b49Claude2801 <a
2802 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2803 class="issues-filter-clear"
2804 >
2805 Clear
2806 </a>
2807 )}
2808 </form>
f5b9ef5Claude2809
2810 <div class="prs-sort-row">
2811 <span class="prs-sort-label">Sort:</span>
2812 {(["newest", "oldest", "updated"] as const).map((s) => (
2813 <a
2814 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2815 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2816 >
2817 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2818 </a>
2819 ))}
2820 </div>
2821
0074234Claude2822 {prList.length === 0 ? (
b078860Claude2823 <div class="prs-empty">
ea9ed4cClaude2824 <div class="prs-empty-inner">
2825 <strong>
80bd7c8Claude2826 {searchQ || authorFilter
2827 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2828 : isAllState
2829 ? "Pick a filter above to browse PRs."
2830 : `No ${state} pull requests.`}
ea9ed4cClaude2831 </strong>
2832 <p class="prs-empty-sub">
80bd7c8Claude2833 {searchQ || authorFilter
2834 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2835 : state === "open"
2836 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2837 : isAllState
2838 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2839 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2840 </p>
2841 <div class="prs-empty-cta">
80bd7c8Claude2842 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2843 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2844 + New pull request
2845 </a>
2846 )}
80bd7c8Claude2847 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2848 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2849 View open PRs
2850 </a>
2851 )}
2852 <a href={`/${ownerName}/${repoName}`} class="btn">
2853 Back to code
2854 </a>
2855 </div>
2856 </div>
b078860Claude2857 </div>
0074234Claude2858 ) : (
b078860Claude2859 <div class="prs-list">
2860 {prList.map(({ pr, author }) => {
2861 const stateClass =
2862 pr.state === "open"
2863 ? pr.isDraft
2864 ? "state-draft"
2865 : "state-open"
2866 : pr.state === "merged"
2867 ? "state-merged"
2868 : "state-closed";
2869 const icon =
2870 pr.state === "open"
2871 ? pr.isDraft
2872 ? "◌"
2873 : "○"
2874 : pr.state === "merged"
2875 ? "⮌"
2876 : "✓";
1aef949Claude2877 const rv = reviewMap.get(pr.id);
b078860Claude2878 return (
2879 <a
2880 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2881 class="prs-row"
2882 style="text-decoration:none;color:inherit"
0074234Claude2883 >
b078860Claude2884 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2885 {icon}
0074234Claude2886 </div>
b078860Claude2887 <div class="prs-row-body">
2888 <h3 class="prs-row-title">
2889 <span>{pr.title}</span>
2890 <span class="prs-row-number">#{pr.number}</span>
2891 </h3>
2892 <div class="prs-row-meta">
2893 <span
2894 class="prs-branch-chips"
2895 title={`${pr.headBranch} into ${pr.baseBranch}`}
2896 >
2897 <span class="prs-branch-chip">{pr.headBranch}</span>
2898 <span class="prs-branch-arrow">{"→"}</span>
2899 <span class="prs-branch-chip">{pr.baseBranch}</span>
2900 </span>
2901 <span>
2902 by{" "}
2903 <strong style="color:var(--text)">
2904 {author.username}
2905 </strong>{" "}
2906 {formatRelative(pr.createdAt)}
2907 </span>
2908 <span class="prs-row-tags">
2909 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2c61840Claude2910 {isAiGeneratedPr(pr.body, pr.headBranch) && (
2911 <span class="prs-tag is-ai" title="Opened by an AI agent">⚡ AI</span>
2912 )}
b078860Claude2913 {pr.state === "merged" && (
2914 <span class="prs-tag is-merged">Merged</span>
2915 )}
1aef949Claude2916 {rv?.approved && !rv.changesRequested && (
2917 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
2918 )}
2919 {rv?.changesRequested && (
2920 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
2921 )}
0369e77Claude2922 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
2923 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
2924 💬 {commentCountMap.get(pr.id)}
2925 </span>
2926 )}
b078860Claude2927 </span>
2928 </div>
0074234Claude2929 </div>
b078860Claude2930 </a>
2931 );
2932 })}
2933 </div>
0074234Claude2934 )}
2935 </Layout>
2936 );
2937});
2938
7a28902Claude2939/* ─────────────────────────────────────────────────────────────────────────
2940 * PR Insights — 90-day analytics for the pull request activity of a repo.
2941 * Route: GET /:owner/:repo/pulls/insights
2942 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
2943 * "insights" is not swallowed by the :number param.
2944 * ───────────────────────────────────────────────────────────────────────── */
2945
2946/** Format a millisecond duration as human-readable string. */
2947function formatMsDuration(ms: number): string {
2948 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
2949 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
2950 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
2951 return `${Math.round(ms / 86_400_000)}d`;
2952}
2953
2954/** Format an ISO week string as "Jan 15". */
2955function formatWeekLabel(isoWeek: string): string {
2956 try {
2957 const d = new Date(isoWeek);
2958 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2959 } catch {
2960 return isoWeek.slice(5, 10);
2961 }
2962}
2963
2964const PR_INSIGHTS_STYLES = `
2965 .pri-page { padding-bottom: 48px; }
2966 .pri-hero {
2967 position: relative;
2968 margin: 0 0 var(--space-5);
2969 padding: 22px 26px 24px;
2970 background: var(--bg-elevated);
2971 border: 1px solid var(--border);
2972 border-radius: 16px;
2973 overflow: hidden;
2974 }
2975 .pri-hero::before {
2976 content: '';
2977 position: absolute; top: 0; left: 0; right: 0;
2978 height: 2px;
6fd5915Claude2979 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7a28902Claude2980 opacity: 0.7;
2981 pointer-events: none;
2982 }
2983 .pri-hero-eyebrow {
2984 font-size: 12px;
2985 color: var(--text-muted);
2986 text-transform: uppercase;
2987 letter-spacing: 0.08em;
2988 font-weight: 600;
2989 margin-bottom: 8px;
2990 }
2991 .pri-hero-title {
2992 font-family: var(--font-display);
2993 font-size: clamp(26px, 3.4vw, 34px);
2994 font-weight: 800;
2995 letter-spacing: -0.025em;
2996 line-height: 1.06;
2997 margin: 0 0 8px;
2998 color: var(--text-strong);
2999 }
3000 .pri-hero-title .gradient-text {
6fd5915Claude3001 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
7a28902Claude3002 -webkit-background-clip: text;
3003 background-clip: text;
3004 -webkit-text-fill-color: transparent;
3005 color: transparent;
3006 }
3007 .pri-hero-sub {
3008 font-size: 14.5px;
3009 color: var(--text-muted);
3010 margin: 0;
3011 line-height: 1.5;
3012 }
3013 .pri-section { margin-bottom: 32px; }
3014 .pri-section-title {
3015 font-size: 13px;
3016 font-weight: 700;
3017 text-transform: uppercase;
3018 letter-spacing: 0.06em;
3019 color: var(--text-muted);
3020 margin: 0 0 14px;
3021 }
3022 .pri-cards {
3023 display: grid;
3024 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
3025 gap: 12px;
3026 }
3027 .pri-card {
3028 padding: 16px 18px;
3029 background: var(--bg-elevated);
3030 border: 1px solid var(--border);
3031 border-radius: 12px;
3032 }
3033 .pri-card-label {
3034 font-size: 12px;
3035 font-weight: 600;
3036 color: var(--text-muted);
3037 text-transform: uppercase;
3038 letter-spacing: 0.05em;
3039 margin-bottom: 6px;
3040 }
3041 .pri-card-value {
3042 font-size: 28px;
3043 font-weight: 800;
3044 letter-spacing: -0.04em;
3045 color: var(--text-strong);
3046 line-height: 1;
3047 }
3048 .pri-card-sub {
3049 font-size: 12px;
3050 color: var(--text-muted);
3051 margin-top: 4px;
3052 }
3053 .pri-chart {
3054 display: flex;
3055 align-items: flex-end;
3056 gap: 6px;
3057 height: 120px;
3058 background: var(--bg-elevated);
3059 border: 1px solid var(--border);
3060 border-radius: 12px;
3061 padding: 16px 16px 0;
3062 }
3063 .pri-bar-col {
3064 flex: 1;
3065 display: flex;
3066 flex-direction: column;
3067 align-items: center;
3068 justify-content: flex-end;
3069 height: 100%;
3070 gap: 4px;
3071 }
3072 .pri-bar {
3073 width: 100%;
3074 min-height: 4px;
3075 border-radius: 4px 4px 0 0;
6fd5915Claude3076 background: linear-gradient(180deg, #5b6ee8 0%, #5b6ee8 100%);
7a28902Claude3077 transition: opacity 140ms;
3078 }
3079 .pri-bar:hover { opacity: 0.8; }
3080 .pri-bar-label {
3081 font-size: 10px;
3082 color: var(--text-muted);
3083 text-align: center;
3084 padding-bottom: 8px;
3085 white-space: nowrap;
3086 overflow: hidden;
3087 text-overflow: ellipsis;
3088 max-width: 100%;
3089 }
3090 .pri-table {
3091 width: 100%;
3092 border-collapse: collapse;
3093 font-size: 13.5px;
3094 }
3095 .pri-table th {
3096 text-align: left;
3097 font-size: 12px;
3098 font-weight: 600;
3099 text-transform: uppercase;
3100 letter-spacing: 0.05em;
3101 color: var(--text-muted);
3102 padding: 8px 12px;
3103 border-bottom: 1px solid var(--border);
3104 }
3105 .pri-table td {
3106 padding: 10px 12px;
3107 border-bottom: 1px solid var(--border);
3108 color: var(--text);
3109 }
3110 .pri-table tr:last-child td { border-bottom: none; }
3111 .pri-table-wrap {
3112 background: var(--bg-elevated);
3113 border: 1px solid var(--border);
3114 border-radius: 12px;
3115 overflow: hidden;
3116 }
3117 .pri-age-row {
3118 display: flex;
3119 align-items: center;
3120 gap: 12px;
3121 padding: 10px 0;
3122 border-bottom: 1px solid var(--border);
3123 font-size: 13.5px;
3124 }
3125 .pri-age-row:last-child { border-bottom: none; }
3126 .pri-age-label {
3127 flex: 0 0 80px;
3128 color: var(--text-muted);
3129 font-size: 12.5px;
3130 font-weight: 600;
3131 }
3132 .pri-age-bar-wrap {
3133 flex: 1;
3134 height: 8px;
3135 background: var(--bg-secondary);
3136 border-radius: 9999px;
3137 overflow: hidden;
3138 }
3139 .pri-age-bar {
3140 height: 100%;
3141 border-radius: 9999px;
6fd5915Claude3142 background: linear-gradient(90deg, #5b6ee8 0%, #5f8fa0 100%);
7a28902Claude3143 min-width: 4px;
3144 }
3145 .pri-age-count {
3146 flex: 0 0 32px;
3147 text-align: right;
3148 font-weight: 600;
3149 color: var(--text-strong);
3150 font-size: 13px;
3151 }
3152 .pri-sparkline {
3153 display: flex;
3154 align-items: flex-end;
3155 gap: 3px;
3156 height: 40px;
3157 }
3158 .pri-spark-bar {
3159 flex: 1;
3160 min-height: 2px;
3161 border-radius: 2px 2px 0 0;
6fd5915Claude3162 background: var(--accent, #5b6ee8);
7a28902Claude3163 opacity: 0.7;
3164 }
3165 .pri-empty {
3166 color: var(--text-muted);
3167 font-size: 14px;
3168 padding: 24px 0;
3169 text-align: center;
3170 }
3171 @media (max-width: 600px) {
3172 .pri-cards { grid-template-columns: repeat(2, 1fr); }
3173 .pri-hero { padding: 18px 18px 20px; }
3174 }
3175`;
3176
3177pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
3178 const { owner: ownerName, repo: repoName } = c.req.param();
3179 const user = c.get("user");
3180
3181 const resolved = await resolveRepo(ownerName, repoName);
3182 if (!resolved) return c.notFound();
3183
3184 const repoId = resolved.repo.id;
3185 const now = Date.now();
3186
3187 // 1. Merged PRs in last 90 days (avg merge time)
3188 const mergedPRs = await db
3189 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
3190 .from(pullRequests)
3191 .where(and(
3192 eq(pullRequests.repositoryId, repoId),
3193 eq(pullRequests.state, "merged"),
3194 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3195 ));
3196
3197 const avgMergeMs = mergedPRs.length > 0
3198 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
3199 : null;
3200
3201 // 2. PR throughput (last 8 weeks)
3202 const weeklyPRs = await db
3203 .select({
3204 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
3205 count: sql<number>`count(*)::int`,
3206 })
3207 .from(pullRequests)
3208 .where(and(
3209 eq(pullRequests.repositoryId, repoId),
3210 sql`${pullRequests.createdAt} > now() - interval '56 days'`
3211 ))
3212 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
3213 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
3214
3215 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
3216
3217 // 3. PR merge rate (last 90 days)
3218 const [rateCounts] = await db
3219 .select({
3220 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
3221 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
3222 })
3223 .from(pullRequests)
3224 .where(and(
3225 eq(pullRequests.repositoryId, repoId),
3226 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3227 ));
3228
3229 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
3230 const mergeRate = totalResolved > 0
3231 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
3232 : null;
3233
3234 // 4. Top reviewers (last 90 days)
3235 const reviewerCounts = await db
3236 .select({
3237 userId: prReviews.reviewerId,
3238 username: users.username,
3239 count: sql<number>`count(*)::int`,
3240 })
3241 .from(prReviews)
3242 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3243 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
3244 .where(and(
3245 eq(pullRequests.repositoryId, repoId),
3246 sql`${prReviews.createdAt} > now() - interval '90 days'`
3247 ))
3248 .groupBy(prReviews.reviewerId, users.username)
3249 .orderBy(desc(sql`count(*)`))
3250 .limit(5);
3251
3252 // 5. Average reviews per merged PR
3253 const [avgReviewRow] = await db
3254 .select({
3255 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
3256 })
3257 .from(pullRequests)
3258 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3259 .where(and(
3260 eq(pullRequests.repositoryId, repoId),
3261 eq(pullRequests.state, "merged"),
3262 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3263 ));
3264
3265 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
3266 ? Math.round(avgReviewRow.avgReviews * 10) / 10
3267 : null;
3268
3269 // 6. Review turnaround — avg time from PR open to first review
3270 const prsWithReviews = await db
3271 .select({
3272 createdAt: pullRequests.createdAt,
3273 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
3274 })
3275 .from(pullRequests)
3276 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3277 .where(and(
3278 eq(pullRequests.repositoryId, repoId),
3279 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3280 ))
3281 .groupBy(pullRequests.id, pullRequests.createdAt);
3282
3283 const avgReviewTurnaroundMs = prsWithReviews.length > 0
3284 ? prsWithReviews.reduce((s, row) => {
3285 const firstMs = new Date(row.firstReview).getTime();
3286 return s + Math.max(0, firstMs - row.createdAt.getTime());
3287 }, 0) / prsWithReviews.length
3288 : null;
3289
3290 // 7. Open PRs by age bucket
3291 const openPRs = await db
3292 .select({ createdAt: pullRequests.createdAt })
3293 .from(pullRequests)
3294 .where(and(
3295 eq(pullRequests.repositoryId, repoId),
3296 eq(pullRequests.state, "open")
3297 ));
3298
3299 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
3300 for (const { createdAt } of openPRs) {
3301 const ageDays = (now - createdAt.getTime()) / 86_400_000;
3302 if (ageDays < 1) ageBuckets.lt1d++;
3303 else if (ageDays < 3) ageBuckets.d1to3++;
3304 else if (ageDays < 7) ageBuckets.d3to7++;
3305 else if (ageDays < 30) ageBuckets.d7to30++;
3306 else ageBuckets.gt30d++;
3307 }
3308 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
3309
3310 // 8. 7-day merge sparkline
3311 const sparklineRows = await db
3312 .select({
3313 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
3314 count: sql<number>`count(*)::int`,
3315 })
3316 .from(pullRequests)
3317 .where(and(
3318 eq(pullRequests.repositoryId, repoId),
3319 eq(pullRequests.state, "merged"),
3320 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
3321 ))
3322 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
3323 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
3324
3325 const sparkMap = new Map<string, number>();
3326 for (const row of sparklineRows) {
3327 sparkMap.set(row.day.slice(0, 10), row.count);
3328 }
3329 const sparkline: number[] = [];
3330 for (let i = 6; i >= 0; i--) {
3331 const d = new Date(now - i * 86_400_000);
3332 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
3333 }
3334 const maxSpark = Math.max(1, ...sparkline);
3335
3336 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
3337 { label: "< 1 day", key: "lt1d" },
3338 { label: "1–3 days", key: "d1to3" },
3339 { label: "3–7 days", key: "d3to7" },
3340 { label: "7–30 days", key: "d7to30" },
3341 { label: "> 30 days", key: "gt30d" },
3342 ];
3343
3344 return c.html(
3345 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
3346 <RepoHeader owner={ownerName} repo={repoName} />
3347 <PrNav owner={ownerName} repo={repoName} active="pulls" />
3348 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
3349
3350 <div class="pri-page">
3351 {/* Hero */}
3352 <div class="pri-hero">
3353 <div class="pri-hero-eyebrow">Pull requests</div>
3354 <h1 class="pri-hero-title">
3355 PR <span class="gradient-text">Insights</span>
3356 </h1>
3357 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
3358 </div>
3359
3360 {/* Stat cards */}
3361 <div class="pri-section">
3362 <div class="pri-section-title">At a glance</div>
3363 <div class="pri-cards">
3364 <div class="pri-card">
3365 <div class="pri-card-label">Avg merge time</div>
3366 <div class="pri-card-value">
3367 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
3368 </div>
3369 <div class="pri-card-sub">last 90 days</div>
3370 </div>
3371 <div class="pri-card">
3372 <div class="pri-card-label">Total merged</div>
3373 <div class="pri-card-value">{mergedPRs.length}</div>
3374 <div class="pri-card-sub">last 90 days</div>
3375 </div>
3376 <div class="pri-card">
3377 <div class="pri-card-label">Open PRs</div>
3378 <div class="pri-card-value">{openPRs.length}</div>
3379 <div class="pri-card-sub">right now</div>
3380 </div>
3381 <div class="pri-card">
3382 <div class="pri-card-label">Merge rate</div>
3383 <div class="pri-card-value">
3384 {mergeRate != null ? `${mergeRate}%` : "—"}
3385 </div>
3386 <div class="pri-card-sub">merged vs closed</div>
3387 </div>
3388 <div class="pri-card">
3389 <div class="pri-card-label">Avg reviews / PR</div>
3390 <div class="pri-card-value">
3391 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
3392 </div>
3393 <div class="pri-card-sub">merged PRs, 90d</div>
3394 </div>
3395 <div class="pri-card">
3396 <div class="pri-card-label">Top reviewer</div>
3397 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
3398 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
3399 </div>
3400 <div class="pri-card-sub">
3401 {reviewerCounts.length > 0
3402 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
3403 : "no reviews yet"}
3404 </div>
3405 </div>
3406 </div>
3407 </div>
3408
3409 {/* Review turnaround */}
3410 <div class="pri-section">
3411 <div class="pri-section-title">Review turnaround</div>
3412 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
3413 <div class="pri-card">
3414 <div class="pri-card-label">Avg time to first review</div>
3415 <div class="pri-card-value">
3416 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
3417 </div>
3418 <div class="pri-card-sub">
3419 {prsWithReviews.length > 0
3420 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
3421 : "no reviewed PRs in 90d"}
3422 </div>
3423 </div>
3424 </div>
3425 </div>
3426
3427 {/* Weekly throughput bar chart */}
3428 <div class="pri-section">
3429 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
3430 {weeklyPRs.length === 0 ? (
3431 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
3432 ) : (
3433 <div class="pri-chart">
3434 {weeklyPRs.map((w) => (
3435 <div class="pri-bar-col">
3436 <div
3437 class="pri-bar"
3438 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
3439 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
3440 />
3441 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
3442 </div>
3443 ))}
3444 </div>
3445 )}
3446 </div>
3447
3448 {/* 7-day merge sparkline */}
3449 <div class="pri-section">
3450 <div class="pri-section-title">Merges this week (daily)</div>
3451 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
3452 <div class="pri-sparkline">
3453 {sparkline.map((v) => (
3454 <div
3455 class="pri-spark-bar"
3456 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
3457 title={`${v} merge${v === 1 ? "" : "s"}`}
3458 />
3459 ))}
3460 </div>
3461 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
3462 <span>7 days ago</span>
3463 <span>Today</span>
3464 </div>
3465 </div>
3466 </div>
3467
3468 {/* Top reviewers table */}
3469 <div class="pri-section">
3470 <div class="pri-section-title">Top reviewers (last 90 days)</div>
3471 {reviewerCounts.length === 0 ? (
3472 <div class="pri-empty">No reviews posted in the last 90 days.</div>
3473 ) : (
3474 <div class="pri-table-wrap">
3475 <table class="pri-table">
3476 <thead>
3477 <tr>
3478 <th>#</th>
3479 <th>Reviewer</th>
3480 <th>Reviews</th>
3481 </tr>
3482 </thead>
3483 <tbody>
3484 {reviewerCounts.map((r, i) => (
3485 <tr>
3486 <td style="color:var(--text-muted)">{i + 1}</td>
3487 <td>
3488 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
3489 {r.username}
3490 </a>
3491 </td>
3492 <td style="font-weight:600">{r.count}</td>
3493 </tr>
3494 ))}
3495 </tbody>
3496 </table>
3497 </div>
3498 )}
3499 </div>
3500
3501 {/* Open PRs by age */}
3502 <div class="pri-section">
3503 <div class="pri-section-title">Open PRs by age</div>
3504 {openPRs.length === 0 ? (
3505 <div class="pri-empty">No open pull requests.</div>
3506 ) : (
3507 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
3508 {ageBucketDefs.map(({ label, key }) => (
3509 <div class="pri-age-row">
3510 <span class="pri-age-label">{label}</span>
3511 <div class="pri-age-bar-wrap">
3512 <div
3513 class="pri-age-bar"
3514 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
3515 />
3516 </div>
3517 <span class="pri-age-count">{ageBuckets[key]}</span>
3518 </div>
3519 ))}
3520 </div>
3521 )}
3522 </div>
3523
3524 {/* Back link */}
3525 <div>
3526 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
3527 {"←"} Back to pull requests
3528 </a>
3529 </div>
3530 </div>
3531 </Layout>
3532 );
3533});
3534
0074234Claude3535// New PR form
3536pulls.get(
3537 "/:owner/:repo/pulls/new",
3538 softAuth,
3539 requireAuth,
04f6b7fClaude3540 requireRepoAccess("write"),
0074234Claude3541 async (c) => {
3542 const { owner: ownerName, repo: repoName } = c.req.param();
3543 const user = c.get("user")!;
3544 const branches = await listBranches(ownerName, repoName);
3545 const error = c.req.query("error");
3546 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude3547 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude3548
3549 return c.html(
3550 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
3551 <RepoHeader owner={ownerName} repo={repoName} />
3552 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude3553 <Container maxWidth={800}>
3554 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude3555 {error && (
bb0f894Claude3556 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude3557 )}
0316dbbClaude3558 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
3559 <Flex gap={12} align="center" style="margin-bottom: 16px">
3560 <Select name="base">
0074234Claude3561 {branches.map((b) => (
3562 <option value={b} selected={b === defaultBase}>
3563 {b}
3564 </option>
3565 ))}
bb0f894Claude3566 </Select>
3567 <Text muted>&larr;</Text>
3568 <Select name="head">
0074234Claude3569 {branches
3570 .filter((b) => b !== defaultBase)
3571 .concat(defaultBase === branches[0] ? [] : [branches[0]])
3572 .map((b) => (
3573 <option value={b}>{b}</option>
3574 ))}
bb0f894Claude3575 </Select>
3576 </Flex>
3577 <FormGroup>
3578 <Input
0074234Claude3579 name="title"
3580 required
3581 placeholder="Title"
bb0f894Claude3582 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]3583 aria-label="Pull request title"
0074234Claude3584 />
bb0f894Claude3585 </FormGroup>
3586 <FormGroup>
3587 <TextArea
0074234Claude3588 name="body"
81c73c1Claude3589 id="pr-body"
0074234Claude3590 rows={8}
3591 placeholder="Description (Markdown supported)"
bb0f894Claude3592 mono
0074234Claude3593 />
bb0f894Claude3594 </FormGroup>
81c73c1Claude3595 <Flex gap={8} align="center">
3596 <Button type="submit" variant="primary">
3597 Create pull request
3598 </Button>
3599 <button
3600 type="button"
3601 id="ai-suggest-desc"
3602 class="btn"
3603 style="font-weight:500"
3604 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
3605 >
3606 Suggest description with AI
3607 </button>
3608 <span
3609 id="ai-suggest-status"
3610 style="color:var(--text-muted);font-size:13px"
3611 />
3612 </Flex>
bb0f894Claude3613 </Form>
81c73c1Claude3614 <script
3615 dangerouslySetInnerHTML={{
3616 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
3617 }}
3618 />
bb0f894Claude3619 </Container>
0074234Claude3620 </Layout>
3621 );
3622 }
3623);
3624
81c73c1Claude3625// AI-suggested PR description — JSON endpoint driven by the form button.
3626// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
3627// 200; the inline script reads `ok` to decide what to do.
3628pulls.post(
3629 "/:owner/:repo/ai/pr-description",
3630 softAuth,
3631 requireAuth,
3632 requireRepoAccess("write"),
3633 async (c) => {
3634 const { owner: ownerName, repo: repoName } = c.req.param();
3635 if (!isAiAvailable()) {
3636 return c.json({
3637 ok: false,
3638 error: "AI is not available — set ANTHROPIC_API_KEY.",
3639 });
3640 }
3641 const body = await c.req.parseBody();
3642 const title = String(body.title || "").trim();
3643 const baseBranch = String(body.base || "").trim();
3644 const headBranch = String(body.head || "").trim();
3645 if (!baseBranch || !headBranch) {
3646 return c.json({ ok: false, error: "Pick base + head branches first." });
3647 }
3648 if (baseBranch === headBranch) {
3649 return c.json({ ok: false, error: "Base and head must differ." });
3650 }
3651
3652 let diff = "";
3653 try {
3654 const cwd = getRepoPath(ownerName, repoName);
3655 const proc = Bun.spawn(
3656 [
3657 "git",
3658 "diff",
3659 `${baseBranch}...${headBranch}`,
3660 "--",
3661 ],
3662 { cwd, stdout: "pipe", stderr: "pipe" }
3663 );
6ea2109Claude3664 // 30s ceiling — without this a pathological diff (huge binary or
3665 // a corrupt ref) hangs the request indefinitely.
3666 const killer = setTimeout(() => proc.kill(), 30_000);
3667 try {
3668 diff = await new Response(proc.stdout).text();
3669 await proc.exited;
3670 } finally {
3671 clearTimeout(killer);
3672 }
81c73c1Claude3673 } catch {
3674 diff = "";
3675 }
3676 if (!diff.trim()) {
3677 return c.json({
3678 ok: false,
3679 error: "No diff between branches — nothing to summarise.",
3680 });
3681 }
3682
3683 let summary = "";
3684 try {
3685 summary = await generatePrSummary(title || "(untitled)", diff);
3686 } catch (err) {
3687 const msg = err instanceof Error ? err.message : "AI request failed.";
3688 return c.json({ ok: false, error: msg });
3689 }
3690 if (!summary.trim()) {
3691 return c.json({ ok: false, error: "AI returned an empty draft." });
3692 }
3693 return c.json({ ok: true, body: summary });
3694 }
3695);
3696
0074234Claude3697// Create PR
3698pulls.post(
3699 "/:owner/:repo/pulls/new",
3700 softAuth,
3701 requireAuth,
04f6b7fClaude3702 requireRepoAccess("write"),
0074234Claude3703 async (c) => {
3704 const { owner: ownerName, repo: repoName } = c.req.param();
3705 const user = c.get("user")!;
3706 const body = await c.req.parseBody();
3707 const title = String(body.title || "").trim();
3708 const prBody = String(body.body || "").trim();
3709 const baseBranch = String(body.base || "main");
3710 const headBranch = String(body.head || "");
3711
3712 if (!title || !headBranch) {
3713 return c.redirect(
3714 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
3715 );
3716 }
3717
3718 if (baseBranch === headBranch) {
3719 return c.redirect(
3720 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
3721 );
3722 }
3723
3724 const resolved = await resolveRepo(ownerName, repoName);
3725 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3726
6fc53bdClaude3727 const isDraft = String(body.draft || "") === "1";
3728
0074234Claude3729 const [pr] = await db
3730 .insert(pullRequests)
3731 .values({
3732 repositoryId: resolved.repo.id,
3733 authorId: user.id,
3734 title,
3735 body: prBody || null,
3736 baseBranch,
3737 headBranch,
6fc53bdClaude3738 isDraft,
0074234Claude3739 })
3740 .returning();
3741
b7b5f75ccanty labs3742 void logActivity({
3743 repositoryId: resolved.repo.id,
3744 userId: user.id,
3745 action: "pr_open",
3746 targetType: "pull_request",
3747 targetId: String(pr.number),
3748 metadata: { baseBranch, headBranch, isDraft },
3749 });
a74f4edccanty labs3750 void fireWebhooks(resolved.repo.id, "pr", {
3751 action: "opened",
3752 number: pr.number,
3753 baseBranch,
3754 headBranch,
3755 });
b7b5f75ccanty labs3756
ec9e3e3Claude3757 // CODEOWNERS — auto-request reviewers based on changed files.
3758 // Fire-and-forget; errors never block PR creation.
3759 (async () => {
3760 try {
3761 const repoDir = getRepoPath(ownerName, repoName);
3762 // Get list of changed files between base and head
3763 const diffProc = Bun.spawn(
3764 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
3765 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3766 );
3767 const rawDiff = await new Response(diffProc.stdout).text();
3768 await diffProc.exited;
3769 const changedFiles = rawDiff.trim().split("\n").filter(Boolean);
3770
3771 if (changedFiles.length > 0) {
3772 // Get CODEOWNERS from the default branch of the repo
3773 const rules = await getCodeownersForRepo(
3774 ownerName,
3775 repoName,
3776 resolved.repo.defaultBranch
3777 );
3778 if (rules.length > 0) {
3779 const ownerUsernames = await reviewersForChangedFiles(
3780 resolved.repo.id,
3781 changedFiles
3782 );
3783 // Filter out the PR author
3784 const filteredOwners = ownerUsernames.filter(
3785 (u) => u !== resolved.owner.username
3786 );
3787
3788 if (filteredOwners.length > 0) {
3789 // Look up user IDs for the owner usernames
3790 const reviewerUsers = await db
3791 .select({ id: users.id, username: users.username })
3792 .from(users)
3793 .where(
3794 inArray(
3795 users.username,
3796 filteredOwners
3797 )
3798 );
3799
3800 // Create review request rows (UNIQUE constraint prevents dupes)
3801 if (reviewerUsers.length > 0) {
3802 await db
3803 .insert(prReviewRequests)
3804 .values(
3805 reviewerUsers.map((u) => ({
3806 prId: pr.id,
3807 reviewerId: u.id,
3808 requestedBy: null as string | null,
3809 }))
3810 )
3811 .onConflictDoNothing();
3812
3813 // Add a PR comment announcing the auto-assigned reviewers
3814 const mentionList = reviewerUsers
3815 .map((u) => `@${u.username}`)
3816 .join(", ");
3817 await db.insert(prComments).values({
3818 pullRequestId: pr.id,
3819 authorId: user.id,
3820 body: `AI: Requested review from ${mentionList} based on CODEOWNERS`,
3821 isAiReview: true,
3822 });
3823 }
3824 }
3825 }
3826 }
3827 } catch (err) {
3828 console.warn("[codeowners] auto-assign failed:", err instanceof Error ? err.message : err);
3829 }
3830 })();
3831
91b054eccanty labs3832 // `on: pull_request` workflow trigger — workflows are already synced
3833 // into the `workflows` table on push (push-workflow-sync.ts); PR-open
3834 // just needs to enqueue runs for the ones whose `on:` includes
3835 // `pull_request`. Fire-and-forget; must never block PR creation. Scoped
3836 // to PR open only — see pr-workflow-sync.ts header for why synchronize/
3837 // close aren't wired here yet.
3838 resolveRef(ownerName, repoName, headBranch)
3839 .then((headSha) => {
3840 if (!headSha) return;
3841 return enqueuePullRequestWorkflows({
3842 repositoryId: resolved.repo.id,
3843 headBranch,
3844 headSha,
3845 triggeredBy: user.id,
3846 });
3847 })
3848 .catch((err) =>
3849 console.warn(
3850 "[pr-workflow-sync] enqueue failed:",
3851 err instanceof Error ? err.message : err
3852 )
3853 );
3854
6fc53bdClaude3855 // Skip AI review on drafts — it runs again when the PR is marked ready.
3856 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3857 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3858 (err) => console.error("[ai-review] Failed:", err)
3859 );
3860 }
3861
3cbe3d6Claude3862 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3863 triggerPrTriage({
3864 ownerName,
3865 repoName,
3866 repositoryId: resolved.repo.id,
3867 prId: pr.id,
3868 prAuthorId: user.id,
3869 title,
3870 body: prBody,
3871 baseBranch,
3872 headBranch,
3873 }).catch((err) => console.error("[pr-triage] Failed:", err));
3874
1d4ff60Claude3875 // Chat notifier — fan out to Slack/Discord/Teams.
3876 import("../lib/chat-notifier")
3877 .then((m) =>
3878 m.notifyChatChannels({
3879 ownerUserId: resolved.repo.ownerId,
3880 repositoryId: resolved.repo.id,
3881 event: {
3882 event: "pr.opened",
3883 repo: `${ownerName}/${repoName}`,
3884 title: `#${pr.number} ${title}`,
3885 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3886 body: prBody || undefined,
3887 actor: user.username,
3888 },
3889 })
3890 )
3891 .catch((err) =>
3892 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3893 );
3894
9dd96b9Test User3895 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude3896 import("../lib/auto-merge")
3897 .then((m) => m.tryAutoMergeNow(pr.id))
3898 .catch((err) => {
3899 console.warn(
3900 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
3901 err instanceof Error ? err.message : err
3902 );
3903 });
9dd96b9Test User3904
1df50d5Claude3905 // Migration 0077 — PR preview build. Fire-and-forget; skips when
3906 // PREVIEW_DOMAIN is unset or the repo has no preview_build_command.
3907 // Resolve head SHA asynchronously so we don't block the redirect.
3908 resolveRef(ownerName, repoName, headBranch)
3909 .then((headSha) => {
3910 if (!headSha) return;
3911 return import("../lib/preview-builder").then((m) =>
3912 m.buildPreview(pr.id, resolved.repo.id, headSha)
3913 );
3914 })
3915 .catch(() => {});
3916
0074234Claude3917 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
3918 }
3919);
3920
3921// View single PR
04f6b7fClaude3922pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude3923 const { owner: ownerName, repo: repoName } = c.req.param();
3924 const prNum = parseInt(c.req.param("number"), 10);
3925 const user = c.get("user");
3926 const tab = c.req.query("tab") || "conversation";
a2c10c5Claude3927 const isSplit = c.req.query("diffview") === "split";
3928 const pendingCount = parseInt(c.req.query("pending") || "0", 10) || 0;
0074234Claude3929
3930 const resolved = await resolveRepo(ownerName, repoName);
3931 if (!resolved) return c.notFound();
3932
3933 const [pr] = await db
3934 .select()
3935 .from(pullRequests)
3936 .where(
3937 and(
3938 eq(pullRequests.repositoryId, resolved.repo.id),
3939 eq(pullRequests.number, prNum)
3940 )
3941 )
3942 .limit(1);
3943
3944 if (!pr) return c.notFound();
3945
3946 const [author] = await db
3947 .select()
3948 .from(users)
3949 .where(eq(users.id, pr.authorId))
3950 .limit(1);
3951
cb5a796Claude3952 const allCommentsRaw = await db
0074234Claude3953 .select({
3954 comment: prComments,
cb5a796Claude3955 author: { id: users.id, username: users.username },
0074234Claude3956 })
3957 .from(prComments)
3958 .innerJoin(users, eq(prComments.authorId, users.id))
3959 .where(eq(prComments.pullRequestId, pr.id))
3960 .orderBy(asc(prComments.createdAt));
3961
cb5a796Claude3962 // Filter pending/rejected/spam for non-owner, non-author viewers.
3963 // Owner always sees everything; comment author sees their own pending
3964 // with an "Awaiting approval" badge in the render below.
3965 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
3966 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
3967 if (viewerIsRepoOwner) return true;
3968 if (comment.moderationStatus === "approved") return true;
3969 if (
3970 user &&
3971 cAuthor.id === user.id &&
3972 comment.moderationStatus === "pending"
3973 ) {
3974 return true;
3975 }
3976 return false;
3977 });
3978 const prPendingCount = viewerIsRepoOwner
3979 ? await countPendingForRepo(resolved.repo.id)
3980 : 0;
3981
6fc53bdClaude3982 // Reactions for the PR body + each comment, in parallel.
3983 const [prReactions, ...prCommentReactions] = await Promise.all([
3984 summariseReactions("pr", pr.id, user?.id),
3985 ...comments.map((row) =>
3986 summariseReactions("pr_comment", row.comment.id, user?.id)
3987 ),
3988 ]);
3989
0a67773Claude3990 // Formal reviews (Approve / Request Changes)
3991 const reviewRows = await db
3992 .select({
3993 id: prReviews.id,
3994 state: prReviews.state,
3995 body: prReviews.body,
3996 isAi: prReviews.isAi,
3997 createdAt: prReviews.createdAt,
3998 reviewerUsername: users.username,
3999 reviewerId: prReviews.reviewerId,
4000 })
4001 .from(prReviews)
4002 .innerJoin(users, eq(prReviews.reviewerId, users.id))
4003 .where(eq(prReviews.pullRequestId, pr.id))
4004 .orderBy(asc(prReviews.createdAt));
4005 // Most recent review per reviewer determines the current state
4006 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
4007 for (const r of reviewRows) {
4008 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
4009 }
4010 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
4011 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
4012 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
4013
ec9e3e3Claude4014 // Requested reviewers from CODEOWNERS auto-assign (migration 0077).
4015 const requestedReviewerRows = await db
4016 .select({
4017 reviewerUsername: users.username,
4018 reviewerId: prReviewRequests.reviewerId,
4019 createdAt: prReviewRequests.createdAt,
4020 })
4021 .from(prReviewRequests)
4022 .innerJoin(users, eq(prReviewRequests.reviewerId, users.id))
4023 .where(eq(prReviewRequests.prId, pr.id))
4024 .orderBy(asc(prReviewRequests.createdAt))
4025 .catch(() => [] as { reviewerUsername: string; reviewerId: string; createdAt: Date }[]);
4026
ace34efClaude4027 // Suggested reviewers — best-effort, never throws
4028 let reviewerSuggestions: ReviewerCandidate[] = [];
4029 try {
4030 if (user) {
4031 reviewerSuggestions = await suggestReviewers(
4032 ownerName, repoName, pr.headBranch, pr.baseBranch,
4033 pr.authorId, resolved.repo.id
4034 );
4035 }
4036 } catch {
4037 // silent degradation
4038 }
4039
0074234Claude4040 const canManage =
4041 user &&
4042 (user.id === resolved.owner.id || user.id === pr.authorId);
4043
1d4ff60Claude4044 // Has any previous AI-test-generator run already tagged this PR? Used
4045 // both to hide the "Generate tests with AI" button and to short-circuit
4046 // the explicit POST handler.
4047 const hasAiTestsMarker = comments.some(({ comment }) =>
4048 (comment.body || "").includes(AI_TESTS_MARKER)
4049 );
4050
e883329Claude4051 const error = c.req.query("error");
c3e0c07Claude4052 const info = c.req.query("info");
e883329Claude4053
4054 // Get gate check status for open PRs
4055 let gateChecks: GateCheckResult[] = [];
240c477Claude4056 let ciStatuses: CommitStatus[] = [];
e883329Claude4057 if (pr.state === "open") {
6f1fd83Claude4058 try {
4059 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4060 if (headSha) {
c166384ccantynz-alt4061 const aiApproved = await isAiReviewApproved(pr.id);
6f1fd83Claude4062 const [gateResult, fetchedCiStatuses] = await Promise.all([
4063 runAllGateChecks(
4064 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
4065 ).catch(() => ({ allPassed: false, checks: [] as GateCheckResult[] })),
4066 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
4067 ]);
4068 gateChecks = gateResult.checks;
4069 ciStatuses = fetchedCiStatuses;
4070 }
4071 } catch {
4072 // git repo missing or unreachable — show PR without gate status
e883329Claude4073 }
4074 }
4075
534f04aClaude4076 // Block M3 — pre-merge risk score. Cache-only on the request path so
4077 // the page never waits on Haiku. On a cache miss for an open PR we
4078 // kick off the computation fire-and-forget; the next refresh shows it.
4079 let prRisk: PrRiskScore | null = null;
4080 let prRiskCalculating = false;
4081 if (pr.state === "open") {
4082 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
4083 if (!prRisk) {
4084 prRiskCalculating = true;
a28cedeClaude4085 void computePrRiskForPullRequest(pr.id).catch((err) => {
4086 console.warn(
4087 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
4088 err instanceof Error ? err.message : err
4089 );
4090 });
534f04aClaude4091 }
4092 }
4093
4bbacbeClaude4094 // Migration 0062 — per-branch preview URL. The head branch always
4095 // has a preview row (unless it's the default branch, which never
4096 // happens for an open PR) once it has been pushed at least once.
4097 const preview = await getPreviewForBranch(
4098 (resolved.repo as { id: string }).id,
4099 pr.headBranch
4100 );
4101
0369e77Claude4102 // Branch ahead/behind counts — how many commits head is ahead of base and
4103 // how many commits base has advanced since head branched off.
4104 let branchAhead = 0;
4105 let branchBehind = 0;
4106 if (pr.state === "open") {
4107 try {
4108 const repoDir = getRepoPath(ownerName, repoName);
4109 const [aheadProc, behindProc] = [
4110 Bun.spawn(
4111 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
4112 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4113 ),
4114 Bun.spawn(
4115 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
4116 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4117 ),
4118 ];
4119 const [aheadTxt, behindTxt] = await Promise.all([
4120 new Response(aheadProc.stdout).text(),
4121 new Response(behindProc.stdout).text(),
4122 ]);
4123 await Promise.all([aheadProc.exited, behindProc.exited]);
4124 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
4125 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
4126 } catch { /* non-blocking */ }
4127 }
4128
6d1bbc2Claude4129 // Linked issues — parse closing keywords from PR title+body, look up issues
4130 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
4131 try {
4132 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
4133 const refs = extractClosingRefsMulti([pr.title, pr.body]);
4134 if (refs.length > 0) {
4135 linkedIssues = await db
4136 .select({ number: issues.number, title: issues.title, state: issues.state })
4137 .from(issues)
4138 .where(and(
4139 eq(issues.repositoryId, resolved.repo.id),
4140 inArray(issues.number, refs),
4141 ));
4142 }
4143 } catch { /* non-blocking */ }
4144
4145 // Task list progress — count markdown checkboxes in PR body
4146 let taskTotal = 0;
4147 let taskChecked = 0;
4148 if (pr.body) {
4149 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
4150 taskTotal++;
4151 if (m[1].trim() !== "") taskChecked++;
4152 }
4153 }
4154
74d8c4dClaude4155 // M15 — PR size badge (best-effort, non-blocking)
4156 let prSizeInfo: PrSizeInfo | null = null;
4157 try {
4158 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
4159 } catch { /* swallow — purely cosmetic */ }
4160
09d5f39Claude4161 // Merge impact analysis — only for open PRs with write access (cached, fast)
4162 let impactAnalysis: ImpactAnalysis | null = null;
4163 if (pr.state === "open" && canManage) {
4164 try {
4165 impactAnalysis = await analyzeImpact(resolved.repo.id, pr.id);
4166 } catch { /* non-blocking */ }
4167 }
1d6db4dClaude4168
47a7a0aClaude4169 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude4170 let diffRaw = "";
4171 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude4172 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude4173 if (tab === "files") {
4174 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude4175 // Run the two git diffs in parallel — they're independent reads of
4176 // the same range. Previously sequential, doubling the wall time on
4177 // big PRs (100+ files = 10-30s for no reason).
0074234Claude4178 const proc = Bun.spawn(
4179 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
4180 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4181 );
4182 const statProc = Bun.spawn(
4183 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
4184 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4185 );
6ea2109Claude4186 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
4187 // would otherwise hang the whole request.
4188 const killer = setTimeout(() => {
4189 proc.kill();
4190 statProc.kill();
4191 }, 30_000);
4192 let stat = "";
4193 try {
4194 [diffRaw, stat] = await Promise.all([
4195 new Response(proc.stdout).text(),
4196 new Response(statProc.stdout).text(),
4197 ]);
4198 await Promise.all([proc.exited, statProc.exited]);
4199 } finally {
4200 clearTimeout(killer);
4201 }
0074234Claude4202
4203 diffFiles = stat
4204 .trim()
4205 .split("\n")
4206 .filter(Boolean)
4207 .map((line) => {
4208 const [add, del, filePath] = line.split("\t");
4209 return {
4210 path: filePath,
4211 status: "modified",
4212 additions: add === "-" ? 0 : parseInt(add, 10),
4213 deletions: del === "-" ? 0 : parseInt(del, 10),
4214 patch: "",
4215 };
4216 });
47a7a0aClaude4217
4218 // Fetch inline comments (file+line anchored) for the files tab
4219 const inlineRows = await db
4220 .select({
4221 id: prComments.id,
4222 filePath: prComments.filePath,
4223 lineNumber: prComments.lineNumber,
4224 body: prComments.body,
4225 isAiReview: prComments.isAiReview,
4226 createdAt: prComments.createdAt,
4227 authorUsername: users.username,
4228 })
4229 .from(prComments)
4230 .innerJoin(users, eq(prComments.authorId, users.id))
4231 .where(
4232 and(
4233 eq(prComments.pullRequestId, pr.id),
4234 eq(prComments.moderationStatus, "approved"),
4235 )
4236 )
4237 .orderBy(asc(prComments.createdAt));
4238
4239 diffInlineComments = inlineRows
4240 .filter(r => r.filePath != null && r.lineNumber != null)
4241 .map(r => ({
4242 id: r.id,
4243 filePath: r.filePath!,
4244 lineNumber: r.lineNumber!,
4245 authorUsername: r.authorUsername,
4246 body: renderMarkdown(r.body),
4247 isAiReview: r.isAiReview,
4248 createdAt: r.createdAt.toISOString(),
4249 }));
0074234Claude4250 }
4251
34e63b9Claude4252 // Proactive pattern warning — get changed file paths and check for recurring
4253 // bug patterns. Fire-and-forget safe; returns null on any error or cache miss.
4254 let patternWarning: Pattern | null = null;
4255 try {
4256 const repoDir = getRepoPath(ownerName, repoName);
4257 const nameOnlyProc = Bun.spawn(
4258 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4259 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4260 );
4261 const nameOnlyRaw = await new Response(nameOnlyProc.stdout).text();
4262 await nameOnlyProc.exited;
4263 const prChangedFiles = nameOnlyRaw.trim().split("\n").filter(Boolean);
4264 if (prChangedFiles.length > 0) {
4265 patternWarning = await getPatternWarning(resolved.repo.id, prChangedFiles);
4266 }
4267 } catch {
4268 // Non-blocking — swallow
4269 }
4270
7f992cdClaude4271 // Bus factor warning — flag files dominated by a single author.
4272 let busRiskFiles: BusFactorFile[] = [];
4273 try {
4274 const repoDir2 = getRepoPath(ownerName, repoName);
4275 const bf2Proc = Bun.spawn(
4276 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4277 { cwd: repoDir2, stdout: "pipe", stderr: "pipe" }
4278 );
4279 const bf2Raw = await new Response(bf2Proc.stdout).text();
4280 await bf2Proc.exited;
4281 const bf2Files = bf2Raw.trim().split("\n").filter(Boolean);
4282 if (bf2Files.length > 0) {
4283 busRiskFiles = await getBusFactorWarning(resolved.repo.id, ownerName, repoName, bf2Files);
4284 }
4285 } catch {
4286 // Non-blocking
4287 }
4288
4289 // PR split suggestion — AI recommends sub-PR decomposition for large PRs.
4290 let splitSuggestion: SplitSuggestion | null = null;
4291 try {
4292 splitSuggestion = await suggestPrSplit(
4293 pr.id,
4294 pr.title,
4295 ownerName,
4296 repoName,
4297 pr.baseBranch,
4298 pr.headBranch
4299 );
4300 } catch {
4301 // Non-blocking
4302 }
4303
b078860Claude4304 // ─── Derived visual state ───
4305 const stateKey =
4306 pr.state === "open"
4307 ? pr.isDraft
4308 ? "draft"
4309 : "open"
4310 : pr.state;
4311 const stateLabel =
4312 stateKey === "open"
4313 ? "Open"
4314 : stateKey === "draft"
4315 ? "Draft"
4316 : stateKey === "merged"
4317 ? "Merged"
4318 : "Closed";
4319 const stateIcon =
4320 stateKey === "open"
4321 ? "○"
4322 : stateKey === "draft"
4323 ? "◌"
4324 : stateKey === "merged"
4325 ? "⮌"
4326 : "✓";
f6730d0ccantynz-alt4327 const stateVariant =
4328 stateKey === "open"
4329 ? "success"
4330 : stateKey === "merged"
4331 ? "info"
4332 : stateKey === "closed"
4333 ? "danger"
4334 : "neutral";
b078860Claude4335 const commentCount = comments.length;
4336 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
4337 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
4338 const mergeBlocked =
4339 gateChecks.length > 0 &&
4340 gateChecks.some(
4341 (c) => !c.passed && c.name !== "Merge check"
4342 );
4343
b558f23Claude4344 // Commits tab — list commits included in this PR (base..head range)
4345 let prCommits: GitCommit[] = [];
4346 if (tab === "commits") {
4347 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
4348 }
4349
cc34156Claude4350 // Review context restore — compute BEFORE recording the visit so the
4351 // previous timestamp is available for the delta calculation.
4352 let reviewCtx: ReviewContext | null = null;
4353 if (user) {
4354 reviewCtx = await getReviewContext(pr.id, user.id, {
4355 ownerName,
4356 repoName,
4357 baseBranch: pr.baseBranch,
4358 headBranch: pr.headBranch,
4359 });
4360 // Fire-and-forget: record the visit AFTER computing context
4361 void recordPrVisit(pr.id, user.id);
4362 }
4363
0074234Claude4364 return c.html(
4365 <Layout
4366 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
4367 user={user}
4368 >
4369 <RepoHeader owner={ownerName} repo={repoName} />
4370 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude4371 <PendingCommentsBanner
4372 owner={ownerName}
4373 repo={repoName}
4374 count={prPendingCount}
4375 />
b078860Claude4376 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
f6730d0ccantynz-alt4377 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
b584e52Claude4378 <div
4379 id="live-comment-banner"
4380 class="alert"
4381 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
4382 >
4383 <strong class="js-live-count">0</strong> new comment(s) —{" "}
4384 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
4385 reload to view
4386 </a>
4387 </div>
4388 <script
4389 dangerouslySetInnerHTML={{
4390 __html: liveCommentBannerScript({
4391 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
4392 bannerElementId: "live-comment-banner",
4393 }),
4394 }}
4395 />
b078860Claude4396
cc34156Claude4397 {/* Review context restore banner — shown when returning after changes */}
4398 {reviewCtx && (
4399 <div
4400 class="context-restore-banner"
4401 id="review-context"
4402 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"
4403 >
4404 <span class="context-icon" style="font-size:18px;flex-shrink:0;margin-top:2px" aria-hidden="true">{"↩"}</span>
4405 <div style="flex:1;min-width:0">
4406 <strong style="font-size:13.5px;color:var(--text-strong,#111)">Welcome back</strong>
4407 <p style="margin:4px 0 0;font-size:13px;color:var(--text,#333);line-height:1.5">{reviewCtx.summary}</p>
4408 <small style="font-size:11.5px;color:var(--text-muted,#777)">
4409 Last visited {formatRelative(new Date(reviewCtx.lastVisitedAt))}
4410 {reviewCtx.commitsSince > 0 && ` · ${reviewCtx.commitsSince} new commit${reviewCtx.commitsSince === 1 ? "" : "s"}`}
4411 {reviewCtx.newComments > 0 && ` · ${reviewCtx.newComments} new comment${reviewCtx.newComments === 1 ? "" : "s"}`}
4412 </small>
4413 {reviewCtx.suggestedStartLine && (
4414 <p style="margin:6px 0 0;font-size:12px;color:var(--accent,#0070f3)">
4415 Start at: <code style="font-size:11px">{reviewCtx.suggestedStartLine}</code>
4416 </p>
4417 )}
4418 </div>
4419 <button
4420 type="button"
4421 onclick="this.closest('.context-restore-banner').remove()"
4422 style="flex-shrink:0;background:none;border:none;cursor:pointer;font-size:18px;color:var(--text-muted,#777);padding:0;line-height:1"
4423 aria-label="Dismiss"
4424 >
4425 {"×"}
4426 </button>
4427 </div>
4428 )}
4429
b078860Claude4430 <div class="prs-detail-hero">
b558f23Claude4431 <div class="prs-edit-title-wrap">
4432 <h1 class="prs-detail-title" id="pr-title-display">
4433 {pr.title}{" "}
4434 <span class="prs-detail-num">#{pr.number}</span>
4435 </h1>
4436 {canManage && pr.state === "open" && (
4437 <button
4438 type="button"
4439 class="prs-edit-btn"
4440 id="pr-edit-toggle"
4441 onclick={`
4442 document.getElementById('pr-title-display').style.display='none';
4443 document.getElementById('pr-edit-toggle').style.display='none';
4444 document.getElementById('pr-edit-form').style.display='flex';
4445 document.getElementById('pr-title-input').focus();
4446 `}
4447 >
4448 Edit
4449 </button>
4450 )}
4451 </div>
4452 {canManage && pr.state === "open" && (
4453 <form
4454 id="pr-edit-form"
4455 method="post"
4456 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
4457 class="prs-edit-form"
4458 style="display:none"
4459 >
4460 <input
4461 id="pr-title-input"
4462 type="text"
4463 name="title"
4464 value={pr.title}
4465 required
4466 maxlength={256}
4467 placeholder="Pull request title"
4468 />
4469 <div class="prs-edit-actions">
4470 <button type="submit" class="prs-edit-save-btn">Save</button>
4471 <button
4472 type="button"
4473 class="prs-edit-cancel-btn"
4474 onclick={`
4475 document.getElementById('pr-edit-form').style.display='none';
4476 document.getElementById('pr-title-display').style.display='';
4477 document.getElementById('pr-edit-toggle').style.display='';
4478 `}
4479 >
4480 Cancel
4481 </button>
4482 </div>
4483 </form>
4484 )}
b078860Claude4485 <div class="prs-detail-meta">
f6730d0ccantynz-alt4486 <GxBadge variant={stateVariant}>
b078860Claude4487 <span aria-hidden="true">{stateIcon}</span>
4488 <span>{stateLabel}</span>
f6730d0ccantynz-alt4489 </GxBadge>
2c61840Claude4490 {isAiGeneratedPr(pr.body, pr.headBranch) && (
4491 <span class="prs-tag is-ai" title="This pull request was opened by an AI agent">⚡ AI-generated</span>
4492 )}
74d8c4dClaude4493 {prSizeInfo && (
4494 <span
4495 class="prs-size-badge"
4496 style={`color:${prSizeInfo.color};background:${prSizeInfo.bgColor}`}
4497 title={`${prSizeInfo.linesChanged} lines changed (+${prSizeInfo.added} −${prSizeInfo.deleted})`}
4498 >
4499 {prSizeInfo.label}
4500 </span>
4501 )}
67dc4e1Claude4502 <TrioVerdictPills
4503 comments={comments.map(({ comment }) => comment)}
4504 />
b078860Claude4505 <span>
4506 <strong>{author?.username}</strong> wants to merge
4507 </span>
4508 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
4509 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
4510 <span class="prs-branch-arrow-lg">{"→"}</span>
4511 <span class="prs-branch-pill">{pr.baseBranch}</span>
4512 </span>
0369e77Claude4513 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
4514 <span
4515 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
4516 title={branchBehind > 0
4517 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
4518 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
4519 >
4520 {branchAhead > 0 ? `↑${branchAhead}` : ""}
4521 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
4522 {branchBehind > 0 ? `↓${branchBehind}` : ""}
4523 </span>
4524 )}
b078860Claude4525 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude4526 {taskTotal > 0 && (
4527 <span
4528 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
4529 title={`${taskChecked} of ${taskTotal} tasks completed`}
4530 >
4531 <span class="prs-tasks-progress" aria-hidden="true">
4532 <span
4533 class="prs-tasks-progress-bar"
4534 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
4535 ></span>
4536 </span>
4537 {taskChecked}/{taskTotal} tasks
4538 </span>
4539 )}
4540 {canManage && pr.state === "open" && branchBehind > 0 && (
4541 <form
4542 method="post"
4543 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
4544 class="prs-inline-form"
4545 >
4546 <button
4547 type="submit"
4548 class="prs-update-branch-btn"
4549 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
4550 >
4551 ↑ Update branch
4552 </button>
4553 </form>
4554 )}
3c03977Claude4555 <span
4556 id="live-pill"
4557 class="live-pill"
4558 title="People editing this PR right now"
4559 >
4560 <span class="live-pill-dot" aria-hidden="true"></span>
4561 <span>
4562 Live: <strong id="live-count">0</strong> editing
4563 </span>
4564 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
4565 </span>
4bbacbeClaude4566 {preview && (
4567 <a
4568 class={`preview-prpill is-${preview.status}`}
4569 href={
4570 preview.status === "ready"
4571 ? preview.previewUrl
4572 : `/${ownerName}/${repoName}/previews`
4573 }
4574 target={preview.status === "ready" ? "_blank" : undefined}
4575 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
4576 title={`Preview · ${previewStatusLabel(preview.status)}`}
4577 >
4578 <span class="preview-prpill-dot" aria-hidden="true"></span>
4579 <span>Preview: </span>
4580 <span>{previewStatusLabel(preview.status)}</span>
4581 </a>
4582 )}
b078860Claude4583 {canManage && pr.state === "open" && pr.isDraft && (
4584 <form
4585 method="post"
4586 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4587 class="prs-inline-form prs-detail-actions"
4588 >
4589 <button type="submit" class="prs-merge-ready-btn">
4590 Ready for review
4591 </button>
4592 </form>
4593 )}
4594 </div>
4595 </div>
3c03977Claude4596 <script
4597 dangerouslySetInnerHTML={{
4598 __html: LIVE_COEDIT_SCRIPT(pr.id),
4599 }}
4600 />
829a046Claude4601 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude4602 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude4603 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude4604
25b1ff7Claude4605 {/* Presence styles + bar (shown only on the files tab so cursor pills work) */}
b271465Claude4606 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES + IMPACT_STYLES }} />
25b1ff7Claude4607 {/* Toast container — always present for join/leave toasts */}
4608 <div id="presence-toasts" class="presence-toast-wrap" aria-live="polite" />
4609 {user && (
4610 <>
4611 <div class="presence-bar" id="presence-bar">
4612 <span class="presence-bar-label">Live reviewers</span>
4613 <div class="presence-avatars" id="presence-avatars" />
4614 <span class="presence-count" id="presence-count">Loading…</span>
4615 </div>
4616 <script
4617 dangerouslySetInnerHTML={{
4618 __html: PR_PRESENCE_SCRIPT(ownerName, repoName, pr.number),
4619 }}
4620 />
4621 </>
4622 )}
4623
b078860Claude4624 <nav class="prs-detail-tabs" aria-label="Pull request sections">
4625 <a
4626 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
4627 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
4628 >
4629 Conversation
4630 <span class="prs-detail-tab-count">{commentCount}</span>
4631 </a>
b558f23Claude4632 <a
4633 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
4634 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
4635 >
4636 Commits
4637 {branchAhead > 0 && (
4638 <span class="prs-detail-tab-count">{branchAhead}</span>
4639 )}
4640 </a>
b078860Claude4641 <a
4642 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
4643 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4644 >
4645 Files changed
4646 {diffFiles.length > 0 && (
4647 <span class="prs-detail-tab-count">{diffFiles.length}</span>
4648 )}
4649 </a>
4650 </nav>
4651
34e63b9Claude4652 {/* Proactive pattern warning — shown when a known recurring bug pattern
4653 overlaps with the files changed in this PR. */}
4654 {patternWarning && (
4655 <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">
4656 <span style="font-size:15px;margin-right:6px" aria-hidden="true">⚠️</span>
4657 <strong>Recurring pattern detected: {patternWarning.title}</strong>
4658 <span style="color:var(--fg-muted)">
4659 {" — "}
4660 This area has had {patternWarning.occurrences} similar fix
4661 {patternWarning.occurrences === 1 ? "" : "es"}.
4662 {patternWarning.rootCauseHypothesis && (
4663 <> Root cause may be in <code style="font-size:12px">{patternWarning.suggestedFile}</code>.</>
4664 )}
4665 </span>
4666 </div>
4667 )}
4668
b558f23Claude4669 {tab === "commits" ? (
4670 <div class="prs-commits-list">
4671 {prCommits.length === 0 ? (
4672 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
4673 ) : (
4674 prCommits.map((commit) => (
4675 <div class="prs-commit-row">
4676 <span class="prs-commit-dot" aria-hidden="true"></span>
4677 <div class="prs-commit-body">
4678 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
4679 <div class="prs-commit-meta">
4680 <strong>{commit.author}</strong> committed{" "}
4681 {formatRelative(new Date(commit.date))}
4682 </div>
4683 </div>
4684 <a
4685 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
4686 class="prs-commit-sha"
4687 title="View commit"
4688 >
4689 {commit.sha.slice(0, 7)}
4690 </a>
4691 </div>
4692 ))
4693 )}
4694 </div>
4695 ) : tab === "files" ? (
1d6db4dClaude4696 <>
4697 {/* PR Split Suggestion — shown when PR has >400 changed lines */}
4698 {splitSuggestion && (
4699 <div class="split-suggestion" id="pr-split-banner">
4700 <div class="split-header">
4701 <span class="split-icon" aria-hidden="true">✂️</span>
4702 <strong>This PR may be too large to review effectively</strong>
4703 <span class="split-stat">
4704 {splitSuggestion.totalLines} lines · {splitSuggestion.totalFiles} files
4705 </span>
4706 <button
4707 class="split-toggle"
4708 type="button"
4709 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';"
4710 >
4711 Show split suggestion
4712 </button>
4713 </div>
4714 <div class="split-body" id="pr-split-body" hidden>
4715 <p class="split-intro">
4716 AI suggests splitting into {splitSuggestion.suggestedPrs.length} PRs:
4717 </p>
4718 {splitSuggestion.suggestedPrs.map((sp, i) => (
4719 <div class="split-pr">
4720 <div class="split-pr-num">{i + 1}</div>
4721 <div class="split-pr-body">
4722 <strong>{sp.title}</strong>
4723 <p>{sp.rationale}</p>
4724 <code>{sp.files.join(", ")}</code>
4725 <span class="split-lines">~{sp.estimatedLines} lines</span>
4726 </div>
4727 </div>
4728 ))}
4729 {splitSuggestion.mergeOrder.length > 0 && (
4730 <p class="split-order">
4731 Suggested merge order:{" "}
4732 <strong>{splitSuggestion.mergeOrder.join(" → ")}</strong>
4733 </p>
4734 )}
4735 </div>
4736 </div>
4737 )}
4738
4739 {/* Bus Factor Warning — shown when changed files overlap at-risk files */}
4740 {busRiskFiles.length > 0 && (() => {
4741 const topRisk = busRiskFiles.some((f) => f.risk === "critical")
4742 ? "critical"
4743 : busRiskFiles.some((f) => f.risk === "high")
4744 ? "high"
4745 : "medium";
4746 return (
4747 <div class={`busfactor-panel busfactor-${topRisk}`}>
4748 <span class="busfactor-icon" aria-hidden="true">⚠️</span>
4749 <div class="busfactor-body">
4750 <strong>Knowledge concentration warning</strong>
4751 <p>
4752 {busRiskFiles.length} file{busRiskFiles.length !== 1 ? "s" : ""} in
4753 this PR {busRiskFiles.length !== 1 ? "are" : "is"} primarily
4754 maintained by one person. Consider pairing on this review.
4755 </p>
4756 <ul>
4757 {busRiskFiles.map((f) => (
4758 <li>
4759 <code>{f.path}</code> —{" "}
4760 <strong>{f.primaryAuthorPct}%</strong> by {f.primaryAuthor}
4761 </li>
4762 ))}
4763 </ul>
4764 </div>
4765 </div>
4766 );
4767 })()}
4768
4769 <DiffView
4770 raw={diffRaw}
4771 files={diffFiles}
4772 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
4773 inlineComments={diffInlineComments}
4774 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
4775 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
a2c10c5Claude4776 pendingReviewUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/review/pending/add` : undefined}
4777 submitReviewUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/review/submit` : undefined}
4778 isSplit={isSplit}
4779 pendingCount={pendingCount}
4780 owner={ownerName}
4781 repo={repoName}
4782 prNumber={pr.number}
1d6db4dClaude4783 />
4784 </>
b078860Claude4785 ) : (
4786 <>
4787 {pr.body && (
4788 <CommentBox
4789 author={author?.username ?? "unknown"}
4790 date={pr.createdAt}
4791 body={renderMarkdown(pr.body)}
4792 />
4793 )}
4794
422a2d4Claude4795 {/* Block H — AI trio review (security/correctness/style). When
4796 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
4797 hoisted into a 3-column card grid above the normal comment
4798 stream so reviewers see verdicts at a glance. Disagreements
4799 are surfaced as a yellow callout. */}
4800 <TrioReviewGrid
4801 comments={comments.map(({ comment }) => comment)}
4802 />
4803
15db0e0Claude4804 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude4805 // Skip trio comments — already rendered in TrioReviewGrid above.
4806 if (isTrioComment(comment.body)) return null;
15db0e0Claude4807 const slashCmd = detectSlashCmdComment(comment.body);
4808 if (slashCmd) {
4809 const visible = stripSlashCmdMarker(comment.body);
4810 return (
4811 <div class={`slash-pill slash-cmd-${slashCmd}`}>
4812 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
4813 <span class="slash-pill-actor">
4814 <strong>{commentAuthor.username}</strong>
4815 {" ran "}
4816 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude4817 </span>
15db0e0Claude4818 <span class="slash-pill-time">
4819 {formatRelative(comment.createdAt)}
4820 </span>
4821 <div class="slash-pill-body">
4822 <MarkdownContent html={renderMarkdown(visible)} />
4823 </div>
4824 </div>
4825 );
4826 }
cb5a796Claude4827 const isPending = comment.moderationStatus === "pending";
15db0e0Claude4828 return (
cb5a796Claude4829 <div
4830 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
4831 >
15db0e0Claude4832 <div class="prs-comment-head">
4833 <strong>{commentAuthor.username}</strong>
a7460bfClaude4834 {commentAuthor.username === BOT_USERNAME && (
4835 <span class="prs-bot-badge">&#x1F916; bot</span>
4836 )}
15db0e0Claude4837 {comment.isAiReview && (
4838 <span class="prs-ai-badge">AI Review</span>
4839 )}
cb5a796Claude4840 {isPending && (
4841 <span
4842 class="modq-pending-badge"
4843 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
4844 >
4845 Awaiting approval
4846 </span>
4847 )}
15db0e0Claude4848 <span class="prs-comment-time">
4849 commented {formatRelative(comment.createdAt)}
4850 </span>
4851 {comment.filePath && (
4852 <span class="prs-comment-loc">
4853 {comment.filePath}
4854 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
4855 </span>
4856 )}
4857 </div>
4858 <div class="prs-comment-body">
4859 <MarkdownContent html={renderMarkdown(comment.body)} />
4860 </div>
0074234Claude4861 </div>
15db0e0Claude4862 );
4863 })}
0074234Claude4864
b078860Claude4865 {/* Quick link to the Files changed tab when there's a diff to look at. */}
4866 {pr.state !== "merged" && (
4867 <a
4868 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4869 class="prs-files-card"
4870 >
4871 <span class="prs-files-card-icon" aria-hidden="true">
4872 {"▤"}
4873 </span>
4874 <div class="prs-files-card-text">
4875 <p class="prs-files-card-title">Files changed</p>
4876 <p class="prs-files-card-sub">
4877 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
4878 </p>
e883329Claude4879 </div>
b078860Claude4880 <span class="prs-files-card-cta">View diff {"→"}</span>
4881 </a>
4882 )}
4883
6d1bbc2Claude4884 {linkedIssues.length > 0 && (
4885 <div class="prs-linked-issues">
4886 <div class="prs-linked-issues-head">
4887 <span>Closing issues</span>
4888 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
4889 </div>
4890 {linkedIssues.map((issue) => (
4891 <a
4892 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
4893 class="prs-linked-issue-row"
4894 >
4895 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
4896 {issue.state === "open" ? "○" : "✓"}
4897 </span>
4898 <span class="prs-linked-issue-title">{issue.title}</span>
4899 <span class="prs-linked-issue-num">#{issue.number}</span>
4900 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
4901 {issue.state}
4902 </span>
4903 </a>
4904 ))}
4905 </div>
4906 )}
4907
b078860Claude4908 {error && (
4909 <div
4910 class="auth-error"
4911 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)"
4912 >
4913 {decodeURIComponent(error)}
4914 </div>
4915 )}
4916
4917 {info && (
4918 <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)">
4919 {decodeURIComponent(info)}
4920 </div>
4921 )}
e883329Claude4922
b078860Claude4923 {pr.state === "open" && (prRisk || prRiskCalculating) && (
4924 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
4925 )}
4926
ec9e3e3Claude4927 {/* ─── Requested reviewers (CODEOWNERS auto-assign, migration 0077) ─── */}
4928 {requestedReviewerRows.length > 0 && (
4929 <div class="prs-review-summary" style="margin-top:14px">
4930 <div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-weight:700;margin-bottom:4px">
4931 Review requested
4932 </div>
4933 {requestedReviewerRows.map((rr) => {
4934 const hasReviewed = latestReviewByReviewer.has(rr.reviewerId);
4935 const review = latestReviewByReviewer.get(rr.reviewerId);
4936 const statusIcon = !hasReviewed ? "⏳" : review?.state === "approved" ? "✓" : "✗";
4937 const statusColor = !hasReviewed
4938 ? "var(--text-muted)"
4939 : review?.state === "approved"
e589f77ccantynz-alt4940 ? "var(--green)"
4941 : "var(--red)";
ec9e3e3Claude4942 return (
4943 <div class="prs-review-row" style={`gap:8px`}>
4944 <span class="prs-reviewer-avatar">
4945 {rr.reviewerUsername.slice(0, 1).toUpperCase()}
4946 </span>
4947 <a href={`/${rr.reviewerUsername}`}
4948 style="flex:1;font-size:13px;color:var(--text);font-weight:600;text-decoration:none">
4949 {rr.reviewerUsername}
4950 </a>
4951 <span style={`font-size:12px;font-weight:600;color:${statusColor}`}>
4952 {statusIcon} {!hasReviewed ? "Pending" : review?.state === "approved" ? "Approved" : "Changes requested"}
4953 </span>
4954 </div>
4955 );
4956 })}
4957 </div>
b271465Claude4958 )}
09d5f39Claude4959 {/* ─── Merge Impact Analysis panel ─────────────────────── */}
4960 {impactAnalysis && pr.state === "open" && (
4961 <ImpactPanel analysis={impactAnalysis} owner={ownerName} />
ec9e3e3Claude4962 )}
4963
0a67773Claude4964 {/* ─── Review summary ─────────────────────────────────── */}
4965 {(approvals.length > 0 || changesRequested.length > 0) && (
4966 <div class="prs-review-summary">
4967 {approvals.length > 0 && (
4968 <div class="prs-review-row prs-review-approved">
4969 <span class="prs-review-icon">✓</span>
4970 <span>
4971 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4972 approved this pull request
4973 </span>
4974 </div>
4975 )}
4976 {changesRequested.length > 0 && (
4977 <div class="prs-review-row prs-review-changes">
4978 <span class="prs-review-icon">✗</span>
4979 <span>
4980 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4981 requested changes
4982 </span>
4983 </div>
4984 )}
4985 </div>
4986 )}
4987
ace34efClaude4988 {/* Suggested reviewers */}
4989 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
4990 <div class="prs-review-summary" style="margin-top:12px">
4991 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
4992 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
4993 Suggested reviewers
4994 </span>
4995 {reviewerSuggestions.map((r) => (
4996 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
4997 style="display:flex;align-items:center;gap:8px;width:100%">
4998 <input type="hidden" name="reviewerId" value={r.userId} />
4999 <span class="prs-reviewer-avatar">
5000 {r.username.slice(0, 1).toUpperCase()}
5001 </span>
5002 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
5003 {r.username}
5004 </a>
5005 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
5006 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
5007 Request
5008 </button>
5009 </form>
5010 ))}
5011 </div>
5012 </div>
5013 )}
5014
b078860Claude5015 {pr.state === "open" && gateChecks.length > 0 && (
5016 <div class="prs-gate-card">
5017 <div class="prs-gate-head">
5018 <h3>Gate checks</h3>
5019 <span class="prs-gate-summary">
5020 {gatesAllPassed
5021 ? `All ${gateChecks.length} checks passed`
5022 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
5023 </span>
c3e0c07Claude5024 </div>
b078860Claude5025 {gateChecks.map((check) => {
5026 const isAi = /ai.*review/i.test(check.name);
5027 const isSkip = check.skipped === true;
5028 const statusClass = isSkip
5029 ? "is-skip"
5030 : check.passed
5031 ? "is-pass"
5032 : "is-fail";
5033 const statusGlyph = isSkip
5034 ? "—"
5035 : check.passed
5036 ? "✓"
5037 : "✗";
5038 const statusLabel = isSkip
5039 ? "Skipped"
5040 : check.passed
5041 ? "Passed"
5042 : "Failing";
5043 return (
5044 <div
5045 class="prs-gate-row"
5046 style={
5047 isAi
6fd5915Claude5048 ? "border-left: 3px solid rgba(91,110,232,0.55); padding-left: 15px"
b078860Claude5049 : ""
5050 }
5051 >
5052 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
5053 {statusGlyph}
5054 </span>
5055 <span class="prs-gate-name">
5056 {check.name}
5057 {isAi && (
5058 <span
6fd5915Claude5059 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"
b078860Claude5060 >
5061 AI
5062 </span>
5063 )}
5064 </span>
5065 <span class="prs-gate-details">{check.details}</span>
5066 <span class={`prs-gate-pill ${statusClass}`}>
5067 {statusLabel}
e883329Claude5068 </span>
5069 </div>
b078860Claude5070 );
5071 })}
5072 <div class="prs-gate-footer">
5073 {gatesAllPassed
5074 ? "All checks passed — ready to merge."
5075 : gateChecks.some(
5076 (c) => !c.passed && c.name === "Merge check"
5077 )
5078 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
5079 : "Some checks failed — resolve issues before merging."}
5080 {aiReviewCount > 0 && (
5081 <>
5082 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
5083 </>
5084 )}
5085 </div>
5086 </div>
5087 )}
5088
240c477Claude5089 {pr.state === "open" && ciStatuses.length > 0 && (
5090 <div class="prs-ci-card">
5091 <div class="prs-ci-head">
5092 <h3>CI checks</h3>
5093 <span class="prs-ci-summary">
5094 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
5095 </span>
5096 </div>
5097 {ciStatuses.map((status) => {
5098 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
5099 return (
5100 <div class="prs-ci-row">
5101 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
5102 <span class="prs-ci-context">{status.context}</span>
5103 {status.description && (
5104 <span class="prs-ci-desc">{status.description}</span>
5105 )}
5106 <span class={`prs-ci-pill is-${status.state}`}>
5107 {status.state}
5108 </span>
5109 {status.targetUrl && (
5110 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
5111 )}
5112 </div>
5113 );
5114 })}
5115 </div>
5116 )}
5117
b078860Claude5118 {/* ─── Merge area / state-aware action card ─────────────── */}
5119 {user && pr.state === "open" && (
5120 <div
5121 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
5122 >
5123 <div class="prs-merge-head">
5124 <strong>
5125 {pr.isDraft
5126 ? "Draft — ready for review?"
5127 : mergeBlocked
5128 ? "Merge blocked"
5129 : "Ready to merge"}
5130 </strong>
e883329Claude5131 </div>
b078860Claude5132 <p class="prs-merge-sub">
5133 {pr.isDraft
5134 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
5135 : mergeBlocked
5136 ? "Resolve the failing gate checks above before this PR can land."
5137 : gateChecks.length > 0
5138 ? gatesAllPassed
5139 ? "All gates green. Merge will fast-forward into the base branch."
5140 : "Conflicts will be auto-resolved by GlueCron AI on merge."
5141 : "Run gate checks by refreshing once your branch has a recent commit."}
5142 </p>
5143 <Form
5144 method="post"
5145 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
5146 >
5147 <FormGroup>
3c03977Claude5148 <div class="live-cursor-host" style="position:relative">
5149 <textarea
5150 name="body"
5151 id="pr-comment-body"
5152 data-live-field="comment_new"
6cd2f0eClaude5153 data-md-preview=""
3c03977Claude5154 rows={5}
5155 required
5156 placeholder="Leave a comment... (Markdown supported)"
5157 style="font-family:var(--font-mono);font-size:13px;width:100%"
5158 ></textarea>
5159 </div>
15db0e0Claude5160 <span class="slash-hint" title="Type a slash-command as the first line">
5161 Type <code>/</code> for commands —{" "}
5162 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
09d5f39Claude5163 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>,{" "}
5164 <code>/stage</code>
15db0e0Claude5165 </span>
b078860Claude5166 </FormGroup>
5167 <div class="prs-merge-actions">
5168 <Button type="submit" variant="primary">
5169 Comment
5170 </Button>
0a67773Claude5171 {user && user.id !== pr.authorId && pr.state === "open" && (
5172 <>
5173 <button
5174 type="submit"
5175 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
5176 name="review_state"
5177 value="approved"
5178 class="prs-review-approve-btn"
5179 title="Approve this pull request"
5180 >
5181 ✓ Approve
5182 </button>
5183 <button
5184 type="submit"
5185 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
5186 name="review_state"
5187 value="changes_requested"
5188 class="prs-review-changes-btn"
5189 title="Request changes before merging"
5190 >
5191 ✗ Request changes
5192 </button>
5193 </>
5194 )}
b078860Claude5195 {canManage && (
5196 <>
5197 {pr.isDraft ? (
5198 <button
5199 type="submit"
5200 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
5201 formnovalidate
5202 class="prs-merge-ready-btn"
5203 >
5204 Ready for review
5205 </button>
5206 ) : (
a164a6dClaude5207 <>
5208 <div class="prs-merge-strategy-wrap">
5209 <span class="prs-merge-strategy-label">Strategy</span>
5210 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
5211 <option value="merge">Merge commit</option>
5212 <option value="squash">Squash and merge</option>
5213 <option value="ff">Fast-forward</option>
5214 </select>
5215 </div>
5216 <button
5217 type="submit"
5218 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
5219 formnovalidate
5220 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
5221 title={
5222 mergeBlocked
5223 ? "Failing gate checks must be resolved before this PR can merge."
5224 : "Merge pull request"
5225 }
5226 >
5227 {"✔"} Merge pull request
5228 </button>
5229 </>
b078860Claude5230 )}
5231 {!pr.isDraft && (
5232 <button
0074234Claude5233 type="submit"
b078860Claude5234 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
5235 formnovalidate
5236 class="prs-merge-back-draft"
5237 title="Convert back to draft"
0074234Claude5238 >
b078860Claude5239 Convert to draft
5240 </button>
5241 )}
5242 {isAiReviewEnabled() && (
5243 <button
5244 type="submit"
5245 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
5246 formnovalidate
5247 class="btn"
5248 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
5249 >
5250 Re-run AI review
5251 </button>
5252 )}
1d4ff60Claude5253 {isAiReviewEnabled() && !hasAiTestsMarker && (
5254 <button
5255 type="submit"
5256 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
5257 formnovalidate
5258 class="btn"
5259 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."
5260 >
5261 Generate tests with AI
5262 </button>
5263 )}
b078860Claude5264 <Button
5265 type="submit"
5266 variant="danger"
5267 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
5268 >
5269 Close
5270 </Button>
5271 </>
5272 )}
5273 </div>
5274 </Form>
5275 </div>
5276 )}
5277
5278 {/* Read-only footers for non-open states. */}
5279 {pr.state === "merged" && (
5280 <div class="prs-merge-card is-merged">
5281 <div class="prs-merge-head">
5282 <strong>{"⮌"} Merged</strong>
0074234Claude5283 </div>
b078860Claude5284 <p class="prs-merge-sub">
5285 This pull request was merged into{" "}
5286 <code>{pr.baseBranch}</code>.
5287 </p>
5288 </div>
5289 )}
5290 {pr.state === "closed" && (
5291 <div class="prs-merge-card is-closed">
5292 <div class="prs-merge-head">
5293 <strong>{"✕"} Closed without merging</strong>
5294 </div>
5295 <p class="prs-merge-sub">
5296 This pull request was closed and not merged.
5297 </p>
5298 </div>
5299 )}
5300 </>
5301 )}
641aa42Claude5302 {/* Keyboard hint bar — shown at the bottom of PR pages */}
5303 <div class="kbd-hints" aria-label="Keyboard shortcuts for this pull request">
5304 <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
5305 </div>
5306 <style dangerouslySetInnerHTML={{ __html: `
5307 .kbd-hints {
5308 position: fixed;
5309 bottom: 0;
5310 left: 0;
5311 right: 0;
5312 z-index: 90;
5313 padding: 6px 24px;
5314 background: var(--bg-secondary);
5315 border-top: 1px solid var(--border);
5316 font-size: 12px;
5317 color: var(--text-muted);
5318 display: flex;
5319 align-items: center;
5320 gap: 8px;
5321 flex-wrap: wrap;
5322 }
5323 .kbd-hints kbd {
5324 font-family: var(--font-mono);
5325 font-size: 10px;
5326 background: var(--bg-elevated);
5327 border: 1px solid var(--border);
5328 border-bottom-width: 2px;
5329 border-radius: 4px;
5330 padding: 1px 5px;
5331 color: var(--text);
5332 line-height: 1.5;
5333 }
5334 /* Padding so the page footer doesn't overlap the hint bar */
5335 main { padding-bottom: 40px; }
5336 ` }} />
5337 {/* Repo context commands for command palette */}
5338 <script
5339 id="cmdk-repo-context"
5340 dangerouslySetInnerHTML={{
5341 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
5342 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
5343 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
5344 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
5345 { label: `View commits — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/commits`, kw: "history log" },
5346 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
5347 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
5348 ])};`,
5349 }}
5350 />
5351 {/* PR keyboard shortcuts script */}
5352 <script dangerouslySetInnerHTML={{ __html: `
5353 (function(){
5354 var commentBox = document.querySelector('textarea[name="body"]');
5355 var mergeBtn = document.querySelector('[data-merge-btn], .prs-merge-btn, button[form*="merge"], form[action*="/merge"] button[type="submit"]');
5356 var editBtn = document.getElementById('pr-edit-toggle');
5357 var approveUrl = ${JSON.stringify(`/${ownerName}/${repoName}/pulls/${pr.number}/review`)};
5358
5359 function isTyping(t){
5360 t = t || {};
5361 var tag = (t.tagName || '').toLowerCase();
5362 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
5363 }
5364
5365 document.addEventListener('keydown', function(e){
5366 if (isTyping(e.target)) return;
5367 if (e.metaKey || e.ctrlKey || e.altKey) return;
5368 if (e.key === 'c') {
5369 e.preventDefault();
5370 if (commentBox) { commentBox.focus(); commentBox.scrollIntoView({block:'center'}); }
5371 }
5372 if (e.key === 'e') {
5373 e.preventDefault();
5374 if (editBtn) { editBtn.click(); }
5375 }
5376 if (e.key === 'm') {
5377 e.preventDefault();
5378 var mBtn = document.querySelector('.prs-merge-btn, form[action*="/merge"] button[type="submit"]');
5379 if (mBtn) { mBtn.focus(); mBtn.scrollIntoView({block:'center'}); }
5380 }
5381 if (e.key === 'a') {
5382 e.preventDefault();
5383 // Navigate to approve review page
5384 window.location.href = approveUrl + '?action=approve';
5385 }
5386 if (e.key === 'r') {
5387 e.preventDefault();
5388 window.location.href = approveUrl + '?action=request_changes';
5389 }
5390 if (e.key === 'Escape') {
5391 var focused = document.activeElement;
5392 if (focused) focused.blur();
5393 }
5394 });
5395 })();
5396 ` }} />
0074234Claude5397 </Layout>
5398 );
5399});
5400
6d1bbc2Claude5401// Update branch — merge base into head so the PR branch is up to date.
5402// Uses a git worktree so the bare repo stays clean. Write access required.
5403pulls.post(
5404 "/:owner/:repo/pulls/:number/update-branch",
5405 softAuth,
5406 requireAuth,
5407 requireRepoAccess("write"),
5408 async (c) => {
5409 const { owner: ownerName, repo: repoName } = c.req.param();
5410 const prNum = parseInt(c.req.param("number"), 10);
5411 const user = c.get("user")!;
5412 const resolved = await resolveRepo(ownerName, repoName);
5413 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5414
5415 const [pr] = await db
5416 .select()
5417 .from(pullRequests)
5418 .where(and(
5419 eq(pullRequests.repositoryId, resolved.repo.id),
5420 eq(pullRequests.number, prNum),
5421 ))
5422 .limit(1);
5423 if (!pr || pr.state !== "open") {
5424 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5425 }
5426
5427 const repoDir = getRepoPath(ownerName, repoName);
5428 const wt = `${repoDir}/_update_wt_${Date.now()}`;
5429 const gitEnv = {
5430 ...process.env,
5431 GIT_AUTHOR_NAME: user.displayName || user.username,
5432 GIT_AUTHOR_EMAIL: user.email,
5433 GIT_COMMITTER_NAME: user.displayName || user.username,
5434 GIT_COMMITTER_EMAIL: user.email,
5435 };
5436
5437 const addWt = Bun.spawn(
5438 ["git", "worktree", "add", wt, pr.headBranch],
5439 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5440 );
5441 if (await addWt.exited !== 0) {
5442 return c.redirect(
5443 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
5444 );
5445 }
5446
5447 let ok = false;
5448 try {
5449 const mergeProc = Bun.spawn(
5450 ["git", "merge", "--no-edit", pr.baseBranch],
5451 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
5452 );
5453 if (await mergeProc.exited === 0) {
5454 ok = true;
5455 } else {
5456 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5457 }
5458 } catch {
5459 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5460 }
5461
5462 await Bun.spawn(
5463 ["git", "worktree", "remove", "--force", wt],
5464 { cwd: repoDir }
5465 ).exited.catch(() => {});
5466
5467 if (ok) {
5468 return c.redirect(
5469 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
5470 );
5471 }
5472 return c.redirect(
5473 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
5474 );
5475 }
5476);
5477
b558f23Claude5478// Edit PR title (and optionally body). Owner or author only.
5479pulls.post(
5480 "/:owner/:repo/pulls/:number/edit",
5481 softAuth,
5482 requireAuth,
5483 requireRepoAccess("write"),
5484 async (c) => {
5485 const { owner: ownerName, repo: repoName } = c.req.param();
5486 const prNum = parseInt(c.req.param("number"), 10);
5487 const user = c.get("user")!;
5488 const resolved = await resolveRepo(ownerName, repoName);
5489 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5490
5491 const [pr] = await db
5492 .select()
5493 .from(pullRequests)
5494 .where(and(
5495 eq(pullRequests.repositoryId, resolved.repo.id),
5496 eq(pullRequests.number, prNum),
5497 ))
5498 .limit(1);
5499 if (!pr || pr.state !== "open") {
5500 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5501 }
5502 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
5503 if (!canEdit) {
5504 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5505 }
5506
5507 const body = await c.req.parseBody();
5508 const newTitle = String(body.title || "").trim().slice(0, 256);
5509 if (!newTitle) {
5510 return c.redirect(
5511 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
5512 );
5513 }
5514
5515 await db
5516 .update(pullRequests)
5517 .set({ title: newTitle, updatedAt: new Date() })
5518 .where(eq(pullRequests.id, pr.id));
5519
5520 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
5521 }
5522);
5523
cb5a796Claude5524// Add comment to PR.
5525//
5526// Permission model mirrors `issues.tsx`: any logged-in user with read
5527// access can submit; `decideInitialStatus` routes non-collaborators
5528// through the moderation queue. Slash commands only fire when the
5529// comment is auto-approved — we don't want a banned/pending comment to
5530// silently trigger AI work on the PR.
0074234Claude5531pulls.post(
5532 "/:owner/:repo/pulls/:number/comment",
5533 softAuth,
5534 requireAuth,
cb5a796Claude5535 requireRepoAccess("read"),
0074234Claude5536 async (c) => {
5537 const { owner: ownerName, repo: repoName } = c.req.param();
5538 const prNum = parseInt(c.req.param("number"), 10);
5539 const user = c.get("user")!;
5540 const body = await c.req.parseBody();
5541 const commentBody = String(body.body || "").trim();
47a7a0aClaude5542 const filePathRaw = String(body.file_path || "").trim();
5543 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
5544 const inlineFilePath = filePathRaw || undefined;
5545 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude5546
5547 if (!commentBody) {
5548 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5549 }
5550
5551 const resolved = await resolveRepo(ownerName, repoName);
5552 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5553
5554 const [pr] = await db
5555 .select()
5556 .from(pullRequests)
5557 .where(
5558 and(
5559 eq(pullRequests.repositoryId, resolved.repo.id),
5560 eq(pullRequests.number, prNum)
5561 )
5562 )
5563 .limit(1);
5564
5565 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5566
cb5a796Claude5567 const decision = await decideInitialStatus({
5568 commenterUserId: user.id,
5569 repositoryId: resolved.repo.id,
5570 kind: "pr",
5571 threadId: pr.id,
5572 });
5573
d4ac5c3Claude5574 const [inserted] = await db
5575 .insert(prComments)
5576 .values({
5577 pullRequestId: pr.id,
5578 authorId: user.id,
5579 body: commentBody,
cb5a796Claude5580 moderationStatus: decision.status,
47a7a0aClaude5581 filePath: inlineFilePath,
5582 lineNumber: inlineLineNumber,
d4ac5c3Claude5583 })
5584 .returning();
5585
cb5a796Claude5586 // Live update: only when the comment is actually visible.
5587 if (inserted && decision.status === "approved") {
b7b5f75ccanty labs5588 void logActivity({
5589 repositoryId: resolved.repo.id,
5590 userId: user.id,
5591 action: "comment",
5592 targetType: "pull_request",
5593 targetId: String(prNum),
5594 metadata: { commentId: inserted.id },
5595 });
a74f4edccanty labs5596 void fireWebhooks(resolved.repo.id, "pr", {
5597 action: "commented",
5598 number: prNum,
5599 });
b7b5f75ccanty labs5600
d4ac5c3Claude5601 try {
5602 const { publish } = await import("../lib/sse");
5603 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
5604 event: "pr-comment",
5605 data: {
5606 pullRequestId: pr.id,
5607 commentId: inserted.id,
5608 authorId: user.id,
5609 authorUsername: user.username,
5610 },
5611 });
5612 } catch {
5613 /* SSE is best-effort */
5614 }
b7ecb14Claude5615 // Notify the PR author — fire-and-forget, never blocks the response.
5616 if (pr.authorId && pr.authorId !== user.id) {
5617 void import("../lib/notify").then(({ createNotification }) =>
5618 createNotification({
5619 userId: pr.authorId,
5620 type: "pr_comment",
5621 title: `New comment on "${pr.title}"`,
5622 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
5623 url: `/${ownerName}/${repoName}/pulls/${prNum}`,
5624 repoId: resolved.repo.id,
5625 })
5626 ).catch(() => { /* never block the response */ });
5627 }
d4ac5c3Claude5628 }
0074234Claude5629
cb5a796Claude5630 if (decision.status === "pending") {
5631 void notifyOwnerOfPendingComment({
5632 repositoryId: resolved.repo.id,
5633 commenterUsername: user.username,
5634 kind: "pr",
5635 threadNumber: prNum,
5636 ownerUsername: ownerName,
5637 repoName,
5638 });
5639 return c.redirect(
5640 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5641 );
5642 }
5643 if (decision.status === "rejected") {
5644 // Silent ban path — same UX as 'pending' so we don't leak the gate.
5645 return c.redirect(
5646 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5647 );
5648 }
5649
15db0e0Claude5650 // Slash-command handoff. We always store the original comment above
5651 // first so free-form text that happens to start with `/` is preserved
5652 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude5653 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude5654 const parsed = parseSlashCommand(commentBody);
5655 if (parsed) {
5656 try {
5657 const result = await executeSlashCommand({
5658 command: parsed.command,
5659 args: parsed.args,
5660 prId: pr.id,
5661 userId: user.id,
5662 repositoryId: resolved.repo.id,
5663 });
5664 await db.insert(prComments).values({
5665 pullRequestId: pr.id,
5666 authorId: user.id,
5667 body: result.body,
5668 });
5669 } catch (err) {
5670 // Defence-in-depth — executeSlashCommand promises not to throw,
5671 // but if it ever does we want the PR thread to know.
5672 await db
5673 .insert(prComments)
5674 .values({
5675 pullRequestId: pr.id,
5676 authorId: user.id,
5677 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
5678 })
5679 .catch(() => {});
5680 }
5681 }
5682
47a7a0aClaude5683 // Inline comments go back to the files tab; conversation comments to the conversation tab
5684 const redirectTab = inlineFilePath ? "?tab=files" : "";
5685 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude5686 }
5687);
5688
a2c10c5Claude5689// ─── Batched PR review workflow ─────────────────────────────────────────────
5690// Reviewers can stage multiple inline comments in a pending_reviews session,
5691// then submit them all at once as an Approve / Request Changes / Comment review.
5692
5693// GET /:owner/:repo/pulls/:number/review/pending
5694// Returns {count, comments} for the current user's pending review session.
5695pulls.get(
5696 "/:owner/:repo/pulls/:number/review/pending",
5697 softAuth,
5698 requireAuth,
5699 requireRepoAccess("read"),
5700 async (c) => {
5701 const { owner: ownerName, repo: repoName } = c.req.param();
5702 const prNum = parseInt(c.req.param("number"), 10);
5703 const user = c.get("user")!;
5704
5705 const resolved = await resolveRepo(ownerName, repoName);
5706 if (!resolved) return c.json({ count: 0, comments: [] });
5707
5708 const [pr] = await db
5709 .select()
5710 .from(pullRequests)
5711 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5712 .limit(1);
5713 if (!pr) return c.json({ count: 0, comments: [] });
5714
5715 const [review] = await db
5716 .select()
5717 .from(pendingReviews)
5718 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5719 .limit(1);
5720
5721 if (!review) return c.json({ count: 0, comments: [] });
5722
5723 const comments = await db
5724 .select({ id: pendingReviewComments.id, filePath: pendingReviewComments.filePath, lineNumber: pendingReviewComments.lineNumber, body: pendingReviewComments.body })
5725 .from(pendingReviewComments)
5726 .where(eq(pendingReviewComments.reviewId, review.id))
5727 .orderBy(asc(pendingReviewComments.createdAt));
5728
5729 return c.json({ count: comments.length, comments });
5730 }
5731);
5732
5733// POST /:owner/:repo/pulls/:number/review/pending/add
5734// Adds a comment to the current user's pending review (creates session if needed).
5735pulls.post(
5736 "/:owner/:repo/pulls/:number/review/pending/add",
5737 softAuth,
5738 requireAuth,
5739 requireRepoAccess("read"),
5740 async (c) => {
5741 const { owner: ownerName, repo: repoName } = c.req.param();
5742 const prNum = parseInt(c.req.param("number"), 10);
5743 const user = c.get("user")!;
5744 const body = await c.req.parseBody();
5745 const filePath = String(body.filePath || "").trim();
5746 const lineNumber = parseInt(String(body.lineNumber || ""), 10);
5747 const commentBody = String(body.body || "").trim();
5748
5749 if (!filePath || !Number.isFinite(lineNumber) || lineNumber <= 0 || !commentBody) {
5750 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5751 }
5752
5753 const resolved = await resolveRepo(ownerName, repoName);
5754 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5755
5756 const [pr] = await db
5757 .select()
5758 .from(pullRequests)
5759 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5760 .limit(1);
5761 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5762
5763 // Upsert the pending_reviews session row
5764 let [review] = await db
5765 .select()
5766 .from(pendingReviews)
5767 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5768 .limit(1);
5769
5770 if (!review) {
5771 [review] = await db
5772 .insert(pendingReviews)
5773 .values({ prId: pr.id, authorId: user.id })
5774 .onConflictDoNothing()
5775 .returning();
5776 // If onConflictDoNothing returned nothing (race), fetch again
5777 if (!review) {
5778 [review] = await db
5779 .select()
5780 .from(pendingReviews)
5781 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5782 .limit(1);
5783 }
5784 }
5785
5786 if (!review) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5787
5788 await db.insert(pendingReviewComments).values({
5789 reviewId: review.id,
5790 filePath,
5791 lineNumber,
5792 body: commentBody,
5793 });
5794
5795 // Count total pending comments for this review
5796 const countRows = await db
5797 .select({ id: pendingReviewComments.id })
5798 .from(pendingReviewComments)
5799 .where(eq(pendingReviewComments.reviewId, review.id));
5800
5801 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files&pending=${countRows.length}`);
5802 }
5803);
5804
5805// POST /:owner/:repo/pulls/:number/review/pending/:commentId/delete
5806// Removes a single comment from the pending review session.
5807pulls.post(
5808 "/:owner/:repo/pulls/:number/review/pending/:commentId/delete",
5809 softAuth,
5810 requireAuth,
5811 requireRepoAccess("read"),
5812 async (c) => {
5813 const { owner: ownerName, repo: repoName, commentId } = c.req.param();
5814 const prNum = parseInt(c.req.param("number"), 10);
5815 const user = c.get("user")!;
5816
5817 const resolved = await resolveRepo(ownerName, repoName);
5818 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5819
5820 const [pr] = await db
5821 .select()
5822 .from(pullRequests)
5823 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5824 .limit(1);
5825 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5826
5827 // Verify ownership via the review session
5828 const [review] = await db
5829 .select()
5830 .from(pendingReviews)
5831 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5832 .limit(1);
5833
5834 if (review) {
5835 await db
5836 .delete(pendingReviewComments)
5837 .where(and(eq(pendingReviewComments.id, commentId), eq(pendingReviewComments.reviewId, review.id)));
5838 }
5839
5840 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5841 }
5842);
5843
5844// POST /:owner/:repo/pulls/:number/review/submit
5845// Submits the pending review: posts all staged comments + a formal review state.
5846pulls.post(
5847 "/:owner/:repo/pulls/:number/review/submit",
5848 softAuth,
5849 requireAuth,
5850 requireRepoAccess("read"),
5851 async (c) => {
5852 const { owner: ownerName, repo: repoName } = c.req.param();
5853 const prNum = parseInt(c.req.param("number"), 10);
5854 const user = c.get("user")!;
5855 const body = await c.req.parseBody();
5856 const reviewBody = String(body.reviewBody || "").trim();
5857 const reviewStateRaw = String(body.reviewState || "comment");
5858
5859 const reviewState =
5860 reviewStateRaw === "approve"
5861 ? "approved"
5862 : reviewStateRaw === "request_changes"
5863 ? "changes_requested"
5864 : "commented";
5865
5866 const resolved = await resolveRepo(ownerName, repoName);
5867 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5868
5869 const [pr] = await db
5870 .select()
5871 .from(pullRequests)
5872 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5873 .limit(1);
5874 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5875
5876 const [review] = await db
5877 .select()
5878 .from(pendingReviews)
5879 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5880 .limit(1);
5881
5882 if (review) {
5883 const pendingComments = await db
5884 .select()
5885 .from(pendingReviewComments)
5886 .where(eq(pendingReviewComments.reviewId, review.id))
5887 .orderBy(asc(pendingReviewComments.createdAt));
5888
5889 // Post each pending comment as a real pr_comment
5890 for (const pc of pendingComments) {
5891 await db.insert(prComments).values({
5892 pullRequestId: pr.id,
5893 authorId: user.id,
5894 body: pc.body,
5895 filePath: pc.filePath,
5896 lineNumber: pc.lineNumber,
5897 isAiReview: false,
5898 });
5899 }
5900
5901 // Delete the pending review session (cascades to comments)
5902 await db.delete(pendingReviews).where(eq(pendingReviews.id, review.id));
5903 }
5904
5905 // Insert the formal review record
5906 await db.insert(prReviews).values({
5907 pullRequestId: pr.id,
5908 reviewerId: user.id,
5909 state: reviewState,
5910 body: reviewBody || null,
5911 isAi: false,
5912 });
5913
5914 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=conversation`);
5915 }
5916);
5917
b5dd694Claude5918// Apply a suggestion from a PR comment — commits the suggested code to the
5919// head branch on behalf of the logged-in user.
5920pulls.post(
5921 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
5922 softAuth,
5923 requireAuth,
5924 requireRepoAccess("read"),
5925 async (c) => {
5926 const { owner: ownerName, repo: repoName } = c.req.param();
5927 const prNum = parseInt(c.req.param("number"), 10);
5928 const commentId = c.req.param("commentId"); // UUID
5929 const user = c.get("user")!;
5930
5931 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
5932
5933 const resolved = await resolveRepo(ownerName, repoName);
5934 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5935
5936 const [pr] = await db
5937 .select()
5938 .from(pullRequests)
5939 .where(
5940 and(
5941 eq(pullRequests.repositoryId, resolved.repo.id),
5942 eq(pullRequests.number, prNum)
5943 )
5944 )
5945 .limit(1);
5946
5947 if (!pr || pr.state !== "open") {
5948 return c.redirect(`${backUrl}&error=pr_not_open`);
5949 }
5950
5951 // Only PR author or repo owner may apply suggestions.
5952 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
5953 return c.redirect(`${backUrl}&error=forbidden`);
5954 }
5955
5956 // Load the comment.
5957 const [comment] = await db
5958 .select()
5959 .from(prComments)
5960 .where(
5961 and(
5962 eq(prComments.id, commentId),
5963 eq(prComments.pullRequestId, pr.id)
5964 )
5965 )
5966 .limit(1);
5967
5968 if (!comment) {
5969 return c.redirect(`${backUrl}&error=comment_not_found`);
5970 }
5971
5972 // Parse suggestion block from comment body.
5973 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
5974 if (!m) {
5975 return c.redirect(`${backUrl}&error=no_suggestion`);
5976 }
5977 const suggestionCode = m[1];
5978
5979 // Get the commenter's details for the commit message co-author line.
5980 const [commenter] = await db
5981 .select()
5982 .from(users)
5983 .where(eq(users.id, comment.authorId))
5984 .limit(1);
5985
5986 // Fetch current file content from head branch.
5987 if (!comment.filePath) {
5988 return c.redirect(`${backUrl}&error=file_not_found`);
5989 }
5990 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
5991 if (!blob) {
5992 return c.redirect(`${backUrl}&error=file_not_found`);
5993 }
5994
5995 // Apply the patch — replace the target line(s) with suggestion lines.
5996 const lines = blob.content.split('\n');
5997 const lineIdx = (comment.lineNumber ?? 1) - 1;
5998 if (lineIdx < 0 || lineIdx >= lines.length) {
5999 return c.redirect(`${backUrl}&error=line_out_of_range`);
6000 }
6001 const suggestionLines = suggestionCode.split('\n');
6002 lines.splice(lineIdx, 1, ...suggestionLines);
6003 const newContent = lines.join('\n');
6004
6005 // Commit the change.
6006 const coAuthorLine = commenter
6007 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
6008 : "";
6009 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
6010
6011 const result = await createOrUpdateFileOnBranch({
6012 owner: ownerName,
6013 name: repoName,
6014 branch: pr.headBranch,
6015 filePath: comment.filePath,
6016 bytes: new TextEncoder().encode(newContent),
6017 message: commitMessage,
6018 authorName: user.username,
6019 authorEmail: `${user.username}@users.noreply.gluecron.com`,
6020 });
6021
6022 if ("error" in result) {
6023 return c.redirect(`${backUrl}&error=apply_failed`);
6024 }
6025
6026 // Post a follow-up comment noting the suggestion was applied.
6027 await db.insert(prComments).values({
6028 pullRequestId: pr.id,
6029 authorId: user.id,
6030 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
6031 });
6032
6033 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
6034 }
6035);
6036
0a67773Claude6037// Formal review — Approve / Request Changes / Comment
6038pulls.post(
6039 "/:owner/:repo/pulls/:number/review",
6040 softAuth,
6041 requireAuth,
6042 requireRepoAccess("read"),
6043 async (c) => {
6044 const { owner: ownerName, repo: repoName } = c.req.param();
6045 const prNum = parseInt(c.req.param("number"), 10);
6046 const user = c.get("user")!;
6047 const body = await c.req.parseBody();
6048 const reviewBody = String(body.body || "").trim();
6049 const reviewState = String(body.review_state || "commented");
6050
6051 const validStates = ["approved", "changes_requested", "commented"];
6052 if (!validStates.includes(reviewState)) {
6053 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6054 }
6055
6056 const resolved = await resolveRepo(ownerName, repoName);
6057 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6058
6059 const [pr] = await db
6060 .select()
6061 .from(pullRequests)
6062 .where(
6063 and(
6064 eq(pullRequests.repositoryId, resolved.repo.id),
6065 eq(pullRequests.number, prNum)
6066 )
6067 )
6068 .limit(1);
6069 if (!pr || pr.state !== "open") {
6070 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6071 }
6072 // Authors can't review their own PR
6073 if (pr.authorId === user.id) {
6074 return c.redirect(
6075 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
6076 );
6077 }
6078
6079 await db.insert(prReviews).values({
6080 pullRequestId: pr.id,
6081 reviewerId: user.id,
6082 state: reviewState,
6083 body: reviewBody || null,
6084 });
6085
6086 const stateLabel =
6087 reviewState === "approved" ? "Approved"
6088 : reviewState === "changes_requested" ? "Changes requested"
6089 : "Commented";
6090 return c.redirect(
6091 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
6092 );
6093 }
6094);
6095
e883329Claude6096// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude6097// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
6098// but we keep it at "write" for v1 so trusted collaborators can ship.
6099// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
6100// surface. Branch-protection rules (evaluated below) are the current mechanism
6101// for locking down merges further on specific branches.
0074234Claude6102pulls.post(
6103 "/:owner/:repo/pulls/:number/merge",
6104 softAuth,
6105 requireAuth,
04f6b7fClaude6106 requireRepoAccess("write"),
0074234Claude6107 async (c) => {
6108 const { owner: ownerName, repo: repoName } = c.req.param();
6109 const prNum = parseInt(c.req.param("number"), 10);
6110 const user = c.get("user")!;
6111
a164a6dClaude6112 // Read merge strategy from form (default: merge commit)
6113 let mergeStrategy = "merge";
6114 try {
6115 const body = await c.req.parseBody();
6116 const s = body.merge_strategy;
6117 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
6118 } catch { /* ignore parse errors — default to merge commit */ }
6119
0074234Claude6120 const resolved = await resolveRepo(ownerName, repoName);
6121 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6122
6123 const [pr] = await db
6124 .select()
6125 .from(pullRequests)
6126 .where(
6127 and(
6128 eq(pullRequests.repositoryId, resolved.repo.id),
6129 eq(pullRequests.number, prNum)
6130 )
6131 )
6132 .limit(1);
6133
6134 if (!pr || pr.state !== "open") {
6135 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6136 }
6137
6fc53bdClaude6138 // Draft PRs cannot be merged — must be marked ready first.
6139 if (pr.isDraft) {
6140 return c.redirect(
6141 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6142 "This PR is a draft. Mark it as ready for review before merging."
6143 )}`
6144 );
6145 }
6146
ec9e3e3Claude6147 // Required reviews check — branch-protection `required_approvals` gate.
6148 // Evaluated before running expensive gate checks so the feedback is fast.
6149 {
6150 const eligibility = await checkMergeEligible(pr.id, resolved.repo.id, pr.baseBranch);
6151 if (!eligibility.eligible && eligibility.reason) {
6152 return c.redirect(
6153 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(eligibility.reason)}`
6154 );
6155 }
6156 }
6157
e883329Claude6158 // Resolve head SHA
6159 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
6160 if (!headSha) {
6161 return c.redirect(
6162 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
6163 );
6164 }
6165
58c39f5Claude6166 // Check if AI review approved this PR.
6167 // The AI summary comment body uses:
6168 // "**AI review:** no blocking issues found." → approved
6169 // "**AI review:** flagged N item(s)..." → not approved
6170 // "severity: blocking" → explicit blocking (future)
6171 // If no AI comments exist yet, treat as approved (gate hasn't run).
c166384ccantynz-alt6172 const aiApproved = await isAiReviewApproved(pr.id);
e883329Claude6173
6174 // Run all green gate checks (GateTest + mergeability + AI review)
6175 const gateResult = await runAllGateChecks(
6176 ownerName,
6177 repoName,
6178 pr.baseBranch,
6179 pr.headBranch,
6180 headSha,
6181 aiApproved
0074234Claude6182 );
6183
e883329Claude6184 // If GateTest or AI review failed (hard blocks), reject the merge
6185 const hardFailures = gateResult.checks.filter(
6186 (check) => !check.passed && check.name !== "Merge check"
6187 );
6188 if (hardFailures.length > 0) {
6189 const errorMsg = hardFailures
6190 .map((f) => `${f.name}: ${f.details}`)
6191 .join("; ");
0074234Claude6192 return c.redirect(
e883329Claude6193 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude6194 );
6195 }
6196
1e162a8Claude6197 // D5 — Branch-protection enforcement. Looks up the matching rule for the
6198 // base branch and blocks the merge if requireAiApproval / requireGreenGates
6199 // / requireHumanReview / requiredApprovals are not satisfied. Independent
6200 // of repo-global settings, so owners can lock specific branches down
6201 // further than the repo default.
6202 const protectionRule = await matchProtection(
6203 resolved.repo.id,
6204 pr.baseBranch
6205 );
6206 if (protectionRule) {
6207 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude6208 const required = await listRequiredChecks(protectionRule.id);
6209 const passingNames = required.length > 0
6210 ? await passingCheckNames(resolved.repo.id, headSha)
6211 : [];
6212 const decision = evaluateProtection(
6213 protectionRule,
6214 {
6215 aiApproved,
6216 humanApprovalCount: humanApprovals,
6217 gateResultGreen: hardFailures.length === 0,
6218 hasFailedGates: hardFailures.length > 0,
6219 passingCheckNames: passingNames,
6220 },
6221 required.map((r) => r.checkName)
6222 );
91b054eccanty labs6223
6224 // CODEOWNERS enforcement — additive to evaluateProtection(), only
6225 // when the rule already requires human review at all (no new DB
6226 // column for this pass). Fail-open on any internal error: a bug here
6227 // must never hard-block every merge platform-wide.
6228 if (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) {
6229 try {
6230 const codeownersDiffProc = Bun.spawn(
6231 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
6232 { cwd: getRepoPath(ownerName, repoName), stdout: "pipe", stderr: "pipe" }
6233 );
6234 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
6235 await codeownersDiffProc.exited;
6236 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
6237 if (changedPaths.length > 0) {
6238 const { satisfied, missingOwners } = await requiredOwnersApproved(
6239 ownerName,
6240 repoName,
6241 resolved.repo.defaultBranch,
6242 pr.id,
6243 changedPaths
6244 );
6245 if (!satisfied) {
6246 decision.allowed = false;
6247 decision.reasons.push(
6248 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
6249 );
6250 }
6251 }
6252 } catch (err) {
6253 console.warn(
6254 "[codeowners] merge enforcement failed:",
6255 err instanceof Error ? err.message : err
6256 );
6257 }
6258 }
6259
1e162a8Claude6260 if (!decision.allowed) {
6261 return c.redirect(
6262 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6263 decision.reasons.join(" ")
6264 )}`
6265 );
6266 }
6267 }
6268
e883329Claude6269 // Attempt the merge — with auto conflict resolution if needed
6270 const repoDir = getRepoPath(ownerName, repoName);
6271 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
6272 const hasConflicts = mergeCheck && !mergeCheck.passed;
6273
6274 if (hasConflicts && isAiReviewEnabled()) {
6275 // Use Claude to auto-resolve conflicts
6276 const mergeResult = await mergeWithAutoResolve(
6277 ownerName,
6278 repoName,
6279 pr.baseBranch,
6280 pr.headBranch,
6281 `Merge pull request #${pr.number}: ${pr.title}`
6282 );
6283
6284 if (!mergeResult.success) {
6285 return c.redirect(
6286 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
6287 );
6288 }
6289
6290 // Post a comment about the auto-resolution
6291 if (mergeResult.resolvedFiles.length > 0) {
6292 await db.insert(prComments).values({
6293 pullRequestId: pr.id,
6294 authorId: user.id,
6295 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
6296 isAiReview: true,
6297 });
6298 }
6299 } else {
a164a6dClaude6300 // Worktree-based merge: supports merge-commit, squash, and fast-forward
6301 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
6302 const gitEnv = {
6303 ...process.env,
6304 GIT_AUTHOR_NAME: user.displayName || user.username,
6305 GIT_AUTHOR_EMAIL: user.email,
6306 GIT_COMMITTER_NAME: user.displayName || user.username,
6307 GIT_COMMITTER_EMAIL: user.email,
6308 };
6309
6310 // Create linked worktree on the base branch
6311 const addWt = Bun.spawn(
6312 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude6313 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
6314 );
a164a6dClaude6315 if (await addWt.exited !== 0) {
6316 return c.redirect(
6317 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
6318 );
6319 }
6320
6321 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
6322 let mergeOk = false;
6323
6324 try {
6325 if (mergeStrategy === "squash") {
6326 // Squash: stage all changes without committing
6327 const squashProc = Bun.spawn(
6328 ["git", "merge", "--squash", headSha],
6329 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6330 );
6331 if (await squashProc.exited !== 0) {
6332 const errTxt = await new Response(squashProc.stderr).text();
6333 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
6334 }
6335 // Commit the squashed changes
6336 const commitProc = Bun.spawn(
6337 ["git", "commit", "-m", commitMsg],
6338 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6339 );
6340 if (await commitProc.exited !== 0) {
6341 const errTxt = await new Response(commitProc.stderr).text();
6342 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
6343 }
6344 mergeOk = true;
6345 } else if (mergeStrategy === "ff") {
6346 // Fast-forward only — fail if FF is not possible
6347 const ffProc = Bun.spawn(
6348 ["git", "merge", "--ff-only", headSha],
6349 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6350 );
6351 if (await ffProc.exited !== 0) {
6352 const errTxt = await new Response(ffProc.stderr).text();
6353 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
6354 }
6355 mergeOk = true;
6356 } else {
6357 // Default: merge commit (--no-ff always creates a merge commit)
6358 const mergeProc = Bun.spawn(
6359 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
6360 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6361 );
6362 if (await mergeProc.exited !== 0) {
6363 const errTxt = await new Response(mergeProc.stderr).text();
6364 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
6365 }
6366 mergeOk = true;
6367 }
6368 } catch (err) {
6369 // Always clean up the worktree before redirecting
6370 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
6371 const msg = err instanceof Error ? err.message : "Merge failed";
6372 return c.redirect(
6373 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
6374 );
6375 }
6376
6377 // Clean up worktree (changes are now in the bare repo via linked worktree)
6378 await Bun.spawn(
6379 ["git", "worktree", "remove", "--force", wt],
6380 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
6381 ).exited.catch(() => {});
e883329Claude6382
a164a6dClaude6383 if (!mergeOk) {
e883329Claude6384 return c.redirect(
a164a6dClaude6385 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude6386 );
6387 }
6388 }
6389
0074234Claude6390 await db
6391 .update(pullRequests)
6392 .set({
6393 state: "merged",
6394 mergedAt: new Date(),
6395 mergedBy: user.id,
6396 updatedAt: new Date(),
6397 })
6398 .where(eq(pullRequests.id, pr.id));
6399
b7b5f75ccanty labs6400 void logActivity({
6401 repositoryId: resolved.repo.id,
6402 userId: user.id,
6403 action: "pr_merge",
6404 targetType: "pull_request",
6405 targetId: String(pr.number),
6406 metadata: { baseBranch: pr.baseBranch, headBranch: pr.headBranch, mergeStrategy },
6407 });
a74f4edccanty labs6408 void fireWebhooks(resolved.repo.id, "pr", {
6409 action: "merged",
6410 number: pr.number,
6411 mergeStrategy,
6412 });
b7b5f75ccanty labs6413
8809b87Claude6414 // Chat notifier — fan out merge event to Slack/Discord/Teams.
6415 import("../lib/chat-notifier")
6416 .then((m) =>
6417 m.notifyChatChannels({
6418 ownerUserId: resolved.repo.ownerId,
6419 repositoryId: resolved.repo.id,
6420 event: {
6421 event: "pr.merged",
6422 repo: `${ownerName}/${repoName}`,
6423 title: `#${pr.number} ${pr.title}`,
6424 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
6425 actor: user.username,
6426 },
6427 })
6428 )
6429 .catch((err) =>
6430 console.warn(`[chat-notifier] PR merge notify failed:`, err)
6431 );
6432
d62fb36Claude6433 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
6434 // and auto-close each matching open issue with a back-link comment. Bounded
6435 // to the same repo for v1 (cross-repo refs ignored). Failures never block
6436 // the merge redirect.
6437 try {
6438 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
6439 const refs = extractClosingRefsMulti([pr.title, pr.body]);
6440 for (const n of refs) {
6441 const [issue] = await db
6442 .select()
6443 .from(issues)
6444 .where(
6445 and(
6446 eq(issues.repositoryId, resolved.repo.id),
6447 eq(issues.number, n)
6448 )
6449 )
6450 .limit(1);
6451 if (!issue || issue.state !== "open") continue;
6452 await db
6453 .update(issues)
6454 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
6455 .where(eq(issues.id, issue.id));
6456 await db.insert(issueComments).values({
6457 issueId: issue.id,
6458 authorId: user.id,
6459 body: `Closed by pull request #${pr.number}.`,
6460 });
6461 }
6462 } catch {
6463 // Never block the merge on close-keyword failures.
6464 }
6465
0074234Claude6466 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6467 }
6468);
6469
6fc53bdClaude6470// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
6471// hasn't run yet on this PR.
6472pulls.post(
6473 "/:owner/:repo/pulls/:number/ready",
6474 softAuth,
6475 requireAuth,
04f6b7fClaude6476 requireRepoAccess("write"),
6fc53bdClaude6477 async (c) => {
6478 const { owner: ownerName, repo: repoName } = c.req.param();
6479 const prNum = parseInt(c.req.param("number"), 10);
6480 const user = c.get("user")!;
6481
6482 const resolved = await resolveRepo(ownerName, repoName);
6483 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6484
6485 const [pr] = await db
6486 .select()
6487 .from(pullRequests)
6488 .where(
6489 and(
6490 eq(pullRequests.repositoryId, resolved.repo.id),
6491 eq(pullRequests.number, prNum)
6492 )
6493 )
6494 .limit(1);
6495 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6496
6497 // Only the author or repo owner can toggle draft state.
6498 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
6499 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6500 }
6501
6502 if (pr.state === "open" && pr.isDraft) {
6503 await db
6504 .update(pullRequests)
6505 .set({ isDraft: false, updatedAt: new Date() })
6506 .where(eq(pullRequests.id, pr.id));
6507
6508 if (isAiReviewEnabled()) {
6509 triggerAiReview(
6510 ownerName,
6511 repoName,
6512 pr.id,
6513 pr.title,
0316dbbClaude6514 pr.body || "",
6fc53bdClaude6515 pr.baseBranch,
6516 pr.headBranch
6517 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
6518 }
6519 }
6520
6521 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6522 }
6523);
6524
6525// Convert a PR back to draft.
6526pulls.post(
6527 "/:owner/:repo/pulls/:number/draft",
6528 softAuth,
6529 requireAuth,
04f6b7fClaude6530 requireRepoAccess("write"),
6fc53bdClaude6531 async (c) => {
6532 const { owner: ownerName, repo: repoName } = c.req.param();
6533 const prNum = parseInt(c.req.param("number"), 10);
6534 const user = c.get("user")!;
6535
6536 const resolved = await resolveRepo(ownerName, repoName);
6537 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6538
6539 const [pr] = await db
6540 .select()
6541 .from(pullRequests)
6542 .where(
6543 and(
6544 eq(pullRequests.repositoryId, resolved.repo.id),
6545 eq(pullRequests.number, prNum)
6546 )
6547 )
6548 .limit(1);
6549 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6550
6551 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
6552 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6553 }
6554
6555 if (pr.state === "open" && !pr.isDraft) {
6556 await db
6557 .update(pullRequests)
6558 .set({ isDraft: true, updatedAt: new Date() })
6559 .where(eq(pullRequests.id, pr.id));
6560 }
6561
6562 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6563 }
6564);
6565
0074234Claude6566// Close PR
6567pulls.post(
6568 "/:owner/:repo/pulls/:number/close",
6569 softAuth,
6570 requireAuth,
04f6b7fClaude6571 requireRepoAccess("write"),
0074234Claude6572 async (c) => {
6573 const { owner: ownerName, repo: repoName } = c.req.param();
6574 const prNum = parseInt(c.req.param("number"), 10);
6575
6576 const resolved = await resolveRepo(ownerName, repoName);
6577 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6578
6579 await db
6580 .update(pullRequests)
6581 .set({
6582 state: "closed",
6583 closedAt: new Date(),
6584 updatedAt: new Date(),
6585 })
6586 .where(
6587 and(
6588 eq(pullRequests.repositoryId, resolved.repo.id),
6589 eq(pullRequests.number, prNum)
6590 )
6591 );
a74f4edccanty labs6592 void fireWebhooks(resolved.repo.id, "pr", {
6593 action: "closed",
6594 number: prNum,
6595 });
0074234Claude6596
6597 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6598 }
6599);
6600
c3e0c07Claude6601// Re-run AI review on demand (e.g. after a force-push). Bypasses the
6602// idempotency marker via { force: true }. Write-access only.
6603pulls.post(
6604 "/:owner/:repo/pulls/:number/ai-rereview",
6605 softAuth,
6606 requireAuth,
6607 requireRepoAccess("write"),
6608 async (c) => {
6609 const { owner: ownerName, repo: repoName } = c.req.param();
6610 const prNum = parseInt(c.req.param("number"), 10);
6611 const resolved = await resolveRepo(ownerName, repoName);
6612 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6613
6614 const [pr] = await db
6615 .select()
6616 .from(pullRequests)
6617 .where(
6618 and(
6619 eq(pullRequests.repositoryId, resolved.repo.id),
6620 eq(pullRequests.number, prNum)
6621 )
6622 )
6623 .limit(1);
6624 if (!pr) {
6625 return c.redirect(`/${ownerName}/${repoName}/pulls`);
6626 }
6627
6628 if (!isAiReviewEnabled()) {
6629 return c.redirect(
6630 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6631 "AI review is not configured (ANTHROPIC_API_KEY)."
6632 )}`
6633 );
6634 }
6635
6636 // Fire-and-forget but with { force: true } to bypass the
6637 // already-reviewed marker. The function still never throws.
6638 triggerAiReview(
6639 ownerName,
6640 repoName,
6641 pr.id,
6642 pr.title || "",
6643 pr.body || "",
6644 pr.baseBranch,
6645 pr.headBranch,
6646 { force: true }
a28cedeClaude6647 ).catch((err) => {
6648 console.warn(
6649 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
6650 err instanceof Error ? err.message : err
6651 );
6652 });
c3e0c07Claude6653
6654 return c.redirect(
6655 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6656 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
6657 )}`
6658 );
6659 }
6660);
6661
1d4ff60Claude6662// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
6663// the PR's head branch carrying just the new test files. Write-access only.
6664// Idempotent — if `ai:added-tests` was previously applied we redirect with
6665// an `info` banner instead of re-firing.
6666pulls.post(
6667 "/:owner/:repo/pulls/:number/generate-tests",
6668 softAuth,
6669 requireAuth,
6670 requireRepoAccess("write"),
6671 async (c) => {
6672 const { owner: ownerName, repo: repoName } = c.req.param();
6673 const prNum = parseInt(c.req.param("number"), 10);
6674 const resolved = await resolveRepo(ownerName, repoName);
6675 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6676
6677 const [pr] = await db
6678 .select()
6679 .from(pullRequests)
6680 .where(
6681 and(
6682 eq(pullRequests.repositoryId, resolved.repo.id),
6683 eq(pullRequests.number, prNum)
6684 )
6685 )
6686 .limit(1);
6687 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6688
6689 if (!isAiReviewEnabled()) {
6690 return c.redirect(
6691 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6692 "AI test generation is not configured (ANTHROPIC_API_KEY)."
6693 )}`
6694 );
6695 }
6696
6697 // Fire-and-forget. The lib never throws.
6698 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
6699 .then((res) => {
6700 if (!res.ok) {
6701 console.warn(
6702 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
6703 );
6704 }
6705 })
6706 .catch((err) => {
6707 console.warn(
6708 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
6709 err instanceof Error ? err.message : err
6710 );
6711 });
6712
6713 return c.redirect(
6714 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6715 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
6716 )}`
6717 );
6718 }
6719);
6720
ace34efClaude6721// ─── Request review ───────────────────────────────────────────────────────────
6722pulls.post(
6723 "/:owner/:repo/pulls/:number/request-review",
6724 softAuth,
6725 requireAuth,
6726 requireRepoAccess("write"),
6727 async (c) => {
6728 const { owner: ownerName, repo: repoName } = c.req.param();
6729 const prNum = parseInt(c.req.param("number"), 10);
6730 const user = c.get("user")!;
6731
6732 const resolved = await resolveRepo(ownerName, repoName);
6733 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6734
6735 const [pr] = await db
6736 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
6737 .from(pullRequests)
6738 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
6739 .limit(1);
6740
6741 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6742
6743 const body = await c.req.formData().catch(() => null);
6744 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
6745
6746 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
6747 return c.redirect(
6748 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
6749 );
6750 }
6751
f4abb8eClaude6752 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
6753 // requesting reviews from arbitrary user IDs outside this repository.
6754 const isOwner = reviewerId === resolved.owner.id;
6755 if (!isOwner) {
6756 const [collab] = await db
6757 .select({ id: repoCollaborators.id })
6758 .from(repoCollaborators)
6759 .where(
6760 and(
6761 eq(repoCollaborators.repositoryId, resolved.repo.id),
6762 eq(repoCollaborators.userId, reviewerId),
6763 isNotNull(repoCollaborators.acceptedAt)
6764 )
6765 )
6766 .limit(1);
6767 if (!collab) {
6768 return c.redirect(
6769 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
6770 );
6771 }
6772 }
6773
ace34efClaude6774 const { requestReview } = await import("../lib/reviewer-suggest");
6775 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
6776
6777 const msg = result.ok
6778 ? "Review requested successfully."
6779 : `Failed to request review: ${result.error ?? "unknown error"}`;
6780
6781 return c.redirect(
6782 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
6783 );
6784 }
6785);
6786
25b1ff7Claude6787// ─── WebSocket presence endpoint ─────────────────────────────────────────────
6788//
6789// GET /:owner/:repo/pulls/:number/presence (WebSocket upgrade)
6790//
6791// Unauthenticated connections are rejected with 401. On connect:
6792// → server sends {type:"init", sessionId, users:[...]}
6793// → server broadcasts {type:"join", user} to all other sessions in the room
6794//
6795// Accepted client messages:
6796// {type:"cursor", line: number} — user hovering a diff line
6797// {type:"typing", line: number, typing: bool} — textarea focus/blur
6798// {type:"ping"} — keep-alive (updates lastSeen)
6799//
6800// The WS `data` payload we store on each socket carries everything needed in
6801// the event handlers so no closure tricks are required.
6802
6803pulls.get(
6804 "/:owner/:repo/pulls/:number/presence",
6805 softAuth,
6806 upgradeWebSocket(async (c) => {
6807 const { owner: ownerName, repo: repoName, number: prNumStr } = c.req.param();
6808 const prNum = parseInt(prNumStr ?? "0", 10);
6809 const user = c.get("user");
6810
6811 // Auth check — no anonymous presence
6812 if (!user) {
6813 // upgradeWebSocket doesn't support returning a non-101 directly;
6814 // we return a dummy handler that immediately closes with 4001.
6815 return {
6816 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6817 ws.close(4001, "Unauthorized");
6818 },
6819 onMessage() {},
6820 onClose() {},
6821 };
6822 }
6823
6824 // Resolve repo to get its numeric id for the room key
6825 const resolved = await resolveRepo(ownerName, repoName);
6826 if (!resolved || isNaN(prNum)) {
6827 return {
6828 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6829 ws.close(4004, "Not found");
6830 },
6831 onMessage() {},
6832 onClose() {},
6833 };
6834 }
6835
6836 const prId = `${resolved.repo.id}:${prNum}`;
6837 const sessionId = `${user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
6838
6839 return {
6840 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6841 // Register and join room
6842 registerSocket(prId, sessionId, {
6843 send: (data: string) => ws.send(data),
6844 readyState: ws.readyState,
6845 });
6846 const presenceUser = joinRoom(prId, sessionId, {
6847 userId: user.id,
6848 username: user.username,
6849 });
6850
6851 // Send init snapshot to the new joiner
6852 const currentUsers = getRoomUsers(prId);
6853 ws.send(
6854 JSON.stringify({
6855 type: "init",
6856 sessionId,
6857 users: currentUsers,
6858 })
6859 );
6860
6861 // Broadcast join to all OTHER sessions
6862 broadcastToRoom(
6863 prId,
6864 {
6865 type: "join",
6866 user: { ...presenceUser, sessionId },
6867 },
6868 sessionId
6869 );
6870 },
6871
6872 onMessage(evt: MessageEvent, _ws: import("hono/ws").WSContext) {
6873 let msg: { type: string; line?: number; typing?: boolean };
6874 try {
6875 msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
6876 } catch {
6877 return;
6878 }
6879
6880 if (msg.type === "ping") {
6881 pingSession(prId, sessionId);
6882 return;
6883 }
6884
6885 if (msg.type === "cursor") {
6886 const line = typeof msg.line === "number" ? msg.line : null;
6887 const updated = updatePresence(prId, sessionId, line, false);
6888 if (updated) {
6889 broadcastToRoom(
6890 prId,
6891 {
6892 type: "cursor",
6893 sessionId,
6894 username: updated.username,
6895 colour: updated.colour,
6896 line,
6897 },
6898 sessionId
6899 );
6900 }
6901 return;
6902 }
6903
6904 if (msg.type === "typing") {
6905 const line = typeof msg.line === "number" ? msg.line : null;
6906 const typing = !!msg.typing;
6907 const updated = updatePresence(prId, sessionId, line, typing);
6908 if (updated) {
6909 broadcastToRoom(
6910 prId,
6911 {
6912 type: "typing",
6913 sessionId,
6914 username: updated.username,
6915 colour: updated.colour,
6916 line,
6917 typing,
6918 },
6919 sessionId
6920 );
6921 }
6922 return;
6923 }
6924 },
6925
6926 onClose() {
6927 leaveRoom(prId, sessionId);
6928 unregisterSocket(prId, sessionId);
6929 broadcastToRoom(prId, { type: "leave", sessionId });
6930 },
6931 };
6932 })
6933);
6934
0074234Claude6935export default pulls;