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