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.tsxBlame6931 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
b078860Claude2698 const openCount = counts?.open ?? 0;
2699 const mergedCount = counts?.merged ?? 0;
2700 const closedCount = counts?.closed ?? 0;
2701 const draftCount = counts?.draft ?? 0;
2702 const allCount = openCount + mergedCount + closedCount;
2703
2704 // "All" is presentational only — the DB query for state='all' matches
2705 // nothing, so we render a friendlier empty state when picked. We do NOT
2706 // change the query logic to keep this commit purely visual.
80bd7c8Claude2707 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude2708 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude2709 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
2710 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
2711 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
2712 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
2713 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude2714 ];
2715 const isAllState = state === "all";
cb5a796Claude2716 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
2717 const prListPendingCount = viewerIsOwnerOnPrList
2718 ? await countPendingForRepo(resolved.repo.id)
2719 : 0;
b078860Claude2720
0074234Claude2721 return c.html(
2722 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2723 <RepoHeader owner={ownerName} repo={repoName} />
2724 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2725 <PendingCommentsBanner
2726 owner={ownerName}
2727 repo={repoName}
2728 count={prListPendingCount}
2729 />
b078860Claude2730 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
f6730d0ccantynz-alt2731 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
2732
2733 <PageHeader
2734 eyebrow="Pull requests"
2735 title="Review, merge with AI."
2736 lede={
2737 openCount === 0 && allCount === 0
2738 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2739 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`
2740 }
2741 actions={
2742 <>
2743 <GxButton href={`/${ownerName}/${repoName}/pulls/insights`} variant="secondary">
7a28902Claude2744 Insights
f6730d0ccantynz-alt2745 </GxButton>
7a28902Claude2746 {user && (
f6730d0ccantynz-alt2747 <GxButton href={`/${ownerName}/${repoName}/pulls/new`} variant="primary">
b078860Claude2748 + New pull request
f6730d0ccantynz-alt2749 </GxButton>
7a28902Claude2750 )}
f6730d0ccantynz-alt2751 </>
2752 }
2753 />
b078860Claude2754
2755 <nav class="prs-tabs" aria-label="Pull request filters">
2756 {tabPills.map((t) => {
2757 const isActive =
2758 state === t.key ||
2759 (t.key === "open" &&
2760 state !== "merged" &&
2761 state !== "closed" &&
2762 state !== "all" &&
2763 state !== "draft");
2764 return (
2765 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2766 <span>{t.label}</span>
2767 <span class="prs-tab-count">{t.count}</span>
2768 </a>
2769 );
2770 })}
2771 </nav>
2772
d790b49Claude2773 <form
2774 method="get"
2775 action={`/${ownerName}/${repoName}/pulls`}
2776 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2777 >
2778 <input type="hidden" name="state" value={state} />
2779 <input
2780 type="search"
2781 name="q"
2782 value={searchQ}
2783 placeholder="Search pull requests…"
2784 class="issues-search-input"
2785 style="flex:1;max-width:380px"
2786 />
80bd7c8Claude2787 <input
2788 type="text"
2789 name="author"
2790 value={authorFilter}
2791 placeholder="Filter by author…"
2792 class="issues-search-input"
2793 style="max-width:200px"
2794 />
d790b49Claude2795 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2796 {(searchQ || authorFilter) && (
d790b49Claude2797 <a
2798 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2799 class="issues-filter-clear"
2800 >
2801 Clear
2802 </a>
2803 )}
2804 </form>
f5b9ef5Claude2805
2806 <div class="prs-sort-row">
2807 <span class="prs-sort-label">Sort:</span>
2808 {(["newest", "oldest", "updated"] as const).map((s) => (
2809 <a
2810 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2811 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2812 >
2813 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2814 </a>
2815 ))}
2816 </div>
2817
0074234Claude2818 {prList.length === 0 ? (
b078860Claude2819 <div class="prs-empty">
ea9ed4cClaude2820 <div class="prs-empty-inner">
2821 <strong>
80bd7c8Claude2822 {searchQ || authorFilter
2823 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2824 : isAllState
2825 ? "Pick a filter above to browse PRs."
2826 : `No ${state} pull requests.`}
ea9ed4cClaude2827 </strong>
2828 <p class="prs-empty-sub">
80bd7c8Claude2829 {searchQ || authorFilter
2830 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2831 : state === "open"
2832 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2833 : isAllState
2834 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2835 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2836 </p>
2837 <div class="prs-empty-cta">
80bd7c8Claude2838 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2839 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2840 + New pull request
2841 </a>
2842 )}
80bd7c8Claude2843 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2844 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2845 View open PRs
2846 </a>
2847 )}
2848 <a href={`/${ownerName}/${repoName}`} class="btn">
2849 Back to code
2850 </a>
2851 </div>
2852 </div>
b078860Claude2853 </div>
0074234Claude2854 ) : (
b078860Claude2855 <div class="prs-list">
2856 {prList.map(({ pr, author }) => {
2857 const stateClass =
2858 pr.state === "open"
2859 ? pr.isDraft
2860 ? "state-draft"
2861 : "state-open"
2862 : pr.state === "merged"
2863 ? "state-merged"
2864 : "state-closed";
2865 const icon =
2866 pr.state === "open"
2867 ? pr.isDraft
2868 ? "◌"
2869 : "○"
2870 : pr.state === "merged"
2871 ? "⮌"
2872 : "✓";
1aef949Claude2873 const rv = reviewMap.get(pr.id);
b078860Claude2874 return (
2875 <a
2876 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2877 class="prs-row"
2878 style="text-decoration:none;color:inherit"
0074234Claude2879 >
b078860Claude2880 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2881 {icon}
0074234Claude2882 </div>
b078860Claude2883 <div class="prs-row-body">
2884 <h3 class="prs-row-title">
2885 <span>{pr.title}</span>
2886 <span class="prs-row-number">#{pr.number}</span>
2887 </h3>
2888 <div class="prs-row-meta">
2889 <span
2890 class="prs-branch-chips"
2891 title={`${pr.headBranch} into ${pr.baseBranch}`}
2892 >
2893 <span class="prs-branch-chip">{pr.headBranch}</span>
2894 <span class="prs-branch-arrow">{"→"}</span>
2895 <span class="prs-branch-chip">{pr.baseBranch}</span>
2896 </span>
2897 <span>
2898 by{" "}
2899 <strong style="color:var(--text)">
2900 {author.username}
2901 </strong>{" "}
2902 {formatRelative(pr.createdAt)}
2903 </span>
2904 <span class="prs-row-tags">
2905 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2c61840Claude2906 {isAiGeneratedPr(pr.body, pr.headBranch) && (
2907 <span class="prs-tag is-ai" title="Opened by an AI agent">⚡ AI</span>
2908 )}
b078860Claude2909 {pr.state === "merged" && (
2910 <span class="prs-tag is-merged">Merged</span>
2911 )}
1aef949Claude2912 {rv?.approved && !rv.changesRequested && (
2913 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
2914 )}
2915 {rv?.changesRequested && (
2916 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
2917 )}
0369e77Claude2918 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
2919 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
2920 💬 {commentCountMap.get(pr.id)}
2921 </span>
2922 )}
b078860Claude2923 </span>
2924 </div>
0074234Claude2925 </div>
b078860Claude2926 </a>
2927 );
2928 })}
2929 </div>
0074234Claude2930 )}
2931 </Layout>
2932 );
2933});
2934
7a28902Claude2935/* ─────────────────────────────────────────────────────────────────────────
2936 * PR Insights — 90-day analytics for the pull request activity of a repo.
2937 * Route: GET /:owner/:repo/pulls/insights
2938 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
2939 * "insights" is not swallowed by the :number param.
2940 * ───────────────────────────────────────────────────────────────────────── */
2941
2942/** Format a millisecond duration as human-readable string. */
2943function formatMsDuration(ms: number): string {
2944 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
2945 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
2946 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
2947 return `${Math.round(ms / 86_400_000)}d`;
2948}
2949
2950/** Format an ISO week string as "Jan 15". */
2951function formatWeekLabel(isoWeek: string): string {
2952 try {
2953 const d = new Date(isoWeek);
2954 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2955 } catch {
2956 return isoWeek.slice(5, 10);
2957 }
2958}
2959
2960const PR_INSIGHTS_STYLES = `
2961 .pri-page { padding-bottom: 48px; }
2962 .pri-hero {
2963 position: relative;
2964 margin: 0 0 var(--space-5);
2965 padding: 22px 26px 24px;
2966 background: var(--bg-elevated);
2967 border: 1px solid var(--border);
2968 border-radius: 16px;
2969 overflow: hidden;
2970 }
2971 .pri-hero::before {
2972 content: '';
2973 position: absolute; top: 0; left: 0; right: 0;
2974 height: 2px;
6fd5915Claude2975 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7a28902Claude2976 opacity: 0.7;
2977 pointer-events: none;
2978 }
2979 .pri-hero-eyebrow {
2980 font-size: 12px;
2981 color: var(--text-muted);
2982 text-transform: uppercase;
2983 letter-spacing: 0.08em;
2984 font-weight: 600;
2985 margin-bottom: 8px;
2986 }
2987 .pri-hero-title {
2988 font-family: var(--font-display);
2989 font-size: clamp(26px, 3.4vw, 34px);
2990 font-weight: 800;
2991 letter-spacing: -0.025em;
2992 line-height: 1.06;
2993 margin: 0 0 8px;
2994 color: var(--text-strong);
2995 }
2996 .pri-hero-title .gradient-text {
6fd5915Claude2997 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
7a28902Claude2998 -webkit-background-clip: text;
2999 background-clip: text;
3000 -webkit-text-fill-color: transparent;
3001 color: transparent;
3002 }
3003 .pri-hero-sub {
3004 font-size: 14.5px;
3005 color: var(--text-muted);
3006 margin: 0;
3007 line-height: 1.5;
3008 }
3009 .pri-section { margin-bottom: 32px; }
3010 .pri-section-title {
3011 font-size: 13px;
3012 font-weight: 700;
3013 text-transform: uppercase;
3014 letter-spacing: 0.06em;
3015 color: var(--text-muted);
3016 margin: 0 0 14px;
3017 }
3018 .pri-cards {
3019 display: grid;
3020 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
3021 gap: 12px;
3022 }
3023 .pri-card {
3024 padding: 16px 18px;
3025 background: var(--bg-elevated);
3026 border: 1px solid var(--border);
3027 border-radius: 12px;
3028 }
3029 .pri-card-label {
3030 font-size: 12px;
3031 font-weight: 600;
3032 color: var(--text-muted);
3033 text-transform: uppercase;
3034 letter-spacing: 0.05em;
3035 margin-bottom: 6px;
3036 }
3037 .pri-card-value {
3038 font-size: 28px;
3039 font-weight: 800;
3040 letter-spacing: -0.04em;
3041 color: var(--text-strong);
3042 line-height: 1;
3043 }
3044 .pri-card-sub {
3045 font-size: 12px;
3046 color: var(--text-muted);
3047 margin-top: 4px;
3048 }
3049 .pri-chart {
3050 display: flex;
3051 align-items: flex-end;
3052 gap: 6px;
3053 height: 120px;
3054 background: var(--bg-elevated);
3055 border: 1px solid var(--border);
3056 border-radius: 12px;
3057 padding: 16px 16px 0;
3058 }
3059 .pri-bar-col {
3060 flex: 1;
3061 display: flex;
3062 flex-direction: column;
3063 align-items: center;
3064 justify-content: flex-end;
3065 height: 100%;
3066 gap: 4px;
3067 }
3068 .pri-bar {
3069 width: 100%;
3070 min-height: 4px;
3071 border-radius: 4px 4px 0 0;
6fd5915Claude3072 background: linear-gradient(180deg, #5b6ee8 0%, #5b6ee8 100%);
7a28902Claude3073 transition: opacity 140ms;
3074 }
3075 .pri-bar:hover { opacity: 0.8; }
3076 .pri-bar-label {
3077 font-size: 10px;
3078 color: var(--text-muted);
3079 text-align: center;
3080 padding-bottom: 8px;
3081 white-space: nowrap;
3082 overflow: hidden;
3083 text-overflow: ellipsis;
3084 max-width: 100%;
3085 }
3086 .pri-table {
3087 width: 100%;
3088 border-collapse: collapse;
3089 font-size: 13.5px;
3090 }
3091 .pri-table th {
3092 text-align: left;
3093 font-size: 12px;
3094 font-weight: 600;
3095 text-transform: uppercase;
3096 letter-spacing: 0.05em;
3097 color: var(--text-muted);
3098 padding: 8px 12px;
3099 border-bottom: 1px solid var(--border);
3100 }
3101 .pri-table td {
3102 padding: 10px 12px;
3103 border-bottom: 1px solid var(--border);
3104 color: var(--text);
3105 }
3106 .pri-table tr:last-child td { border-bottom: none; }
3107 .pri-table-wrap {
3108 background: var(--bg-elevated);
3109 border: 1px solid var(--border);
3110 border-radius: 12px;
3111 overflow: hidden;
3112 }
3113 .pri-age-row {
3114 display: flex;
3115 align-items: center;
3116 gap: 12px;
3117 padding: 10px 0;
3118 border-bottom: 1px solid var(--border);
3119 font-size: 13.5px;
3120 }
3121 .pri-age-row:last-child { border-bottom: none; }
3122 .pri-age-label {
3123 flex: 0 0 80px;
3124 color: var(--text-muted);
3125 font-size: 12.5px;
3126 font-weight: 600;
3127 }
3128 .pri-age-bar-wrap {
3129 flex: 1;
3130 height: 8px;
3131 background: var(--bg-secondary);
3132 border-radius: 9999px;
3133 overflow: hidden;
3134 }
3135 .pri-age-bar {
3136 height: 100%;
3137 border-radius: 9999px;
6fd5915Claude3138 background: linear-gradient(90deg, #5b6ee8 0%, #5f8fa0 100%);
7a28902Claude3139 min-width: 4px;
3140 }
3141 .pri-age-count {
3142 flex: 0 0 32px;
3143 text-align: right;
3144 font-weight: 600;
3145 color: var(--text-strong);
3146 font-size: 13px;
3147 }
3148 .pri-sparkline {
3149 display: flex;
3150 align-items: flex-end;
3151 gap: 3px;
3152 height: 40px;
3153 }
3154 .pri-spark-bar {
3155 flex: 1;
3156 min-height: 2px;
3157 border-radius: 2px 2px 0 0;
6fd5915Claude3158 background: var(--accent, #5b6ee8);
7a28902Claude3159 opacity: 0.7;
3160 }
3161 .pri-empty {
3162 color: var(--text-muted);
3163 font-size: 14px;
3164 padding: 24px 0;
3165 text-align: center;
3166 }
3167 @media (max-width: 600px) {
3168 .pri-cards { grid-template-columns: repeat(2, 1fr); }
3169 .pri-hero { padding: 18px 18px 20px; }
3170 }
3171`;
3172
3173pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
3174 const { owner: ownerName, repo: repoName } = c.req.param();
3175 const user = c.get("user");
3176
3177 const resolved = await resolveRepo(ownerName, repoName);
3178 if (!resolved) return c.notFound();
3179
3180 const repoId = resolved.repo.id;
3181 const now = Date.now();
3182
3183 // 1. Merged PRs in last 90 days (avg merge time)
3184 const mergedPRs = await db
3185 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
3186 .from(pullRequests)
3187 .where(and(
3188 eq(pullRequests.repositoryId, repoId),
3189 eq(pullRequests.state, "merged"),
3190 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3191 ));
3192
3193 const avgMergeMs = mergedPRs.length > 0
3194 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
3195 : null;
3196
3197 // 2. PR throughput (last 8 weeks)
3198 const weeklyPRs = await db
3199 .select({
3200 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
3201 count: sql<number>`count(*)::int`,
3202 })
3203 .from(pullRequests)
3204 .where(and(
3205 eq(pullRequests.repositoryId, repoId),
3206 sql`${pullRequests.createdAt} > now() - interval '56 days'`
3207 ))
3208 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
3209 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
3210
3211 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
3212
3213 // 3. PR merge rate (last 90 days)
3214 const [rateCounts] = await db
3215 .select({
3216 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
3217 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
3218 })
3219 .from(pullRequests)
3220 .where(and(
3221 eq(pullRequests.repositoryId, repoId),
3222 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3223 ));
3224
3225 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
3226 const mergeRate = totalResolved > 0
3227 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
3228 : null;
3229
3230 // 4. Top reviewers (last 90 days)
3231 const reviewerCounts = await db
3232 .select({
3233 userId: prReviews.reviewerId,
3234 username: users.username,
3235 count: sql<number>`count(*)::int`,
3236 })
3237 .from(prReviews)
3238 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3239 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
3240 .where(and(
3241 eq(pullRequests.repositoryId, repoId),
3242 sql`${prReviews.createdAt} > now() - interval '90 days'`
3243 ))
3244 .groupBy(prReviews.reviewerId, users.username)
3245 .orderBy(desc(sql`count(*)`))
3246 .limit(5);
3247
3248 // 5. Average reviews per merged PR
3249 const [avgReviewRow] = await db
3250 .select({
3251 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
3252 })
3253 .from(pullRequests)
3254 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3255 .where(and(
3256 eq(pullRequests.repositoryId, repoId),
3257 eq(pullRequests.state, "merged"),
3258 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3259 ));
3260
3261 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
3262 ? Math.round(avgReviewRow.avgReviews * 10) / 10
3263 : null;
3264
3265 // 6. Review turnaround — avg time from PR open to first review
3266 const prsWithReviews = await db
3267 .select({
3268 createdAt: pullRequests.createdAt,
3269 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
3270 })
3271 .from(pullRequests)
3272 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3273 .where(and(
3274 eq(pullRequests.repositoryId, repoId),
3275 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3276 ))
3277 .groupBy(pullRequests.id, pullRequests.createdAt);
3278
3279 const avgReviewTurnaroundMs = prsWithReviews.length > 0
3280 ? prsWithReviews.reduce((s, row) => {
3281 const firstMs = new Date(row.firstReview).getTime();
3282 return s + Math.max(0, firstMs - row.createdAt.getTime());
3283 }, 0) / prsWithReviews.length
3284 : null;
3285
3286 // 7. Open PRs by age bucket
3287 const openPRs = await db
3288 .select({ createdAt: pullRequests.createdAt })
3289 .from(pullRequests)
3290 .where(and(
3291 eq(pullRequests.repositoryId, repoId),
3292 eq(pullRequests.state, "open")
3293 ));
3294
3295 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
3296 for (const { createdAt } of openPRs) {
3297 const ageDays = (now - createdAt.getTime()) / 86_400_000;
3298 if (ageDays < 1) ageBuckets.lt1d++;
3299 else if (ageDays < 3) ageBuckets.d1to3++;
3300 else if (ageDays < 7) ageBuckets.d3to7++;
3301 else if (ageDays < 30) ageBuckets.d7to30++;
3302 else ageBuckets.gt30d++;
3303 }
3304 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
3305
3306 // 8. 7-day merge sparkline
3307 const sparklineRows = await db
3308 .select({
3309 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
3310 count: sql<number>`count(*)::int`,
3311 })
3312 .from(pullRequests)
3313 .where(and(
3314 eq(pullRequests.repositoryId, repoId),
3315 eq(pullRequests.state, "merged"),
3316 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
3317 ))
3318 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
3319 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
3320
3321 const sparkMap = new Map<string, number>();
3322 for (const row of sparklineRows) {
3323 sparkMap.set(row.day.slice(0, 10), row.count);
3324 }
3325 const sparkline: number[] = [];
3326 for (let i = 6; i >= 0; i--) {
3327 const d = new Date(now - i * 86_400_000);
3328 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
3329 }
3330 const maxSpark = Math.max(1, ...sparkline);
3331
3332 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
3333 { label: "< 1 day", key: "lt1d" },
3334 { label: "1–3 days", key: "d1to3" },
3335 { label: "3–7 days", key: "d3to7" },
3336 { label: "7–30 days", key: "d7to30" },
3337 { label: "> 30 days", key: "gt30d" },
3338 ];
3339
3340 return c.html(
3341 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
3342 <RepoHeader owner={ownerName} repo={repoName} />
3343 <PrNav owner={ownerName} repo={repoName} active="pulls" />
3344 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
3345
3346 <div class="pri-page">
3347 {/* Hero */}
3348 <div class="pri-hero">
3349 <div class="pri-hero-eyebrow">Pull requests</div>
3350 <h1 class="pri-hero-title">
3351 PR <span class="gradient-text">Insights</span>
3352 </h1>
3353 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
3354 </div>
3355
3356 {/* Stat cards */}
3357 <div class="pri-section">
3358 <div class="pri-section-title">At a glance</div>
3359 <div class="pri-cards">
3360 <div class="pri-card">
3361 <div class="pri-card-label">Avg merge time</div>
3362 <div class="pri-card-value">
3363 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
3364 </div>
3365 <div class="pri-card-sub">last 90 days</div>
3366 </div>
3367 <div class="pri-card">
3368 <div class="pri-card-label">Total merged</div>
3369 <div class="pri-card-value">{mergedPRs.length}</div>
3370 <div class="pri-card-sub">last 90 days</div>
3371 </div>
3372 <div class="pri-card">
3373 <div class="pri-card-label">Open PRs</div>
3374 <div class="pri-card-value">{openPRs.length}</div>
3375 <div class="pri-card-sub">right now</div>
3376 </div>
3377 <div class="pri-card">
3378 <div class="pri-card-label">Merge rate</div>
3379 <div class="pri-card-value">
3380 {mergeRate != null ? `${mergeRate}%` : "—"}
3381 </div>
3382 <div class="pri-card-sub">merged vs closed</div>
3383 </div>
3384 <div class="pri-card">
3385 <div class="pri-card-label">Avg reviews / PR</div>
3386 <div class="pri-card-value">
3387 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
3388 </div>
3389 <div class="pri-card-sub">merged PRs, 90d</div>
3390 </div>
3391 <div class="pri-card">
3392 <div class="pri-card-label">Top reviewer</div>
3393 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
3394 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
3395 </div>
3396 <div class="pri-card-sub">
3397 {reviewerCounts.length > 0
3398 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
3399 : "no reviews yet"}
3400 </div>
3401 </div>
3402 </div>
3403 </div>
3404
3405 {/* Review turnaround */}
3406 <div class="pri-section">
3407 <div class="pri-section-title">Review turnaround</div>
3408 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
3409 <div class="pri-card">
3410 <div class="pri-card-label">Avg time to first review</div>
3411 <div class="pri-card-value">
3412 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
3413 </div>
3414 <div class="pri-card-sub">
3415 {prsWithReviews.length > 0
3416 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
3417 : "no reviewed PRs in 90d"}
3418 </div>
3419 </div>
3420 </div>
3421 </div>
3422
3423 {/* Weekly throughput bar chart */}
3424 <div class="pri-section">
3425 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
3426 {weeklyPRs.length === 0 ? (
3427 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
3428 ) : (
3429 <div class="pri-chart">
3430 {weeklyPRs.map((w) => (
3431 <div class="pri-bar-col">
3432 <div
3433 class="pri-bar"
3434 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
3435 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
3436 />
3437 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
3438 </div>
3439 ))}
3440 </div>
3441 )}
3442 </div>
3443
3444 {/* 7-day merge sparkline */}
3445 <div class="pri-section">
3446 <div class="pri-section-title">Merges this week (daily)</div>
3447 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
3448 <div class="pri-sparkline">
3449 {sparkline.map((v) => (
3450 <div
3451 class="pri-spark-bar"
3452 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
3453 title={`${v} merge${v === 1 ? "" : "s"}`}
3454 />
3455 ))}
3456 </div>
3457 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
3458 <span>7 days ago</span>
3459 <span>Today</span>
3460 </div>
3461 </div>
3462 </div>
3463
3464 {/* Top reviewers table */}
3465 <div class="pri-section">
3466 <div class="pri-section-title">Top reviewers (last 90 days)</div>
3467 {reviewerCounts.length === 0 ? (
3468 <div class="pri-empty">No reviews posted in the last 90 days.</div>
3469 ) : (
3470 <div class="pri-table-wrap">
3471 <table class="pri-table">
3472 <thead>
3473 <tr>
3474 <th>#</th>
3475 <th>Reviewer</th>
3476 <th>Reviews</th>
3477 </tr>
3478 </thead>
3479 <tbody>
3480 {reviewerCounts.map((r, i) => (
3481 <tr>
3482 <td style="color:var(--text-muted)">{i + 1}</td>
3483 <td>
3484 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
3485 {r.username}
3486 </a>
3487 </td>
3488 <td style="font-weight:600">{r.count}</td>
3489 </tr>
3490 ))}
3491 </tbody>
3492 </table>
3493 </div>
3494 )}
3495 </div>
3496
3497 {/* Open PRs by age */}
3498 <div class="pri-section">
3499 <div class="pri-section-title">Open PRs by age</div>
3500 {openPRs.length === 0 ? (
3501 <div class="pri-empty">No open pull requests.</div>
3502 ) : (
3503 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
3504 {ageBucketDefs.map(({ label, key }) => (
3505 <div class="pri-age-row">
3506 <span class="pri-age-label">{label}</span>
3507 <div class="pri-age-bar-wrap">
3508 <div
3509 class="pri-age-bar"
3510 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
3511 />
3512 </div>
3513 <span class="pri-age-count">{ageBuckets[key]}</span>
3514 </div>
3515 ))}
3516 </div>
3517 )}
3518 </div>
3519
3520 {/* Back link */}
3521 <div>
3522 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
3523 {"←"} Back to pull requests
3524 </a>
3525 </div>
3526 </div>
3527 </Layout>
3528 );
3529});
3530
0074234Claude3531// New PR form
3532pulls.get(
3533 "/:owner/:repo/pulls/new",
3534 softAuth,
3535 requireAuth,
04f6b7fClaude3536 requireRepoAccess("write"),
0074234Claude3537 async (c) => {
3538 const { owner: ownerName, repo: repoName } = c.req.param();
3539 const user = c.get("user")!;
3540 const branches = await listBranches(ownerName, repoName);
3541 const error = c.req.query("error");
3542 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude3543 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude3544
3545 return c.html(
3546 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
3547 <RepoHeader owner={ownerName} repo={repoName} />
3548 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude3549 <Container maxWidth={800}>
3550 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude3551 {error && (
bb0f894Claude3552 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude3553 )}
0316dbbClaude3554 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
3555 <Flex gap={12} align="center" style="margin-bottom: 16px">
3556 <Select name="base">
0074234Claude3557 {branches.map((b) => (
3558 <option value={b} selected={b === defaultBase}>
3559 {b}
3560 </option>
3561 ))}
bb0f894Claude3562 </Select>
3563 <Text muted>&larr;</Text>
3564 <Select name="head">
0074234Claude3565 {branches
3566 .filter((b) => b !== defaultBase)
3567 .concat(defaultBase === branches[0] ? [] : [branches[0]])
3568 .map((b) => (
3569 <option value={b}>{b}</option>
3570 ))}
bb0f894Claude3571 </Select>
3572 </Flex>
3573 <FormGroup>
3574 <Input
0074234Claude3575 name="title"
3576 required
3577 placeholder="Title"
bb0f894Claude3578 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]3579 aria-label="Pull request title"
0074234Claude3580 />
bb0f894Claude3581 </FormGroup>
3582 <FormGroup>
3583 <TextArea
0074234Claude3584 name="body"
81c73c1Claude3585 id="pr-body"
0074234Claude3586 rows={8}
3587 placeholder="Description (Markdown supported)"
bb0f894Claude3588 mono
0074234Claude3589 />
bb0f894Claude3590 </FormGroup>
81c73c1Claude3591 <Flex gap={8} align="center">
3592 <Button type="submit" variant="primary">
3593 Create pull request
3594 </Button>
3595 <button
3596 type="button"
3597 id="ai-suggest-desc"
3598 class="btn"
3599 style="font-weight:500"
3600 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
3601 >
3602 Suggest description with AI
3603 </button>
3604 <span
3605 id="ai-suggest-status"
3606 style="color:var(--text-muted);font-size:13px"
3607 />
3608 </Flex>
bb0f894Claude3609 </Form>
81c73c1Claude3610 <script
3611 dangerouslySetInnerHTML={{
3612 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
3613 }}
3614 />
bb0f894Claude3615 </Container>
0074234Claude3616 </Layout>
3617 );
3618 }
3619);
3620
81c73c1Claude3621// AI-suggested PR description — JSON endpoint driven by the form button.
3622// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
3623// 200; the inline script reads `ok` to decide what to do.
3624pulls.post(
3625 "/:owner/:repo/ai/pr-description",
3626 softAuth,
3627 requireAuth,
3628 requireRepoAccess("write"),
3629 async (c) => {
3630 const { owner: ownerName, repo: repoName } = c.req.param();
3631 if (!isAiAvailable()) {
3632 return c.json({
3633 ok: false,
3634 error: "AI is not available — set ANTHROPIC_API_KEY.",
3635 });
3636 }
3637 const body = await c.req.parseBody();
3638 const title = String(body.title || "").trim();
3639 const baseBranch = String(body.base || "").trim();
3640 const headBranch = String(body.head || "").trim();
3641 if (!baseBranch || !headBranch) {
3642 return c.json({ ok: false, error: "Pick base + head branches first." });
3643 }
3644 if (baseBranch === headBranch) {
3645 return c.json({ ok: false, error: "Base and head must differ." });
3646 }
3647
3648 let diff = "";
3649 try {
3650 const cwd = getRepoPath(ownerName, repoName);
3651 const proc = Bun.spawn(
3652 [
3653 "git",
3654 "diff",
3655 `${baseBranch}...${headBranch}`,
3656 "--",
3657 ],
3658 { cwd, stdout: "pipe", stderr: "pipe" }
3659 );
6ea2109Claude3660 // 30s ceiling — without this a pathological diff (huge binary or
3661 // a corrupt ref) hangs the request indefinitely.
3662 const killer = setTimeout(() => proc.kill(), 30_000);
3663 try {
3664 diff = await new Response(proc.stdout).text();
3665 await proc.exited;
3666 } finally {
3667 clearTimeout(killer);
3668 }
81c73c1Claude3669 } catch {
3670 diff = "";
3671 }
3672 if (!diff.trim()) {
3673 return c.json({
3674 ok: false,
3675 error: "No diff between branches — nothing to summarise.",
3676 });
3677 }
3678
3679 let summary = "";
3680 try {
3681 summary = await generatePrSummary(title || "(untitled)", diff);
3682 } catch (err) {
3683 const msg = err instanceof Error ? err.message : "AI request failed.";
3684 return c.json({ ok: false, error: msg });
3685 }
3686 if (!summary.trim()) {
3687 return c.json({ ok: false, error: "AI returned an empty draft." });
3688 }
3689 return c.json({ ok: true, body: summary });
3690 }
3691);
3692
0074234Claude3693// Create PR
3694pulls.post(
3695 "/:owner/:repo/pulls/new",
3696 softAuth,
3697 requireAuth,
04f6b7fClaude3698 requireRepoAccess("write"),
0074234Claude3699 async (c) => {
3700 const { owner: ownerName, repo: repoName } = c.req.param();
3701 const user = c.get("user")!;
3702 const body = await c.req.parseBody();
3703 const title = String(body.title || "").trim();
3704 const prBody = String(body.body || "").trim();
3705 const baseBranch = String(body.base || "main");
3706 const headBranch = String(body.head || "");
3707
3708 if (!title || !headBranch) {
3709 return c.redirect(
3710 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
3711 );
3712 }
3713
3714 if (baseBranch === headBranch) {
3715 return c.redirect(
3716 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
3717 );
3718 }
3719
3720 const resolved = await resolveRepo(ownerName, repoName);
3721 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3722
6fc53bdClaude3723 const isDraft = String(body.draft || "") === "1";
3724
0074234Claude3725 const [pr] = await db
3726 .insert(pullRequests)
3727 .values({
3728 repositoryId: resolved.repo.id,
3729 authorId: user.id,
3730 title,
3731 body: prBody || null,
3732 baseBranch,
3733 headBranch,
6fc53bdClaude3734 isDraft,
0074234Claude3735 })
3736 .returning();
3737
b7b5f75ccanty labs3738 void logActivity({
3739 repositoryId: resolved.repo.id,
3740 userId: user.id,
3741 action: "pr_open",
3742 targetType: "pull_request",
3743 targetId: String(pr.number),
3744 metadata: { baseBranch, headBranch, isDraft },
3745 });
a74f4edccanty labs3746 void fireWebhooks(resolved.repo.id, "pr", {
3747 action: "opened",
3748 number: pr.number,
3749 baseBranch,
3750 headBranch,
3751 });
b7b5f75ccanty labs3752
ec9e3e3Claude3753 // CODEOWNERS — auto-request reviewers based on changed files.
3754 // Fire-and-forget; errors never block PR creation.
3755 (async () => {
3756 try {
3757 const repoDir = getRepoPath(ownerName, repoName);
3758 // Get list of changed files between base and head
3759 const diffProc = Bun.spawn(
3760 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
3761 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3762 );
3763 const rawDiff = await new Response(diffProc.stdout).text();
3764 await diffProc.exited;
3765 const changedFiles = rawDiff.trim().split("\n").filter(Boolean);
3766
3767 if (changedFiles.length > 0) {
3768 // Get CODEOWNERS from the default branch of the repo
3769 const rules = await getCodeownersForRepo(
3770 ownerName,
3771 repoName,
3772 resolved.repo.defaultBranch
3773 );
3774 if (rules.length > 0) {
3775 const ownerUsernames = await reviewersForChangedFiles(
3776 resolved.repo.id,
3777 changedFiles
3778 );
3779 // Filter out the PR author
3780 const filteredOwners = ownerUsernames.filter(
3781 (u) => u !== resolved.owner.username
3782 );
3783
3784 if (filteredOwners.length > 0) {
3785 // Look up user IDs for the owner usernames
3786 const reviewerUsers = await db
3787 .select({ id: users.id, username: users.username })
3788 .from(users)
3789 .where(
3790 inArray(
3791 users.username,
3792 filteredOwners
3793 )
3794 );
3795
3796 // Create review request rows (UNIQUE constraint prevents dupes)
3797 if (reviewerUsers.length > 0) {
3798 await db
3799 .insert(prReviewRequests)
3800 .values(
3801 reviewerUsers.map((u) => ({
3802 prId: pr.id,
3803 reviewerId: u.id,
3804 requestedBy: null as string | null,
3805 }))
3806 )
3807 .onConflictDoNothing();
3808
3809 // Add a PR comment announcing the auto-assigned reviewers
3810 const mentionList = reviewerUsers
3811 .map((u) => `@${u.username}`)
3812 .join(", ");
3813 await db.insert(prComments).values({
3814 pullRequestId: pr.id,
3815 authorId: user.id,
3816 body: `AI: Requested review from ${mentionList} based on CODEOWNERS`,
3817 isAiReview: true,
3818 });
3819 }
3820 }
3821 }
3822 }
3823 } catch (err) {
3824 console.warn("[codeowners] auto-assign failed:", err instanceof Error ? err.message : err);
3825 }
3826 })();
3827
91b054eccanty labs3828 // `on: pull_request` workflow trigger — workflows are already synced
3829 // into the `workflows` table on push (push-workflow-sync.ts); PR-open
3830 // just needs to enqueue runs for the ones whose `on:` includes
3831 // `pull_request`. Fire-and-forget; must never block PR creation. Scoped
3832 // to PR open only — see pr-workflow-sync.ts header for why synchronize/
3833 // close aren't wired here yet.
3834 resolveRef(ownerName, repoName, headBranch)
3835 .then((headSha) => {
3836 if (!headSha) return;
3837 return enqueuePullRequestWorkflows({
3838 repositoryId: resolved.repo.id,
3839 headBranch,
3840 headSha,
3841 triggeredBy: user.id,
3842 });
3843 })
3844 .catch((err) =>
3845 console.warn(
3846 "[pr-workflow-sync] enqueue failed:",
3847 err instanceof Error ? err.message : err
3848 )
3849 );
3850
6fc53bdClaude3851 // Skip AI review on drafts — it runs again when the PR is marked ready.
3852 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3853 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3854 (err) => console.error("[ai-review] Failed:", err)
3855 );
3856 }
3857
3cbe3d6Claude3858 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3859 triggerPrTriage({
3860 ownerName,
3861 repoName,
3862 repositoryId: resolved.repo.id,
3863 prId: pr.id,
3864 prAuthorId: user.id,
3865 title,
3866 body: prBody,
3867 baseBranch,
3868 headBranch,
3869 }).catch((err) => console.error("[pr-triage] Failed:", err));
3870
1d4ff60Claude3871 // Chat notifier — fan out to Slack/Discord/Teams.
3872 import("../lib/chat-notifier")
3873 .then((m) =>
3874 m.notifyChatChannels({
3875 ownerUserId: resolved.repo.ownerId,
3876 repositoryId: resolved.repo.id,
3877 event: {
3878 event: "pr.opened",
3879 repo: `${ownerName}/${repoName}`,
3880 title: `#${pr.number} ${title}`,
3881 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3882 body: prBody || undefined,
3883 actor: user.username,
3884 },
3885 })
3886 )
3887 .catch((err) =>
3888 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3889 );
3890
9dd96b9Test User3891 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude3892 import("../lib/auto-merge")
3893 .then((m) => m.tryAutoMergeNow(pr.id))
3894 .catch((err) => {
3895 console.warn(
3896 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
3897 err instanceof Error ? err.message : err
3898 );
3899 });
9dd96b9Test User3900
1df50d5Claude3901 // Migration 0077 — PR preview build. Fire-and-forget; skips when
3902 // PREVIEW_DOMAIN is unset or the repo has no preview_build_command.
3903 // Resolve head SHA asynchronously so we don't block the redirect.
3904 resolveRef(ownerName, repoName, headBranch)
3905 .then((headSha) => {
3906 if (!headSha) return;
3907 return import("../lib/preview-builder").then((m) =>
3908 m.buildPreview(pr.id, resolved.repo.id, headSha)
3909 );
3910 })
3911 .catch(() => {});
3912
0074234Claude3913 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
3914 }
3915);
3916
3917// View single PR
04f6b7fClaude3918pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude3919 const { owner: ownerName, repo: repoName } = c.req.param();
3920 const prNum = parseInt(c.req.param("number"), 10);
3921 const user = c.get("user");
3922 const tab = c.req.query("tab") || "conversation";
a2c10c5Claude3923 const isSplit = c.req.query("diffview") === "split";
3924 const pendingCount = parseInt(c.req.query("pending") || "0", 10) || 0;
0074234Claude3925
3926 const resolved = await resolveRepo(ownerName, repoName);
3927 if (!resolved) return c.notFound();
3928
3929 const [pr] = await db
3930 .select()
3931 .from(pullRequests)
3932 .where(
3933 and(
3934 eq(pullRequests.repositoryId, resolved.repo.id),
3935 eq(pullRequests.number, prNum)
3936 )
3937 )
3938 .limit(1);
3939
3940 if (!pr) return c.notFound();
3941
3942 const [author] = await db
3943 .select()
3944 .from(users)
3945 .where(eq(users.id, pr.authorId))
3946 .limit(1);
3947
cb5a796Claude3948 const allCommentsRaw = await db
0074234Claude3949 .select({
3950 comment: prComments,
cb5a796Claude3951 author: { id: users.id, username: users.username },
0074234Claude3952 })
3953 .from(prComments)
3954 .innerJoin(users, eq(prComments.authorId, users.id))
3955 .where(eq(prComments.pullRequestId, pr.id))
3956 .orderBy(asc(prComments.createdAt));
3957
cb5a796Claude3958 // Filter pending/rejected/spam for non-owner, non-author viewers.
3959 // Owner always sees everything; comment author sees their own pending
3960 // with an "Awaiting approval" badge in the render below.
3961 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
3962 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
3963 if (viewerIsRepoOwner) return true;
3964 if (comment.moderationStatus === "approved") return true;
3965 if (
3966 user &&
3967 cAuthor.id === user.id &&
3968 comment.moderationStatus === "pending"
3969 ) {
3970 return true;
3971 }
3972 return false;
3973 });
3974 const prPendingCount = viewerIsRepoOwner
3975 ? await countPendingForRepo(resolved.repo.id)
3976 : 0;
3977
6fc53bdClaude3978 // Reactions for the PR body + each comment, in parallel.
3979 const [prReactions, ...prCommentReactions] = await Promise.all([
3980 summariseReactions("pr", pr.id, user?.id),
3981 ...comments.map((row) =>
3982 summariseReactions("pr_comment", row.comment.id, user?.id)
3983 ),
3984 ]);
3985
0a67773Claude3986 // Formal reviews (Approve / Request Changes)
3987 const reviewRows = await db
3988 .select({
3989 id: prReviews.id,
3990 state: prReviews.state,
3991 body: prReviews.body,
3992 isAi: prReviews.isAi,
3993 createdAt: prReviews.createdAt,
3994 reviewerUsername: users.username,
3995 reviewerId: prReviews.reviewerId,
3996 })
3997 .from(prReviews)
3998 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3999 .where(eq(prReviews.pullRequestId, pr.id))
4000 .orderBy(asc(prReviews.createdAt));
4001 // Most recent review per reviewer determines the current state
4002 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
4003 for (const r of reviewRows) {
4004 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
4005 }
4006 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
4007 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
4008 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
4009
ec9e3e3Claude4010 // Requested reviewers from CODEOWNERS auto-assign (migration 0077).
4011 const requestedReviewerRows = await db
4012 .select({
4013 reviewerUsername: users.username,
4014 reviewerId: prReviewRequests.reviewerId,
4015 createdAt: prReviewRequests.createdAt,
4016 })
4017 .from(prReviewRequests)
4018 .innerJoin(users, eq(prReviewRequests.reviewerId, users.id))
4019 .where(eq(prReviewRequests.prId, pr.id))
4020 .orderBy(asc(prReviewRequests.createdAt))
4021 .catch(() => [] as { reviewerUsername: string; reviewerId: string; createdAt: Date }[]);
4022
ace34efClaude4023 // Suggested reviewers — best-effort, never throws
4024 let reviewerSuggestions: ReviewerCandidate[] = [];
4025 try {
4026 if (user) {
4027 reviewerSuggestions = await suggestReviewers(
4028 ownerName, repoName, pr.headBranch, pr.baseBranch,
4029 pr.authorId, resolved.repo.id
4030 );
4031 }
4032 } catch {
4033 // silent degradation
4034 }
4035
0074234Claude4036 const canManage =
4037 user &&
4038 (user.id === resolved.owner.id || user.id === pr.authorId);
4039
1d4ff60Claude4040 // Has any previous AI-test-generator run already tagged this PR? Used
4041 // both to hide the "Generate tests with AI" button and to short-circuit
4042 // the explicit POST handler.
4043 const hasAiTestsMarker = comments.some(({ comment }) =>
4044 (comment.body || "").includes(AI_TESTS_MARKER)
4045 );
4046
e883329Claude4047 const error = c.req.query("error");
c3e0c07Claude4048 const info = c.req.query("info");
e883329Claude4049
4050 // Get gate check status for open PRs
4051 let gateChecks: GateCheckResult[] = [];
240c477Claude4052 let ciStatuses: CommitStatus[] = [];
e883329Claude4053 if (pr.state === "open") {
6f1fd83Claude4054 try {
4055 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4056 if (headSha) {
c166384ccantynz-alt4057 const aiApproved = await isAiReviewApproved(pr.id);
6f1fd83Claude4058 const [gateResult, fetchedCiStatuses] = await Promise.all([
4059 runAllGateChecks(
4060 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
4061 ).catch(() => ({ allPassed: false, checks: [] as GateCheckResult[] })),
4062 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
4063 ]);
4064 gateChecks = gateResult.checks;
4065 ciStatuses = fetchedCiStatuses;
4066 }
4067 } catch {
4068 // git repo missing or unreachable — show PR without gate status
e883329Claude4069 }
4070 }
4071
534f04aClaude4072 // Block M3 — pre-merge risk score. Cache-only on the request path so
4073 // the page never waits on Haiku. On a cache miss for an open PR we
4074 // kick off the computation fire-and-forget; the next refresh shows it.
4075 let prRisk: PrRiskScore | null = null;
4076 let prRiskCalculating = false;
4077 if (pr.state === "open") {
4078 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
4079 if (!prRisk) {
4080 prRiskCalculating = true;
a28cedeClaude4081 void computePrRiskForPullRequest(pr.id).catch((err) => {
4082 console.warn(
4083 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
4084 err instanceof Error ? err.message : err
4085 );
4086 });
534f04aClaude4087 }
4088 }
4089
4bbacbeClaude4090 // Migration 0062 — per-branch preview URL. The head branch always
4091 // has a preview row (unless it's the default branch, which never
4092 // happens for an open PR) once it has been pushed at least once.
4093 const preview = await getPreviewForBranch(
4094 (resolved.repo as { id: string }).id,
4095 pr.headBranch
4096 );
4097
0369e77Claude4098 // Branch ahead/behind counts — how many commits head is ahead of base and
4099 // how many commits base has advanced since head branched off.
4100 let branchAhead = 0;
4101 let branchBehind = 0;
4102 if (pr.state === "open") {
4103 try {
4104 const repoDir = getRepoPath(ownerName, repoName);
4105 const [aheadProc, behindProc] = [
4106 Bun.spawn(
4107 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
4108 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4109 ),
4110 Bun.spawn(
4111 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
4112 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4113 ),
4114 ];
4115 const [aheadTxt, behindTxt] = await Promise.all([
4116 new Response(aheadProc.stdout).text(),
4117 new Response(behindProc.stdout).text(),
4118 ]);
4119 await Promise.all([aheadProc.exited, behindProc.exited]);
4120 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
4121 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
4122 } catch { /* non-blocking */ }
4123 }
4124
6d1bbc2Claude4125 // Linked issues — parse closing keywords from PR title+body, look up issues
4126 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
4127 try {
4128 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
4129 const refs = extractClosingRefsMulti([pr.title, pr.body]);
4130 if (refs.length > 0) {
4131 linkedIssues = await db
4132 .select({ number: issues.number, title: issues.title, state: issues.state })
4133 .from(issues)
4134 .where(and(
4135 eq(issues.repositoryId, resolved.repo.id),
4136 inArray(issues.number, refs),
4137 ));
4138 }
4139 } catch { /* non-blocking */ }
4140
4141 // Task list progress — count markdown checkboxes in PR body
4142 let taskTotal = 0;
4143 let taskChecked = 0;
4144 if (pr.body) {
4145 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
4146 taskTotal++;
4147 if (m[1].trim() !== "") taskChecked++;
4148 }
4149 }
4150
74d8c4dClaude4151 // M15 — PR size badge (best-effort, non-blocking)
4152 let prSizeInfo: PrSizeInfo | null = null;
4153 try {
4154 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
4155 } catch { /* swallow — purely cosmetic */ }
4156
09d5f39Claude4157 // Merge impact analysis — only for open PRs with write access (cached, fast)
4158 let impactAnalysis: ImpactAnalysis | null = null;
4159 if (pr.state === "open" && canManage) {
4160 try {
4161 impactAnalysis = await analyzeImpact(resolved.repo.id, pr.id);
4162 } catch { /* non-blocking */ }
4163 }
1d6db4dClaude4164
47a7a0aClaude4165 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude4166 let diffRaw = "";
4167 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude4168 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude4169 if (tab === "files") {
4170 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude4171 // Run the two git diffs in parallel — they're independent reads of
4172 // the same range. Previously sequential, doubling the wall time on
4173 // big PRs (100+ files = 10-30s for no reason).
0074234Claude4174 const proc = Bun.spawn(
4175 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
4176 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4177 );
4178 const statProc = Bun.spawn(
4179 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
4180 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4181 );
6ea2109Claude4182 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
4183 // would otherwise hang the whole request.
4184 const killer = setTimeout(() => {
4185 proc.kill();
4186 statProc.kill();
4187 }, 30_000);
4188 let stat = "";
4189 try {
4190 [diffRaw, stat] = await Promise.all([
4191 new Response(proc.stdout).text(),
4192 new Response(statProc.stdout).text(),
4193 ]);
4194 await Promise.all([proc.exited, statProc.exited]);
4195 } finally {
4196 clearTimeout(killer);
4197 }
0074234Claude4198
4199 diffFiles = stat
4200 .trim()
4201 .split("\n")
4202 .filter(Boolean)
4203 .map((line) => {
4204 const [add, del, filePath] = line.split("\t");
4205 return {
4206 path: filePath,
4207 status: "modified",
4208 additions: add === "-" ? 0 : parseInt(add, 10),
4209 deletions: del === "-" ? 0 : parseInt(del, 10),
4210 patch: "",
4211 };
4212 });
47a7a0aClaude4213
4214 // Fetch inline comments (file+line anchored) for the files tab
4215 const inlineRows = await db
4216 .select({
4217 id: prComments.id,
4218 filePath: prComments.filePath,
4219 lineNumber: prComments.lineNumber,
4220 body: prComments.body,
4221 isAiReview: prComments.isAiReview,
4222 createdAt: prComments.createdAt,
4223 authorUsername: users.username,
4224 })
4225 .from(prComments)
4226 .innerJoin(users, eq(prComments.authorId, users.id))
4227 .where(
4228 and(
4229 eq(prComments.pullRequestId, pr.id),
4230 eq(prComments.moderationStatus, "approved"),
4231 )
4232 )
4233 .orderBy(asc(prComments.createdAt));
4234
4235 diffInlineComments = inlineRows
4236 .filter(r => r.filePath != null && r.lineNumber != null)
4237 .map(r => ({
4238 id: r.id,
4239 filePath: r.filePath!,
4240 lineNumber: r.lineNumber!,
4241 authorUsername: r.authorUsername,
4242 body: renderMarkdown(r.body),
4243 isAiReview: r.isAiReview,
4244 createdAt: r.createdAt.toISOString(),
4245 }));
0074234Claude4246 }
4247
34e63b9Claude4248 // Proactive pattern warning — get changed file paths and check for recurring
4249 // bug patterns. Fire-and-forget safe; returns null on any error or cache miss.
4250 let patternWarning: Pattern | null = null;
4251 try {
4252 const repoDir = getRepoPath(ownerName, repoName);
4253 const nameOnlyProc = Bun.spawn(
4254 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4255 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4256 );
4257 const nameOnlyRaw = await new Response(nameOnlyProc.stdout).text();
4258 await nameOnlyProc.exited;
4259 const prChangedFiles = nameOnlyRaw.trim().split("\n").filter(Boolean);
4260 if (prChangedFiles.length > 0) {
4261 patternWarning = await getPatternWarning(resolved.repo.id, prChangedFiles);
4262 }
4263 } catch {
4264 // Non-blocking — swallow
4265 }
4266
7f992cdClaude4267 // Bus factor warning — flag files dominated by a single author.
4268 let busRiskFiles: BusFactorFile[] = [];
4269 try {
4270 const repoDir2 = getRepoPath(ownerName, repoName);
4271 const bf2Proc = Bun.spawn(
4272 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4273 { cwd: repoDir2, stdout: "pipe", stderr: "pipe" }
4274 );
4275 const bf2Raw = await new Response(bf2Proc.stdout).text();
4276 await bf2Proc.exited;
4277 const bf2Files = bf2Raw.trim().split("\n").filter(Boolean);
4278 if (bf2Files.length > 0) {
4279 busRiskFiles = await getBusFactorWarning(resolved.repo.id, ownerName, repoName, bf2Files);
4280 }
4281 } catch {
4282 // Non-blocking
4283 }
4284
4285 // PR split suggestion — AI recommends sub-PR decomposition for large PRs.
4286 let splitSuggestion: SplitSuggestion | null = null;
4287 try {
4288 splitSuggestion = await suggestPrSplit(
4289 pr.id,
4290 pr.title,
4291 ownerName,
4292 repoName,
4293 pr.baseBranch,
4294 pr.headBranch
4295 );
4296 } catch {
4297 // Non-blocking
4298 }
4299
b078860Claude4300 // ─── Derived visual state ───
4301 const stateKey =
4302 pr.state === "open"
4303 ? pr.isDraft
4304 ? "draft"
4305 : "open"
4306 : pr.state;
4307 const stateLabel =
4308 stateKey === "open"
4309 ? "Open"
4310 : stateKey === "draft"
4311 ? "Draft"
4312 : stateKey === "merged"
4313 ? "Merged"
4314 : "Closed";
4315 const stateIcon =
4316 stateKey === "open"
4317 ? "○"
4318 : stateKey === "draft"
4319 ? "◌"
4320 : stateKey === "merged"
4321 ? "⮌"
4322 : "✓";
f6730d0ccantynz-alt4323 const stateVariant =
4324 stateKey === "open"
4325 ? "success"
4326 : stateKey === "merged"
4327 ? "info"
4328 : stateKey === "closed"
4329 ? "danger"
4330 : "neutral";
b078860Claude4331 const commentCount = comments.length;
4332 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
4333 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
4334 const mergeBlocked =
4335 gateChecks.length > 0 &&
4336 gateChecks.some(
4337 (c) => !c.passed && c.name !== "Merge check"
4338 );
4339
b558f23Claude4340 // Commits tab — list commits included in this PR (base..head range)
4341 let prCommits: GitCommit[] = [];
4342 if (tab === "commits") {
4343 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
4344 }
4345
cc34156Claude4346 // Review context restore — compute BEFORE recording the visit so the
4347 // previous timestamp is available for the delta calculation.
4348 let reviewCtx: ReviewContext | null = null;
4349 if (user) {
4350 reviewCtx = await getReviewContext(pr.id, user.id, {
4351 ownerName,
4352 repoName,
4353 baseBranch: pr.baseBranch,
4354 headBranch: pr.headBranch,
4355 });
4356 // Fire-and-forget: record the visit AFTER computing context
4357 void recordPrVisit(pr.id, user.id);
4358 }
4359
0074234Claude4360 return c.html(
4361 <Layout
4362 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
4363 user={user}
4364 >
4365 <RepoHeader owner={ownerName} repo={repoName} />
4366 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude4367 <PendingCommentsBanner
4368 owner={ownerName}
4369 repo={repoName}
4370 count={prPendingCount}
4371 />
b078860Claude4372 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
f6730d0ccantynz-alt4373 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
b584e52Claude4374 <div
4375 id="live-comment-banner"
4376 class="alert"
4377 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
4378 >
4379 <strong class="js-live-count">0</strong> new comment(s) —{" "}
4380 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
4381 reload to view
4382 </a>
4383 </div>
4384 <script
4385 dangerouslySetInnerHTML={{
4386 __html: liveCommentBannerScript({
4387 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
4388 bannerElementId: "live-comment-banner",
4389 }),
4390 }}
4391 />
b078860Claude4392
cc34156Claude4393 {/* Review context restore banner — shown when returning after changes */}
4394 {reviewCtx && (
4395 <div
4396 class="context-restore-banner"
4397 id="review-context"
4398 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"
4399 >
4400 <span class="context-icon" style="font-size:18px;flex-shrink:0;margin-top:2px" aria-hidden="true">{"↩"}</span>
4401 <div style="flex:1;min-width:0">
4402 <strong style="font-size:13.5px;color:var(--text-strong,#111)">Welcome back</strong>
4403 <p style="margin:4px 0 0;font-size:13px;color:var(--text,#333);line-height:1.5">{reviewCtx.summary}</p>
4404 <small style="font-size:11.5px;color:var(--text-muted,#777)">
4405 Last visited {formatRelative(new Date(reviewCtx.lastVisitedAt))}
4406 {reviewCtx.commitsSince > 0 && ` · ${reviewCtx.commitsSince} new commit${reviewCtx.commitsSince === 1 ? "" : "s"}`}
4407 {reviewCtx.newComments > 0 && ` · ${reviewCtx.newComments} new comment${reviewCtx.newComments === 1 ? "" : "s"}`}
4408 </small>
4409 {reviewCtx.suggestedStartLine && (
4410 <p style="margin:6px 0 0;font-size:12px;color:var(--accent,#0070f3)">
4411 Start at: <code style="font-size:11px">{reviewCtx.suggestedStartLine}</code>
4412 </p>
4413 )}
4414 </div>
4415 <button
4416 type="button"
4417 onclick="this.closest('.context-restore-banner').remove()"
4418 style="flex-shrink:0;background:none;border:none;cursor:pointer;font-size:18px;color:var(--text-muted,#777);padding:0;line-height:1"
4419 aria-label="Dismiss"
4420 >
4421 {"×"}
4422 </button>
4423 </div>
4424 )}
4425
b078860Claude4426 <div class="prs-detail-hero">
b558f23Claude4427 <div class="prs-edit-title-wrap">
4428 <h1 class="prs-detail-title" id="pr-title-display">
4429 {pr.title}{" "}
4430 <span class="prs-detail-num">#{pr.number}</span>
4431 </h1>
4432 {canManage && pr.state === "open" && (
4433 <button
4434 type="button"
4435 class="prs-edit-btn"
4436 id="pr-edit-toggle"
4437 onclick={`
4438 document.getElementById('pr-title-display').style.display='none';
4439 document.getElementById('pr-edit-toggle').style.display='none';
4440 document.getElementById('pr-edit-form').style.display='flex';
4441 document.getElementById('pr-title-input').focus();
4442 `}
4443 >
4444 Edit
4445 </button>
4446 )}
4447 </div>
4448 {canManage && pr.state === "open" && (
4449 <form
4450 id="pr-edit-form"
4451 method="post"
4452 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
4453 class="prs-edit-form"
4454 style="display:none"
4455 >
4456 <input
4457 id="pr-title-input"
4458 type="text"
4459 name="title"
4460 value={pr.title}
4461 required
4462 maxlength={256}
4463 placeholder="Pull request title"
4464 />
4465 <div class="prs-edit-actions">
4466 <button type="submit" class="prs-edit-save-btn">Save</button>
4467 <button
4468 type="button"
4469 class="prs-edit-cancel-btn"
4470 onclick={`
4471 document.getElementById('pr-edit-form').style.display='none';
4472 document.getElementById('pr-title-display').style.display='';
4473 document.getElementById('pr-edit-toggle').style.display='';
4474 `}
4475 >
4476 Cancel
4477 </button>
4478 </div>
4479 </form>
4480 )}
b078860Claude4481 <div class="prs-detail-meta">
f6730d0ccantynz-alt4482 <GxBadge variant={stateVariant}>
b078860Claude4483 <span aria-hidden="true">{stateIcon}</span>
4484 <span>{stateLabel}</span>
f6730d0ccantynz-alt4485 </GxBadge>
2c61840Claude4486 {isAiGeneratedPr(pr.body, pr.headBranch) && (
4487 <span class="prs-tag is-ai" title="This pull request was opened by an AI agent">⚡ AI-generated</span>
4488 )}
74d8c4dClaude4489 {prSizeInfo && (
4490 <span
4491 class="prs-size-badge"
4492 style={`color:${prSizeInfo.color};background:${prSizeInfo.bgColor}`}
4493 title={`${prSizeInfo.linesChanged} lines changed (+${prSizeInfo.added} −${prSizeInfo.deleted})`}
4494 >
4495 {prSizeInfo.label}
4496 </span>
4497 )}
67dc4e1Claude4498 <TrioVerdictPills
4499 comments={comments.map(({ comment }) => comment)}
4500 />
b078860Claude4501 <span>
4502 <strong>{author?.username}</strong> wants to merge
4503 </span>
4504 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
4505 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
4506 <span class="prs-branch-arrow-lg">{"→"}</span>
4507 <span class="prs-branch-pill">{pr.baseBranch}</span>
4508 </span>
0369e77Claude4509 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
4510 <span
4511 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
4512 title={branchBehind > 0
4513 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
4514 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
4515 >
4516 {branchAhead > 0 ? `↑${branchAhead}` : ""}
4517 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
4518 {branchBehind > 0 ? `↓${branchBehind}` : ""}
4519 </span>
4520 )}
b078860Claude4521 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude4522 {taskTotal > 0 && (
4523 <span
4524 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
4525 title={`${taskChecked} of ${taskTotal} tasks completed`}
4526 >
4527 <span class="prs-tasks-progress" aria-hidden="true">
4528 <span
4529 class="prs-tasks-progress-bar"
4530 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
4531 ></span>
4532 </span>
4533 {taskChecked}/{taskTotal} tasks
4534 </span>
4535 )}
4536 {canManage && pr.state === "open" && branchBehind > 0 && (
4537 <form
4538 method="post"
4539 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
4540 class="prs-inline-form"
4541 >
4542 <button
4543 type="submit"
4544 class="prs-update-branch-btn"
4545 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
4546 >
4547 ↑ Update branch
4548 </button>
4549 </form>
4550 )}
3c03977Claude4551 <span
4552 id="live-pill"
4553 class="live-pill"
4554 title="People editing this PR right now"
4555 >
4556 <span class="live-pill-dot" aria-hidden="true"></span>
4557 <span>
4558 Live: <strong id="live-count">0</strong> editing
4559 </span>
4560 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
4561 </span>
4bbacbeClaude4562 {preview && (
4563 <a
4564 class={`preview-prpill is-${preview.status}`}
4565 href={
4566 preview.status === "ready"
4567 ? preview.previewUrl
4568 : `/${ownerName}/${repoName}/previews`
4569 }
4570 target={preview.status === "ready" ? "_blank" : undefined}
4571 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
4572 title={`Preview · ${previewStatusLabel(preview.status)}`}
4573 >
4574 <span class="preview-prpill-dot" aria-hidden="true"></span>
4575 <span>Preview: </span>
4576 <span>{previewStatusLabel(preview.status)}</span>
4577 </a>
4578 )}
b078860Claude4579 {canManage && pr.state === "open" && pr.isDraft && (
4580 <form
4581 method="post"
4582 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4583 class="prs-inline-form prs-detail-actions"
4584 >
4585 <button type="submit" class="prs-merge-ready-btn">
4586 Ready for review
4587 </button>
4588 </form>
4589 )}
4590 </div>
4591 </div>
3c03977Claude4592 <script
4593 dangerouslySetInnerHTML={{
4594 __html: LIVE_COEDIT_SCRIPT(pr.id),
4595 }}
4596 />
829a046Claude4597 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude4598 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude4599 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude4600
25b1ff7Claude4601 {/* Presence styles + bar (shown only on the files tab so cursor pills work) */}
b271465Claude4602 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES + IMPACT_STYLES }} />
25b1ff7Claude4603 {/* Toast container — always present for join/leave toasts */}
4604 <div id="presence-toasts" class="presence-toast-wrap" aria-live="polite" />
4605 {user && (
4606 <>
4607 <div class="presence-bar" id="presence-bar">
4608 <span class="presence-bar-label">Live reviewers</span>
4609 <div class="presence-avatars" id="presence-avatars" />
4610 <span class="presence-count" id="presence-count">Loading…</span>
4611 </div>
4612 <script
4613 dangerouslySetInnerHTML={{
4614 __html: PR_PRESENCE_SCRIPT(ownerName, repoName, pr.number),
4615 }}
4616 />
4617 </>
4618 )}
4619
b078860Claude4620 <nav class="prs-detail-tabs" aria-label="Pull request sections">
4621 <a
4622 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
4623 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
4624 >
4625 Conversation
4626 <span class="prs-detail-tab-count">{commentCount}</span>
4627 </a>
b558f23Claude4628 <a
4629 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
4630 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
4631 >
4632 Commits
4633 {branchAhead > 0 && (
4634 <span class="prs-detail-tab-count">{branchAhead}</span>
4635 )}
4636 </a>
b078860Claude4637 <a
4638 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
4639 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4640 >
4641 Files changed
4642 {diffFiles.length > 0 && (
4643 <span class="prs-detail-tab-count">{diffFiles.length}</span>
4644 )}
4645 </a>
4646 </nav>
4647
34e63b9Claude4648 {/* Proactive pattern warning — shown when a known recurring bug pattern
4649 overlaps with the files changed in this PR. */}
4650 {patternWarning && (
4651 <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">
4652 <span style="font-size:15px;margin-right:6px" aria-hidden="true">⚠️</span>
4653 <strong>Recurring pattern detected: {patternWarning.title}</strong>
4654 <span style="color:var(--fg-muted)">
4655 {" — "}
4656 This area has had {patternWarning.occurrences} similar fix
4657 {patternWarning.occurrences === 1 ? "" : "es"}.
4658 {patternWarning.rootCauseHypothesis && (
4659 <> Root cause may be in <code style="font-size:12px">{patternWarning.suggestedFile}</code>.</>
4660 )}
4661 </span>
4662 </div>
4663 )}
4664
b558f23Claude4665 {tab === "commits" ? (
4666 <div class="prs-commits-list">
4667 {prCommits.length === 0 ? (
4668 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
4669 ) : (
4670 prCommits.map((commit) => (
4671 <div class="prs-commit-row">
4672 <span class="prs-commit-dot" aria-hidden="true"></span>
4673 <div class="prs-commit-body">
4674 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
4675 <div class="prs-commit-meta">
4676 <strong>{commit.author}</strong> committed{" "}
4677 {formatRelative(new Date(commit.date))}
4678 </div>
4679 </div>
4680 <a
4681 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
4682 class="prs-commit-sha"
4683 title="View commit"
4684 >
4685 {commit.sha.slice(0, 7)}
4686 </a>
4687 </div>
4688 ))
4689 )}
4690 </div>
4691 ) : tab === "files" ? (
1d6db4dClaude4692 <>
4693 {/* PR Split Suggestion — shown when PR has >400 changed lines */}
4694 {splitSuggestion && (
4695 <div class="split-suggestion" id="pr-split-banner">
4696 <div class="split-header">
4697 <span class="split-icon" aria-hidden="true">✂️</span>
4698 <strong>This PR may be too large to review effectively</strong>
4699 <span class="split-stat">
4700 {splitSuggestion.totalLines} lines · {splitSuggestion.totalFiles} files
4701 </span>
4702 <button
4703 class="split-toggle"
4704 type="button"
4705 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';"
4706 >
4707 Show split suggestion
4708 </button>
4709 </div>
4710 <div class="split-body" id="pr-split-body" hidden>
4711 <p class="split-intro">
4712 AI suggests splitting into {splitSuggestion.suggestedPrs.length} PRs:
4713 </p>
4714 {splitSuggestion.suggestedPrs.map((sp, i) => (
4715 <div class="split-pr">
4716 <div class="split-pr-num">{i + 1}</div>
4717 <div class="split-pr-body">
4718 <strong>{sp.title}</strong>
4719 <p>{sp.rationale}</p>
4720 <code>{sp.files.join(", ")}</code>
4721 <span class="split-lines">~{sp.estimatedLines} lines</span>
4722 </div>
4723 </div>
4724 ))}
4725 {splitSuggestion.mergeOrder.length > 0 && (
4726 <p class="split-order">
4727 Suggested merge order:{" "}
4728 <strong>{splitSuggestion.mergeOrder.join(" → ")}</strong>
4729 </p>
4730 )}
4731 </div>
4732 </div>
4733 )}
4734
4735 {/* Bus Factor Warning — shown when changed files overlap at-risk files */}
4736 {busRiskFiles.length > 0 && (() => {
4737 const topRisk = busRiskFiles.some((f) => f.risk === "critical")
4738 ? "critical"
4739 : busRiskFiles.some((f) => f.risk === "high")
4740 ? "high"
4741 : "medium";
4742 return (
4743 <div class={`busfactor-panel busfactor-${topRisk}`}>
4744 <span class="busfactor-icon" aria-hidden="true">⚠️</span>
4745 <div class="busfactor-body">
4746 <strong>Knowledge concentration warning</strong>
4747 <p>
4748 {busRiskFiles.length} file{busRiskFiles.length !== 1 ? "s" : ""} in
4749 this PR {busRiskFiles.length !== 1 ? "are" : "is"} primarily
4750 maintained by one person. Consider pairing on this review.
4751 </p>
4752 <ul>
4753 {busRiskFiles.map((f) => (
4754 <li>
4755 <code>{f.path}</code> —{" "}
4756 <strong>{f.primaryAuthorPct}%</strong> by {f.primaryAuthor}
4757 </li>
4758 ))}
4759 </ul>
4760 </div>
4761 </div>
4762 );
4763 })()}
4764
4765 <DiffView
4766 raw={diffRaw}
4767 files={diffFiles}
4768 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
4769 inlineComments={diffInlineComments}
4770 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
4771 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
a2c10c5Claude4772 pendingReviewUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/review/pending/add` : undefined}
4773 submitReviewUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/review/submit` : undefined}
4774 isSplit={isSplit}
4775 pendingCount={pendingCount}
4776 owner={ownerName}
4777 repo={repoName}
4778 prNumber={pr.number}
1d6db4dClaude4779 />
4780 </>
b078860Claude4781 ) : (
4782 <>
4783 {pr.body && (
4784 <CommentBox
4785 author={author?.username ?? "unknown"}
4786 date={pr.createdAt}
4787 body={renderMarkdown(pr.body)}
4788 />
4789 )}
4790
422a2d4Claude4791 {/* Block H — AI trio review (security/correctness/style). When
4792 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
4793 hoisted into a 3-column card grid above the normal comment
4794 stream so reviewers see verdicts at a glance. Disagreements
4795 are surfaced as a yellow callout. */}
4796 <TrioReviewGrid
4797 comments={comments.map(({ comment }) => comment)}
4798 />
4799
15db0e0Claude4800 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude4801 // Skip trio comments — already rendered in TrioReviewGrid above.
4802 if (isTrioComment(comment.body)) return null;
15db0e0Claude4803 const slashCmd = detectSlashCmdComment(comment.body);
4804 if (slashCmd) {
4805 const visible = stripSlashCmdMarker(comment.body);
4806 return (
4807 <div class={`slash-pill slash-cmd-${slashCmd}`}>
4808 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
4809 <span class="slash-pill-actor">
4810 <strong>{commentAuthor.username}</strong>
4811 {" ran "}
4812 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude4813 </span>
15db0e0Claude4814 <span class="slash-pill-time">
4815 {formatRelative(comment.createdAt)}
4816 </span>
4817 <div class="slash-pill-body">
4818 <MarkdownContent html={renderMarkdown(visible)} />
4819 </div>
4820 </div>
4821 );
4822 }
cb5a796Claude4823 const isPending = comment.moderationStatus === "pending";
15db0e0Claude4824 return (
cb5a796Claude4825 <div
4826 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
4827 >
15db0e0Claude4828 <div class="prs-comment-head">
4829 <strong>{commentAuthor.username}</strong>
a7460bfClaude4830 {commentAuthor.username === BOT_USERNAME && (
4831 <span class="prs-bot-badge">&#x1F916; bot</span>
4832 )}
15db0e0Claude4833 {comment.isAiReview && (
4834 <span class="prs-ai-badge">AI Review</span>
4835 )}
cb5a796Claude4836 {isPending && (
4837 <span
4838 class="modq-pending-badge"
4839 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
4840 >
4841 Awaiting approval
4842 </span>
4843 )}
15db0e0Claude4844 <span class="prs-comment-time">
4845 commented {formatRelative(comment.createdAt)}
4846 </span>
4847 {comment.filePath && (
4848 <span class="prs-comment-loc">
4849 {comment.filePath}
4850 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
4851 </span>
4852 )}
4853 </div>
4854 <div class="prs-comment-body">
4855 <MarkdownContent html={renderMarkdown(comment.body)} />
4856 </div>
0074234Claude4857 </div>
15db0e0Claude4858 );
4859 })}
0074234Claude4860
b078860Claude4861 {/* Quick link to the Files changed tab when there's a diff to look at. */}
4862 {pr.state !== "merged" && (
4863 <a
4864 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4865 class="prs-files-card"
4866 >
4867 <span class="prs-files-card-icon" aria-hidden="true">
4868 {"▤"}
4869 </span>
4870 <div class="prs-files-card-text">
4871 <p class="prs-files-card-title">Files changed</p>
4872 <p class="prs-files-card-sub">
4873 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
4874 </p>
e883329Claude4875 </div>
b078860Claude4876 <span class="prs-files-card-cta">View diff {"→"}</span>
4877 </a>
4878 )}
4879
6d1bbc2Claude4880 {linkedIssues.length > 0 && (
4881 <div class="prs-linked-issues">
4882 <div class="prs-linked-issues-head">
4883 <span>Closing issues</span>
4884 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
4885 </div>
4886 {linkedIssues.map((issue) => (
4887 <a
4888 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
4889 class="prs-linked-issue-row"
4890 >
4891 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
4892 {issue.state === "open" ? "○" : "✓"}
4893 </span>
4894 <span class="prs-linked-issue-title">{issue.title}</span>
4895 <span class="prs-linked-issue-num">#{issue.number}</span>
4896 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
4897 {issue.state}
4898 </span>
4899 </a>
4900 ))}
4901 </div>
4902 )}
4903
b078860Claude4904 {error && (
4905 <div
4906 class="auth-error"
4907 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)"
4908 >
4909 {decodeURIComponent(error)}
4910 </div>
4911 )}
4912
4913 {info && (
4914 <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)">
4915 {decodeURIComponent(info)}
4916 </div>
4917 )}
e883329Claude4918
b078860Claude4919 {pr.state === "open" && (prRisk || prRiskCalculating) && (
4920 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
4921 )}
4922
ec9e3e3Claude4923 {/* ─── Requested reviewers (CODEOWNERS auto-assign, migration 0077) ─── */}
4924 {requestedReviewerRows.length > 0 && (
4925 <div class="prs-review-summary" style="margin-top:14px">
4926 <div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-weight:700;margin-bottom:4px">
4927 Review requested
4928 </div>
4929 {requestedReviewerRows.map((rr) => {
4930 const hasReviewed = latestReviewByReviewer.has(rr.reviewerId);
4931 const review = latestReviewByReviewer.get(rr.reviewerId);
4932 const statusIcon = !hasReviewed ? "⏳" : review?.state === "approved" ? "✓" : "✗";
4933 const statusColor = !hasReviewed
4934 ? "var(--text-muted)"
4935 : review?.state === "approved"
e589f77ccantynz-alt4936 ? "var(--green)"
4937 : "var(--red)";
ec9e3e3Claude4938 return (
4939 <div class="prs-review-row" style={`gap:8px`}>
4940 <span class="prs-reviewer-avatar">
4941 {rr.reviewerUsername.slice(0, 1).toUpperCase()}
4942 </span>
4943 <a href={`/${rr.reviewerUsername}`}
4944 style="flex:1;font-size:13px;color:var(--text);font-weight:600;text-decoration:none">
4945 {rr.reviewerUsername}
4946 </a>
4947 <span style={`font-size:12px;font-weight:600;color:${statusColor}`}>
4948 {statusIcon} {!hasReviewed ? "Pending" : review?.state === "approved" ? "Approved" : "Changes requested"}
4949 </span>
4950 </div>
4951 );
4952 })}
4953 </div>
b271465Claude4954 )}
09d5f39Claude4955 {/* ─── Merge Impact Analysis panel ─────────────────────── */}
4956 {impactAnalysis && pr.state === "open" && (
4957 <ImpactPanel analysis={impactAnalysis} owner={ownerName} />
ec9e3e3Claude4958 )}
4959
0a67773Claude4960 {/* ─── Review summary ─────────────────────────────────── */}
4961 {(approvals.length > 0 || changesRequested.length > 0) && (
4962 <div class="prs-review-summary">
4963 {approvals.length > 0 && (
4964 <div class="prs-review-row prs-review-approved">
4965 <span class="prs-review-icon">✓</span>
4966 <span>
4967 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4968 approved this pull request
4969 </span>
4970 </div>
4971 )}
4972 {changesRequested.length > 0 && (
4973 <div class="prs-review-row prs-review-changes">
4974 <span class="prs-review-icon">✗</span>
4975 <span>
4976 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4977 requested changes
4978 </span>
4979 </div>
4980 )}
4981 </div>
4982 )}
4983
ace34efClaude4984 {/* Suggested reviewers */}
4985 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
4986 <div class="prs-review-summary" style="margin-top:12px">
4987 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
4988 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
4989 Suggested reviewers
4990 </span>
4991 {reviewerSuggestions.map((r) => (
4992 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
4993 style="display:flex;align-items:center;gap:8px;width:100%">
4994 <input type="hidden" name="reviewerId" value={r.userId} />
4995 <span class="prs-reviewer-avatar">
4996 {r.username.slice(0, 1).toUpperCase()}
4997 </span>
4998 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
4999 {r.username}
5000 </a>
5001 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
5002 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
5003 Request
5004 </button>
5005 </form>
5006 ))}
5007 </div>
5008 </div>
5009 )}
5010
b078860Claude5011 {pr.state === "open" && gateChecks.length > 0 && (
5012 <div class="prs-gate-card">
5013 <div class="prs-gate-head">
5014 <h3>Gate checks</h3>
5015 <span class="prs-gate-summary">
5016 {gatesAllPassed
5017 ? `All ${gateChecks.length} checks passed`
5018 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
5019 </span>
c3e0c07Claude5020 </div>
b078860Claude5021 {gateChecks.map((check) => {
5022 const isAi = /ai.*review/i.test(check.name);
5023 const isSkip = check.skipped === true;
5024 const statusClass = isSkip
5025 ? "is-skip"
5026 : check.passed
5027 ? "is-pass"
5028 : "is-fail";
5029 const statusGlyph = isSkip
5030 ? "—"
5031 : check.passed
5032 ? "✓"
5033 : "✗";
5034 const statusLabel = isSkip
5035 ? "Skipped"
5036 : check.passed
5037 ? "Passed"
5038 : "Failing";
5039 return (
5040 <div
5041 class="prs-gate-row"
5042 style={
5043 isAi
6fd5915Claude5044 ? "border-left: 3px solid rgba(91,110,232,0.55); padding-left: 15px"
b078860Claude5045 : ""
5046 }
5047 >
5048 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
5049 {statusGlyph}
5050 </span>
5051 <span class="prs-gate-name">
5052 {check.name}
5053 {isAi && (
5054 <span
6fd5915Claude5055 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"
b078860Claude5056 >
5057 AI
5058 </span>
5059 )}
5060 </span>
5061 <span class="prs-gate-details">{check.details}</span>
5062 <span class={`prs-gate-pill ${statusClass}`}>
5063 {statusLabel}
e883329Claude5064 </span>
5065 </div>
b078860Claude5066 );
5067 })}
5068 <div class="prs-gate-footer">
5069 {gatesAllPassed
5070 ? "All checks passed — ready to merge."
5071 : gateChecks.some(
5072 (c) => !c.passed && c.name === "Merge check"
5073 )
5074 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
5075 : "Some checks failed — resolve issues before merging."}
5076 {aiReviewCount > 0 && (
5077 <>
5078 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
5079 </>
5080 )}
5081 </div>
5082 </div>
5083 )}
5084
240c477Claude5085 {pr.state === "open" && ciStatuses.length > 0 && (
5086 <div class="prs-ci-card">
5087 <div class="prs-ci-head">
5088 <h3>CI checks</h3>
5089 <span class="prs-ci-summary">
5090 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
5091 </span>
5092 </div>
5093 {ciStatuses.map((status) => {
5094 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
5095 return (
5096 <div class="prs-ci-row">
5097 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
5098 <span class="prs-ci-context">{status.context}</span>
5099 {status.description && (
5100 <span class="prs-ci-desc">{status.description}</span>
5101 )}
5102 <span class={`prs-ci-pill is-${status.state}`}>
5103 {status.state}
5104 </span>
5105 {status.targetUrl && (
5106 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
5107 )}
5108 </div>
5109 );
5110 })}
5111 </div>
5112 )}
5113
b078860Claude5114 {/* ─── Merge area / state-aware action card ─────────────── */}
5115 {user && pr.state === "open" && (
5116 <div
5117 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
5118 >
5119 <div class="prs-merge-head">
5120 <strong>
5121 {pr.isDraft
5122 ? "Draft — ready for review?"
5123 : mergeBlocked
5124 ? "Merge blocked"
5125 : "Ready to merge"}
5126 </strong>
e883329Claude5127 </div>
b078860Claude5128 <p class="prs-merge-sub">
5129 {pr.isDraft
5130 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
5131 : mergeBlocked
5132 ? "Resolve the failing gate checks above before this PR can land."
5133 : gateChecks.length > 0
5134 ? gatesAllPassed
5135 ? "All gates green. Merge will fast-forward into the base branch."
5136 : "Conflicts will be auto-resolved by GlueCron AI on merge."
5137 : "Run gate checks by refreshing once your branch has a recent commit."}
5138 </p>
5139 <Form
5140 method="post"
5141 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
5142 >
5143 <FormGroup>
3c03977Claude5144 <div class="live-cursor-host" style="position:relative">
5145 <textarea
5146 name="body"
5147 id="pr-comment-body"
5148 data-live-field="comment_new"
6cd2f0eClaude5149 data-md-preview=""
3c03977Claude5150 rows={5}
5151 required
5152 placeholder="Leave a comment... (Markdown supported)"
5153 style="font-family:var(--font-mono);font-size:13px;width:100%"
5154 ></textarea>
5155 </div>
15db0e0Claude5156 <span class="slash-hint" title="Type a slash-command as the first line">
5157 Type <code>/</code> for commands —{" "}
5158 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
09d5f39Claude5159 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>,{" "}
5160 <code>/stage</code>
15db0e0Claude5161 </span>
b078860Claude5162 </FormGroup>
5163 <div class="prs-merge-actions">
5164 <Button type="submit" variant="primary">
5165 Comment
5166 </Button>
0a67773Claude5167 {user && user.id !== pr.authorId && pr.state === "open" && (
5168 <>
5169 <button
5170 type="submit"
5171 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
5172 name="review_state"
5173 value="approved"
5174 class="prs-review-approve-btn"
5175 title="Approve this pull request"
5176 >
5177 ✓ Approve
5178 </button>
5179 <button
5180 type="submit"
5181 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
5182 name="review_state"
5183 value="changes_requested"
5184 class="prs-review-changes-btn"
5185 title="Request changes before merging"
5186 >
5187 ✗ Request changes
5188 </button>
5189 </>
5190 )}
b078860Claude5191 {canManage && (
5192 <>
5193 {pr.isDraft ? (
5194 <button
5195 type="submit"
5196 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
5197 formnovalidate
5198 class="prs-merge-ready-btn"
5199 >
5200 Ready for review
5201 </button>
5202 ) : (
a164a6dClaude5203 <>
5204 <div class="prs-merge-strategy-wrap">
5205 <span class="prs-merge-strategy-label">Strategy</span>
5206 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
5207 <option value="merge">Merge commit</option>
5208 <option value="squash">Squash and merge</option>
5209 <option value="ff">Fast-forward</option>
5210 </select>
5211 </div>
5212 <button
5213 type="submit"
5214 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
5215 formnovalidate
5216 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
5217 title={
5218 mergeBlocked
5219 ? "Failing gate checks must be resolved before this PR can merge."
5220 : "Merge pull request"
5221 }
5222 >
5223 {"✔"} Merge pull request
5224 </button>
5225 </>
b078860Claude5226 )}
5227 {!pr.isDraft && (
5228 <button
0074234Claude5229 type="submit"
b078860Claude5230 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
5231 formnovalidate
5232 class="prs-merge-back-draft"
5233 title="Convert back to draft"
0074234Claude5234 >
b078860Claude5235 Convert to draft
5236 </button>
5237 )}
5238 {isAiReviewEnabled() && (
5239 <button
5240 type="submit"
5241 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
5242 formnovalidate
5243 class="btn"
5244 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
5245 >
5246 Re-run AI review
5247 </button>
5248 )}
1d4ff60Claude5249 {isAiReviewEnabled() && !hasAiTestsMarker && (
5250 <button
5251 type="submit"
5252 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
5253 formnovalidate
5254 class="btn"
5255 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."
5256 >
5257 Generate tests with AI
5258 </button>
5259 )}
b078860Claude5260 <Button
5261 type="submit"
5262 variant="danger"
5263 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
5264 >
5265 Close
5266 </Button>
5267 </>
5268 )}
5269 </div>
5270 </Form>
5271 </div>
5272 )}
5273
5274 {/* Read-only footers for non-open states. */}
5275 {pr.state === "merged" && (
5276 <div class="prs-merge-card is-merged">
5277 <div class="prs-merge-head">
5278 <strong>{"⮌"} Merged</strong>
0074234Claude5279 </div>
b078860Claude5280 <p class="prs-merge-sub">
5281 This pull request was merged into{" "}
5282 <code>{pr.baseBranch}</code>.
5283 </p>
5284 </div>
5285 )}
5286 {pr.state === "closed" && (
5287 <div class="prs-merge-card is-closed">
5288 <div class="prs-merge-head">
5289 <strong>{"✕"} Closed without merging</strong>
5290 </div>
5291 <p class="prs-merge-sub">
5292 This pull request was closed and not merged.
5293 </p>
5294 </div>
5295 )}
5296 </>
5297 )}
641aa42Claude5298 {/* Keyboard hint bar — shown at the bottom of PR pages */}
5299 <div class="kbd-hints" aria-label="Keyboard shortcuts for this pull request">
5300 <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
5301 </div>
5302 <style dangerouslySetInnerHTML={{ __html: `
5303 .kbd-hints {
5304 position: fixed;
5305 bottom: 0;
5306 left: 0;
5307 right: 0;
5308 z-index: 90;
5309 padding: 6px 24px;
5310 background: var(--bg-secondary);
5311 border-top: 1px solid var(--border);
5312 font-size: 12px;
5313 color: var(--text-muted);
5314 display: flex;
5315 align-items: center;
5316 gap: 8px;
5317 flex-wrap: wrap;
5318 }
5319 .kbd-hints kbd {
5320 font-family: var(--font-mono);
5321 font-size: 10px;
5322 background: var(--bg-elevated);
5323 border: 1px solid var(--border);
5324 border-bottom-width: 2px;
5325 border-radius: 4px;
5326 padding: 1px 5px;
5327 color: var(--text);
5328 line-height: 1.5;
5329 }
5330 /* Padding so the page footer doesn't overlap the hint bar */
5331 main { padding-bottom: 40px; }
5332 ` }} />
5333 {/* Repo context commands for command palette */}
5334 <script
5335 id="cmdk-repo-context"
5336 dangerouslySetInnerHTML={{
5337 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
5338 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
5339 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
5340 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
5341 { label: `View commits — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/commits`, kw: "history log" },
5342 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
5343 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
5344 ])};`,
5345 }}
5346 />
5347 {/* PR keyboard shortcuts script */}
5348 <script dangerouslySetInnerHTML={{ __html: `
5349 (function(){
5350 var commentBox = document.querySelector('textarea[name="body"]');
5351 var mergeBtn = document.querySelector('[data-merge-btn], .prs-merge-btn, button[form*="merge"], form[action*="/merge"] button[type="submit"]');
5352 var editBtn = document.getElementById('pr-edit-toggle');
5353 var approveUrl = ${JSON.stringify(`/${ownerName}/${repoName}/pulls/${pr.number}/review`)};
5354
5355 function isTyping(t){
5356 t = t || {};
5357 var tag = (t.tagName || '').toLowerCase();
5358 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
5359 }
5360
5361 document.addEventListener('keydown', function(e){
5362 if (isTyping(e.target)) return;
5363 if (e.metaKey || e.ctrlKey || e.altKey) return;
5364 if (e.key === 'c') {
5365 e.preventDefault();
5366 if (commentBox) { commentBox.focus(); commentBox.scrollIntoView({block:'center'}); }
5367 }
5368 if (e.key === 'e') {
5369 e.preventDefault();
5370 if (editBtn) { editBtn.click(); }
5371 }
5372 if (e.key === 'm') {
5373 e.preventDefault();
5374 var mBtn = document.querySelector('.prs-merge-btn, form[action*="/merge"] button[type="submit"]');
5375 if (mBtn) { mBtn.focus(); mBtn.scrollIntoView({block:'center'}); }
5376 }
5377 if (e.key === 'a') {
5378 e.preventDefault();
5379 // Navigate to approve review page
5380 window.location.href = approveUrl + '?action=approve';
5381 }
5382 if (e.key === 'r') {
5383 e.preventDefault();
5384 window.location.href = approveUrl + '?action=request_changes';
5385 }
5386 if (e.key === 'Escape') {
5387 var focused = document.activeElement;
5388 if (focused) focused.blur();
5389 }
5390 });
5391 })();
5392 ` }} />
0074234Claude5393 </Layout>
5394 );
5395});
5396
6d1bbc2Claude5397// Update branch — merge base into head so the PR branch is up to date.
5398// Uses a git worktree so the bare repo stays clean. Write access required.
5399pulls.post(
5400 "/:owner/:repo/pulls/:number/update-branch",
5401 softAuth,
5402 requireAuth,
5403 requireRepoAccess("write"),
5404 async (c) => {
5405 const { owner: ownerName, repo: repoName } = c.req.param();
5406 const prNum = parseInt(c.req.param("number"), 10);
5407 const user = c.get("user")!;
5408 const resolved = await resolveRepo(ownerName, repoName);
5409 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5410
5411 const [pr] = await db
5412 .select()
5413 .from(pullRequests)
5414 .where(and(
5415 eq(pullRequests.repositoryId, resolved.repo.id),
5416 eq(pullRequests.number, prNum),
5417 ))
5418 .limit(1);
5419 if (!pr || pr.state !== "open") {
5420 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5421 }
5422
5423 const repoDir = getRepoPath(ownerName, repoName);
5424 const wt = `${repoDir}/_update_wt_${Date.now()}`;
5425 const gitEnv = {
5426 ...process.env,
5427 GIT_AUTHOR_NAME: user.displayName || user.username,
5428 GIT_AUTHOR_EMAIL: user.email,
5429 GIT_COMMITTER_NAME: user.displayName || user.username,
5430 GIT_COMMITTER_EMAIL: user.email,
5431 };
5432
5433 const addWt = Bun.spawn(
5434 ["git", "worktree", "add", wt, pr.headBranch],
5435 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5436 );
5437 if (await addWt.exited !== 0) {
5438 return c.redirect(
5439 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
5440 );
5441 }
5442
5443 let ok = false;
5444 try {
5445 const mergeProc = Bun.spawn(
5446 ["git", "merge", "--no-edit", pr.baseBranch],
5447 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
5448 );
5449 if (await mergeProc.exited === 0) {
5450 ok = true;
5451 } else {
5452 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5453 }
5454 } catch {
5455 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5456 }
5457
5458 await Bun.spawn(
5459 ["git", "worktree", "remove", "--force", wt],
5460 { cwd: repoDir }
5461 ).exited.catch(() => {});
5462
5463 if (ok) {
5464 return c.redirect(
5465 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
5466 );
5467 }
5468 return c.redirect(
5469 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
5470 );
5471 }
5472);
5473
b558f23Claude5474// Edit PR title (and optionally body). Owner or author only.
5475pulls.post(
5476 "/:owner/:repo/pulls/:number/edit",
5477 softAuth,
5478 requireAuth,
5479 requireRepoAccess("write"),
5480 async (c) => {
5481 const { owner: ownerName, repo: repoName } = c.req.param();
5482 const prNum = parseInt(c.req.param("number"), 10);
5483 const user = c.get("user")!;
5484 const resolved = await resolveRepo(ownerName, repoName);
5485 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5486
5487 const [pr] = await db
5488 .select()
5489 .from(pullRequests)
5490 .where(and(
5491 eq(pullRequests.repositoryId, resolved.repo.id),
5492 eq(pullRequests.number, prNum),
5493 ))
5494 .limit(1);
5495 if (!pr || pr.state !== "open") {
5496 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5497 }
5498 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
5499 if (!canEdit) {
5500 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5501 }
5502
5503 const body = await c.req.parseBody();
5504 const newTitle = String(body.title || "").trim().slice(0, 256);
5505 if (!newTitle) {
5506 return c.redirect(
5507 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
5508 );
5509 }
5510
5511 await db
5512 .update(pullRequests)
5513 .set({ title: newTitle, updatedAt: new Date() })
5514 .where(eq(pullRequests.id, pr.id));
5515
5516 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
5517 }
5518);
5519
cb5a796Claude5520// Add comment to PR.
5521//
5522// Permission model mirrors `issues.tsx`: any logged-in user with read
5523// access can submit; `decideInitialStatus` routes non-collaborators
5524// through the moderation queue. Slash commands only fire when the
5525// comment is auto-approved — we don't want a banned/pending comment to
5526// silently trigger AI work on the PR.
0074234Claude5527pulls.post(
5528 "/:owner/:repo/pulls/:number/comment",
5529 softAuth,
5530 requireAuth,
cb5a796Claude5531 requireRepoAccess("read"),
0074234Claude5532 async (c) => {
5533 const { owner: ownerName, repo: repoName } = c.req.param();
5534 const prNum = parseInt(c.req.param("number"), 10);
5535 const user = c.get("user")!;
5536 const body = await c.req.parseBody();
5537 const commentBody = String(body.body || "").trim();
47a7a0aClaude5538 const filePathRaw = String(body.file_path || "").trim();
5539 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
5540 const inlineFilePath = filePathRaw || undefined;
5541 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude5542
5543 if (!commentBody) {
5544 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5545 }
5546
5547 const resolved = await resolveRepo(ownerName, repoName);
5548 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5549
5550 const [pr] = await db
5551 .select()
5552 .from(pullRequests)
5553 .where(
5554 and(
5555 eq(pullRequests.repositoryId, resolved.repo.id),
5556 eq(pullRequests.number, prNum)
5557 )
5558 )
5559 .limit(1);
5560
5561 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5562
cb5a796Claude5563 const decision = await decideInitialStatus({
5564 commenterUserId: user.id,
5565 repositoryId: resolved.repo.id,
5566 kind: "pr",
5567 threadId: pr.id,
5568 });
5569
d4ac5c3Claude5570 const [inserted] = await db
5571 .insert(prComments)
5572 .values({
5573 pullRequestId: pr.id,
5574 authorId: user.id,
5575 body: commentBody,
cb5a796Claude5576 moderationStatus: decision.status,
47a7a0aClaude5577 filePath: inlineFilePath,
5578 lineNumber: inlineLineNumber,
d4ac5c3Claude5579 })
5580 .returning();
5581
cb5a796Claude5582 // Live update: only when the comment is actually visible.
5583 if (inserted && decision.status === "approved") {
b7b5f75ccanty labs5584 void logActivity({
5585 repositoryId: resolved.repo.id,
5586 userId: user.id,
5587 action: "comment",
5588 targetType: "pull_request",
5589 targetId: String(prNum),
5590 metadata: { commentId: inserted.id },
5591 });
a74f4edccanty labs5592 void fireWebhooks(resolved.repo.id, "pr", {
5593 action: "commented",
5594 number: prNum,
5595 });
b7b5f75ccanty labs5596
d4ac5c3Claude5597 try {
5598 const { publish } = await import("../lib/sse");
5599 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
5600 event: "pr-comment",
5601 data: {
5602 pullRequestId: pr.id,
5603 commentId: inserted.id,
5604 authorId: user.id,
5605 authorUsername: user.username,
5606 },
5607 });
5608 } catch {
5609 /* SSE is best-effort */
5610 }
b7ecb14Claude5611 // Notify the PR author — fire-and-forget, never blocks the response.
5612 if (pr.authorId && pr.authorId !== user.id) {
5613 void import("../lib/notify").then(({ createNotification }) =>
5614 createNotification({
5615 userId: pr.authorId,
5616 type: "pr_comment",
5617 title: `New comment on "${pr.title}"`,
5618 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
5619 url: `/${ownerName}/${repoName}/pulls/${prNum}`,
5620 repoId: resolved.repo.id,
5621 })
5622 ).catch(() => { /* never block the response */ });
5623 }
d4ac5c3Claude5624 }
0074234Claude5625
cb5a796Claude5626 if (decision.status === "pending") {
5627 void notifyOwnerOfPendingComment({
5628 repositoryId: resolved.repo.id,
5629 commenterUsername: user.username,
5630 kind: "pr",
5631 threadNumber: prNum,
5632 ownerUsername: ownerName,
5633 repoName,
5634 });
5635 return c.redirect(
5636 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5637 );
5638 }
5639 if (decision.status === "rejected") {
5640 // Silent ban path — same UX as 'pending' so we don't leak the gate.
5641 return c.redirect(
5642 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5643 );
5644 }
5645
15db0e0Claude5646 // Slash-command handoff. We always store the original comment above
5647 // first so free-form text that happens to start with `/` is preserved
5648 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude5649 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude5650 const parsed = parseSlashCommand(commentBody);
5651 if (parsed) {
5652 try {
5653 const result = await executeSlashCommand({
5654 command: parsed.command,
5655 args: parsed.args,
5656 prId: pr.id,
5657 userId: user.id,
5658 repositoryId: resolved.repo.id,
5659 });
5660 await db.insert(prComments).values({
5661 pullRequestId: pr.id,
5662 authorId: user.id,
5663 body: result.body,
5664 });
5665 } catch (err) {
5666 // Defence-in-depth — executeSlashCommand promises not to throw,
5667 // but if it ever does we want the PR thread to know.
5668 await db
5669 .insert(prComments)
5670 .values({
5671 pullRequestId: pr.id,
5672 authorId: user.id,
5673 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
5674 })
5675 .catch(() => {});
5676 }
5677 }
5678
47a7a0aClaude5679 // Inline comments go back to the files tab; conversation comments to the conversation tab
5680 const redirectTab = inlineFilePath ? "?tab=files" : "";
5681 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude5682 }
5683);
5684
a2c10c5Claude5685// ─── Batched PR review workflow ─────────────────────────────────────────────
5686// Reviewers can stage multiple inline comments in a pending_reviews session,
5687// then submit them all at once as an Approve / Request Changes / Comment review.
5688
5689// GET /:owner/:repo/pulls/:number/review/pending
5690// Returns {count, comments} for the current user's pending review session.
5691pulls.get(
5692 "/:owner/:repo/pulls/:number/review/pending",
5693 softAuth,
5694 requireAuth,
5695 requireRepoAccess("read"),
5696 async (c) => {
5697 const { owner: ownerName, repo: repoName } = c.req.param();
5698 const prNum = parseInt(c.req.param("number"), 10);
5699 const user = c.get("user")!;
5700
5701 const resolved = await resolveRepo(ownerName, repoName);
5702 if (!resolved) return c.json({ count: 0, comments: [] });
5703
5704 const [pr] = await db
5705 .select()
5706 .from(pullRequests)
5707 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5708 .limit(1);
5709 if (!pr) return c.json({ count: 0, comments: [] });
5710
5711 const [review] = await db
5712 .select()
5713 .from(pendingReviews)
5714 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5715 .limit(1);
5716
5717 if (!review) return c.json({ count: 0, comments: [] });
5718
5719 const comments = await db
5720 .select({ id: pendingReviewComments.id, filePath: pendingReviewComments.filePath, lineNumber: pendingReviewComments.lineNumber, body: pendingReviewComments.body })
5721 .from(pendingReviewComments)
5722 .where(eq(pendingReviewComments.reviewId, review.id))
5723 .orderBy(asc(pendingReviewComments.createdAt));
5724
5725 return c.json({ count: comments.length, comments });
5726 }
5727);
5728
5729// POST /:owner/:repo/pulls/:number/review/pending/add
5730// Adds a comment to the current user's pending review (creates session if needed).
5731pulls.post(
5732 "/:owner/:repo/pulls/:number/review/pending/add",
5733 softAuth,
5734 requireAuth,
5735 requireRepoAccess("read"),
5736 async (c) => {
5737 const { owner: ownerName, repo: repoName } = c.req.param();
5738 const prNum = parseInt(c.req.param("number"), 10);
5739 const user = c.get("user")!;
5740 const body = await c.req.parseBody();
5741 const filePath = String(body.filePath || "").trim();
5742 const lineNumber = parseInt(String(body.lineNumber || ""), 10);
5743 const commentBody = String(body.body || "").trim();
5744
5745 if (!filePath || !Number.isFinite(lineNumber) || lineNumber <= 0 || !commentBody) {
5746 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5747 }
5748
5749 const resolved = await resolveRepo(ownerName, repoName);
5750 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5751
5752 const [pr] = await db
5753 .select()
5754 .from(pullRequests)
5755 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5756 .limit(1);
5757 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5758
5759 // Upsert the pending_reviews session row
5760 let [review] = await db
5761 .select()
5762 .from(pendingReviews)
5763 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5764 .limit(1);
5765
5766 if (!review) {
5767 [review] = await db
5768 .insert(pendingReviews)
5769 .values({ prId: pr.id, authorId: user.id })
5770 .onConflictDoNothing()
5771 .returning();
5772 // If onConflictDoNothing returned nothing (race), fetch again
5773 if (!review) {
5774 [review] = await db
5775 .select()
5776 .from(pendingReviews)
5777 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5778 .limit(1);
5779 }
5780 }
5781
5782 if (!review) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5783
5784 await db.insert(pendingReviewComments).values({
5785 reviewId: review.id,
5786 filePath,
5787 lineNumber,
5788 body: commentBody,
5789 });
5790
5791 // Count total pending comments for this review
5792 const countRows = await db
5793 .select({ id: pendingReviewComments.id })
5794 .from(pendingReviewComments)
5795 .where(eq(pendingReviewComments.reviewId, review.id));
5796
5797 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files&pending=${countRows.length}`);
5798 }
5799);
5800
5801// POST /:owner/:repo/pulls/:number/review/pending/:commentId/delete
5802// Removes a single comment from the pending review session.
5803pulls.post(
5804 "/:owner/:repo/pulls/:number/review/pending/:commentId/delete",
5805 softAuth,
5806 requireAuth,
5807 requireRepoAccess("read"),
5808 async (c) => {
5809 const { owner: ownerName, repo: repoName, commentId } = c.req.param();
5810 const prNum = parseInt(c.req.param("number"), 10);
5811 const user = c.get("user")!;
5812
5813 const resolved = await resolveRepo(ownerName, repoName);
5814 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5815
5816 const [pr] = await db
5817 .select()
5818 .from(pullRequests)
5819 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5820 .limit(1);
5821 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5822
5823 // Verify ownership via the review session
5824 const [review] = await db
5825 .select()
5826 .from(pendingReviews)
5827 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5828 .limit(1);
5829
5830 if (review) {
5831 await db
5832 .delete(pendingReviewComments)
5833 .where(and(eq(pendingReviewComments.id, commentId), eq(pendingReviewComments.reviewId, review.id)));
5834 }
5835
5836 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5837 }
5838);
5839
5840// POST /:owner/:repo/pulls/:number/review/submit
5841// Submits the pending review: posts all staged comments + a formal review state.
5842pulls.post(
5843 "/:owner/:repo/pulls/:number/review/submit",
5844 softAuth,
5845 requireAuth,
5846 requireRepoAccess("read"),
5847 async (c) => {
5848 const { owner: ownerName, repo: repoName } = c.req.param();
5849 const prNum = parseInt(c.req.param("number"), 10);
5850 const user = c.get("user")!;
5851 const body = await c.req.parseBody();
5852 const reviewBody = String(body.reviewBody || "").trim();
5853 const reviewStateRaw = String(body.reviewState || "comment");
5854
5855 const reviewState =
5856 reviewStateRaw === "approve"
5857 ? "approved"
5858 : reviewStateRaw === "request_changes"
5859 ? "changes_requested"
5860 : "commented";
5861
5862 const resolved = await resolveRepo(ownerName, repoName);
5863 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5864
5865 const [pr] = await db
5866 .select()
5867 .from(pullRequests)
5868 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5869 .limit(1);
5870 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5871
5872 const [review] = await db
5873 .select()
5874 .from(pendingReviews)
5875 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5876 .limit(1);
5877
5878 if (review) {
5879 const pendingComments = await db
5880 .select()
5881 .from(pendingReviewComments)
5882 .where(eq(pendingReviewComments.reviewId, review.id))
5883 .orderBy(asc(pendingReviewComments.createdAt));
5884
5885 // Post each pending comment as a real pr_comment
5886 for (const pc of pendingComments) {
5887 await db.insert(prComments).values({
5888 pullRequestId: pr.id,
5889 authorId: user.id,
5890 body: pc.body,
5891 filePath: pc.filePath,
5892 lineNumber: pc.lineNumber,
5893 isAiReview: false,
5894 });
5895 }
5896
5897 // Delete the pending review session (cascades to comments)
5898 await db.delete(pendingReviews).where(eq(pendingReviews.id, review.id));
5899 }
5900
5901 // Insert the formal review record
5902 await db.insert(prReviews).values({
5903 pullRequestId: pr.id,
5904 reviewerId: user.id,
5905 state: reviewState,
5906 body: reviewBody || null,
5907 isAi: false,
5908 });
5909
5910 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=conversation`);
5911 }
5912);
5913
b5dd694Claude5914// Apply a suggestion from a PR comment — commits the suggested code to the
5915// head branch on behalf of the logged-in user.
5916pulls.post(
5917 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
5918 softAuth,
5919 requireAuth,
5920 requireRepoAccess("read"),
5921 async (c) => {
5922 const { owner: ownerName, repo: repoName } = c.req.param();
5923 const prNum = parseInt(c.req.param("number"), 10);
5924 const commentId = c.req.param("commentId"); // UUID
5925 const user = c.get("user")!;
5926
5927 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
5928
5929 const resolved = await resolveRepo(ownerName, repoName);
5930 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5931
5932 const [pr] = await db
5933 .select()
5934 .from(pullRequests)
5935 .where(
5936 and(
5937 eq(pullRequests.repositoryId, resolved.repo.id),
5938 eq(pullRequests.number, prNum)
5939 )
5940 )
5941 .limit(1);
5942
5943 if (!pr || pr.state !== "open") {
5944 return c.redirect(`${backUrl}&error=pr_not_open`);
5945 }
5946
5947 // Only PR author or repo owner may apply suggestions.
5948 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
5949 return c.redirect(`${backUrl}&error=forbidden`);
5950 }
5951
5952 // Load the comment.
5953 const [comment] = await db
5954 .select()
5955 .from(prComments)
5956 .where(
5957 and(
5958 eq(prComments.id, commentId),
5959 eq(prComments.pullRequestId, pr.id)
5960 )
5961 )
5962 .limit(1);
5963
5964 if (!comment) {
5965 return c.redirect(`${backUrl}&error=comment_not_found`);
5966 }
5967
5968 // Parse suggestion block from comment body.
5969 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
5970 if (!m) {
5971 return c.redirect(`${backUrl}&error=no_suggestion`);
5972 }
5973 const suggestionCode = m[1];
5974
5975 // Get the commenter's details for the commit message co-author line.
5976 const [commenter] = await db
5977 .select()
5978 .from(users)
5979 .where(eq(users.id, comment.authorId))
5980 .limit(1);
5981
5982 // Fetch current file content from head branch.
5983 if (!comment.filePath) {
5984 return c.redirect(`${backUrl}&error=file_not_found`);
5985 }
5986 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
5987 if (!blob) {
5988 return c.redirect(`${backUrl}&error=file_not_found`);
5989 }
5990
5991 // Apply the patch — replace the target line(s) with suggestion lines.
5992 const lines = blob.content.split('\n');
5993 const lineIdx = (comment.lineNumber ?? 1) - 1;
5994 if (lineIdx < 0 || lineIdx >= lines.length) {
5995 return c.redirect(`${backUrl}&error=line_out_of_range`);
5996 }
5997 const suggestionLines = suggestionCode.split('\n');
5998 lines.splice(lineIdx, 1, ...suggestionLines);
5999 const newContent = lines.join('\n');
6000
6001 // Commit the change.
6002 const coAuthorLine = commenter
6003 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
6004 : "";
6005 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
6006
6007 const result = await createOrUpdateFileOnBranch({
6008 owner: ownerName,
6009 name: repoName,
6010 branch: pr.headBranch,
6011 filePath: comment.filePath,
6012 bytes: new TextEncoder().encode(newContent),
6013 message: commitMessage,
6014 authorName: user.username,
6015 authorEmail: `${user.username}@users.noreply.gluecron.com`,
6016 });
6017
6018 if ("error" in result) {
6019 return c.redirect(`${backUrl}&error=apply_failed`);
6020 }
6021
6022 // Post a follow-up comment noting the suggestion was applied.
6023 await db.insert(prComments).values({
6024 pullRequestId: pr.id,
6025 authorId: user.id,
6026 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
6027 });
6028
6029 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
6030 }
6031);
6032
0a67773Claude6033// Formal review — Approve / Request Changes / Comment
6034pulls.post(
6035 "/:owner/:repo/pulls/:number/review",
6036 softAuth,
6037 requireAuth,
6038 requireRepoAccess("read"),
6039 async (c) => {
6040 const { owner: ownerName, repo: repoName } = c.req.param();
6041 const prNum = parseInt(c.req.param("number"), 10);
6042 const user = c.get("user")!;
6043 const body = await c.req.parseBody();
6044 const reviewBody = String(body.body || "").trim();
6045 const reviewState = String(body.review_state || "commented");
6046
6047 const validStates = ["approved", "changes_requested", "commented"];
6048 if (!validStates.includes(reviewState)) {
6049 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6050 }
6051
6052 const resolved = await resolveRepo(ownerName, repoName);
6053 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6054
6055 const [pr] = await db
6056 .select()
6057 .from(pullRequests)
6058 .where(
6059 and(
6060 eq(pullRequests.repositoryId, resolved.repo.id),
6061 eq(pullRequests.number, prNum)
6062 )
6063 )
6064 .limit(1);
6065 if (!pr || pr.state !== "open") {
6066 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6067 }
6068 // Authors can't review their own PR
6069 if (pr.authorId === user.id) {
6070 return c.redirect(
6071 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
6072 );
6073 }
6074
6075 await db.insert(prReviews).values({
6076 pullRequestId: pr.id,
6077 reviewerId: user.id,
6078 state: reviewState,
6079 body: reviewBody || null,
6080 });
6081
6082 const stateLabel =
6083 reviewState === "approved" ? "Approved"
6084 : reviewState === "changes_requested" ? "Changes requested"
6085 : "Commented";
6086 return c.redirect(
6087 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
6088 );
6089 }
6090);
6091
e883329Claude6092// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude6093// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
6094// but we keep it at "write" for v1 so trusted collaborators can ship.
6095// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
6096// surface. Branch-protection rules (evaluated below) are the current mechanism
6097// for locking down merges further on specific branches.
0074234Claude6098pulls.post(
6099 "/:owner/:repo/pulls/:number/merge",
6100 softAuth,
6101 requireAuth,
04f6b7fClaude6102 requireRepoAccess("write"),
0074234Claude6103 async (c) => {
6104 const { owner: ownerName, repo: repoName } = c.req.param();
6105 const prNum = parseInt(c.req.param("number"), 10);
6106 const user = c.get("user")!;
6107
a164a6dClaude6108 // Read merge strategy from form (default: merge commit)
6109 let mergeStrategy = "merge";
6110 try {
6111 const body = await c.req.parseBody();
6112 const s = body.merge_strategy;
6113 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
6114 } catch { /* ignore parse errors — default to merge commit */ }
6115
0074234Claude6116 const resolved = await resolveRepo(ownerName, repoName);
6117 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6118
6119 const [pr] = await db
6120 .select()
6121 .from(pullRequests)
6122 .where(
6123 and(
6124 eq(pullRequests.repositoryId, resolved.repo.id),
6125 eq(pullRequests.number, prNum)
6126 )
6127 )
6128 .limit(1);
6129
6130 if (!pr || pr.state !== "open") {
6131 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6132 }
6133
6fc53bdClaude6134 // Draft PRs cannot be merged — must be marked ready first.
6135 if (pr.isDraft) {
6136 return c.redirect(
6137 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6138 "This PR is a draft. Mark it as ready for review before merging."
6139 )}`
6140 );
6141 }
6142
ec9e3e3Claude6143 // Required reviews check — branch-protection `required_approvals` gate.
6144 // Evaluated before running expensive gate checks so the feedback is fast.
6145 {
6146 const eligibility = await checkMergeEligible(pr.id, resolved.repo.id, pr.baseBranch);
6147 if (!eligibility.eligible && eligibility.reason) {
6148 return c.redirect(
6149 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(eligibility.reason)}`
6150 );
6151 }
6152 }
6153
e883329Claude6154 // Resolve head SHA
6155 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
6156 if (!headSha) {
6157 return c.redirect(
6158 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
6159 );
6160 }
6161
58c39f5Claude6162 // Check if AI review approved this PR.
6163 // The AI summary comment body uses:
6164 // "**AI review:** no blocking issues found." → approved
6165 // "**AI review:** flagged N item(s)..." → not approved
6166 // "severity: blocking" → explicit blocking (future)
6167 // If no AI comments exist yet, treat as approved (gate hasn't run).
c166384ccantynz-alt6168 const aiApproved = await isAiReviewApproved(pr.id);
e883329Claude6169
6170 // Run all green gate checks (GateTest + mergeability + AI review)
6171 const gateResult = await runAllGateChecks(
6172 ownerName,
6173 repoName,
6174 pr.baseBranch,
6175 pr.headBranch,
6176 headSha,
6177 aiApproved
0074234Claude6178 );
6179
e883329Claude6180 // If GateTest or AI review failed (hard blocks), reject the merge
6181 const hardFailures = gateResult.checks.filter(
6182 (check) => !check.passed && check.name !== "Merge check"
6183 );
6184 if (hardFailures.length > 0) {
6185 const errorMsg = hardFailures
6186 .map((f) => `${f.name}: ${f.details}`)
6187 .join("; ");
0074234Claude6188 return c.redirect(
e883329Claude6189 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude6190 );
6191 }
6192
1e162a8Claude6193 // D5 — Branch-protection enforcement. Looks up the matching rule for the
6194 // base branch and blocks the merge if requireAiApproval / requireGreenGates
6195 // / requireHumanReview / requiredApprovals are not satisfied. Independent
6196 // of repo-global settings, so owners can lock specific branches down
6197 // further than the repo default.
6198 const protectionRule = await matchProtection(
6199 resolved.repo.id,
6200 pr.baseBranch
6201 );
6202 if (protectionRule) {
6203 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude6204 const required = await listRequiredChecks(protectionRule.id);
6205 const passingNames = required.length > 0
6206 ? await passingCheckNames(resolved.repo.id, headSha)
6207 : [];
6208 const decision = evaluateProtection(
6209 protectionRule,
6210 {
6211 aiApproved,
6212 humanApprovalCount: humanApprovals,
6213 gateResultGreen: hardFailures.length === 0,
6214 hasFailedGates: hardFailures.length > 0,
6215 passingCheckNames: passingNames,
6216 },
6217 required.map((r) => r.checkName)
6218 );
91b054eccanty labs6219
6220 // CODEOWNERS enforcement — additive to evaluateProtection(), only
6221 // when the rule already requires human review at all (no new DB
6222 // column for this pass). Fail-open on any internal error: a bug here
6223 // must never hard-block every merge platform-wide.
6224 if (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) {
6225 try {
6226 const codeownersDiffProc = Bun.spawn(
6227 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
6228 { cwd: getRepoPath(ownerName, repoName), stdout: "pipe", stderr: "pipe" }
6229 );
6230 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
6231 await codeownersDiffProc.exited;
6232 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
6233 if (changedPaths.length > 0) {
6234 const { satisfied, missingOwners } = await requiredOwnersApproved(
6235 ownerName,
6236 repoName,
6237 resolved.repo.defaultBranch,
6238 pr.id,
6239 changedPaths
6240 );
6241 if (!satisfied) {
6242 decision.allowed = false;
6243 decision.reasons.push(
6244 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
6245 );
6246 }
6247 }
6248 } catch (err) {
6249 console.warn(
6250 "[codeowners] merge enforcement failed:",
6251 err instanceof Error ? err.message : err
6252 );
6253 }
6254 }
6255
1e162a8Claude6256 if (!decision.allowed) {
6257 return c.redirect(
6258 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6259 decision.reasons.join(" ")
6260 )}`
6261 );
6262 }
6263 }
6264
e883329Claude6265 // Attempt the merge — with auto conflict resolution if needed
6266 const repoDir = getRepoPath(ownerName, repoName);
6267 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
6268 const hasConflicts = mergeCheck && !mergeCheck.passed;
6269
6270 if (hasConflicts && isAiReviewEnabled()) {
6271 // Use Claude to auto-resolve conflicts
6272 const mergeResult = await mergeWithAutoResolve(
6273 ownerName,
6274 repoName,
6275 pr.baseBranch,
6276 pr.headBranch,
6277 `Merge pull request #${pr.number}: ${pr.title}`
6278 );
6279
6280 if (!mergeResult.success) {
6281 return c.redirect(
6282 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
6283 );
6284 }
6285
6286 // Post a comment about the auto-resolution
6287 if (mergeResult.resolvedFiles.length > 0) {
6288 await db.insert(prComments).values({
6289 pullRequestId: pr.id,
6290 authorId: user.id,
6291 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
6292 isAiReview: true,
6293 });
6294 }
6295 } else {
a164a6dClaude6296 // Worktree-based merge: supports merge-commit, squash, and fast-forward
6297 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
6298 const gitEnv = {
6299 ...process.env,
6300 GIT_AUTHOR_NAME: user.displayName || user.username,
6301 GIT_AUTHOR_EMAIL: user.email,
6302 GIT_COMMITTER_NAME: user.displayName || user.username,
6303 GIT_COMMITTER_EMAIL: user.email,
6304 };
6305
6306 // Create linked worktree on the base branch
6307 const addWt = Bun.spawn(
6308 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude6309 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
6310 );
a164a6dClaude6311 if (await addWt.exited !== 0) {
6312 return c.redirect(
6313 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
6314 );
6315 }
6316
6317 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
6318 let mergeOk = false;
6319
6320 try {
6321 if (mergeStrategy === "squash") {
6322 // Squash: stage all changes without committing
6323 const squashProc = Bun.spawn(
6324 ["git", "merge", "--squash", headSha],
6325 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6326 );
6327 if (await squashProc.exited !== 0) {
6328 const errTxt = await new Response(squashProc.stderr).text();
6329 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
6330 }
6331 // Commit the squashed changes
6332 const commitProc = Bun.spawn(
6333 ["git", "commit", "-m", commitMsg],
6334 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6335 );
6336 if (await commitProc.exited !== 0) {
6337 const errTxt = await new Response(commitProc.stderr).text();
6338 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
6339 }
6340 mergeOk = true;
6341 } else if (mergeStrategy === "ff") {
6342 // Fast-forward only — fail if FF is not possible
6343 const ffProc = Bun.spawn(
6344 ["git", "merge", "--ff-only", headSha],
6345 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6346 );
6347 if (await ffProc.exited !== 0) {
6348 const errTxt = await new Response(ffProc.stderr).text();
6349 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
6350 }
6351 mergeOk = true;
6352 } else {
6353 // Default: merge commit (--no-ff always creates a merge commit)
6354 const mergeProc = Bun.spawn(
6355 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
6356 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6357 );
6358 if (await mergeProc.exited !== 0) {
6359 const errTxt = await new Response(mergeProc.stderr).text();
6360 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
6361 }
6362 mergeOk = true;
6363 }
6364 } catch (err) {
6365 // Always clean up the worktree before redirecting
6366 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
6367 const msg = err instanceof Error ? err.message : "Merge failed";
6368 return c.redirect(
6369 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
6370 );
6371 }
6372
6373 // Clean up worktree (changes are now in the bare repo via linked worktree)
6374 await Bun.spawn(
6375 ["git", "worktree", "remove", "--force", wt],
6376 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
6377 ).exited.catch(() => {});
e883329Claude6378
a164a6dClaude6379 if (!mergeOk) {
e883329Claude6380 return c.redirect(
a164a6dClaude6381 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude6382 );
6383 }
6384 }
6385
0074234Claude6386 await db
6387 .update(pullRequests)
6388 .set({
6389 state: "merged",
6390 mergedAt: new Date(),
6391 mergedBy: user.id,
6392 updatedAt: new Date(),
6393 })
6394 .where(eq(pullRequests.id, pr.id));
6395
b7b5f75ccanty labs6396 void logActivity({
6397 repositoryId: resolved.repo.id,
6398 userId: user.id,
6399 action: "pr_merge",
6400 targetType: "pull_request",
6401 targetId: String(pr.number),
6402 metadata: { baseBranch: pr.baseBranch, headBranch: pr.headBranch, mergeStrategy },
6403 });
a74f4edccanty labs6404 void fireWebhooks(resolved.repo.id, "pr", {
6405 action: "merged",
6406 number: pr.number,
6407 mergeStrategy,
6408 });
b7b5f75ccanty labs6409
8809b87Claude6410 // Chat notifier — fan out merge event to Slack/Discord/Teams.
6411 import("../lib/chat-notifier")
6412 .then((m) =>
6413 m.notifyChatChannels({
6414 ownerUserId: resolved.repo.ownerId,
6415 repositoryId: resolved.repo.id,
6416 event: {
6417 event: "pr.merged",
6418 repo: `${ownerName}/${repoName}`,
6419 title: `#${pr.number} ${pr.title}`,
6420 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
6421 actor: user.username,
6422 },
6423 })
6424 )
6425 .catch((err) =>
6426 console.warn(`[chat-notifier] PR merge notify failed:`, err)
6427 );
6428
d62fb36Claude6429 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
6430 // and auto-close each matching open issue with a back-link comment. Bounded
6431 // to the same repo for v1 (cross-repo refs ignored). Failures never block
6432 // the merge redirect.
6433 try {
6434 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
6435 const refs = extractClosingRefsMulti([pr.title, pr.body]);
6436 for (const n of refs) {
6437 const [issue] = await db
6438 .select()
6439 .from(issues)
6440 .where(
6441 and(
6442 eq(issues.repositoryId, resolved.repo.id),
6443 eq(issues.number, n)
6444 )
6445 )
6446 .limit(1);
6447 if (!issue || issue.state !== "open") continue;
6448 await db
6449 .update(issues)
6450 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
6451 .where(eq(issues.id, issue.id));
6452 await db.insert(issueComments).values({
6453 issueId: issue.id,
6454 authorId: user.id,
6455 body: `Closed by pull request #${pr.number}.`,
6456 });
6457 }
6458 } catch {
6459 // Never block the merge on close-keyword failures.
6460 }
6461
0074234Claude6462 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6463 }
6464);
6465
6fc53bdClaude6466// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
6467// hasn't run yet on this PR.
6468pulls.post(
6469 "/:owner/:repo/pulls/:number/ready",
6470 softAuth,
6471 requireAuth,
04f6b7fClaude6472 requireRepoAccess("write"),
6fc53bdClaude6473 async (c) => {
6474 const { owner: ownerName, repo: repoName } = c.req.param();
6475 const prNum = parseInt(c.req.param("number"), 10);
6476 const user = c.get("user")!;
6477
6478 const resolved = await resolveRepo(ownerName, repoName);
6479 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6480
6481 const [pr] = await db
6482 .select()
6483 .from(pullRequests)
6484 .where(
6485 and(
6486 eq(pullRequests.repositoryId, resolved.repo.id),
6487 eq(pullRequests.number, prNum)
6488 )
6489 )
6490 .limit(1);
6491 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6492
6493 // Only the author or repo owner can toggle draft state.
6494 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
6495 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6496 }
6497
6498 if (pr.state === "open" && pr.isDraft) {
6499 await db
6500 .update(pullRequests)
6501 .set({ isDraft: false, updatedAt: new Date() })
6502 .where(eq(pullRequests.id, pr.id));
6503
6504 if (isAiReviewEnabled()) {
6505 triggerAiReview(
6506 ownerName,
6507 repoName,
6508 pr.id,
6509 pr.title,
0316dbbClaude6510 pr.body || "",
6fc53bdClaude6511 pr.baseBranch,
6512 pr.headBranch
6513 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
6514 }
6515 }
6516
6517 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6518 }
6519);
6520
6521// Convert a PR back to draft.
6522pulls.post(
6523 "/:owner/:repo/pulls/:number/draft",
6524 softAuth,
6525 requireAuth,
04f6b7fClaude6526 requireRepoAccess("write"),
6fc53bdClaude6527 async (c) => {
6528 const { owner: ownerName, repo: repoName } = c.req.param();
6529 const prNum = parseInt(c.req.param("number"), 10);
6530 const user = c.get("user")!;
6531
6532 const resolved = await resolveRepo(ownerName, repoName);
6533 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6534
6535 const [pr] = await db
6536 .select()
6537 .from(pullRequests)
6538 .where(
6539 and(
6540 eq(pullRequests.repositoryId, resolved.repo.id),
6541 eq(pullRequests.number, prNum)
6542 )
6543 )
6544 .limit(1);
6545 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6546
6547 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
6548 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6549 }
6550
6551 if (pr.state === "open" && !pr.isDraft) {
6552 await db
6553 .update(pullRequests)
6554 .set({ isDraft: true, updatedAt: new Date() })
6555 .where(eq(pullRequests.id, pr.id));
6556 }
6557
6558 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6559 }
6560);
6561
0074234Claude6562// Close PR
6563pulls.post(
6564 "/:owner/:repo/pulls/:number/close",
6565 softAuth,
6566 requireAuth,
04f6b7fClaude6567 requireRepoAccess("write"),
0074234Claude6568 async (c) => {
6569 const { owner: ownerName, repo: repoName } = c.req.param();
6570 const prNum = parseInt(c.req.param("number"), 10);
6571
6572 const resolved = await resolveRepo(ownerName, repoName);
6573 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6574
6575 await db
6576 .update(pullRequests)
6577 .set({
6578 state: "closed",
6579 closedAt: new Date(),
6580 updatedAt: new Date(),
6581 })
6582 .where(
6583 and(
6584 eq(pullRequests.repositoryId, resolved.repo.id),
6585 eq(pullRequests.number, prNum)
6586 )
6587 );
a74f4edccanty labs6588 void fireWebhooks(resolved.repo.id, "pr", {
6589 action: "closed",
6590 number: prNum,
6591 });
0074234Claude6592
6593 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6594 }
6595);
6596
c3e0c07Claude6597// Re-run AI review on demand (e.g. after a force-push). Bypasses the
6598// idempotency marker via { force: true }. Write-access only.
6599pulls.post(
6600 "/:owner/:repo/pulls/:number/ai-rereview",
6601 softAuth,
6602 requireAuth,
6603 requireRepoAccess("write"),
6604 async (c) => {
6605 const { owner: ownerName, repo: repoName } = c.req.param();
6606 const prNum = parseInt(c.req.param("number"), 10);
6607 const resolved = await resolveRepo(ownerName, repoName);
6608 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6609
6610 const [pr] = await db
6611 .select()
6612 .from(pullRequests)
6613 .where(
6614 and(
6615 eq(pullRequests.repositoryId, resolved.repo.id),
6616 eq(pullRequests.number, prNum)
6617 )
6618 )
6619 .limit(1);
6620 if (!pr) {
6621 return c.redirect(`/${ownerName}/${repoName}/pulls`);
6622 }
6623
6624 if (!isAiReviewEnabled()) {
6625 return c.redirect(
6626 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6627 "AI review is not configured (ANTHROPIC_API_KEY)."
6628 )}`
6629 );
6630 }
6631
6632 // Fire-and-forget but with { force: true } to bypass the
6633 // already-reviewed marker. The function still never throws.
6634 triggerAiReview(
6635 ownerName,
6636 repoName,
6637 pr.id,
6638 pr.title || "",
6639 pr.body || "",
6640 pr.baseBranch,
6641 pr.headBranch,
6642 { force: true }
a28cedeClaude6643 ).catch((err) => {
6644 console.warn(
6645 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
6646 err instanceof Error ? err.message : err
6647 );
6648 });
c3e0c07Claude6649
6650 return c.redirect(
6651 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6652 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
6653 )}`
6654 );
6655 }
6656);
6657
1d4ff60Claude6658// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
6659// the PR's head branch carrying just the new test files. Write-access only.
6660// Idempotent — if `ai:added-tests` was previously applied we redirect with
6661// an `info` banner instead of re-firing.
6662pulls.post(
6663 "/:owner/:repo/pulls/:number/generate-tests",
6664 softAuth,
6665 requireAuth,
6666 requireRepoAccess("write"),
6667 async (c) => {
6668 const { owner: ownerName, repo: repoName } = c.req.param();
6669 const prNum = parseInt(c.req.param("number"), 10);
6670 const resolved = await resolveRepo(ownerName, repoName);
6671 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6672
6673 const [pr] = await db
6674 .select()
6675 .from(pullRequests)
6676 .where(
6677 and(
6678 eq(pullRequests.repositoryId, resolved.repo.id),
6679 eq(pullRequests.number, prNum)
6680 )
6681 )
6682 .limit(1);
6683 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6684
6685 if (!isAiReviewEnabled()) {
6686 return c.redirect(
6687 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6688 "AI test generation is not configured (ANTHROPIC_API_KEY)."
6689 )}`
6690 );
6691 }
6692
6693 // Fire-and-forget. The lib never throws.
6694 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
6695 .then((res) => {
6696 if (!res.ok) {
6697 console.warn(
6698 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
6699 );
6700 }
6701 })
6702 .catch((err) => {
6703 console.warn(
6704 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
6705 err instanceof Error ? err.message : err
6706 );
6707 });
6708
6709 return c.redirect(
6710 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6711 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
6712 )}`
6713 );
6714 }
6715);
6716
ace34efClaude6717// ─── Request review ───────────────────────────────────────────────────────────
6718pulls.post(
6719 "/:owner/:repo/pulls/:number/request-review",
6720 softAuth,
6721 requireAuth,
6722 requireRepoAccess("write"),
6723 async (c) => {
6724 const { owner: ownerName, repo: repoName } = c.req.param();
6725 const prNum = parseInt(c.req.param("number"), 10);
6726 const user = c.get("user")!;
6727
6728 const resolved = await resolveRepo(ownerName, repoName);
6729 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6730
6731 const [pr] = await db
6732 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
6733 .from(pullRequests)
6734 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
6735 .limit(1);
6736
6737 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6738
6739 const body = await c.req.formData().catch(() => null);
6740 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
6741
6742 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
6743 return c.redirect(
6744 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
6745 );
6746 }
6747
f4abb8eClaude6748 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
6749 // requesting reviews from arbitrary user IDs outside this repository.
6750 const isOwner = reviewerId === resolved.owner.id;
6751 if (!isOwner) {
6752 const [collab] = await db
6753 .select({ id: repoCollaborators.id })
6754 .from(repoCollaborators)
6755 .where(
6756 and(
6757 eq(repoCollaborators.repositoryId, resolved.repo.id),
6758 eq(repoCollaborators.userId, reviewerId),
6759 isNotNull(repoCollaborators.acceptedAt)
6760 )
6761 )
6762 .limit(1);
6763 if (!collab) {
6764 return c.redirect(
6765 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
6766 );
6767 }
6768 }
6769
ace34efClaude6770 const { requestReview } = await import("../lib/reviewer-suggest");
6771 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
6772
6773 const msg = result.ok
6774 ? "Review requested successfully."
6775 : `Failed to request review: ${result.error ?? "unknown error"}`;
6776
6777 return c.redirect(
6778 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
6779 );
6780 }
6781);
6782
25b1ff7Claude6783// ─── WebSocket presence endpoint ─────────────────────────────────────────────
6784//
6785// GET /:owner/:repo/pulls/:number/presence (WebSocket upgrade)
6786//
6787// Unauthenticated connections are rejected with 401. On connect:
6788// → server sends {type:"init", sessionId, users:[...]}
6789// → server broadcasts {type:"join", user} to all other sessions in the room
6790//
6791// Accepted client messages:
6792// {type:"cursor", line: number} — user hovering a diff line
6793// {type:"typing", line: number, typing: bool} — textarea focus/blur
6794// {type:"ping"} — keep-alive (updates lastSeen)
6795//
6796// The WS `data` payload we store on each socket carries everything needed in
6797// the event handlers so no closure tricks are required.
6798
6799pulls.get(
6800 "/:owner/:repo/pulls/:number/presence",
6801 softAuth,
6802 upgradeWebSocket(async (c) => {
6803 const { owner: ownerName, repo: repoName, number: prNumStr } = c.req.param();
6804 const prNum = parseInt(prNumStr ?? "0", 10);
6805 const user = c.get("user");
6806
6807 // Auth check — no anonymous presence
6808 if (!user) {
6809 // upgradeWebSocket doesn't support returning a non-101 directly;
6810 // we return a dummy handler that immediately closes with 4001.
6811 return {
6812 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6813 ws.close(4001, "Unauthorized");
6814 },
6815 onMessage() {},
6816 onClose() {},
6817 };
6818 }
6819
6820 // Resolve repo to get its numeric id for the room key
6821 const resolved = await resolveRepo(ownerName, repoName);
6822 if (!resolved || isNaN(prNum)) {
6823 return {
6824 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6825 ws.close(4004, "Not found");
6826 },
6827 onMessage() {},
6828 onClose() {},
6829 };
6830 }
6831
6832 const prId = `${resolved.repo.id}:${prNum}`;
6833 const sessionId = `${user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
6834
6835 return {
6836 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6837 // Register and join room
6838 registerSocket(prId, sessionId, {
6839 send: (data: string) => ws.send(data),
6840 readyState: ws.readyState,
6841 });
6842 const presenceUser = joinRoom(prId, sessionId, {
6843 userId: user.id,
6844 username: user.username,
6845 });
6846
6847 // Send init snapshot to the new joiner
6848 const currentUsers = getRoomUsers(prId);
6849 ws.send(
6850 JSON.stringify({
6851 type: "init",
6852 sessionId,
6853 users: currentUsers,
6854 })
6855 );
6856
6857 // Broadcast join to all OTHER sessions
6858 broadcastToRoom(
6859 prId,
6860 {
6861 type: "join",
6862 user: { ...presenceUser, sessionId },
6863 },
6864 sessionId
6865 );
6866 },
6867
6868 onMessage(evt: MessageEvent, _ws: import("hono/ws").WSContext) {
6869 let msg: { type: string; line?: number; typing?: boolean };
6870 try {
6871 msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
6872 } catch {
6873 return;
6874 }
6875
6876 if (msg.type === "ping") {
6877 pingSession(prId, sessionId);
6878 return;
6879 }
6880
6881 if (msg.type === "cursor") {
6882 const line = typeof msg.line === "number" ? msg.line : null;
6883 const updated = updatePresence(prId, sessionId, line, false);
6884 if (updated) {
6885 broadcastToRoom(
6886 prId,
6887 {
6888 type: "cursor",
6889 sessionId,
6890 username: updated.username,
6891 colour: updated.colour,
6892 line,
6893 },
6894 sessionId
6895 );
6896 }
6897 return;
6898 }
6899
6900 if (msg.type === "typing") {
6901 const line = typeof msg.line === "number" ? msg.line : null;
6902 const typing = !!msg.typing;
6903 const updated = updatePresence(prId, sessionId, line, typing);
6904 if (updated) {
6905 broadcastToRoom(
6906 prId,
6907 {
6908 type: "typing",
6909 sessionId,
6910 username: updated.username,
6911 colour: updated.colour,
6912 line,
6913 typing,
6914 },
6915 sessionId
6916 );
6917 }
6918 return;
6919 }
6920 },
6921
6922 onClose() {
6923 leaveRoom(prId, sessionId);
6924 unregisterSocket(prId, sessionId);
6925 broadcastToRoom(prId, { type: "leave", sessionId });
6926 },
6927 };
6928 })
6929);
6930
0074234Claude6931export default pulls;