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