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.tsxBlame7024 lines · 5 contributors
0074234Claude1/**
2 * Pull request routes — create, list, view, merge, close, comment.
b078860Claude3 *
4 * The list view (`GET /:owner/:repo/pulls`) and detail view
5 * (`GET /:owner/:repo/pulls/:number`) carry the 2026 polish: hero with
6 * gradient title + hairline strip, pill-style state tabs, soft-lift
7 * row cards, conversation thread with AI-review accent border, distinct
8 * gate-check rows, and a gradient-bordered "Merge pull request" button.
9 *
10 * All visual styling is scoped via `.prs-*` class prefixes inside inline
11 * <style> blocks so other surfaces are untouched. No business logic was
12 * changed in this polish pass — AI review triggers, auto-merge wiring,
13 * gate evaluation, and the merge handler are preserved exactly.
0074234Claude14 */
15
16import { Hono } from "hono";
f4abb8eClaude17import { eq, and, desc, asc, sql, inArray, ilike, ne, isNotNull } from "drizzle-orm";
0074234Claude18import { db } from "../db";
19import {
20 pullRequests,
21 prComments,
0a67773Claude22 prReviews,
ec9e3e3Claude23 prReviewRequests,
0074234Claude24 repositories,
25 users,
d62fb36Claude26 issues,
27 issueComments,
f4abb8eClaude28 repoCollaborators,
a2c10c5Claude29 pendingReviews,
30 pendingReviewComments,
0074234Claude31} from "../db/schema";
32import { Layout } from "../views/layout";
621081eccantynz-alt33import { RepoHeader, RepoNav } from "../views/components";
cb5a796Claude34import { PendingCommentsBanner } from "../views/pending-comments-banner";
47a7a0aClaude35import { DiffView, type InlineDiffComment } from "../views/diff-view";
6fc53bdClaude36import { ReactionsBar } from "../views/reactions";
37import { summariseReactions } from "../lib/reactions";
24cf2caClaude38import { loadPrTemplate } from "../lib/templates";
0074234Claude39import { renderMarkdown } from "../lib/markdown";
15db0e0Claude40import {
41 parseSlashCommand,
42 executeSlashCommand,
43 detectSlashCmdComment,
44 stripSlashCmdMarker,
45} from "../lib/pr-slash-commands";
b584e52Claude46import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude47import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude48import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude49import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
0074234Claude50import { softAuth, requireAuth } from "../middleware/auth";
51import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude52import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude53import {
54 decideInitialStatus,
55 notifyOwnerOfPendingComment,
56 countPendingForRepo,
57} from "../lib/comment-moderation";
c166384ccantynz-alt58import { isAiReviewEnabled, triggerAiReview, isAiReviewApproved } from "../lib/ai-review";
79ed944Claude59import {
60 TRIO_COMMENT_MARKER,
61 TRIO_SUMMARY_MARKER,
67dc4e1Claude62 isTrioReviewEnabled,
79ed944Claude63 type TrioPersona,
64} from "../lib/ai-review-trio";
1d4ff60Claude65import {
66 generateTestsForPr,
67 AI_TESTS_MARKER,
68} from "../lib/ai-test-generator";
0316dbbClaude69import { triggerPrTriage } from "../lib/pr-triage";
b7b5f75ccanty labs70import { logActivity } from "../lib/notify";
a74f4edccanty labs71import { fireWebhooks } from "./webhooks";
81c73c1Claude72import { generatePrSummary } from "../lib/ai-generators";
73import { isAiAvailable } from "../lib/ai-client";
cc34156Claude74import { getReviewContext, recordPrVisit, type ReviewContext } from "../lib/review-context";
534f04aClaude75import {
76 computePrRiskForPullRequest,
77 getCachedPrRisk,
78 type PrRiskScore,
79} from "../lib/pr-risk";
0316dbbClaude80import { runAllGateChecks } from "../lib/gate";
81import type { GateCheckResult } from "../lib/gate";
82import {
83 matchProtection,
84 countHumanApprovals,
85 listRequiredChecks,
86 passingCheckNames,
87 evaluateProtection,
88} from "../lib/branch-protection";
89import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude90import {
91 listBranches,
92 getRepoPath,
e883329Claude93 resolveRef,
b5dd694Claude94 getBlob,
95 createOrUpdateFileOnBranch,
b558f23Claude96 commitsBetween,
0074234Claude97} from "../git/repository";
b558f23Claude98import type { GitDiffFile, GitCommit } from "../git/repository";
240c477Claude99import { listStatuses } from "../lib/commit-statuses";
100import type { CommitStatus } from "../db/schema";
0074234Claude101import { html } from "hono/html";
4bbacbeClaude102import {
103 getPreviewForBranch,
104 previewStatusLabel,
105} from "../lib/branch-previews";
1e162a8Claude106import {
bb0f894Claude107 Flex,
108 Container,
109 Badge,
110 Button,
111 LinkButton,
112 Form,
113 FormGroup,
114 Input,
115 TextArea,
116 Select,
117 EmptyState,
118 FilterTabs,
119 TabNav,
120 List,
121 ListItem,
122 Text,
123 Alert,
124 MarkdownContent,
125 CommentBox,
126 formatRelative,
127} from "../views/ui";
0074234Claude128
ace34efClaude129import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
74d8c4dClaude130import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
a7460bfClaude131import { BOT_USERNAME } from "../lib/bot-user";
09d5f39Claude132import { analyzeImpact, type ImpactAnalysis } from "../lib/pr-impact";
133import { getPreviewDir, isPreviewExpired } from "../lib/pr-stage";
7f992cdClaude134import {
135 getCodeownersForRepo,
136 reviewersForChangedFiles,
91b054eccanty labs137 requiredOwnersApproved,
7f992cdClaude138} from "../lib/codeowners";
91b054eccanty labs139import { enqueuePullRequestWorkflows } from "../lib/pr-workflow-sync";
7f992cdClaude140import { getPatternWarning, type Pattern } from "../lib/pattern-detector";
141import { suggestPrSplit, type SplitSuggestion } from "../lib/pr-splitter";
142import {
143 joinRoom,
144 leaveRoom,
145 broadcastToRoom,
146 registerSocket,
147 unregisterSocket,
148 getRoomUsers,
149 pingSession,
150 updatePresence,
151} from "../lib/pr-presence";
152import { getBusFactorWarning, type BusFactorFile } from "../lib/bus-factor";
153import { checkMergeEligible } from "../lib/branch-rules";
154import { createBunWebSocket } from "hono/bun";
155
156const { upgradeWebSocket, websocket: presenceWebsocket } = createBunWebSocket();
157export { presenceWebsocket };
ace34efClaude158
0074234Claude159const pulls = new Hono<AuthEnv>();
160
b078860Claude161/* ──────────────────────────────────────────────────────────────────────
162 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
163 * into the issue tracker or any other route. Tokens come from layout.tsx
164 * `:root` so light/dark stays consistent if/when light mode lands.
165 * ──────────────────────────────────────────────────────────────────── */
166const PRS_LIST_STYLES = `
167 .prs-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
621081eccantynz-alt2192// Thin wrapper over the shared RepoNav so the PR pages render the ONE
2193// canonical repo nav instead of a stripped 4-tab bar. Owner-directed nav
2194// unification this session.
0074234Claude2195const PrNav = ({
2196 owner,
2197 repo,
2198 active,
2199}: {
2200 owner: string;
2201 repo: string;
2202 active: "code" | "issues" | "pulls" | "commits";
621081eccantynz-alt2203}) => <RepoNav owner={owner} repo={repo} active={active} />;
0074234Claude2204
534f04aClaude2205/**
2206 * Block M3 — pre-merge risk score card. Pure presentational helper.
2207 * Rendered in the conversation tab above the gate checks block. Hidden
2208 * entirely when the PR is closed/merged or there is nothing cached and
2209 * nothing in-flight.
2210 */
2211function PrRiskCard({
2212 risk,
2213 calculating,
2214}: {
2215 risk: PrRiskScore | null;
2216 calculating: boolean;
2217}) {
2218 if (!risk) {
2219 return (
2220 <div
2221 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
2222 >
2223 <strong style="font-size: 13px; color: var(--text)">
2224 Risk score: calculating…
2225 </strong>
2226 <div style="font-size: 12px; margin-top: 4px">
2227 Refresh in a moment to see the pre-merge risk score for this PR.
2228 </div>
2229 </div>
2230 );
2231 }
2232
2233 const palette = riskBandPalette(risk.band);
2234 const label = riskBandLabel(risk.band);
2235
2236 return (
2237 <div
2238 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
2239 >
2240 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
2241 <strong>Risk score:</strong>
2242 <span style={`color:${palette.border};font-weight:600`}>
2243 {palette.icon} {label} ({risk.score}/10)
2244 </span>
2245 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
2246 {risk.commitSha.slice(0, 7)}
2247 </span>
2248 </div>
2249 {risk.aiSummary && (
2250 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
2251 {risk.aiSummary}
2252 </div>
2253 )}
2254 <details style="margin-top:10px">
2255 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
2256 See full signal breakdown
2257 </summary>
2258 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
2259 <li>files changed: {risk.signals.filesChanged}</li>
2260 <li>
2261 lines added/removed: {risk.signals.linesAdded} /{" "}
2262 {risk.signals.linesRemoved}
2263 </li>
2264 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
2265 <li>
2266 schema migration touched:{" "}
2267 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
2268 </li>
2269 <li>
2270 locked / sensitive path touched:{" "}
2271 {risk.signals.lockedPathTouched ? "yes" : "no"}
2272 </li>
2273 <li>
2274 adds new dependency:{" "}
2275 {risk.signals.addsNewDependency ? "yes" : "no"}
2276 </li>
2277 <li>
2278 bumps major dependency:{" "}
2279 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
2280 </li>
2281 <li>
2282 tests added for new code:{" "}
2283 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
2284 </li>
2285 <li>
2286 diff-minus-test ratio:{" "}
2287 {risk.signals.diffMinusTestRatio.toFixed(2)}
2288 </li>
2289 </ul>
2290 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2291 How is this calculated? The score is a transparent sum of
2292 weighted signals — see <code>src/lib/pr-risk.ts</code>
2293 {" "}<code>computePrRiskScore</code>.
2294 </div>
2295 </details>
2296 {calculating && (
2297 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2298 (recomputing for the latest commit — refresh to update)
2299 </div>
2300 )}
2301 </div>
2302 );
2303}
2304
2305function riskBandPalette(band: PrRiskScore["band"]): {
2306 border: string;
2307 icon: string;
2308} {
2309 switch (band) {
2310 case "low":
2311 return { border: "var(--green)", icon: "" };
2312 case "medium":
2313 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
2314 case "high":
2315 return { border: "var(--orange, #db6d28)", icon: "⚠" };
2316 case "critical":
2317 return { border: "var(--red)", icon: "\u{1F6D1}" };
2318 }
2319}
2320
2321function riskBandLabel(band: PrRiskScore["band"]): string {
2322 switch (band) {
2323 case "low":
2324 return "LOW";
2325 case "medium":
2326 return "MEDIUM";
2327 case "high":
2328 return "HIGH";
2329 case "critical":
2330 return "CRITICAL";
2331 }
2332}
2333
09d5f39Claude2334// ---------------------------------------------------------------------------
2335// Merge Impact Analysis — collapsible panel showing affected files and
2336// downstream repos. Shown on open PRs for users with write access.
2337// ---------------------------------------------------------------------------
2338
2339function ImpactPanel({ analysis, owner }: { analysis: ImpactAnalysis; owner: string }) {
2340 const score = analysis.riskScore;
2341 const band = score <= 30 ? "low" : score <= 60 ? "medium" : "high";
2342 const hasAny =
2343 analysis.affectedTestFiles.length > 0 ||
2344 analysis.affectedFiles.length > 0 ||
2345 analysis.downstreamRepos.length > 0;
2346
2347 return (
2348 <div class="impact-panel">
2349 <div
2350 class="impact-header"
2351 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)"
2352 >
2353 <span class={`impact-score score-${band}`}>{score}</span>
2354 <strong>Merge Impact</strong>
2355 <span class="impact-summary">{analysis.riskSummary}</span>
2356 <button class="impact-toggle" type="button" aria-label="Toggle impact panel">
2357
2358 </button>
2359 </div>
2360 <div class="impact-body" hidden>
2361 <div class="impact-section">
2362 <h4>Affected test files ({analysis.affectedTestFiles.length})</h4>
2363 {analysis.affectedTestFiles.length === 0 ? (
2364 <span class="impact-empty">No test files reference the changed files.</span>
2365 ) : (
2366 <ul class="impact-file-list">
2367 {analysis.affectedTestFiles.map((f) => (
2368 <li title={f}>{f}</li>
2369 ))}
2370 </ul>
2371 )}
2372 </div>
2373 <div class="impact-section">
2374 <h4>Affected source files ({analysis.affectedFiles.length})</h4>
2375 {analysis.affectedFiles.length === 0 ? (
2376 <span class="impact-empty">No other source files import the changed files.</span>
2377 ) : (
2378 <ul class="impact-file-list">
2379 {analysis.affectedFiles.map((f) => (
2380 <li title={f}>{f}</li>
2381 ))}
2382 </ul>
2383 )}
2384 </div>
2385 {analysis.downstreamRepos.length > 0 && (
2386 <div class="impact-section impact-downstream">
2387 <h4>Downstream repos ({analysis.downstreamRepos.length})</h4>
2388 <ul class="impact-file-list">
2389 {analysis.downstreamRepos.map((r) => (
2390 <li>
2391 <a
2392 href={`/${r.owner}/${r.repo}`}
2393 style="color:var(--text-link);text-decoration:none"
2394 >
2395 {r.owner}/{r.repo}
2396 </a>
2397 {" "}
2398 <span style="color:var(--text-muted);font-size:11px">
2399 via {r.matchedDependency}
2400 </span>
2401 </li>
2402 ))}
2403 </ul>
2404 </div>
2405 )}
2406 </div>
2407 </div>
2408 );
2409}
2410
422a2d4Claude2411// ---------------------------------------------------------------------------
2412// AI Trio Review — 3-column card grid + disagreement callout.
2413//
2414// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
2415// per run: one per persona (security/correctness/style) plus a top-level
2416// summary. We surface them here as a single grid above the normal
2417// comment stream so reviewers see the verdicts at a glance.
2418// ---------------------------------------------------------------------------
2419
2420const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
2421
2422interface TrioCommentLike {
2423 body: string;
2424}
2425
2426function isTrioComment(body: string | null | undefined): boolean {
2427 if (!body) return false;
2428 return (
2429 body.includes(TRIO_SUMMARY_MARKER) ||
2430 body.includes(TRIO_COMMENT_MARKER.security) ||
2431 body.includes(TRIO_COMMENT_MARKER.correctness) ||
2432 body.includes(TRIO_COMMENT_MARKER.style)
2433 );
2434}
2435
2436function trioPersonaOfComment(body: string): TrioPersona | null {
2437 for (const p of TRIO_PERSONAS) {
2438 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
2439 }
2440 return null;
2441}
2442
2443/**
2444 * Best-effort verdict parse from a persona comment body. The body shape
2445 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
2446 * we only need the "Pass" / "Fail" word from the H2 heading.
2447 */
2448function trioVerdictOfBody(body: string): "pass" | "fail" | null {
2449 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
2450 if (!m) return null;
2451 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
2452}
2453
2454/**
2455 * Parse the disagreement bullet list out of the summary comment so we
2456 * can render it as a polished callout strip. Returns [] when nothing
2457 * matches — the comment author may have edited the marker out.
2458 */
2459function parseDisagreements(summaryBody: string): Array<{
2460 file: string;
2461 failing: string;
2462 passing: string;
2463}> {
2464 const out: Array<{ file: string; failing: string; passing: string }> = [];
2465 // Each disagreement line looks like:
2466 // - `path:42` — security, style say ✗, correctness say ✓
2467 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
2468 let m: RegExpExecArray | null;
2469 while ((m = re.exec(summaryBody)) !== null) {
2470 out.push({
2471 file: m[1].trim(),
2472 failing: m[2].trim().replace(/[,\s]+$/g, ""),
2473 passing: m[3].trim().replace(/[,\s]+$/g, ""),
2474 });
2475 }
2476 return out;
2477}
2478
2479function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
2480 // Find the most recent persona comments + summary. We iterate from
2481 // the end so re-reviews (multiple runs on the same PR) display the
2482 // freshest verdict.
2483 const latest: Partial<Record<TrioPersona, string>> = {};
2484 let summaryBody: string | null = null;
2485 for (let i = comments.length - 1; i >= 0; i--) {
2486 const body = comments[i].body || "";
2487 if (!isTrioComment(body)) continue;
2488 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
2489 summaryBody = body;
2490 continue;
2491 }
2492 const persona = trioPersonaOfComment(body);
2493 if (persona && !latest[persona]) latest[persona] = body;
2494 }
2495 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2496 if (!anyPersona && !summaryBody) return null;
2497
2498 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
2499
2500 return (
67dc4e1Claude2501 <div class="trio-wrap" id="trio-review-section">
422a2d4Claude2502 <div class="trio-header">
2503 <span class="trio-header-dot" aria-hidden="true"></span>
2504 <strong>AI Trio Review</strong>
2505 <span class="trio-header-sub">
2506 Three independent reviewers ran in parallel.
2507 </span>
2508 </div>
2509 <div class="trio-grid">
2510 {TRIO_PERSONAS.map((persona) => {
2511 const body = latest[persona];
2512 const verdict = body ? trioVerdictOfBody(body) : null;
2513 const stateClass =
2514 verdict === "fail"
2515 ? "is-fail"
2516 : verdict === "pass"
2517 ? "is-pass"
2518 : "is-pending";
2519 return (
2520 <div class={`trio-card trio-${persona} ${stateClass}`}>
2521 <div class="trio-card-head">
2522 <span class="trio-card-icon" aria-hidden="true">
2523 {persona === "security"
2524 ? "🛡"
2525 : persona === "correctness"
2526 ? "✓"
2527 : "✎"}
2528 </span>
2529 <strong class="trio-card-title">
2530 {persona[0].toUpperCase() + persona.slice(1)}
2531 </strong>
2532 <span class="trio-card-verdict">
2533 {verdict === "pass"
2534 ? "Pass"
2535 : verdict === "fail"
2536 ? "Fail"
2537 : "Pending"}
2538 </span>
2539 </div>
2540 <div class="trio-card-body">
2541 {body ? (
2542 <MarkdownContent
2543 html={renderMarkdown(stripTrioHeading(body))}
2544 />
2545 ) : (
2546 <span class="trio-card-empty">
2547 Awaiting reviewer output.
2548 </span>
2549 )}
2550 </div>
2551 </div>
2552 );
2553 })}
2554 </div>
2555 {disagreements.length > 0 && (
2556 <div class="trio-disagreement-strip" role="note">
2557 <span class="trio-disagreement-icon" aria-hidden="true">
2558
2559 </span>
2560 <div class="trio-disagreement-body">
2561 <strong>Reviewers disagree — review carefully.</strong>
2562 <ul class="trio-disagreement-list">
2563 {disagreements.map((d) => (
2564 <li>
2565 <code>{d.file}</code> — {d.failing} says ✗,{" "}
2566 {d.passing} says ✓
2567 </li>
2568 ))}
2569 </ul>
2570 </div>
2571 </div>
2572 )}
2573 </div>
2574 );
2575}
2576
2577/**
2578 * Strip the marker comment + first H2 heading from a persona body so
2579 * the card body shows just the findings list (verdict is already in
2580 * the card head). Best-effort — malformed bodies render whole.
2581 */
2582function stripTrioHeading(body: string): string {
2583 return body
2584 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
2585 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
2586 .trim();
2587}
2588
67dc4e1Claude2589/**
2590 * Three small verdict pills rendered inline in the PR header. Each pill
2591 * links to the `#trio-review-section` anchor so clicking scrolls to the
2592 * full card grid. Only shown when `AI_TRIO_REVIEW_ENABLED=1` and at
2593 * least one persona comment exists.
2594 */
2595function TrioVerdictPills({
2596 comments,
2597}: {
2598 comments: TrioCommentLike[];
2599}) {
2600 if (!isTrioReviewEnabled()) return null;
2601
2602 // Find latest persona verdicts (same logic as TrioReviewGrid).
2603 const latest: Partial<Record<TrioPersona, string>> = {};
2604 for (let i = comments.length - 1; i >= 0; i--) {
2605 const body = comments[i].body || "";
2606 if (!isTrioComment(body)) continue;
2607 if (body.includes(TRIO_SUMMARY_MARKER)) continue;
2608 const persona = trioPersonaOfComment(body);
2609 if (persona && !latest[persona]) latest[persona] = body;
2610 }
2611
2612 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2613 if (!anyPersona) return null;
2614
2615 const PERSONA_LABEL: Record<TrioPersona, string> = {
2616 security: "Security",
2617 correctness: "Correctness",
2618 style: "Style",
2619 };
2620 const PERSONA_ICON: Record<TrioPersona, string> = {
2621 security: "🛡",
2622 correctness: "✓",
2623 style: "✎",
2624 };
2625
2626 return (
2627 <span class="trio-pills-wrap" aria-label="AI Trio Review verdicts">
2628 {TRIO_PERSONAS.map((persona) => {
2629 const body = latest[persona];
2630 const verdict = body ? trioVerdictOfBody(body) : null;
2631 const stateClass =
2632 verdict === "pass"
2633 ? "is-pass"
2634 : verdict === "fail"
2635 ? "is-fail"
2636 : "is-pending";
2637 const glyph =
2638 verdict === "pass" ? "✓" : verdict === "fail" ? "✗" : "⟳";
2639 return (
2640 <a
2641 href="#trio-review-section"
2642 class={`trio-pill ${stateClass}`}
2643 title={`AI ${PERSONA_LABEL[persona]} Review — ${verdict === "pass" ? "Pass" : verdict === "fail" ? "Fail" : "Pending"}`}
2644 >
2645 <span aria-hidden="true">{PERSONA_ICON[persona]}</span>
2646 {PERSONA_LABEL[persona]} {glyph}
2647 </a>
2648 );
2649 })}
2650 </span>
2651 );
2652}
2653
2c61840Claude2654// Detect AI-generated PRs from body markers and branch naming conventions.
2655// No DB column required — pattern-matched at render time.
2656function isAiGeneratedPr(body: string | null, headBranch: string): boolean {
2657 const AI_BRANCH_PREFIXES = ["claude/", "copilot/", "ai/", "bot/", "gluecron-ai/", "devin/"];
2658 if (AI_BRANCH_PREFIXES.some((p) => headBranch.startsWith(p))) return true;
2659 if (!body) return false;
2660 const AI_BODY_MARKERS = [
2661 "🤖 Generated with",
2662 "Co-Authored-By: Claude",
2663 "Claude-Session:",
2664 "gluecron:ai-generated",
2665 "AI-generated",
2666 "generated by claude",
2667 "generated with claude code",
2668 "copilot workspace",
2669 ];
2670 const lower = body.toLowerCase();
2671 return AI_BODY_MARKERS.some((m) => lower.includes(m.toLowerCase()));
2672}
2673
0074234Claude2674// List PRs
04f6b7fClaude2675pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2676 const { owner: ownerName, repo: repoName } = c.req.param();
2677 const user = c.get("user");
2678 const state = c.req.query("state") || "open";
d790b49Claude2679 const searchQ = c.req.query("q")?.trim() || "";
80bd7c8Claude2680 const authorFilter = c.req.query("author")?.trim() || "";
f5b9ef5Claude2681 const sortPr = (c.req.query("sort") || "newest").trim();
0074234Claude2682
ea9ed4cClaude2683 // ── Loading skeleton (flag-gated) ──
2684 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
2685 // the user see the page structure before counts + select resolve.
2686 // Behind a flag for now — we don't ship flashes.
2687 if (c.req.query("skeleton") === "1") {
2688 return c.html(
2689 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2690 <RepoHeader owner={ownerName} repo={repoName} />
2691 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2692 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2693 <style
2694 dangerouslySetInnerHTML={{
2695 __html: `
404b398Claude2696 .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; }
2697 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude2698 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
2699 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
2700 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
2701 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
2702 .prs-skel-row { height: 76px; border-radius: 12px; }
2703 `,
2704 }}
2705 />
2706 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
2707 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
2708 <div class="prs-skel-list" aria-hidden="true">
2709 {Array.from({ length: 6 }).map(() => (
2710 <div class="prs-skel prs-skel-row" />
2711 ))}
2712 </div>
2713 <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">
2714 Loading pull requests for {ownerName}/{repoName}…
2715 </span>
2716 </Layout>
2717 );
2718 }
2719
0074234Claude2720 const resolved = await resolveRepo(ownerName, repoName);
2721 if (!resolved) return c.notFound();
2722
6fc53bdClaude2723 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
2724 const stateFilter =
2725 state === "draft"
2726 ? and(
2727 eq(pullRequests.state, "open"),
2728 eq(pullRequests.isDraft, true)
2729 )
2730 : eq(pullRequests.state, state);
2731
0074234Claude2732 const prList = await db
2733 .select({
2734 pr: pullRequests,
2735 author: { username: users.username },
2736 })
2737 .from(pullRequests)
2738 .innerJoin(users, eq(pullRequests.authorId, users.id))
2739 .where(
d790b49Claude2740 and(
2741 eq(pullRequests.repositoryId, resolved.repo.id),
2742 stateFilter,
2743 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
80bd7c8Claude2744 authorFilter ? eq(users.username, authorFilter) : undefined,
d790b49Claude2745 )
0074234Claude2746 )
f5b9ef5Claude2747 .orderBy(
2748 sortPr === "oldest" ? asc(pullRequests.createdAt)
2749 : sortPr === "updated" ? desc(pullRequests.updatedAt)
2750 : desc(pullRequests.createdAt) // newest (default)
2751 );
0074234Claude2752
0369e77Claude2753 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude2754 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude2755 const commentCountMap = new Map<string, number>();
1aef949Claude2756 if (prList.length > 0) {
2757 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude2758 const [reviewRows, commentRows] = await Promise.all([
2759 db
2760 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
2761 .from(prReviews)
2762 .where(inArray(prReviews.pullRequestId, prIds)),
2763 db
2764 .select({
2765 prId: prComments.pullRequestId,
2766 cnt: sql<number>`count(*)::int`,
2767 })
2768 .from(prComments)
2769 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
2770 .groupBy(prComments.pullRequestId),
2771 ]);
1aef949Claude2772 for (const r of reviewRows) {
2773 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
2774 if (r.state === "approved") entry.approved = true;
2775 if (r.state === "changes_requested") entry.changesRequested = true;
2776 reviewMap.set(r.prId, entry);
2777 }
0369e77Claude2778 for (const r of commentRows) {
2779 commentCountMap.set(r.prId, Number(r.cnt));
2780 }
1aef949Claude2781 }
2782
0074234Claude2783 const [counts] = await db
2784 .select({
2785 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude2786 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude2787 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
2788 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
2789 })
2790 .from(pullRequests)
2791 .where(eq(pullRequests.repositoryId, resolved.repo.id));
2792
b078860Claude2793 const openCount = counts?.open ?? 0;
2794 const mergedCount = counts?.merged ?? 0;
2795 const closedCount = counts?.closed ?? 0;
2796 const draftCount = counts?.draft ?? 0;
2797 const allCount = openCount + mergedCount + closedCount;
2798
2799 // "All" is presentational only — the DB query for state='all' matches
2800 // nothing, so we render a friendlier empty state when picked. We do NOT
2801 // change the query logic to keep this commit purely visual.
80bd7c8Claude2802 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude2803 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude2804 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
2805 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
2806 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
2807 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
2808 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude2809 ];
2810 const isAllState = state === "all";
cb5a796Claude2811 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
2812 const prListPendingCount = viewerIsOwnerOnPrList
2813 ? await countPendingForRepo(resolved.repo.id)
2814 : 0;
b078860Claude2815
0074234Claude2816 return c.html(
2817 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2818 <RepoHeader owner={ownerName} repo={repoName} />
2819 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2820 <PendingCommentsBanner
2821 owner={ownerName}
2822 repo={repoName}
2823 count={prListPendingCount}
2824 />
b078860Claude2825 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2826
2827 <div class="prs-hero">
2828 <div class="prs-hero-inner">
2829 <div class="prs-hero-text">
2830 <div class="prs-hero-eyebrow">Pull requests</div>
2831 <h1 class="prs-hero-title">
2832 Review, <span class="gradient-text">merge with AI</span>.
2833 </h1>
2834 <p class="prs-hero-sub">
2835 {openCount === 0 && allCount === 0
2836 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2837 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
2838 </p>
2839 </div>
7a28902Claude2840 <div class="prs-hero-actions">
2841 <a
2842 href={`/${ownerName}/${repoName}/pulls/insights`}
2843 class="prs-cta"
2844 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
2845 >
2846 Insights
2847 </a>
2848 {user && (
b078860Claude2849 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
2850 + New pull request
2851 </a>
7a28902Claude2852 )}
2853 </div>
b078860Claude2854 </div>
2855 </div>
2856
2857 <nav class="prs-tabs" aria-label="Pull request filters">
2858 {tabPills.map((t) => {
2859 const isActive =
2860 state === t.key ||
2861 (t.key === "open" &&
2862 state !== "merged" &&
2863 state !== "closed" &&
2864 state !== "all" &&
2865 state !== "draft");
2866 return (
2867 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2868 <span>{t.label}</span>
2869 <span class="prs-tab-count">{t.count}</span>
2870 </a>
2871 );
2872 })}
2873 </nav>
2874
d790b49Claude2875 <form
2876 method="get"
2877 action={`/${ownerName}/${repoName}/pulls`}
2878 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2879 >
2880 <input type="hidden" name="state" value={state} />
2881 <input
2882 type="search"
2883 name="q"
2884 value={searchQ}
2885 placeholder="Search pull requests…"
2886 class="issues-search-input"
2887 style="flex:1;max-width:380px"
2888 />
80bd7c8Claude2889 <input
2890 type="text"
2891 name="author"
2892 value={authorFilter}
2893 placeholder="Filter by author…"
2894 class="issues-search-input"
2895 style="max-width:200px"
2896 />
d790b49Claude2897 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2898 {(searchQ || authorFilter) && (
d790b49Claude2899 <a
2900 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2901 class="issues-filter-clear"
2902 >
2903 Clear
2904 </a>
2905 )}
2906 </form>
f5b9ef5Claude2907
2908 <div class="prs-sort-row">
2909 <span class="prs-sort-label">Sort:</span>
2910 {(["newest", "oldest", "updated"] as const).map((s) => (
2911 <a
2912 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2913 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2914 >
2915 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2916 </a>
2917 ))}
2918 </div>
2919
0074234Claude2920 {prList.length === 0 ? (
b078860Claude2921 <div class="prs-empty">
ea9ed4cClaude2922 <div class="prs-empty-inner">
2923 <strong>
80bd7c8Claude2924 {searchQ || authorFilter
2925 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2926 : isAllState
2927 ? "Pick a filter above to browse PRs."
2928 : `No ${state} pull requests.`}
ea9ed4cClaude2929 </strong>
2930 <p class="prs-empty-sub">
80bd7c8Claude2931 {searchQ || authorFilter
2932 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2933 : state === "open"
2934 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2935 : isAllState
2936 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2937 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2938 </p>
2939 <div class="prs-empty-cta">
80bd7c8Claude2940 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2941 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2942 + New pull request
2943 </a>
2944 )}
80bd7c8Claude2945 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2946 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2947 View open PRs
2948 </a>
2949 )}
2950 <a href={`/${ownerName}/${repoName}`} class="btn">
2951 Back to code
2952 </a>
2953 </div>
2954 </div>
b078860Claude2955 </div>
0074234Claude2956 ) : (
b078860Claude2957 <div class="prs-list">
2958 {prList.map(({ pr, author }) => {
2959 const stateClass =
2960 pr.state === "open"
2961 ? pr.isDraft
2962 ? "state-draft"
2963 : "state-open"
2964 : pr.state === "merged"
2965 ? "state-merged"
2966 : "state-closed";
2967 const icon =
2968 pr.state === "open"
2969 ? pr.isDraft
2970 ? "◌"
2971 : "○"
2972 : pr.state === "merged"
2973 ? "⮌"
2974 : "✓";
1aef949Claude2975 const rv = reviewMap.get(pr.id);
b078860Claude2976 return (
2977 <a
2978 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2979 class="prs-row"
2980 style="text-decoration:none;color:inherit"
0074234Claude2981 >
b078860Claude2982 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2983 {icon}
0074234Claude2984 </div>
b078860Claude2985 <div class="prs-row-body">
2986 <h3 class="prs-row-title">
2987 <span>{pr.title}</span>
2988 <span class="prs-row-number">#{pr.number}</span>
2989 </h3>
2990 <div class="prs-row-meta">
2991 <span
2992 class="prs-branch-chips"
2993 title={`${pr.headBranch} into ${pr.baseBranch}`}
2994 >
2995 <span class="prs-branch-chip">{pr.headBranch}</span>
2996 <span class="prs-branch-arrow">{"→"}</span>
2997 <span class="prs-branch-chip">{pr.baseBranch}</span>
2998 </span>
2999 <span>
3000 by{" "}
3001 <strong style="color:var(--text)">
3002 {author.username}
3003 </strong>{" "}
3004 {formatRelative(pr.createdAt)}
3005 </span>
3006 <span class="prs-row-tags">
3007 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2c61840Claude3008 {isAiGeneratedPr(pr.body, pr.headBranch) && (
3009 <span class="prs-tag is-ai" title="Opened by an AI agent">⚡ AI</span>
3010 )}
b078860Claude3011 {pr.state === "merged" && (
3012 <span class="prs-tag is-merged">Merged</span>
3013 )}
1aef949Claude3014 {rv?.approved && !rv.changesRequested && (
3015 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
3016 )}
3017 {rv?.changesRequested && (
3018 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
3019 )}
0369e77Claude3020 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
3021 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
3022 💬 {commentCountMap.get(pr.id)}
3023 </span>
3024 )}
b078860Claude3025 </span>
3026 </div>
0074234Claude3027 </div>
b078860Claude3028 </a>
3029 );
3030 })}
3031 </div>
0074234Claude3032 )}
3033 </Layout>
3034 );
3035});
3036
7a28902Claude3037/* ─────────────────────────────────────────────────────────────────────────
3038 * PR Insights — 90-day analytics for the pull request activity of a repo.
3039 * Route: GET /:owner/:repo/pulls/insights
3040 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
3041 * "insights" is not swallowed by the :number param.
3042 * ───────────────────────────────────────────────────────────────────────── */
3043
3044/** Format a millisecond duration as human-readable string. */
3045function formatMsDuration(ms: number): string {
3046 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
3047 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
3048 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
3049 return `${Math.round(ms / 86_400_000)}d`;
3050}
3051
3052/** Format an ISO week string as "Jan 15". */
3053function formatWeekLabel(isoWeek: string): string {
3054 try {
3055 const d = new Date(isoWeek);
3056 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
3057 } catch {
3058 return isoWeek.slice(5, 10);
3059 }
3060}
3061
3062const PR_INSIGHTS_STYLES = `
3063 .pri-page { padding-bottom: 48px; }
3064 .pri-hero {
3065 position: relative;
3066 margin: 0 0 var(--space-5);
3067 padding: 22px 26px 24px;
3068 background: var(--bg-elevated);
3069 border: 1px solid var(--border);
3070 border-radius: 16px;
3071 overflow: hidden;
3072 }
3073 .pri-hero::before {
3074 content: '';
3075 position: absolute; top: 0; left: 0; right: 0;
3076 height: 2px;
6fd5915Claude3077 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7a28902Claude3078 opacity: 0.7;
3079 pointer-events: none;
3080 }
3081 .pri-hero-eyebrow {
3082 font-size: 12px;
3083 color: var(--text-muted);
3084 text-transform: uppercase;
3085 letter-spacing: 0.08em;
3086 font-weight: 600;
3087 margin-bottom: 8px;
3088 }
3089 .pri-hero-title {
3090 font-family: var(--font-display);
3091 font-size: clamp(26px, 3.4vw, 34px);
3092 font-weight: 800;
3093 letter-spacing: -0.025em;
3094 line-height: 1.06;
3095 margin: 0 0 8px;
3096 color: var(--text-strong);
3097 }
3098 .pri-hero-title .gradient-text {
6fd5915Claude3099 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
7a28902Claude3100 -webkit-background-clip: text;
3101 background-clip: text;
3102 -webkit-text-fill-color: transparent;
3103 color: transparent;
3104 }
3105 .pri-hero-sub {
3106 font-size: 14.5px;
3107 color: var(--text-muted);
3108 margin: 0;
3109 line-height: 1.5;
3110 }
3111 .pri-section { margin-bottom: 32px; }
3112 .pri-section-title {
3113 font-size: 13px;
3114 font-weight: 700;
3115 text-transform: uppercase;
3116 letter-spacing: 0.06em;
3117 color: var(--text-muted);
3118 margin: 0 0 14px;
3119 }
3120 .pri-cards {
3121 display: grid;
3122 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
3123 gap: 12px;
3124 }
3125 .pri-card {
3126 padding: 16px 18px;
3127 background: var(--bg-elevated);
3128 border: 1px solid var(--border);
3129 border-radius: 12px;
3130 }
3131 .pri-card-label {
3132 font-size: 12px;
3133 font-weight: 600;
3134 color: var(--text-muted);
3135 text-transform: uppercase;
3136 letter-spacing: 0.05em;
3137 margin-bottom: 6px;
3138 }
3139 .pri-card-value {
3140 font-size: 28px;
3141 font-weight: 800;
3142 letter-spacing: -0.04em;
3143 color: var(--text-strong);
3144 line-height: 1;
3145 }
3146 .pri-card-sub {
3147 font-size: 12px;
3148 color: var(--text-muted);
3149 margin-top: 4px;
3150 }
3151 .pri-chart {
3152 display: flex;
3153 align-items: flex-end;
3154 gap: 6px;
3155 height: 120px;
3156 background: var(--bg-elevated);
3157 border: 1px solid var(--border);
3158 border-radius: 12px;
3159 padding: 16px 16px 0;
3160 }
3161 .pri-bar-col {
3162 flex: 1;
3163 display: flex;
3164 flex-direction: column;
3165 align-items: center;
3166 justify-content: flex-end;
3167 height: 100%;
3168 gap: 4px;
3169 }
3170 .pri-bar {
3171 width: 100%;
3172 min-height: 4px;
3173 border-radius: 4px 4px 0 0;
6fd5915Claude3174 background: linear-gradient(180deg, #5b6ee8 0%, #5b6ee8 100%);
7a28902Claude3175 transition: opacity 140ms;
3176 }
3177 .pri-bar:hover { opacity: 0.8; }
3178 .pri-bar-label {
3179 font-size: 10px;
3180 color: var(--text-muted);
3181 text-align: center;
3182 padding-bottom: 8px;
3183 white-space: nowrap;
3184 overflow: hidden;
3185 text-overflow: ellipsis;
3186 max-width: 100%;
3187 }
3188 .pri-table {
3189 width: 100%;
3190 border-collapse: collapse;
3191 font-size: 13.5px;
3192 }
3193 .pri-table th {
3194 text-align: left;
3195 font-size: 12px;
3196 font-weight: 600;
3197 text-transform: uppercase;
3198 letter-spacing: 0.05em;
3199 color: var(--text-muted);
3200 padding: 8px 12px;
3201 border-bottom: 1px solid var(--border);
3202 }
3203 .pri-table td {
3204 padding: 10px 12px;
3205 border-bottom: 1px solid var(--border);
3206 color: var(--text);
3207 }
3208 .pri-table tr:last-child td { border-bottom: none; }
3209 .pri-table-wrap {
3210 background: var(--bg-elevated);
3211 border: 1px solid var(--border);
3212 border-radius: 12px;
3213 overflow: hidden;
3214 }
3215 .pri-age-row {
3216 display: flex;
3217 align-items: center;
3218 gap: 12px;
3219 padding: 10px 0;
3220 border-bottom: 1px solid var(--border);
3221 font-size: 13.5px;
3222 }
3223 .pri-age-row:last-child { border-bottom: none; }
3224 .pri-age-label {
3225 flex: 0 0 80px;
3226 color: var(--text-muted);
3227 font-size: 12.5px;
3228 font-weight: 600;
3229 }
3230 .pri-age-bar-wrap {
3231 flex: 1;
3232 height: 8px;
3233 background: var(--bg-secondary);
3234 border-radius: 9999px;
3235 overflow: hidden;
3236 }
3237 .pri-age-bar {
3238 height: 100%;
3239 border-radius: 9999px;
6fd5915Claude3240 background: linear-gradient(90deg, #5b6ee8 0%, #5f8fa0 100%);
7a28902Claude3241 min-width: 4px;
3242 }
3243 .pri-age-count {
3244 flex: 0 0 32px;
3245 text-align: right;
3246 font-weight: 600;
3247 color: var(--text-strong);
3248 font-size: 13px;
3249 }
3250 .pri-sparkline {
3251 display: flex;
3252 align-items: flex-end;
3253 gap: 3px;
3254 height: 40px;
3255 }
3256 .pri-spark-bar {
3257 flex: 1;
3258 min-height: 2px;
3259 border-radius: 2px 2px 0 0;
6fd5915Claude3260 background: var(--accent, #5b6ee8);
7a28902Claude3261 opacity: 0.7;
3262 }
3263 .pri-empty {
3264 color: var(--text-muted);
3265 font-size: 14px;
3266 padding: 24px 0;
3267 text-align: center;
3268 }
3269 @media (max-width: 600px) {
3270 .pri-cards { grid-template-columns: repeat(2, 1fr); }
3271 .pri-hero { padding: 18px 18px 20px; }
3272 }
3273`;
3274
3275pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
3276 const { owner: ownerName, repo: repoName } = c.req.param();
3277 const user = c.get("user");
3278
3279 const resolved = await resolveRepo(ownerName, repoName);
3280 if (!resolved) return c.notFound();
3281
3282 const repoId = resolved.repo.id;
3283 const now = Date.now();
3284
3285 // 1. Merged PRs in last 90 days (avg merge time)
3286 const mergedPRs = await db
3287 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
3288 .from(pullRequests)
3289 .where(and(
3290 eq(pullRequests.repositoryId, repoId),
3291 eq(pullRequests.state, "merged"),
3292 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3293 ));
3294
3295 const avgMergeMs = mergedPRs.length > 0
3296 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
3297 : null;
3298
3299 // 2. PR throughput (last 8 weeks)
3300 const weeklyPRs = await db
3301 .select({
3302 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
3303 count: sql<number>`count(*)::int`,
3304 })
3305 .from(pullRequests)
3306 .where(and(
3307 eq(pullRequests.repositoryId, repoId),
3308 sql`${pullRequests.createdAt} > now() - interval '56 days'`
3309 ))
3310 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
3311 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
3312
3313 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
3314
3315 // 3. PR merge rate (last 90 days)
3316 const [rateCounts] = await db
3317 .select({
3318 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
3319 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
3320 })
3321 .from(pullRequests)
3322 .where(and(
3323 eq(pullRequests.repositoryId, repoId),
3324 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3325 ));
3326
3327 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
3328 const mergeRate = totalResolved > 0
3329 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
3330 : null;
3331
3332 // 4. Top reviewers (last 90 days)
3333 const reviewerCounts = await db
3334 .select({
3335 userId: prReviews.reviewerId,
3336 username: users.username,
3337 count: sql<number>`count(*)::int`,
3338 })
3339 .from(prReviews)
3340 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3341 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
3342 .where(and(
3343 eq(pullRequests.repositoryId, repoId),
3344 sql`${prReviews.createdAt} > now() - interval '90 days'`
3345 ))
3346 .groupBy(prReviews.reviewerId, users.username)
3347 .orderBy(desc(sql`count(*)`))
3348 .limit(5);
3349
3350 // 5. Average reviews per merged PR
3351 const [avgReviewRow] = await db
3352 .select({
3353 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
3354 })
3355 .from(pullRequests)
3356 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3357 .where(and(
3358 eq(pullRequests.repositoryId, repoId),
3359 eq(pullRequests.state, "merged"),
3360 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3361 ));
3362
3363 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
3364 ? Math.round(avgReviewRow.avgReviews * 10) / 10
3365 : null;
3366
3367 // 6. Review turnaround — avg time from PR open to first review
3368 const prsWithReviews = await db
3369 .select({
3370 createdAt: pullRequests.createdAt,
3371 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
3372 })
3373 .from(pullRequests)
3374 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3375 .where(and(
3376 eq(pullRequests.repositoryId, repoId),
3377 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3378 ))
3379 .groupBy(pullRequests.id, pullRequests.createdAt);
3380
3381 const avgReviewTurnaroundMs = prsWithReviews.length > 0
3382 ? prsWithReviews.reduce((s, row) => {
3383 const firstMs = new Date(row.firstReview).getTime();
3384 return s + Math.max(0, firstMs - row.createdAt.getTime());
3385 }, 0) / prsWithReviews.length
3386 : null;
3387
3388 // 7. Open PRs by age bucket
3389 const openPRs = await db
3390 .select({ createdAt: pullRequests.createdAt })
3391 .from(pullRequests)
3392 .where(and(
3393 eq(pullRequests.repositoryId, repoId),
3394 eq(pullRequests.state, "open")
3395 ));
3396
3397 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
3398 for (const { createdAt } of openPRs) {
3399 const ageDays = (now - createdAt.getTime()) / 86_400_000;
3400 if (ageDays < 1) ageBuckets.lt1d++;
3401 else if (ageDays < 3) ageBuckets.d1to3++;
3402 else if (ageDays < 7) ageBuckets.d3to7++;
3403 else if (ageDays < 30) ageBuckets.d7to30++;
3404 else ageBuckets.gt30d++;
3405 }
3406 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
3407
3408 // 8. 7-day merge sparkline
3409 const sparklineRows = await db
3410 .select({
3411 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
3412 count: sql<number>`count(*)::int`,
3413 })
3414 .from(pullRequests)
3415 .where(and(
3416 eq(pullRequests.repositoryId, repoId),
3417 eq(pullRequests.state, "merged"),
3418 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
3419 ))
3420 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
3421 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
3422
3423 const sparkMap = new Map<string, number>();
3424 for (const row of sparklineRows) {
3425 sparkMap.set(row.day.slice(0, 10), row.count);
3426 }
3427 const sparkline: number[] = [];
3428 for (let i = 6; i >= 0; i--) {
3429 const d = new Date(now - i * 86_400_000);
3430 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
3431 }
3432 const maxSpark = Math.max(1, ...sparkline);
3433
3434 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
3435 { label: "< 1 day", key: "lt1d" },
3436 { label: "1–3 days", key: "d1to3" },
3437 { label: "3–7 days", key: "d3to7" },
3438 { label: "7–30 days", key: "d7to30" },
3439 { label: "> 30 days", key: "gt30d" },
3440 ];
3441
3442 return c.html(
3443 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
3444 <RepoHeader owner={ownerName} repo={repoName} />
3445 <PrNav owner={ownerName} repo={repoName} active="pulls" />
3446 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
3447
3448 <div class="pri-page">
3449 {/* Hero */}
3450 <div class="pri-hero">
3451 <div class="pri-hero-eyebrow">Pull requests</div>
3452 <h1 class="pri-hero-title">
3453 PR <span class="gradient-text">Insights</span>
3454 </h1>
3455 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
3456 </div>
3457
3458 {/* Stat cards */}
3459 <div class="pri-section">
3460 <div class="pri-section-title">At a glance</div>
3461 <div class="pri-cards">
3462 <div class="pri-card">
3463 <div class="pri-card-label">Avg merge time</div>
3464 <div class="pri-card-value">
3465 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
3466 </div>
3467 <div class="pri-card-sub">last 90 days</div>
3468 </div>
3469 <div class="pri-card">
3470 <div class="pri-card-label">Total merged</div>
3471 <div class="pri-card-value">{mergedPRs.length}</div>
3472 <div class="pri-card-sub">last 90 days</div>
3473 </div>
3474 <div class="pri-card">
3475 <div class="pri-card-label">Open PRs</div>
3476 <div class="pri-card-value">{openPRs.length}</div>
3477 <div class="pri-card-sub">right now</div>
3478 </div>
3479 <div class="pri-card">
3480 <div class="pri-card-label">Merge rate</div>
3481 <div class="pri-card-value">
3482 {mergeRate != null ? `${mergeRate}%` : "—"}
3483 </div>
3484 <div class="pri-card-sub">merged vs closed</div>
3485 </div>
3486 <div class="pri-card">
3487 <div class="pri-card-label">Avg reviews / PR</div>
3488 <div class="pri-card-value">
3489 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
3490 </div>
3491 <div class="pri-card-sub">merged PRs, 90d</div>
3492 </div>
3493 <div class="pri-card">
3494 <div class="pri-card-label">Top reviewer</div>
3495 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
3496 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
3497 </div>
3498 <div class="pri-card-sub">
3499 {reviewerCounts.length > 0
3500 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
3501 : "no reviews yet"}
3502 </div>
3503 </div>
3504 </div>
3505 </div>
3506
3507 {/* Review turnaround */}
3508 <div class="pri-section">
3509 <div class="pri-section-title">Review turnaround</div>
3510 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
3511 <div class="pri-card">
3512 <div class="pri-card-label">Avg time to first review</div>
3513 <div class="pri-card-value">
3514 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
3515 </div>
3516 <div class="pri-card-sub">
3517 {prsWithReviews.length > 0
3518 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
3519 : "no reviewed PRs in 90d"}
3520 </div>
3521 </div>
3522 </div>
3523 </div>
3524
3525 {/* Weekly throughput bar chart */}
3526 <div class="pri-section">
3527 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
3528 {weeklyPRs.length === 0 ? (
3529 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
3530 ) : (
3531 <div class="pri-chart">
3532 {weeklyPRs.map((w) => (
3533 <div class="pri-bar-col">
3534 <div
3535 class="pri-bar"
3536 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
3537 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
3538 />
3539 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
3540 </div>
3541 ))}
3542 </div>
3543 )}
3544 </div>
3545
3546 {/* 7-day merge sparkline */}
3547 <div class="pri-section">
3548 <div class="pri-section-title">Merges this week (daily)</div>
3549 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
3550 <div class="pri-sparkline">
3551 {sparkline.map((v) => (
3552 <div
3553 class="pri-spark-bar"
3554 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
3555 title={`${v} merge${v === 1 ? "" : "s"}`}
3556 />
3557 ))}
3558 </div>
3559 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
3560 <span>7 days ago</span>
3561 <span>Today</span>
3562 </div>
3563 </div>
3564 </div>
3565
3566 {/* Top reviewers table */}
3567 <div class="pri-section">
3568 <div class="pri-section-title">Top reviewers (last 90 days)</div>
3569 {reviewerCounts.length === 0 ? (
3570 <div class="pri-empty">No reviews posted in the last 90 days.</div>
3571 ) : (
3572 <div class="pri-table-wrap">
3573 <table class="pri-table">
3574 <thead>
3575 <tr>
3576 <th>#</th>
3577 <th>Reviewer</th>
3578 <th>Reviews</th>
3579 </tr>
3580 </thead>
3581 <tbody>
3582 {reviewerCounts.map((r, i) => (
3583 <tr>
3584 <td style="color:var(--text-muted)">{i + 1}</td>
3585 <td>
3586 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
3587 {r.username}
3588 </a>
3589 </td>
3590 <td style="font-weight:600">{r.count}</td>
3591 </tr>
3592 ))}
3593 </tbody>
3594 </table>
3595 </div>
3596 )}
3597 </div>
3598
3599 {/* Open PRs by age */}
3600 <div class="pri-section">
3601 <div class="pri-section-title">Open PRs by age</div>
3602 {openPRs.length === 0 ? (
3603 <div class="pri-empty">No open pull requests.</div>
3604 ) : (
3605 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
3606 {ageBucketDefs.map(({ label, key }) => (
3607 <div class="pri-age-row">
3608 <span class="pri-age-label">{label}</span>
3609 <div class="pri-age-bar-wrap">
3610 <div
3611 class="pri-age-bar"
3612 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
3613 />
3614 </div>
3615 <span class="pri-age-count">{ageBuckets[key]}</span>
3616 </div>
3617 ))}
3618 </div>
3619 )}
3620 </div>
3621
3622 {/* Back link */}
3623 <div>
3624 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
3625 {"←"} Back to pull requests
3626 </a>
3627 </div>
3628 </div>
3629 </Layout>
3630 );
3631});
3632
0074234Claude3633// New PR form
3634pulls.get(
3635 "/:owner/:repo/pulls/new",
3636 softAuth,
3637 requireAuth,
04f6b7fClaude3638 requireRepoAccess("write"),
0074234Claude3639 async (c) => {
3640 const { owner: ownerName, repo: repoName } = c.req.param();
3641 const user = c.get("user")!;
3642 const branches = await listBranches(ownerName, repoName);
3643 const error = c.req.query("error");
3644 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude3645 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude3646
3647 return c.html(
3648 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
3649 <RepoHeader owner={ownerName} repo={repoName} />
3650 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude3651 <Container maxWidth={800}>
3652 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude3653 {error && (
bb0f894Claude3654 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude3655 )}
0316dbbClaude3656 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
3657 <Flex gap={12} align="center" style="margin-bottom: 16px">
3658 <Select name="base">
0074234Claude3659 {branches.map((b) => (
3660 <option value={b} selected={b === defaultBase}>
3661 {b}
3662 </option>
3663 ))}
bb0f894Claude3664 </Select>
3665 <Text muted>&larr;</Text>
3666 <Select name="head">
0074234Claude3667 {branches
3668 .filter((b) => b !== defaultBase)
3669 .concat(defaultBase === branches[0] ? [] : [branches[0]])
3670 .map((b) => (
3671 <option value={b}>{b}</option>
3672 ))}
bb0f894Claude3673 </Select>
3674 </Flex>
3675 <FormGroup>
3676 <Input
0074234Claude3677 name="title"
3678 required
3679 placeholder="Title"
bb0f894Claude3680 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]3681 aria-label="Pull request title"
0074234Claude3682 />
bb0f894Claude3683 </FormGroup>
3684 <FormGroup>
3685 <TextArea
0074234Claude3686 name="body"
81c73c1Claude3687 id="pr-body"
0074234Claude3688 rows={8}
3689 placeholder="Description (Markdown supported)"
bb0f894Claude3690 mono
0074234Claude3691 />
bb0f894Claude3692 </FormGroup>
81c73c1Claude3693 <Flex gap={8} align="center">
3694 <Button type="submit" variant="primary">
3695 Create pull request
3696 </Button>
3697 <button
3698 type="button"
3699 id="ai-suggest-desc"
3700 class="btn"
3701 style="font-weight:500"
3702 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
3703 >
3704 Suggest description with AI
3705 </button>
3706 <span
3707 id="ai-suggest-status"
3708 style="color:var(--text-muted);font-size:13px"
3709 />
3710 </Flex>
bb0f894Claude3711 </Form>
81c73c1Claude3712 <script
3713 dangerouslySetInnerHTML={{
3714 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
3715 }}
3716 />
bb0f894Claude3717 </Container>
0074234Claude3718 </Layout>
3719 );
3720 }
3721);
3722
81c73c1Claude3723// AI-suggested PR description — JSON endpoint driven by the form button.
3724// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
3725// 200; the inline script reads `ok` to decide what to do.
3726pulls.post(
3727 "/:owner/:repo/ai/pr-description",
3728 softAuth,
3729 requireAuth,
3730 requireRepoAccess("write"),
3731 async (c) => {
3732 const { owner: ownerName, repo: repoName } = c.req.param();
3733 if (!isAiAvailable()) {
3734 return c.json({
3735 ok: false,
3736 error: "AI is not available — set ANTHROPIC_API_KEY.",
3737 });
3738 }
3739 const body = await c.req.parseBody();
3740 const title = String(body.title || "").trim();
3741 const baseBranch = String(body.base || "").trim();
3742 const headBranch = String(body.head || "").trim();
3743 if (!baseBranch || !headBranch) {
3744 return c.json({ ok: false, error: "Pick base + head branches first." });
3745 }
3746 if (baseBranch === headBranch) {
3747 return c.json({ ok: false, error: "Base and head must differ." });
3748 }
3749
3750 let diff = "";
3751 try {
3752 const cwd = getRepoPath(ownerName, repoName);
3753 const proc = Bun.spawn(
3754 [
3755 "git",
3756 "diff",
3757 `${baseBranch}...${headBranch}`,
3758 "--",
3759 ],
3760 { cwd, stdout: "pipe", stderr: "pipe" }
3761 );
6ea2109Claude3762 // 30s ceiling — without this a pathological diff (huge binary or
3763 // a corrupt ref) hangs the request indefinitely.
3764 const killer = setTimeout(() => proc.kill(), 30_000);
3765 try {
3766 diff = await new Response(proc.stdout).text();
3767 await proc.exited;
3768 } finally {
3769 clearTimeout(killer);
3770 }
81c73c1Claude3771 } catch {
3772 diff = "";
3773 }
3774 if (!diff.trim()) {
3775 return c.json({
3776 ok: false,
3777 error: "No diff between branches — nothing to summarise.",
3778 });
3779 }
3780
3781 let summary = "";
3782 try {
3783 summary = await generatePrSummary(title || "(untitled)", diff);
3784 } catch (err) {
3785 const msg = err instanceof Error ? err.message : "AI request failed.";
3786 return c.json({ ok: false, error: msg });
3787 }
3788 if (!summary.trim()) {
3789 return c.json({ ok: false, error: "AI returned an empty draft." });
3790 }
3791 return c.json({ ok: true, body: summary });
3792 }
3793);
3794
0074234Claude3795// Create PR
3796pulls.post(
3797 "/:owner/:repo/pulls/new",
3798 softAuth,
3799 requireAuth,
04f6b7fClaude3800 requireRepoAccess("write"),
0074234Claude3801 async (c) => {
3802 const { owner: ownerName, repo: repoName } = c.req.param();
3803 const user = c.get("user")!;
3804 const body = await c.req.parseBody();
3805 const title = String(body.title || "").trim();
3806 const prBody = String(body.body || "").trim();
3807 const baseBranch = String(body.base || "main");
3808 const headBranch = String(body.head || "");
3809
3810 if (!title || !headBranch) {
3811 return c.redirect(
3812 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
3813 );
3814 }
3815
3816 if (baseBranch === headBranch) {
3817 return c.redirect(
3818 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
3819 );
3820 }
3821
3822 const resolved = await resolveRepo(ownerName, repoName);
3823 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3824
6fc53bdClaude3825 const isDraft = String(body.draft || "") === "1";
3826
0074234Claude3827 const [pr] = await db
3828 .insert(pullRequests)
3829 .values({
3830 repositoryId: resolved.repo.id,
3831 authorId: user.id,
3832 title,
3833 body: prBody || null,
3834 baseBranch,
3835 headBranch,
6fc53bdClaude3836 isDraft,
0074234Claude3837 })
3838 .returning();
3839
b7b5f75ccanty labs3840 void logActivity({
3841 repositoryId: resolved.repo.id,
3842 userId: user.id,
3843 action: "pr_open",
3844 targetType: "pull_request",
3845 targetId: String(pr.number),
3846 metadata: { baseBranch, headBranch, isDraft },
3847 });
a74f4edccanty labs3848 void fireWebhooks(resolved.repo.id, "pr", {
3849 action: "opened",
3850 number: pr.number,
3851 baseBranch,
3852 headBranch,
3853 });
b7b5f75ccanty labs3854
ec9e3e3Claude3855 // CODEOWNERS — auto-request reviewers based on changed files.
3856 // Fire-and-forget; errors never block PR creation.
3857 (async () => {
3858 try {
3859 const repoDir = getRepoPath(ownerName, repoName);
3860 // Get list of changed files between base and head
3861 const diffProc = Bun.spawn(
3862 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
3863 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3864 );
3865 const rawDiff = await new Response(diffProc.stdout).text();
3866 await diffProc.exited;
3867 const changedFiles = rawDiff.trim().split("\n").filter(Boolean);
3868
3869 if (changedFiles.length > 0) {
3870 // Get CODEOWNERS from the default branch of the repo
3871 const rules = await getCodeownersForRepo(
3872 ownerName,
3873 repoName,
3874 resolved.repo.defaultBranch
3875 );
3876 if (rules.length > 0) {
3877 const ownerUsernames = await reviewersForChangedFiles(
3878 resolved.repo.id,
3879 changedFiles
3880 );
3881 // Filter out the PR author
3882 const filteredOwners = ownerUsernames.filter(
3883 (u) => u !== resolved.owner.username
3884 );
3885
3886 if (filteredOwners.length > 0) {
3887 // Look up user IDs for the owner usernames
3888 const reviewerUsers = await db
3889 .select({ id: users.id, username: users.username })
3890 .from(users)
3891 .where(
3892 inArray(
3893 users.username,
3894 filteredOwners
3895 )
3896 );
3897
3898 // Create review request rows (UNIQUE constraint prevents dupes)
3899 if (reviewerUsers.length > 0) {
3900 await db
3901 .insert(prReviewRequests)
3902 .values(
3903 reviewerUsers.map((u) => ({
3904 prId: pr.id,
3905 reviewerId: u.id,
3906 requestedBy: null as string | null,
3907 }))
3908 )
3909 .onConflictDoNothing();
3910
3911 // Add a PR comment announcing the auto-assigned reviewers
3912 const mentionList = reviewerUsers
3913 .map((u) => `@${u.username}`)
3914 .join(", ");
3915 await db.insert(prComments).values({
3916 pullRequestId: pr.id,
3917 authorId: user.id,
3918 body: `AI: Requested review from ${mentionList} based on CODEOWNERS`,
3919 isAiReview: true,
3920 });
3921 }
3922 }
3923 }
3924 }
3925 } catch (err) {
3926 console.warn("[codeowners] auto-assign failed:", err instanceof Error ? err.message : err);
3927 }
3928 })();
3929
91b054eccanty labs3930 // `on: pull_request` workflow trigger — workflows are already synced
3931 // into the `workflows` table on push (push-workflow-sync.ts); PR-open
3932 // just needs to enqueue runs for the ones whose `on:` includes
3933 // `pull_request`. Fire-and-forget; must never block PR creation. Scoped
3934 // to PR open only — see pr-workflow-sync.ts header for why synchronize/
3935 // close aren't wired here yet.
3936 resolveRef(ownerName, repoName, headBranch)
3937 .then((headSha) => {
3938 if (!headSha) return;
3939 return enqueuePullRequestWorkflows({
3940 repositoryId: resolved.repo.id,
3941 headBranch,
3942 headSha,
3943 triggeredBy: user.id,
3944 });
3945 })
3946 .catch((err) =>
3947 console.warn(
3948 "[pr-workflow-sync] enqueue failed:",
3949 err instanceof Error ? err.message : err
3950 )
3951 );
3952
6fc53bdClaude3953 // Skip AI review on drafts — it runs again when the PR is marked ready.
3954 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3955 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3956 (err) => console.error("[ai-review] Failed:", err)
3957 );
3958 }
3959
3cbe3d6Claude3960 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3961 triggerPrTriage({
3962 ownerName,
3963 repoName,
3964 repositoryId: resolved.repo.id,
3965 prId: pr.id,
3966 prAuthorId: user.id,
3967 title,
3968 body: prBody,
3969 baseBranch,
3970 headBranch,
3971 }).catch((err) => console.error("[pr-triage] Failed:", err));
3972
1d4ff60Claude3973 // Chat notifier — fan out to Slack/Discord/Teams.
3974 import("../lib/chat-notifier")
3975 .then((m) =>
3976 m.notifyChatChannels({
3977 ownerUserId: resolved.repo.ownerId,
3978 repositoryId: resolved.repo.id,
3979 event: {
3980 event: "pr.opened",
3981 repo: `${ownerName}/${repoName}`,
3982 title: `#${pr.number} ${title}`,
3983 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3984 body: prBody || undefined,
3985 actor: user.username,
3986 },
3987 })
3988 )
3989 .catch((err) =>
3990 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3991 );
3992
9dd96b9Test User3993 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude3994 import("../lib/auto-merge")
3995 .then((m) => m.tryAutoMergeNow(pr.id))
3996 .catch((err) => {
3997 console.warn(
3998 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
3999 err instanceof Error ? err.message : err
4000 );
4001 });
9dd96b9Test User4002
1df50d5Claude4003 // Migration 0077 — PR preview build. Fire-and-forget; skips when
4004 // PREVIEW_DOMAIN is unset or the repo has no preview_build_command.
4005 // Resolve head SHA asynchronously so we don't block the redirect.
4006 resolveRef(ownerName, repoName, headBranch)
4007 .then((headSha) => {
4008 if (!headSha) return;
4009 return import("../lib/preview-builder").then((m) =>
4010 m.buildPreview(pr.id, resolved.repo.id, headSha)
4011 );
4012 })
4013 .catch(() => {});
4014
0074234Claude4015 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
4016 }
4017);
4018
4019// View single PR
04f6b7fClaude4020pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude4021 const { owner: ownerName, repo: repoName } = c.req.param();
4022 const prNum = parseInt(c.req.param("number"), 10);
4023 const user = c.get("user");
4024 const tab = c.req.query("tab") || "conversation";
a2c10c5Claude4025 const isSplit = c.req.query("diffview") === "split";
4026 const pendingCount = parseInt(c.req.query("pending") || "0", 10) || 0;
0074234Claude4027
4028 const resolved = await resolveRepo(ownerName, repoName);
4029 if (!resolved) return c.notFound();
4030
4031 const [pr] = await db
4032 .select()
4033 .from(pullRequests)
4034 .where(
4035 and(
4036 eq(pullRequests.repositoryId, resolved.repo.id),
4037 eq(pullRequests.number, prNum)
4038 )
4039 )
4040 .limit(1);
4041
4042 if (!pr) return c.notFound();
4043
4044 const [author] = await db
4045 .select()
4046 .from(users)
4047 .where(eq(users.id, pr.authorId))
4048 .limit(1);
4049
cb5a796Claude4050 const allCommentsRaw = await db
0074234Claude4051 .select({
4052 comment: prComments,
cb5a796Claude4053 author: { id: users.id, username: users.username },
0074234Claude4054 })
4055 .from(prComments)
4056 .innerJoin(users, eq(prComments.authorId, users.id))
4057 .where(eq(prComments.pullRequestId, pr.id))
4058 .orderBy(asc(prComments.createdAt));
4059
cb5a796Claude4060 // Filter pending/rejected/spam for non-owner, non-author viewers.
4061 // Owner always sees everything; comment author sees their own pending
4062 // with an "Awaiting approval" badge in the render below.
4063 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
4064 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
4065 if (viewerIsRepoOwner) return true;
4066 if (comment.moderationStatus === "approved") return true;
4067 if (
4068 user &&
4069 cAuthor.id === user.id &&
4070 comment.moderationStatus === "pending"
4071 ) {
4072 return true;
4073 }
4074 return false;
4075 });
4076 const prPendingCount = viewerIsRepoOwner
4077 ? await countPendingForRepo(resolved.repo.id)
4078 : 0;
4079
6fc53bdClaude4080 // Reactions for the PR body + each comment, in parallel.
4081 const [prReactions, ...prCommentReactions] = await Promise.all([
4082 summariseReactions("pr", pr.id, user?.id),
4083 ...comments.map((row) =>
4084 summariseReactions("pr_comment", row.comment.id, user?.id)
4085 ),
4086 ]);
4087
0a67773Claude4088 // Formal reviews (Approve / Request Changes)
4089 const reviewRows = await db
4090 .select({
4091 id: prReviews.id,
4092 state: prReviews.state,
4093 body: prReviews.body,
4094 isAi: prReviews.isAi,
4095 createdAt: prReviews.createdAt,
4096 reviewerUsername: users.username,
4097 reviewerId: prReviews.reviewerId,
4098 })
4099 .from(prReviews)
4100 .innerJoin(users, eq(prReviews.reviewerId, users.id))
4101 .where(eq(prReviews.pullRequestId, pr.id))
4102 .orderBy(asc(prReviews.createdAt));
4103 // Most recent review per reviewer determines the current state
4104 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
4105 for (const r of reviewRows) {
4106 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
4107 }
4108 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
4109 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
4110 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
4111
ec9e3e3Claude4112 // Requested reviewers from CODEOWNERS auto-assign (migration 0077).
4113 const requestedReviewerRows = await db
4114 .select({
4115 reviewerUsername: users.username,
4116 reviewerId: prReviewRequests.reviewerId,
4117 createdAt: prReviewRequests.createdAt,
4118 })
4119 .from(prReviewRequests)
4120 .innerJoin(users, eq(prReviewRequests.reviewerId, users.id))
4121 .where(eq(prReviewRequests.prId, pr.id))
4122 .orderBy(asc(prReviewRequests.createdAt))
4123 .catch(() => [] as { reviewerUsername: string; reviewerId: string; createdAt: Date }[]);
4124
ace34efClaude4125 // Suggested reviewers — best-effort, never throws
4126 let reviewerSuggestions: ReviewerCandidate[] = [];
4127 try {
4128 if (user) {
4129 reviewerSuggestions = await suggestReviewers(
4130 ownerName, repoName, pr.headBranch, pr.baseBranch,
4131 pr.authorId, resolved.repo.id
4132 );
4133 }
4134 } catch {
4135 // silent degradation
4136 }
4137
0074234Claude4138 const canManage =
4139 user &&
4140 (user.id === resolved.owner.id || user.id === pr.authorId);
4141
1d4ff60Claude4142 // Has any previous AI-test-generator run already tagged this PR? Used
4143 // both to hide the "Generate tests with AI" button and to short-circuit
4144 // the explicit POST handler.
4145 const hasAiTestsMarker = comments.some(({ comment }) =>
4146 (comment.body || "").includes(AI_TESTS_MARKER)
4147 );
4148
e883329Claude4149 const error = c.req.query("error");
c3e0c07Claude4150 const info = c.req.query("info");
e883329Claude4151
4152 // Get gate check status for open PRs
4153 let gateChecks: GateCheckResult[] = [];
240c477Claude4154 let ciStatuses: CommitStatus[] = [];
e883329Claude4155 if (pr.state === "open") {
6f1fd83Claude4156 try {
4157 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4158 if (headSha) {
c166384ccantynz-alt4159 const aiApproved = await isAiReviewApproved(pr.id);
6f1fd83Claude4160 const [gateResult, fetchedCiStatuses] = await Promise.all([
4161 runAllGateChecks(
4162 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
4163 ).catch(() => ({ allPassed: false, checks: [] as GateCheckResult[] })),
4164 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
4165 ]);
4166 gateChecks = gateResult.checks;
4167 ciStatuses = fetchedCiStatuses;
4168 }
4169 } catch {
4170 // git repo missing or unreachable — show PR without gate status
e883329Claude4171 }
4172 }
4173
534f04aClaude4174 // Block M3 — pre-merge risk score. Cache-only on the request path so
4175 // the page never waits on Haiku. On a cache miss for an open PR we
4176 // kick off the computation fire-and-forget; the next refresh shows it.
4177 let prRisk: PrRiskScore | null = null;
4178 let prRiskCalculating = false;
4179 if (pr.state === "open") {
4180 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
4181 if (!prRisk) {
4182 prRiskCalculating = true;
a28cedeClaude4183 void computePrRiskForPullRequest(pr.id).catch((err) => {
4184 console.warn(
4185 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
4186 err instanceof Error ? err.message : err
4187 );
4188 });
534f04aClaude4189 }
4190 }
4191
4bbacbeClaude4192 // Migration 0062 — per-branch preview URL. The head branch always
4193 // has a preview row (unless it's the default branch, which never
4194 // happens for an open PR) once it has been pushed at least once.
4195 const preview = await getPreviewForBranch(
4196 (resolved.repo as { id: string }).id,
4197 pr.headBranch
4198 );
4199
0369e77Claude4200 // Branch ahead/behind counts — how many commits head is ahead of base and
4201 // how many commits base has advanced since head branched off.
4202 let branchAhead = 0;
4203 let branchBehind = 0;
4204 if (pr.state === "open") {
4205 try {
4206 const repoDir = getRepoPath(ownerName, repoName);
4207 const [aheadProc, behindProc] = [
4208 Bun.spawn(
4209 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
4210 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4211 ),
4212 Bun.spawn(
4213 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
4214 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4215 ),
4216 ];
4217 const [aheadTxt, behindTxt] = await Promise.all([
4218 new Response(aheadProc.stdout).text(),
4219 new Response(behindProc.stdout).text(),
4220 ]);
4221 await Promise.all([aheadProc.exited, behindProc.exited]);
4222 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
4223 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
4224 } catch { /* non-blocking */ }
4225 }
4226
6d1bbc2Claude4227 // Linked issues — parse closing keywords from PR title+body, look up issues
4228 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
4229 try {
4230 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
4231 const refs = extractClosingRefsMulti([pr.title, pr.body]);
4232 if (refs.length > 0) {
4233 linkedIssues = await db
4234 .select({ number: issues.number, title: issues.title, state: issues.state })
4235 .from(issues)
4236 .where(and(
4237 eq(issues.repositoryId, resolved.repo.id),
4238 inArray(issues.number, refs),
4239 ));
4240 }
4241 } catch { /* non-blocking */ }
4242
4243 // Task list progress — count markdown checkboxes in PR body
4244 let taskTotal = 0;
4245 let taskChecked = 0;
4246 if (pr.body) {
4247 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
4248 taskTotal++;
4249 if (m[1].trim() !== "") taskChecked++;
4250 }
4251 }
4252
74d8c4dClaude4253 // M15 — PR size badge (best-effort, non-blocking)
4254 let prSizeInfo: PrSizeInfo | null = null;
4255 try {
4256 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
4257 } catch { /* swallow — purely cosmetic */ }
4258
09d5f39Claude4259 // Merge impact analysis — only for open PRs with write access (cached, fast)
4260 let impactAnalysis: ImpactAnalysis | null = null;
4261 if (pr.state === "open" && canManage) {
4262 try {
4263 impactAnalysis = await analyzeImpact(resolved.repo.id, pr.id);
4264 } catch { /* non-blocking */ }
4265 }
1d6db4dClaude4266
47a7a0aClaude4267 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude4268 let diffRaw = "";
4269 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude4270 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude4271 if (tab === "files") {
4272 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude4273 // Run the two git diffs in parallel — they're independent reads of
4274 // the same range. Previously sequential, doubling the wall time on
4275 // big PRs (100+ files = 10-30s for no reason).
0074234Claude4276 const proc = Bun.spawn(
4277 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
4278 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4279 );
4280 const statProc = Bun.spawn(
4281 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
4282 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4283 );
6ea2109Claude4284 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
4285 // would otherwise hang the whole request.
4286 const killer = setTimeout(() => {
4287 proc.kill();
4288 statProc.kill();
4289 }, 30_000);
4290 let stat = "";
4291 try {
4292 [diffRaw, stat] = await Promise.all([
4293 new Response(proc.stdout).text(),
4294 new Response(statProc.stdout).text(),
4295 ]);
4296 await Promise.all([proc.exited, statProc.exited]);
4297 } finally {
4298 clearTimeout(killer);
4299 }
0074234Claude4300
4301 diffFiles = stat
4302 .trim()
4303 .split("\n")
4304 .filter(Boolean)
4305 .map((line) => {
4306 const [add, del, filePath] = line.split("\t");
4307 return {
4308 path: filePath,
4309 status: "modified",
4310 additions: add === "-" ? 0 : parseInt(add, 10),
4311 deletions: del === "-" ? 0 : parseInt(del, 10),
4312 patch: "",
4313 };
4314 });
47a7a0aClaude4315
4316 // Fetch inline comments (file+line anchored) for the files tab
4317 const inlineRows = await db
4318 .select({
4319 id: prComments.id,
4320 filePath: prComments.filePath,
4321 lineNumber: prComments.lineNumber,
4322 body: prComments.body,
4323 isAiReview: prComments.isAiReview,
4324 createdAt: prComments.createdAt,
4325 authorUsername: users.username,
4326 })
4327 .from(prComments)
4328 .innerJoin(users, eq(prComments.authorId, users.id))
4329 .where(
4330 and(
4331 eq(prComments.pullRequestId, pr.id),
4332 eq(prComments.moderationStatus, "approved"),
4333 )
4334 )
4335 .orderBy(asc(prComments.createdAt));
4336
4337 diffInlineComments = inlineRows
4338 .filter(r => r.filePath != null && r.lineNumber != null)
4339 .map(r => ({
4340 id: r.id,
4341 filePath: r.filePath!,
4342 lineNumber: r.lineNumber!,
4343 authorUsername: r.authorUsername,
4344 body: renderMarkdown(r.body),
4345 isAiReview: r.isAiReview,
4346 createdAt: r.createdAt.toISOString(),
4347 }));
0074234Claude4348 }
4349
34e63b9Claude4350 // Proactive pattern warning — get changed file paths and check for recurring
4351 // bug patterns. Fire-and-forget safe; returns null on any error or cache miss.
4352 let patternWarning: Pattern | null = null;
4353 try {
4354 const repoDir = getRepoPath(ownerName, repoName);
4355 const nameOnlyProc = Bun.spawn(
4356 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4357 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4358 );
4359 const nameOnlyRaw = await new Response(nameOnlyProc.stdout).text();
4360 await nameOnlyProc.exited;
4361 const prChangedFiles = nameOnlyRaw.trim().split("\n").filter(Boolean);
4362 if (prChangedFiles.length > 0) {
4363 patternWarning = await getPatternWarning(resolved.repo.id, prChangedFiles);
4364 }
4365 } catch {
4366 // Non-blocking — swallow
4367 }
4368
7f992cdClaude4369 // Bus factor warning — flag files dominated by a single author.
4370 let busRiskFiles: BusFactorFile[] = [];
4371 try {
4372 const repoDir2 = getRepoPath(ownerName, repoName);
4373 const bf2Proc = Bun.spawn(
4374 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4375 { cwd: repoDir2, stdout: "pipe", stderr: "pipe" }
4376 );
4377 const bf2Raw = await new Response(bf2Proc.stdout).text();
4378 await bf2Proc.exited;
4379 const bf2Files = bf2Raw.trim().split("\n").filter(Boolean);
4380 if (bf2Files.length > 0) {
4381 busRiskFiles = await getBusFactorWarning(resolved.repo.id, ownerName, repoName, bf2Files);
4382 }
4383 } catch {
4384 // Non-blocking
4385 }
4386
4387 // PR split suggestion — AI recommends sub-PR decomposition for large PRs.
4388 let splitSuggestion: SplitSuggestion | null = null;
4389 try {
4390 splitSuggestion = await suggestPrSplit(
4391 pr.id,
4392 pr.title,
4393 ownerName,
4394 repoName,
4395 pr.baseBranch,
4396 pr.headBranch
4397 );
4398 } catch {
4399 // Non-blocking
4400 }
4401
b078860Claude4402 // ─── Derived visual state ───
4403 const stateKey =
4404 pr.state === "open"
4405 ? pr.isDraft
4406 ? "draft"
4407 : "open"
4408 : pr.state;
4409 const stateLabel =
4410 stateKey === "open"
4411 ? "Open"
4412 : stateKey === "draft"
4413 ? "Draft"
4414 : stateKey === "merged"
4415 ? "Merged"
4416 : "Closed";
4417 const stateIcon =
4418 stateKey === "open"
4419 ? "○"
4420 : stateKey === "draft"
4421 ? "◌"
4422 : stateKey === "merged"
4423 ? "⮌"
4424 : "✓";
4425 const commentCount = comments.length;
4426 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
4427 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
4428 const mergeBlocked =
4429 gateChecks.length > 0 &&
4430 gateChecks.some(
4431 (c) => !c.passed && c.name !== "Merge check"
4432 );
4433
b558f23Claude4434 // Commits tab — list commits included in this PR (base..head range)
4435 let prCommits: GitCommit[] = [];
4436 if (tab === "commits") {
4437 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
4438 }
4439
cc34156Claude4440 // Review context restore — compute BEFORE recording the visit so the
4441 // previous timestamp is available for the delta calculation.
4442 let reviewCtx: ReviewContext | null = null;
4443 if (user) {
4444 reviewCtx = await getReviewContext(pr.id, user.id, {
4445 ownerName,
4446 repoName,
4447 baseBranch: pr.baseBranch,
4448 headBranch: pr.headBranch,
4449 });
4450 // Fire-and-forget: record the visit AFTER computing context
4451 void recordPrVisit(pr.id, user.id);
4452 }
4453
0074234Claude4454 return c.html(
4455 <Layout
4456 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
4457 user={user}
4458 >
4459 <RepoHeader owner={ownerName} repo={repoName} />
4460 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude4461 <PendingCommentsBanner
4462 owner={ownerName}
4463 repo={repoName}
4464 count={prPendingCount}
4465 />
b078860Claude4466 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude4467 <div
4468 id="live-comment-banner"
4469 class="alert"
4470 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
4471 >
4472 <strong class="js-live-count">0</strong> new comment(s) —{" "}
4473 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
4474 reload to view
4475 </a>
4476 </div>
4477 <script
4478 dangerouslySetInnerHTML={{
4479 __html: liveCommentBannerScript({
4480 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
4481 bannerElementId: "live-comment-banner",
4482 }),
4483 }}
4484 />
b078860Claude4485
cc34156Claude4486 {/* Review context restore banner — shown when returning after changes */}
4487 {reviewCtx && (
4488 <div
4489 class="context-restore-banner"
4490 id="review-context"
4491 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"
4492 >
4493 <span class="context-icon" style="font-size:18px;flex-shrink:0;margin-top:2px" aria-hidden="true">{"↩"}</span>
4494 <div style="flex:1;min-width:0">
4495 <strong style="font-size:13.5px;color:var(--text-strong,#111)">Welcome back</strong>
4496 <p style="margin:4px 0 0;font-size:13px;color:var(--text,#333);line-height:1.5">{reviewCtx.summary}</p>
4497 <small style="font-size:11.5px;color:var(--text-muted,#777)">
4498 Last visited {formatRelative(new Date(reviewCtx.lastVisitedAt))}
4499 {reviewCtx.commitsSince > 0 && ` · ${reviewCtx.commitsSince} new commit${reviewCtx.commitsSince === 1 ? "" : "s"}`}
4500 {reviewCtx.newComments > 0 && ` · ${reviewCtx.newComments} new comment${reviewCtx.newComments === 1 ? "" : "s"}`}
4501 </small>
4502 {reviewCtx.suggestedStartLine && (
4503 <p style="margin:6px 0 0;font-size:12px;color:var(--accent,#0070f3)">
4504 Start at: <code style="font-size:11px">{reviewCtx.suggestedStartLine}</code>
4505 </p>
4506 )}
4507 </div>
4508 <button
4509 type="button"
4510 onclick="this.closest('.context-restore-banner').remove()"
4511 style="flex-shrink:0;background:none;border:none;cursor:pointer;font-size:18px;color:var(--text-muted,#777);padding:0;line-height:1"
4512 aria-label="Dismiss"
4513 >
4514 {"×"}
4515 </button>
4516 </div>
4517 )}
4518
b078860Claude4519 <div class="prs-detail-hero">
b558f23Claude4520 <div class="prs-edit-title-wrap">
4521 <h1 class="prs-detail-title" id="pr-title-display">
4522 {pr.title}{" "}
4523 <span class="prs-detail-num">#{pr.number}</span>
4524 </h1>
4525 {canManage && pr.state === "open" && (
4526 <button
4527 type="button"
4528 class="prs-edit-btn"
4529 id="pr-edit-toggle"
4530 onclick={`
4531 document.getElementById('pr-title-display').style.display='none';
4532 document.getElementById('pr-edit-toggle').style.display='none';
4533 document.getElementById('pr-edit-form').style.display='flex';
4534 document.getElementById('pr-title-input').focus();
4535 `}
4536 >
4537 Edit
4538 </button>
4539 )}
4540 </div>
4541 {canManage && pr.state === "open" && (
4542 <form
4543 id="pr-edit-form"
4544 method="post"
4545 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
4546 class="prs-edit-form"
4547 style="display:none"
4548 >
4549 <input
4550 id="pr-title-input"
4551 type="text"
4552 name="title"
4553 value={pr.title}
4554 required
4555 maxlength={256}
4556 placeholder="Pull request title"
4557 />
4558 <div class="prs-edit-actions">
4559 <button type="submit" class="prs-edit-save-btn">Save</button>
4560 <button
4561 type="button"
4562 class="prs-edit-cancel-btn"
4563 onclick={`
4564 document.getElementById('pr-edit-form').style.display='none';
4565 document.getElementById('pr-title-display').style.display='';
4566 document.getElementById('pr-edit-toggle').style.display='';
4567 `}
4568 >
4569 Cancel
4570 </button>
4571 </div>
4572 </form>
4573 )}
b078860Claude4574 <div class="prs-detail-meta">
4575 <span class={`prs-state-pill state-${stateKey}`}>
4576 <span aria-hidden="true">{stateIcon}</span>
4577 <span>{stateLabel}</span>
4578 </span>
2c61840Claude4579 {isAiGeneratedPr(pr.body, pr.headBranch) && (
4580 <span class="prs-tag is-ai" title="This pull request was opened by an AI agent">⚡ AI-generated</span>
4581 )}
74d8c4dClaude4582 {prSizeInfo && (
4583 <span
4584 class="prs-size-badge"
4585 style={`color:${prSizeInfo.color};background:${prSizeInfo.bgColor}`}
4586 title={`${prSizeInfo.linesChanged} lines changed (+${prSizeInfo.added} −${prSizeInfo.deleted})`}
4587 >
4588 {prSizeInfo.label}
4589 </span>
4590 )}
67dc4e1Claude4591 <TrioVerdictPills
4592 comments={comments.map(({ comment }) => comment)}
4593 />
b078860Claude4594 <span>
4595 <strong>{author?.username}</strong> wants to merge
4596 </span>
4597 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
4598 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
4599 <span class="prs-branch-arrow-lg">{"→"}</span>
4600 <span class="prs-branch-pill">{pr.baseBranch}</span>
4601 </span>
0369e77Claude4602 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
4603 <span
4604 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
4605 title={branchBehind > 0
4606 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
4607 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
4608 >
4609 {branchAhead > 0 ? `↑${branchAhead}` : ""}
4610 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
4611 {branchBehind > 0 ? `↓${branchBehind}` : ""}
4612 </span>
4613 )}
b078860Claude4614 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude4615 {taskTotal > 0 && (
4616 <span
4617 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
4618 title={`${taskChecked} of ${taskTotal} tasks completed`}
4619 >
4620 <span class="prs-tasks-progress" aria-hidden="true">
4621 <span
4622 class="prs-tasks-progress-bar"
4623 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
4624 ></span>
4625 </span>
4626 {taskChecked}/{taskTotal} tasks
4627 </span>
4628 )}
4629 {canManage && pr.state === "open" && branchBehind > 0 && (
4630 <form
4631 method="post"
4632 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
4633 class="prs-inline-form"
4634 >
4635 <button
4636 type="submit"
4637 class="prs-update-branch-btn"
4638 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
4639 >
4640 ↑ Update branch
4641 </button>
4642 </form>
4643 )}
3c03977Claude4644 <span
4645 id="live-pill"
4646 class="live-pill"
4647 title="People editing this PR right now"
4648 >
4649 <span class="live-pill-dot" aria-hidden="true"></span>
4650 <span>
4651 Live: <strong id="live-count">0</strong> editing
4652 </span>
4653 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
4654 </span>
4bbacbeClaude4655 {preview && (
4656 <a
4657 class={`preview-prpill is-${preview.status}`}
4658 href={
4659 preview.status === "ready"
4660 ? preview.previewUrl
4661 : `/${ownerName}/${repoName}/previews`
4662 }
4663 target={preview.status === "ready" ? "_blank" : undefined}
4664 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
4665 title={`Preview · ${previewStatusLabel(preview.status)}`}
4666 >
4667 <span class="preview-prpill-dot" aria-hidden="true"></span>
4668 <span>Preview: </span>
4669 <span>{previewStatusLabel(preview.status)}</span>
4670 </a>
4671 )}
b078860Claude4672 {canManage && pr.state === "open" && pr.isDraft && (
4673 <form
4674 method="post"
4675 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4676 class="prs-inline-form prs-detail-actions"
4677 >
4678 <button type="submit" class="prs-merge-ready-btn">
4679 Ready for review
4680 </button>
4681 </form>
4682 )}
4683 </div>
4684 </div>
3c03977Claude4685 <script
4686 dangerouslySetInnerHTML={{
4687 __html: LIVE_COEDIT_SCRIPT(pr.id),
4688 }}
4689 />
829a046Claude4690 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude4691 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude4692 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude4693
25b1ff7Claude4694 {/* Presence styles + bar (shown only on the files tab so cursor pills work) */}
b271465Claude4695 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES + IMPACT_STYLES }} />
25b1ff7Claude4696 {/* Toast container — always present for join/leave toasts */}
4697 <div id="presence-toasts" class="presence-toast-wrap" aria-live="polite" />
4698 {user && (
4699 <>
4700 <div class="presence-bar" id="presence-bar">
4701 <span class="presence-bar-label">Live reviewers</span>
4702 <div class="presence-avatars" id="presence-avatars" />
4703 <span class="presence-count" id="presence-count">Loading…</span>
4704 </div>
4705 <script
4706 dangerouslySetInnerHTML={{
4707 __html: PR_PRESENCE_SCRIPT(ownerName, repoName, pr.number),
4708 }}
4709 />
4710 </>
4711 )}
4712
b078860Claude4713 <nav class="prs-detail-tabs" aria-label="Pull request sections">
4714 <a
4715 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
4716 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
4717 >
4718 Conversation
4719 <span class="prs-detail-tab-count">{commentCount}</span>
4720 </a>
b558f23Claude4721 <a
4722 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
4723 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
4724 >
4725 Commits
4726 {branchAhead > 0 && (
4727 <span class="prs-detail-tab-count">{branchAhead}</span>
4728 )}
4729 </a>
b078860Claude4730 <a
4731 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
4732 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4733 >
4734 Files changed
4735 {diffFiles.length > 0 && (
4736 <span class="prs-detail-tab-count">{diffFiles.length}</span>
4737 )}
4738 </a>
4739 </nav>
4740
34e63b9Claude4741 {/* Proactive pattern warning — shown when a known recurring bug pattern
4742 overlaps with the files changed in this PR. */}
4743 {patternWarning && (
4744 <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">
4745 <span style="font-size:15px;margin-right:6px" aria-hidden="true">⚠️</span>
4746 <strong>Recurring pattern detected: {patternWarning.title}</strong>
4747 <span style="color:var(--fg-muted)">
4748 {" — "}
4749 This area has had {patternWarning.occurrences} similar fix
4750 {patternWarning.occurrences === 1 ? "" : "es"}.
4751 {patternWarning.rootCauseHypothesis && (
4752 <> Root cause may be in <code style="font-size:12px">{patternWarning.suggestedFile}</code>.</>
4753 )}
4754 </span>
4755 </div>
4756 )}
4757
b558f23Claude4758 {tab === "commits" ? (
4759 <div class="prs-commits-list">
4760 {prCommits.length === 0 ? (
4761 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
4762 ) : (
4763 prCommits.map((commit) => (
4764 <div class="prs-commit-row">
4765 <span class="prs-commit-dot" aria-hidden="true"></span>
4766 <div class="prs-commit-body">
4767 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
4768 <div class="prs-commit-meta">
4769 <strong>{commit.author}</strong> committed{" "}
4770 {formatRelative(new Date(commit.date))}
4771 </div>
4772 </div>
4773 <a
4774 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
4775 class="prs-commit-sha"
4776 title="View commit"
4777 >
4778 {commit.sha.slice(0, 7)}
4779 </a>
4780 </div>
4781 ))
4782 )}
4783 </div>
4784 ) : tab === "files" ? (
1d6db4dClaude4785 <>
4786 {/* PR Split Suggestion — shown when PR has >400 changed lines */}
4787 {splitSuggestion && (
4788 <div class="split-suggestion" id="pr-split-banner">
4789 <div class="split-header">
4790 <span class="split-icon" aria-hidden="true">✂️</span>
4791 <strong>This PR may be too large to review effectively</strong>
4792 <span class="split-stat">
4793 {splitSuggestion.totalLines} lines · {splitSuggestion.totalFiles} files
4794 </span>
4795 <button
4796 class="split-toggle"
4797 type="button"
4798 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';"
4799 >
4800 Show split suggestion
4801 </button>
4802 </div>
4803 <div class="split-body" id="pr-split-body" hidden>
4804 <p class="split-intro">
4805 AI suggests splitting into {splitSuggestion.suggestedPrs.length} PRs:
4806 </p>
4807 {splitSuggestion.suggestedPrs.map((sp, i) => (
4808 <div class="split-pr">
4809 <div class="split-pr-num">{i + 1}</div>
4810 <div class="split-pr-body">
4811 <strong>{sp.title}</strong>
4812 <p>{sp.rationale}</p>
4813 <code>{sp.files.join(", ")}</code>
4814 <span class="split-lines">~{sp.estimatedLines} lines</span>
4815 </div>
4816 </div>
4817 ))}
4818 {splitSuggestion.mergeOrder.length > 0 && (
4819 <p class="split-order">
4820 Suggested merge order:{" "}
4821 <strong>{splitSuggestion.mergeOrder.join(" → ")}</strong>
4822 </p>
4823 )}
4824 </div>
4825 </div>
4826 )}
4827
4828 {/* Bus Factor Warning — shown when changed files overlap at-risk files */}
4829 {busRiskFiles.length > 0 && (() => {
4830 const topRisk = busRiskFiles.some((f) => f.risk === "critical")
4831 ? "critical"
4832 : busRiskFiles.some((f) => f.risk === "high")
4833 ? "high"
4834 : "medium";
4835 return (
4836 <div class={`busfactor-panel busfactor-${topRisk}`}>
4837 <span class="busfactor-icon" aria-hidden="true">⚠️</span>
4838 <div class="busfactor-body">
4839 <strong>Knowledge concentration warning</strong>
4840 <p>
4841 {busRiskFiles.length} file{busRiskFiles.length !== 1 ? "s" : ""} in
4842 this PR {busRiskFiles.length !== 1 ? "are" : "is"} primarily
4843 maintained by one person. Consider pairing on this review.
4844 </p>
4845 <ul>
4846 {busRiskFiles.map((f) => (
4847 <li>
4848 <code>{f.path}</code> —{" "}
4849 <strong>{f.primaryAuthorPct}%</strong> by {f.primaryAuthor}
4850 </li>
4851 ))}
4852 </ul>
4853 </div>
4854 </div>
4855 );
4856 })()}
4857
4858 <DiffView
4859 raw={diffRaw}
4860 files={diffFiles}
4861 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
4862 inlineComments={diffInlineComments}
4863 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
4864 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
a2c10c5Claude4865 pendingReviewUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/review/pending/add` : undefined}
4866 submitReviewUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/review/submit` : undefined}
4867 isSplit={isSplit}
4868 pendingCount={pendingCount}
4869 owner={ownerName}
4870 repo={repoName}
4871 prNumber={pr.number}
1d6db4dClaude4872 />
4873 </>
b078860Claude4874 ) : (
4875 <>
4876 {pr.body && (
4877 <CommentBox
4878 author={author?.username ?? "unknown"}
4879 date={pr.createdAt}
4880 body={renderMarkdown(pr.body)}
4881 />
4882 )}
4883
422a2d4Claude4884 {/* Block H — AI trio review (security/correctness/style). When
4885 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
4886 hoisted into a 3-column card grid above the normal comment
4887 stream so reviewers see verdicts at a glance. Disagreements
4888 are surfaced as a yellow callout. */}
4889 <TrioReviewGrid
4890 comments={comments.map(({ comment }) => comment)}
4891 />
4892
15db0e0Claude4893 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude4894 // Skip trio comments — already rendered in TrioReviewGrid above.
4895 if (isTrioComment(comment.body)) return null;
15db0e0Claude4896 const slashCmd = detectSlashCmdComment(comment.body);
4897 if (slashCmd) {
4898 const visible = stripSlashCmdMarker(comment.body);
4899 return (
4900 <div class={`slash-pill slash-cmd-${slashCmd}`}>
4901 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
4902 <span class="slash-pill-actor">
4903 <strong>{commentAuthor.username}</strong>
4904 {" ran "}
4905 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude4906 </span>
15db0e0Claude4907 <span class="slash-pill-time">
4908 {formatRelative(comment.createdAt)}
4909 </span>
4910 <div class="slash-pill-body">
4911 <MarkdownContent html={renderMarkdown(visible)} />
4912 </div>
4913 </div>
4914 );
4915 }
cb5a796Claude4916 const isPending = comment.moderationStatus === "pending";
15db0e0Claude4917 return (
cb5a796Claude4918 <div
4919 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
4920 >
15db0e0Claude4921 <div class="prs-comment-head">
4922 <strong>{commentAuthor.username}</strong>
a7460bfClaude4923 {commentAuthor.username === BOT_USERNAME && (
4924 <span class="prs-bot-badge">&#x1F916; bot</span>
4925 )}
15db0e0Claude4926 {comment.isAiReview && (
4927 <span class="prs-ai-badge">AI Review</span>
4928 )}
cb5a796Claude4929 {isPending && (
4930 <span
4931 class="modq-pending-badge"
4932 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
4933 >
4934 Awaiting approval
4935 </span>
4936 )}
15db0e0Claude4937 <span class="prs-comment-time">
4938 commented {formatRelative(comment.createdAt)}
4939 </span>
4940 {comment.filePath && (
4941 <span class="prs-comment-loc">
4942 {comment.filePath}
4943 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
4944 </span>
4945 )}
4946 </div>
4947 <div class="prs-comment-body">
4948 <MarkdownContent html={renderMarkdown(comment.body)} />
4949 </div>
0074234Claude4950 </div>
15db0e0Claude4951 );
4952 })}
0074234Claude4953
b078860Claude4954 {/* Quick link to the Files changed tab when there's a diff to look at. */}
4955 {pr.state !== "merged" && (
4956 <a
4957 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4958 class="prs-files-card"
4959 >
4960 <span class="prs-files-card-icon" aria-hidden="true">
4961 {"▤"}
4962 </span>
4963 <div class="prs-files-card-text">
4964 <p class="prs-files-card-title">Files changed</p>
4965 <p class="prs-files-card-sub">
4966 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
4967 </p>
e883329Claude4968 </div>
b078860Claude4969 <span class="prs-files-card-cta">View diff {"→"}</span>
4970 </a>
4971 )}
4972
6d1bbc2Claude4973 {linkedIssues.length > 0 && (
4974 <div class="prs-linked-issues">
4975 <div class="prs-linked-issues-head">
4976 <span>Closing issues</span>
4977 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
4978 </div>
4979 {linkedIssues.map((issue) => (
4980 <a
4981 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
4982 class="prs-linked-issue-row"
4983 >
4984 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
4985 {issue.state === "open" ? "○" : "✓"}
4986 </span>
4987 <span class="prs-linked-issue-title">{issue.title}</span>
4988 <span class="prs-linked-issue-num">#{issue.number}</span>
4989 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
4990 {issue.state}
4991 </span>
4992 </a>
4993 ))}
4994 </div>
4995 )}
4996
b078860Claude4997 {error && (
4998 <div
4999 class="auth-error"
5000 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)"
5001 >
5002 {decodeURIComponent(error)}
5003 </div>
5004 )}
5005
5006 {info && (
5007 <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)">
5008 {decodeURIComponent(info)}
5009 </div>
5010 )}
e883329Claude5011
b078860Claude5012 {pr.state === "open" && (prRisk || prRiskCalculating) && (
5013 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
5014 )}
5015
ec9e3e3Claude5016 {/* ─── Requested reviewers (CODEOWNERS auto-assign, migration 0077) ─── */}
5017 {requestedReviewerRows.length > 0 && (
5018 <div class="prs-review-summary" style="margin-top:14px">
5019 <div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-weight:700;margin-bottom:4px">
5020 Review requested
5021 </div>
5022 {requestedReviewerRows.map((rr) => {
5023 const hasReviewed = latestReviewByReviewer.has(rr.reviewerId);
5024 const review = latestReviewByReviewer.get(rr.reviewerId);
5025 const statusIcon = !hasReviewed ? "⏳" : review?.state === "approved" ? "✓" : "✗";
5026 const statusColor = !hasReviewed
5027 ? "var(--text-muted)"
5028 : review?.state === "approved"
5029 ? "#34d399"
5030 : "#f87171";
5031 return (
5032 <div class="prs-review-row" style={`gap:8px`}>
5033 <span class="prs-reviewer-avatar">
5034 {rr.reviewerUsername.slice(0, 1).toUpperCase()}
5035 </span>
5036 <a href={`/${rr.reviewerUsername}`}
5037 style="flex:1;font-size:13px;color:var(--text);font-weight:600;text-decoration:none">
5038 {rr.reviewerUsername}
5039 </a>
5040 <span style={`font-size:12px;font-weight:600;color:${statusColor}`}>
5041 {statusIcon} {!hasReviewed ? "Pending" : review?.state === "approved" ? "Approved" : "Changes requested"}
5042 </span>
5043 </div>
5044 );
5045 })}
5046 </div>
b271465Claude5047 )}
09d5f39Claude5048 {/* ─── Merge Impact Analysis panel ─────────────────────── */}
5049 {impactAnalysis && pr.state === "open" && (
5050 <ImpactPanel analysis={impactAnalysis} owner={ownerName} />
ec9e3e3Claude5051 )}
5052
0a67773Claude5053 {/* ─── Review summary ─────────────────────────────────── */}
5054 {(approvals.length > 0 || changesRequested.length > 0) && (
5055 <div class="prs-review-summary">
5056 {approvals.length > 0 && (
5057 <div class="prs-review-row prs-review-approved">
5058 <span class="prs-review-icon">✓</span>
5059 <span>
5060 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
5061 approved this pull request
5062 </span>
5063 </div>
5064 )}
5065 {changesRequested.length > 0 && (
5066 <div class="prs-review-row prs-review-changes">
5067 <span class="prs-review-icon">✗</span>
5068 <span>
5069 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
5070 requested changes
5071 </span>
5072 </div>
5073 )}
5074 </div>
5075 )}
5076
ace34efClaude5077 {/* Suggested reviewers */}
5078 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
5079 <div class="prs-review-summary" style="margin-top:12px">
5080 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
5081 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
5082 Suggested reviewers
5083 </span>
5084 {reviewerSuggestions.map((r) => (
5085 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
5086 style="display:flex;align-items:center;gap:8px;width:100%">
5087 <input type="hidden" name="reviewerId" value={r.userId} />
5088 <span class="prs-reviewer-avatar">
5089 {r.username.slice(0, 1).toUpperCase()}
5090 </span>
5091 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
5092 {r.username}
5093 </a>
5094 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
5095 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
5096 Request
5097 </button>
5098 </form>
5099 ))}
5100 </div>
5101 </div>
5102 )}
5103
b078860Claude5104 {pr.state === "open" && gateChecks.length > 0 && (
5105 <div class="prs-gate-card">
5106 <div class="prs-gate-head">
5107 <h3>Gate checks</h3>
5108 <span class="prs-gate-summary">
5109 {gatesAllPassed
5110 ? `All ${gateChecks.length} checks passed`
5111 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
5112 </span>
c3e0c07Claude5113 </div>
b078860Claude5114 {gateChecks.map((check) => {
5115 const isAi = /ai.*review/i.test(check.name);
5116 const isSkip = check.skipped === true;
5117 const statusClass = isSkip
5118 ? "is-skip"
5119 : check.passed
5120 ? "is-pass"
5121 : "is-fail";
5122 const statusGlyph = isSkip
5123 ? "—"
5124 : check.passed
5125 ? "✓"
5126 : "✗";
5127 const statusLabel = isSkip
5128 ? "Skipped"
5129 : check.passed
5130 ? "Passed"
5131 : "Failing";
5132 return (
5133 <div
5134 class="prs-gate-row"
5135 style={
5136 isAi
6fd5915Claude5137 ? "border-left: 3px solid rgba(91,110,232,0.55); padding-left: 15px"
b078860Claude5138 : ""
5139 }
5140 >
5141 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
5142 {statusGlyph}
5143 </span>
5144 <span class="prs-gate-name">
5145 {check.name}
5146 {isAi && (
5147 <span
6fd5915Claude5148 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"
b078860Claude5149 >
5150 AI
5151 </span>
5152 )}
5153 </span>
5154 <span class="prs-gate-details">{check.details}</span>
5155 <span class={`prs-gate-pill ${statusClass}`}>
5156 {statusLabel}
e883329Claude5157 </span>
5158 </div>
b078860Claude5159 );
5160 })}
5161 <div class="prs-gate-footer">
5162 {gatesAllPassed
5163 ? "All checks passed — ready to merge."
5164 : gateChecks.some(
5165 (c) => !c.passed && c.name === "Merge check"
5166 )
5167 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
5168 : "Some checks failed — resolve issues before merging."}
5169 {aiReviewCount > 0 && (
5170 <>
5171 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
5172 </>
5173 )}
5174 </div>
5175 </div>
5176 )}
5177
240c477Claude5178 {pr.state === "open" && ciStatuses.length > 0 && (
5179 <div class="prs-ci-card">
5180 <div class="prs-ci-head">
5181 <h3>CI checks</h3>
5182 <span class="prs-ci-summary">
5183 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
5184 </span>
5185 </div>
5186 {ciStatuses.map((status) => {
5187 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
5188 return (
5189 <div class="prs-ci-row">
5190 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
5191 <span class="prs-ci-context">{status.context}</span>
5192 {status.description && (
5193 <span class="prs-ci-desc">{status.description}</span>
5194 )}
5195 <span class={`prs-ci-pill is-${status.state}`}>
5196 {status.state}
5197 </span>
5198 {status.targetUrl && (
5199 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
5200 )}
5201 </div>
5202 );
5203 })}
5204 </div>
5205 )}
5206
b078860Claude5207 {/* ─── Merge area / state-aware action card ─────────────── */}
5208 {user && pr.state === "open" && (
5209 <div
5210 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
5211 >
5212 <div class="prs-merge-head">
5213 <strong>
5214 {pr.isDraft
5215 ? "Draft — ready for review?"
5216 : mergeBlocked
5217 ? "Merge blocked"
5218 : "Ready to merge"}
5219 </strong>
e883329Claude5220 </div>
b078860Claude5221 <p class="prs-merge-sub">
5222 {pr.isDraft
5223 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
5224 : mergeBlocked
5225 ? "Resolve the failing gate checks above before this PR can land."
5226 : gateChecks.length > 0
5227 ? gatesAllPassed
5228 ? "All gates green. Merge will fast-forward into the base branch."
5229 : "Conflicts will be auto-resolved by GlueCron AI on merge."
5230 : "Run gate checks by refreshing once your branch has a recent commit."}
5231 </p>
5232 <Form
5233 method="post"
5234 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
5235 >
5236 <FormGroup>
3c03977Claude5237 <div class="live-cursor-host" style="position:relative">
5238 <textarea
5239 name="body"
5240 id="pr-comment-body"
5241 data-live-field="comment_new"
6cd2f0eClaude5242 data-md-preview=""
3c03977Claude5243 rows={5}
5244 required
5245 placeholder="Leave a comment... (Markdown supported)"
5246 style="font-family:var(--font-mono);font-size:13px;width:100%"
5247 ></textarea>
5248 </div>
15db0e0Claude5249 <span class="slash-hint" title="Type a slash-command as the first line">
5250 Type <code>/</code> for commands —{" "}
5251 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
09d5f39Claude5252 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>,{" "}
5253 <code>/stage</code>
15db0e0Claude5254 </span>
b078860Claude5255 </FormGroup>
5256 <div class="prs-merge-actions">
5257 <Button type="submit" variant="primary">
5258 Comment
5259 </Button>
0a67773Claude5260 {user && user.id !== pr.authorId && pr.state === "open" && (
5261 <>
5262 <button
5263 type="submit"
5264 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
5265 name="review_state"
5266 value="approved"
5267 class="prs-review-approve-btn"
5268 title="Approve this pull request"
5269 >
5270 ✓ Approve
5271 </button>
5272 <button
5273 type="submit"
5274 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
5275 name="review_state"
5276 value="changes_requested"
5277 class="prs-review-changes-btn"
5278 title="Request changes before merging"
5279 >
5280 ✗ Request changes
5281 </button>
5282 </>
5283 )}
b078860Claude5284 {canManage && (
5285 <>
5286 {pr.isDraft ? (
5287 <button
5288 type="submit"
5289 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
5290 formnovalidate
5291 class="prs-merge-ready-btn"
5292 >
5293 Ready for review
5294 </button>
5295 ) : (
a164a6dClaude5296 <>
5297 <div class="prs-merge-strategy-wrap">
5298 <span class="prs-merge-strategy-label">Strategy</span>
5299 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
5300 <option value="merge">Merge commit</option>
5301 <option value="squash">Squash and merge</option>
5302 <option value="ff">Fast-forward</option>
5303 </select>
5304 </div>
5305 <button
5306 type="submit"
5307 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
5308 formnovalidate
5309 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
5310 title={
5311 mergeBlocked
5312 ? "Failing gate checks must be resolved before this PR can merge."
5313 : "Merge pull request"
5314 }
5315 >
5316 {"✔"} Merge pull request
5317 </button>
5318 </>
b078860Claude5319 )}
5320 {!pr.isDraft && (
5321 <button
0074234Claude5322 type="submit"
b078860Claude5323 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
5324 formnovalidate
5325 class="prs-merge-back-draft"
5326 title="Convert back to draft"
0074234Claude5327 >
b078860Claude5328 Convert to draft
5329 </button>
5330 )}
5331 {isAiReviewEnabled() && (
5332 <button
5333 type="submit"
5334 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
5335 formnovalidate
5336 class="btn"
5337 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
5338 >
5339 Re-run AI review
5340 </button>
5341 )}
1d4ff60Claude5342 {isAiReviewEnabled() && !hasAiTestsMarker && (
5343 <button
5344 type="submit"
5345 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
5346 formnovalidate
5347 class="btn"
5348 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."
5349 >
5350 Generate tests with AI
5351 </button>
5352 )}
b078860Claude5353 <Button
5354 type="submit"
5355 variant="danger"
5356 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
5357 >
5358 Close
5359 </Button>
5360 </>
5361 )}
5362 </div>
5363 </Form>
5364 </div>
5365 )}
5366
5367 {/* Read-only footers for non-open states. */}
5368 {pr.state === "merged" && (
5369 <div class="prs-merge-card is-merged">
5370 <div class="prs-merge-head">
5371 <strong>{"⮌"} Merged</strong>
0074234Claude5372 </div>
b078860Claude5373 <p class="prs-merge-sub">
5374 This pull request was merged into{" "}
5375 <code>{pr.baseBranch}</code>.
5376 </p>
5377 </div>
5378 )}
5379 {pr.state === "closed" && (
5380 <div class="prs-merge-card is-closed">
5381 <div class="prs-merge-head">
5382 <strong>{"✕"} Closed without merging</strong>
5383 </div>
5384 <p class="prs-merge-sub">
5385 This pull request was closed and not merged.
5386 </p>
5387 </div>
5388 )}
5389 </>
5390 )}
641aa42Claude5391 {/* Keyboard hint bar — shown at the bottom of PR pages */}
5392 <div class="kbd-hints" aria-label="Keyboard shortcuts for this pull request">
5393 <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
5394 </div>
5395 <style dangerouslySetInnerHTML={{ __html: `
5396 .kbd-hints {
5397 position: fixed;
5398 bottom: 0;
5399 left: 0;
5400 right: 0;
5401 z-index: 90;
5402 padding: 6px 24px;
5403 background: var(--bg-secondary);
5404 border-top: 1px solid var(--border);
5405 font-size: 12px;
5406 color: var(--text-muted);
5407 display: flex;
5408 align-items: center;
5409 gap: 8px;
5410 flex-wrap: wrap;
5411 }
5412 .kbd-hints kbd {
5413 font-family: var(--font-mono);
5414 font-size: 10px;
5415 background: var(--bg-elevated);
5416 border: 1px solid var(--border);
5417 border-bottom-width: 2px;
5418 border-radius: 4px;
5419 padding: 1px 5px;
5420 color: var(--text);
5421 line-height: 1.5;
5422 }
5423 /* Padding so the page footer doesn't overlap the hint bar */
5424 main { padding-bottom: 40px; }
5425 ` }} />
5426 {/* Repo context commands for command palette */}
5427 <script
5428 id="cmdk-repo-context"
5429 dangerouslySetInnerHTML={{
5430 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
5431 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
5432 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
5433 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
5434 { label: `View commits — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/commits`, kw: "history log" },
5435 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
5436 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
5437 ])};`,
5438 }}
5439 />
5440 {/* PR keyboard shortcuts script */}
5441 <script dangerouslySetInnerHTML={{ __html: `
5442 (function(){
5443 var commentBox = document.querySelector('textarea[name="body"]');
5444 var mergeBtn = document.querySelector('[data-merge-btn], .prs-merge-btn, button[form*="merge"], form[action*="/merge"] button[type="submit"]');
5445 var editBtn = document.getElementById('pr-edit-toggle');
5446 var approveUrl = ${JSON.stringify(`/${ownerName}/${repoName}/pulls/${pr.number}/review`)};
5447
5448 function isTyping(t){
5449 t = t || {};
5450 var tag = (t.tagName || '').toLowerCase();
5451 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
5452 }
5453
5454 document.addEventListener('keydown', function(e){
5455 if (isTyping(e.target)) return;
5456 if (e.metaKey || e.ctrlKey || e.altKey) return;
5457 if (e.key === 'c') {
5458 e.preventDefault();
5459 if (commentBox) { commentBox.focus(); commentBox.scrollIntoView({block:'center'}); }
5460 }
5461 if (e.key === 'e') {
5462 e.preventDefault();
5463 if (editBtn) { editBtn.click(); }
5464 }
5465 if (e.key === 'm') {
5466 e.preventDefault();
5467 var mBtn = document.querySelector('.prs-merge-btn, form[action*="/merge"] button[type="submit"]');
5468 if (mBtn) { mBtn.focus(); mBtn.scrollIntoView({block:'center'}); }
5469 }
5470 if (e.key === 'a') {
5471 e.preventDefault();
5472 // Navigate to approve review page
5473 window.location.href = approveUrl + '?action=approve';
5474 }
5475 if (e.key === 'r') {
5476 e.preventDefault();
5477 window.location.href = approveUrl + '?action=request_changes';
5478 }
5479 if (e.key === 'Escape') {
5480 var focused = document.activeElement;
5481 if (focused) focused.blur();
5482 }
5483 });
5484 })();
5485 ` }} />
0074234Claude5486 </Layout>
5487 );
5488});
5489
6d1bbc2Claude5490// Update branch — merge base into head so the PR branch is up to date.
5491// Uses a git worktree so the bare repo stays clean. Write access required.
5492pulls.post(
5493 "/:owner/:repo/pulls/:number/update-branch",
5494 softAuth,
5495 requireAuth,
5496 requireRepoAccess("write"),
5497 async (c) => {
5498 const { owner: ownerName, repo: repoName } = c.req.param();
5499 const prNum = parseInt(c.req.param("number"), 10);
5500 const user = c.get("user")!;
5501 const resolved = await resolveRepo(ownerName, repoName);
5502 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5503
5504 const [pr] = await db
5505 .select()
5506 .from(pullRequests)
5507 .where(and(
5508 eq(pullRequests.repositoryId, resolved.repo.id),
5509 eq(pullRequests.number, prNum),
5510 ))
5511 .limit(1);
5512 if (!pr || pr.state !== "open") {
5513 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5514 }
5515
5516 const repoDir = getRepoPath(ownerName, repoName);
5517 const wt = `${repoDir}/_update_wt_${Date.now()}`;
5518 const gitEnv = {
5519 ...process.env,
5520 GIT_AUTHOR_NAME: user.displayName || user.username,
5521 GIT_AUTHOR_EMAIL: user.email,
5522 GIT_COMMITTER_NAME: user.displayName || user.username,
5523 GIT_COMMITTER_EMAIL: user.email,
5524 };
5525
5526 const addWt = Bun.spawn(
5527 ["git", "worktree", "add", wt, pr.headBranch],
5528 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5529 );
5530 if (await addWt.exited !== 0) {
5531 return c.redirect(
5532 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
5533 );
5534 }
5535
5536 let ok = false;
5537 try {
5538 const mergeProc = Bun.spawn(
5539 ["git", "merge", "--no-edit", pr.baseBranch],
5540 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
5541 );
5542 if (await mergeProc.exited === 0) {
5543 ok = true;
5544 } else {
5545 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5546 }
5547 } catch {
5548 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5549 }
5550
5551 await Bun.spawn(
5552 ["git", "worktree", "remove", "--force", wt],
5553 { cwd: repoDir }
5554 ).exited.catch(() => {});
5555
5556 if (ok) {
5557 return c.redirect(
5558 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
5559 );
5560 }
5561 return c.redirect(
5562 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
5563 );
5564 }
5565);
5566
b558f23Claude5567// Edit PR title (and optionally body). Owner or author only.
5568pulls.post(
5569 "/:owner/:repo/pulls/:number/edit",
5570 softAuth,
5571 requireAuth,
5572 requireRepoAccess("write"),
5573 async (c) => {
5574 const { owner: ownerName, repo: repoName } = c.req.param();
5575 const prNum = parseInt(c.req.param("number"), 10);
5576 const user = c.get("user")!;
5577 const resolved = await resolveRepo(ownerName, repoName);
5578 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5579
5580 const [pr] = await db
5581 .select()
5582 .from(pullRequests)
5583 .where(and(
5584 eq(pullRequests.repositoryId, resolved.repo.id),
5585 eq(pullRequests.number, prNum),
5586 ))
5587 .limit(1);
5588 if (!pr || pr.state !== "open") {
5589 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5590 }
5591 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
5592 if (!canEdit) {
5593 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5594 }
5595
5596 const body = await c.req.parseBody();
5597 const newTitle = String(body.title || "").trim().slice(0, 256);
5598 if (!newTitle) {
5599 return c.redirect(
5600 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
5601 );
5602 }
5603
5604 await db
5605 .update(pullRequests)
5606 .set({ title: newTitle, updatedAt: new Date() })
5607 .where(eq(pullRequests.id, pr.id));
5608
5609 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
5610 }
5611);
5612
cb5a796Claude5613// Add comment to PR.
5614//
5615// Permission model mirrors `issues.tsx`: any logged-in user with read
5616// access can submit; `decideInitialStatus` routes non-collaborators
5617// through the moderation queue. Slash commands only fire when the
5618// comment is auto-approved — we don't want a banned/pending comment to
5619// silently trigger AI work on the PR.
0074234Claude5620pulls.post(
5621 "/:owner/:repo/pulls/:number/comment",
5622 softAuth,
5623 requireAuth,
cb5a796Claude5624 requireRepoAccess("read"),
0074234Claude5625 async (c) => {
5626 const { owner: ownerName, repo: repoName } = c.req.param();
5627 const prNum = parseInt(c.req.param("number"), 10);
5628 const user = c.get("user")!;
5629 const body = await c.req.parseBody();
5630 const commentBody = String(body.body || "").trim();
47a7a0aClaude5631 const filePathRaw = String(body.file_path || "").trim();
5632 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
5633 const inlineFilePath = filePathRaw || undefined;
5634 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude5635
5636 if (!commentBody) {
5637 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5638 }
5639
5640 const resolved = await resolveRepo(ownerName, repoName);
5641 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5642
5643 const [pr] = await db
5644 .select()
5645 .from(pullRequests)
5646 .where(
5647 and(
5648 eq(pullRequests.repositoryId, resolved.repo.id),
5649 eq(pullRequests.number, prNum)
5650 )
5651 )
5652 .limit(1);
5653
5654 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5655
cb5a796Claude5656 const decision = await decideInitialStatus({
5657 commenterUserId: user.id,
5658 repositoryId: resolved.repo.id,
5659 kind: "pr",
5660 threadId: pr.id,
5661 });
5662
d4ac5c3Claude5663 const [inserted] = await db
5664 .insert(prComments)
5665 .values({
5666 pullRequestId: pr.id,
5667 authorId: user.id,
5668 body: commentBody,
cb5a796Claude5669 moderationStatus: decision.status,
47a7a0aClaude5670 filePath: inlineFilePath,
5671 lineNumber: inlineLineNumber,
d4ac5c3Claude5672 })
5673 .returning();
5674
cb5a796Claude5675 // Live update: only when the comment is actually visible.
5676 if (inserted && decision.status === "approved") {
b7b5f75ccanty labs5677 void logActivity({
5678 repositoryId: resolved.repo.id,
5679 userId: user.id,
5680 action: "comment",
5681 targetType: "pull_request",
5682 targetId: String(prNum),
5683 metadata: { commentId: inserted.id },
5684 });
a74f4edccanty labs5685 void fireWebhooks(resolved.repo.id, "pr", {
5686 action: "commented",
5687 number: prNum,
5688 });
b7b5f75ccanty labs5689
d4ac5c3Claude5690 try {
5691 const { publish } = await import("../lib/sse");
5692 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
5693 event: "pr-comment",
5694 data: {
5695 pullRequestId: pr.id,
5696 commentId: inserted.id,
5697 authorId: user.id,
5698 authorUsername: user.username,
5699 },
5700 });
5701 } catch {
5702 /* SSE is best-effort */
5703 }
b7ecb14Claude5704 // Notify the PR author — fire-and-forget, never blocks the response.
5705 if (pr.authorId && pr.authorId !== user.id) {
5706 void import("../lib/notify").then(({ createNotification }) =>
5707 createNotification({
5708 userId: pr.authorId,
5709 type: "pr_comment",
5710 title: `New comment on "${pr.title}"`,
5711 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
5712 url: `/${ownerName}/${repoName}/pulls/${prNum}`,
5713 repoId: resolved.repo.id,
5714 })
5715 ).catch(() => { /* never block the response */ });
5716 }
d4ac5c3Claude5717 }
0074234Claude5718
cb5a796Claude5719 if (decision.status === "pending") {
5720 void notifyOwnerOfPendingComment({
5721 repositoryId: resolved.repo.id,
5722 commenterUsername: user.username,
5723 kind: "pr",
5724 threadNumber: prNum,
5725 ownerUsername: ownerName,
5726 repoName,
5727 });
5728 return c.redirect(
5729 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5730 );
5731 }
5732 if (decision.status === "rejected") {
5733 // Silent ban path — same UX as 'pending' so we don't leak the gate.
5734 return c.redirect(
5735 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5736 );
5737 }
5738
15db0e0Claude5739 // Slash-command handoff. We always store the original comment above
5740 // first so free-form text that happens to start with `/` is preserved
5741 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude5742 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude5743 const parsed = parseSlashCommand(commentBody);
5744 if (parsed) {
5745 try {
5746 const result = await executeSlashCommand({
5747 command: parsed.command,
5748 args: parsed.args,
5749 prId: pr.id,
5750 userId: user.id,
5751 repositoryId: resolved.repo.id,
5752 });
5753 await db.insert(prComments).values({
5754 pullRequestId: pr.id,
5755 authorId: user.id,
5756 body: result.body,
5757 });
5758 } catch (err) {
5759 // Defence-in-depth — executeSlashCommand promises not to throw,
5760 // but if it ever does we want the PR thread to know.
5761 await db
5762 .insert(prComments)
5763 .values({
5764 pullRequestId: pr.id,
5765 authorId: user.id,
5766 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
5767 })
5768 .catch(() => {});
5769 }
5770 }
5771
47a7a0aClaude5772 // Inline comments go back to the files tab; conversation comments to the conversation tab
5773 const redirectTab = inlineFilePath ? "?tab=files" : "";
5774 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude5775 }
5776);
5777
a2c10c5Claude5778// ─── Batched PR review workflow ─────────────────────────────────────────────
5779// Reviewers can stage multiple inline comments in a pending_reviews session,
5780// then submit them all at once as an Approve / Request Changes / Comment review.
5781
5782// GET /:owner/:repo/pulls/:number/review/pending
5783// Returns {count, comments} for the current user's pending review session.
5784pulls.get(
5785 "/:owner/:repo/pulls/:number/review/pending",
5786 softAuth,
5787 requireAuth,
5788 requireRepoAccess("read"),
5789 async (c) => {
5790 const { owner: ownerName, repo: repoName } = c.req.param();
5791 const prNum = parseInt(c.req.param("number"), 10);
5792 const user = c.get("user")!;
5793
5794 const resolved = await resolveRepo(ownerName, repoName);
5795 if (!resolved) return c.json({ count: 0, comments: [] });
5796
5797 const [pr] = await db
5798 .select()
5799 .from(pullRequests)
5800 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5801 .limit(1);
5802 if (!pr) return c.json({ count: 0, comments: [] });
5803
5804 const [review] = await db
5805 .select()
5806 .from(pendingReviews)
5807 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5808 .limit(1);
5809
5810 if (!review) return c.json({ count: 0, comments: [] });
5811
5812 const comments = await db
5813 .select({ id: pendingReviewComments.id, filePath: pendingReviewComments.filePath, lineNumber: pendingReviewComments.lineNumber, body: pendingReviewComments.body })
5814 .from(pendingReviewComments)
5815 .where(eq(pendingReviewComments.reviewId, review.id))
5816 .orderBy(asc(pendingReviewComments.createdAt));
5817
5818 return c.json({ count: comments.length, comments });
5819 }
5820);
5821
5822// POST /:owner/:repo/pulls/:number/review/pending/add
5823// Adds a comment to the current user's pending review (creates session if needed).
5824pulls.post(
5825 "/:owner/:repo/pulls/:number/review/pending/add",
5826 softAuth,
5827 requireAuth,
5828 requireRepoAccess("read"),
5829 async (c) => {
5830 const { owner: ownerName, repo: repoName } = c.req.param();
5831 const prNum = parseInt(c.req.param("number"), 10);
5832 const user = c.get("user")!;
5833 const body = await c.req.parseBody();
5834 const filePath = String(body.filePath || "").trim();
5835 const lineNumber = parseInt(String(body.lineNumber || ""), 10);
5836 const commentBody = String(body.body || "").trim();
5837
5838 if (!filePath || !Number.isFinite(lineNumber) || lineNumber <= 0 || !commentBody) {
5839 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5840 }
5841
5842 const resolved = await resolveRepo(ownerName, repoName);
5843 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5844
5845 const [pr] = await db
5846 .select()
5847 .from(pullRequests)
5848 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5849 .limit(1);
5850 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5851
5852 // Upsert the pending_reviews session row
5853 let [review] = await db
5854 .select()
5855 .from(pendingReviews)
5856 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5857 .limit(1);
5858
5859 if (!review) {
5860 [review] = await db
5861 .insert(pendingReviews)
5862 .values({ prId: pr.id, authorId: user.id })
5863 .onConflictDoNothing()
5864 .returning();
5865 // If onConflictDoNothing returned nothing (race), fetch again
5866 if (!review) {
5867 [review] = await db
5868 .select()
5869 .from(pendingReviews)
5870 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5871 .limit(1);
5872 }
5873 }
5874
5875 if (!review) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5876
5877 await db.insert(pendingReviewComments).values({
5878 reviewId: review.id,
5879 filePath,
5880 lineNumber,
5881 body: commentBody,
5882 });
5883
5884 // Count total pending comments for this review
5885 const countRows = await db
5886 .select({ id: pendingReviewComments.id })
5887 .from(pendingReviewComments)
5888 .where(eq(pendingReviewComments.reviewId, review.id));
5889
5890 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files&pending=${countRows.length}`);
5891 }
5892);
5893
5894// POST /:owner/:repo/pulls/:number/review/pending/:commentId/delete
5895// Removes a single comment from the pending review session.
5896pulls.post(
5897 "/:owner/:repo/pulls/:number/review/pending/:commentId/delete",
5898 softAuth,
5899 requireAuth,
5900 requireRepoAccess("read"),
5901 async (c) => {
5902 const { owner: ownerName, repo: repoName, commentId } = c.req.param();
5903 const prNum = parseInt(c.req.param("number"), 10);
5904 const user = c.get("user")!;
5905
5906 const resolved = await resolveRepo(ownerName, repoName);
5907 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5908
5909 const [pr] = await db
5910 .select()
5911 .from(pullRequests)
5912 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5913 .limit(1);
5914 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5915
5916 // Verify ownership via the review session
5917 const [review] = await db
5918 .select()
5919 .from(pendingReviews)
5920 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5921 .limit(1);
5922
5923 if (review) {
5924 await db
5925 .delete(pendingReviewComments)
5926 .where(and(eq(pendingReviewComments.id, commentId), eq(pendingReviewComments.reviewId, review.id)));
5927 }
5928
5929 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5930 }
5931);
5932
5933// POST /:owner/:repo/pulls/:number/review/submit
5934// Submits the pending review: posts all staged comments + a formal review state.
5935pulls.post(
5936 "/:owner/:repo/pulls/:number/review/submit",
5937 softAuth,
5938 requireAuth,
5939 requireRepoAccess("read"),
5940 async (c) => {
5941 const { owner: ownerName, repo: repoName } = c.req.param();
5942 const prNum = parseInt(c.req.param("number"), 10);
5943 const user = c.get("user")!;
5944 const body = await c.req.parseBody();
5945 const reviewBody = String(body.reviewBody || "").trim();
5946 const reviewStateRaw = String(body.reviewState || "comment");
5947
5948 const reviewState =
5949 reviewStateRaw === "approve"
5950 ? "approved"
5951 : reviewStateRaw === "request_changes"
5952 ? "changes_requested"
5953 : "commented";
5954
5955 const resolved = await resolveRepo(ownerName, repoName);
5956 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5957
5958 const [pr] = await db
5959 .select()
5960 .from(pullRequests)
5961 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5962 .limit(1);
5963 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5964
5965 const [review] = await db
5966 .select()
5967 .from(pendingReviews)
5968 .where(and(eq(pendingReviews.prId, pr.id), eq(pendingReviews.authorId, user.id)))
5969 .limit(1);
5970
5971 if (review) {
5972 const pendingComments = await db
5973 .select()
5974 .from(pendingReviewComments)
5975 .where(eq(pendingReviewComments.reviewId, review.id))
5976 .orderBy(asc(pendingReviewComments.createdAt));
5977
5978 // Post each pending comment as a real pr_comment
5979 for (const pc of pendingComments) {
5980 await db.insert(prComments).values({
5981 pullRequestId: pr.id,
5982 authorId: user.id,
5983 body: pc.body,
5984 filePath: pc.filePath,
5985 lineNumber: pc.lineNumber,
5986 isAiReview: false,
5987 });
5988 }
5989
5990 // Delete the pending review session (cascades to comments)
5991 await db.delete(pendingReviews).where(eq(pendingReviews.id, review.id));
5992 }
5993
5994 // Insert the formal review record
5995 await db.insert(prReviews).values({
5996 pullRequestId: pr.id,
5997 reviewerId: user.id,
5998 state: reviewState,
5999 body: reviewBody || null,
6000 isAi: false,
6001 });
6002
6003 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=conversation`);
6004 }
6005);
6006
b5dd694Claude6007// Apply a suggestion from a PR comment — commits the suggested code to the
6008// head branch on behalf of the logged-in user.
6009pulls.post(
6010 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
6011 softAuth,
6012 requireAuth,
6013 requireRepoAccess("read"),
6014 async (c) => {
6015 const { owner: ownerName, repo: repoName } = c.req.param();
6016 const prNum = parseInt(c.req.param("number"), 10);
6017 const commentId = c.req.param("commentId"); // UUID
6018 const user = c.get("user")!;
6019
6020 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
6021
6022 const resolved = await resolveRepo(ownerName, repoName);
6023 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6024
6025 const [pr] = await db
6026 .select()
6027 .from(pullRequests)
6028 .where(
6029 and(
6030 eq(pullRequests.repositoryId, resolved.repo.id),
6031 eq(pullRequests.number, prNum)
6032 )
6033 )
6034 .limit(1);
6035
6036 if (!pr || pr.state !== "open") {
6037 return c.redirect(`${backUrl}&error=pr_not_open`);
6038 }
6039
6040 // Only PR author or repo owner may apply suggestions.
6041 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
6042 return c.redirect(`${backUrl}&error=forbidden`);
6043 }
6044
6045 // Load the comment.
6046 const [comment] = await db
6047 .select()
6048 .from(prComments)
6049 .where(
6050 and(
6051 eq(prComments.id, commentId),
6052 eq(prComments.pullRequestId, pr.id)
6053 )
6054 )
6055 .limit(1);
6056
6057 if (!comment) {
6058 return c.redirect(`${backUrl}&error=comment_not_found`);
6059 }
6060
6061 // Parse suggestion block from comment body.
6062 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
6063 if (!m) {
6064 return c.redirect(`${backUrl}&error=no_suggestion`);
6065 }
6066 const suggestionCode = m[1];
6067
6068 // Get the commenter's details for the commit message co-author line.
6069 const [commenter] = await db
6070 .select()
6071 .from(users)
6072 .where(eq(users.id, comment.authorId))
6073 .limit(1);
6074
6075 // Fetch current file content from head branch.
6076 if (!comment.filePath) {
6077 return c.redirect(`${backUrl}&error=file_not_found`);
6078 }
6079 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
6080 if (!blob) {
6081 return c.redirect(`${backUrl}&error=file_not_found`);
6082 }
6083
6084 // Apply the patch — replace the target line(s) with suggestion lines.
6085 const lines = blob.content.split('\n');
6086 const lineIdx = (comment.lineNumber ?? 1) - 1;
6087 if (lineIdx < 0 || lineIdx >= lines.length) {
6088 return c.redirect(`${backUrl}&error=line_out_of_range`);
6089 }
6090 const suggestionLines = suggestionCode.split('\n');
6091 lines.splice(lineIdx, 1, ...suggestionLines);
6092 const newContent = lines.join('\n');
6093
6094 // Commit the change.
6095 const coAuthorLine = commenter
6096 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
6097 : "";
6098 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
6099
6100 const result = await createOrUpdateFileOnBranch({
6101 owner: ownerName,
6102 name: repoName,
6103 branch: pr.headBranch,
6104 filePath: comment.filePath,
6105 bytes: new TextEncoder().encode(newContent),
6106 message: commitMessage,
6107 authorName: user.username,
6108 authorEmail: `${user.username}@users.noreply.gluecron.com`,
6109 });
6110
6111 if ("error" in result) {
6112 return c.redirect(`${backUrl}&error=apply_failed`);
6113 }
6114
6115 // Post a follow-up comment noting the suggestion was applied.
6116 await db.insert(prComments).values({
6117 pullRequestId: pr.id,
6118 authorId: user.id,
6119 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
6120 });
6121
6122 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
6123 }
6124);
6125
0a67773Claude6126// Formal review — Approve / Request Changes / Comment
6127pulls.post(
6128 "/:owner/:repo/pulls/:number/review",
6129 softAuth,
6130 requireAuth,
6131 requireRepoAccess("read"),
6132 async (c) => {
6133 const { owner: ownerName, repo: repoName } = c.req.param();
6134 const prNum = parseInt(c.req.param("number"), 10);
6135 const user = c.get("user")!;
6136 const body = await c.req.parseBody();
6137 const reviewBody = String(body.body || "").trim();
6138 const reviewState = String(body.review_state || "commented");
6139
6140 const validStates = ["approved", "changes_requested", "commented"];
6141 if (!validStates.includes(reviewState)) {
6142 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6143 }
6144
6145 const resolved = await resolveRepo(ownerName, repoName);
6146 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6147
6148 const [pr] = await db
6149 .select()
6150 .from(pullRequests)
6151 .where(
6152 and(
6153 eq(pullRequests.repositoryId, resolved.repo.id),
6154 eq(pullRequests.number, prNum)
6155 )
6156 )
6157 .limit(1);
6158 if (!pr || pr.state !== "open") {
6159 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6160 }
6161 // Authors can't review their own PR
6162 if (pr.authorId === user.id) {
6163 return c.redirect(
6164 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
6165 );
6166 }
6167
6168 await db.insert(prReviews).values({
6169 pullRequestId: pr.id,
6170 reviewerId: user.id,
6171 state: reviewState,
6172 body: reviewBody || null,
6173 });
6174
6175 const stateLabel =
6176 reviewState === "approved" ? "Approved"
6177 : reviewState === "changes_requested" ? "Changes requested"
6178 : "Commented";
6179 return c.redirect(
6180 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
6181 );
6182 }
6183);
6184
e883329Claude6185// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude6186// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
6187// but we keep it at "write" for v1 so trusted collaborators can ship.
6188// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
6189// surface. Branch-protection rules (evaluated below) are the current mechanism
6190// for locking down merges further on specific branches.
0074234Claude6191pulls.post(
6192 "/:owner/:repo/pulls/:number/merge",
6193 softAuth,
6194 requireAuth,
04f6b7fClaude6195 requireRepoAccess("write"),
0074234Claude6196 async (c) => {
6197 const { owner: ownerName, repo: repoName } = c.req.param();
6198 const prNum = parseInt(c.req.param("number"), 10);
6199 const user = c.get("user")!;
6200
a164a6dClaude6201 // Read merge strategy from form (default: merge commit)
6202 let mergeStrategy = "merge";
6203 try {
6204 const body = await c.req.parseBody();
6205 const s = body.merge_strategy;
6206 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
6207 } catch { /* ignore parse errors — default to merge commit */ }
6208
0074234Claude6209 const resolved = await resolveRepo(ownerName, repoName);
6210 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6211
6212 const [pr] = await db
6213 .select()
6214 .from(pullRequests)
6215 .where(
6216 and(
6217 eq(pullRequests.repositoryId, resolved.repo.id),
6218 eq(pullRequests.number, prNum)
6219 )
6220 )
6221 .limit(1);
6222
6223 if (!pr || pr.state !== "open") {
6224 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6225 }
6226
6fc53bdClaude6227 // Draft PRs cannot be merged — must be marked ready first.
6228 if (pr.isDraft) {
6229 return c.redirect(
6230 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6231 "This PR is a draft. Mark it as ready for review before merging."
6232 )}`
6233 );
6234 }
6235
ec9e3e3Claude6236 // Required reviews check — branch-protection `required_approvals` gate.
6237 // Evaluated before running expensive gate checks so the feedback is fast.
6238 {
6239 const eligibility = await checkMergeEligible(pr.id, resolved.repo.id, pr.baseBranch);
6240 if (!eligibility.eligible && eligibility.reason) {
6241 return c.redirect(
6242 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(eligibility.reason)}`
6243 );
6244 }
6245 }
6246
e883329Claude6247 // Resolve head SHA
6248 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
6249 if (!headSha) {
6250 return c.redirect(
6251 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
6252 );
6253 }
6254
58c39f5Claude6255 // Check if AI review approved this PR.
6256 // The AI summary comment body uses:
6257 // "**AI review:** no blocking issues found." → approved
6258 // "**AI review:** flagged N item(s)..." → not approved
6259 // "severity: blocking" → explicit blocking (future)
6260 // If no AI comments exist yet, treat as approved (gate hasn't run).
c166384ccantynz-alt6261 const aiApproved = await isAiReviewApproved(pr.id);
e883329Claude6262
6263 // Run all green gate checks (GateTest + mergeability + AI review)
6264 const gateResult = await runAllGateChecks(
6265 ownerName,
6266 repoName,
6267 pr.baseBranch,
6268 pr.headBranch,
6269 headSha,
6270 aiApproved
0074234Claude6271 );
6272
e883329Claude6273 // If GateTest or AI review failed (hard blocks), reject the merge
6274 const hardFailures = gateResult.checks.filter(
6275 (check) => !check.passed && check.name !== "Merge check"
6276 );
6277 if (hardFailures.length > 0) {
6278 const errorMsg = hardFailures
6279 .map((f) => `${f.name}: ${f.details}`)
6280 .join("; ");
0074234Claude6281 return c.redirect(
e883329Claude6282 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude6283 );
6284 }
6285
1e162a8Claude6286 // D5 — Branch-protection enforcement. Looks up the matching rule for the
6287 // base branch and blocks the merge if requireAiApproval / requireGreenGates
6288 // / requireHumanReview / requiredApprovals are not satisfied. Independent
6289 // of repo-global settings, so owners can lock specific branches down
6290 // further than the repo default.
6291 const protectionRule = await matchProtection(
6292 resolved.repo.id,
6293 pr.baseBranch
6294 );
6295 if (protectionRule) {
6296 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude6297 const required = await listRequiredChecks(protectionRule.id);
6298 const passingNames = required.length > 0
6299 ? await passingCheckNames(resolved.repo.id, headSha)
6300 : [];
6301 const decision = evaluateProtection(
6302 protectionRule,
6303 {
6304 aiApproved,
6305 humanApprovalCount: humanApprovals,
6306 gateResultGreen: hardFailures.length === 0,
6307 hasFailedGates: hardFailures.length > 0,
6308 passingCheckNames: passingNames,
6309 },
6310 required.map((r) => r.checkName)
6311 );
91b054eccanty labs6312
6313 // CODEOWNERS enforcement — additive to evaluateProtection(), only
6314 // when the rule already requires human review at all (no new DB
6315 // column for this pass). Fail-open on any internal error: a bug here
6316 // must never hard-block every merge platform-wide.
6317 if (protectionRule.requireHumanReview || protectionRule.requiredApprovals > 0) {
6318 try {
6319 const codeownersDiffProc = Bun.spawn(
6320 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
6321 { cwd: getRepoPath(ownerName, repoName), stdout: "pipe", stderr: "pipe" }
6322 );
6323 const codeownersDiffRaw = await new Response(codeownersDiffProc.stdout).text();
6324 await codeownersDiffProc.exited;
6325 const changedPaths = codeownersDiffRaw.trim().split("\n").filter(Boolean);
6326 if (changedPaths.length > 0) {
6327 const { satisfied, missingOwners } = await requiredOwnersApproved(
6328 ownerName,
6329 repoName,
6330 resolved.repo.defaultBranch,
6331 pr.id,
6332 changedPaths
6333 );
6334 if (!satisfied) {
6335 decision.allowed = false;
6336 decision.reasons.push(
6337 `Branch protection '${protectionRule.pattern}' requires CODEOWNERS approval from: ${missingOwners.join(", ")}.`
6338 );
6339 }
6340 }
6341 } catch (err) {
6342 console.warn(
6343 "[codeowners] merge enforcement failed:",
6344 err instanceof Error ? err.message : err
6345 );
6346 }
6347 }
6348
1e162a8Claude6349 if (!decision.allowed) {
6350 return c.redirect(
6351 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6352 decision.reasons.join(" ")
6353 )}`
6354 );
6355 }
6356 }
6357
e883329Claude6358 // Attempt the merge — with auto conflict resolution if needed
6359 const repoDir = getRepoPath(ownerName, repoName);
6360 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
6361 const hasConflicts = mergeCheck && !mergeCheck.passed;
6362
6363 if (hasConflicts && isAiReviewEnabled()) {
6364 // Use Claude to auto-resolve conflicts
6365 const mergeResult = await mergeWithAutoResolve(
6366 ownerName,
6367 repoName,
6368 pr.baseBranch,
6369 pr.headBranch,
6370 `Merge pull request #${pr.number}: ${pr.title}`
6371 );
6372
6373 if (!mergeResult.success) {
6374 return c.redirect(
6375 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
6376 );
6377 }
6378
6379 // Post a comment about the auto-resolution
6380 if (mergeResult.resolvedFiles.length > 0) {
6381 await db.insert(prComments).values({
6382 pullRequestId: pr.id,
6383 authorId: user.id,
6384 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
6385 isAiReview: true,
6386 });
6387 }
6388 } else {
a164a6dClaude6389 // Worktree-based merge: supports merge-commit, squash, and fast-forward
6390 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
6391 const gitEnv = {
6392 ...process.env,
6393 GIT_AUTHOR_NAME: user.displayName || user.username,
6394 GIT_AUTHOR_EMAIL: user.email,
6395 GIT_COMMITTER_NAME: user.displayName || user.username,
6396 GIT_COMMITTER_EMAIL: user.email,
6397 };
6398
6399 // Create linked worktree on the base branch
6400 const addWt = Bun.spawn(
6401 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude6402 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
6403 );
a164a6dClaude6404 if (await addWt.exited !== 0) {
6405 return c.redirect(
6406 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
6407 );
6408 }
6409
6410 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
6411 let mergeOk = false;
6412
6413 try {
6414 if (mergeStrategy === "squash") {
6415 // Squash: stage all changes without committing
6416 const squashProc = Bun.spawn(
6417 ["git", "merge", "--squash", headSha],
6418 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6419 );
6420 if (await squashProc.exited !== 0) {
6421 const errTxt = await new Response(squashProc.stderr).text();
6422 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
6423 }
6424 // Commit the squashed changes
6425 const commitProc = Bun.spawn(
6426 ["git", "commit", "-m", commitMsg],
6427 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6428 );
6429 if (await commitProc.exited !== 0) {
6430 const errTxt = await new Response(commitProc.stderr).text();
6431 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
6432 }
6433 mergeOk = true;
6434 } else if (mergeStrategy === "ff") {
6435 // Fast-forward only — fail if FF is not possible
6436 const ffProc = Bun.spawn(
6437 ["git", "merge", "--ff-only", headSha],
6438 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6439 );
6440 if (await ffProc.exited !== 0) {
6441 const errTxt = await new Response(ffProc.stderr).text();
6442 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
6443 }
6444 mergeOk = true;
6445 } else {
6446 // Default: merge commit (--no-ff always creates a merge commit)
6447 const mergeProc = Bun.spawn(
6448 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
6449 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
6450 );
6451 if (await mergeProc.exited !== 0) {
6452 const errTxt = await new Response(mergeProc.stderr).text();
6453 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
6454 }
6455 mergeOk = true;
6456 }
6457 } catch (err) {
6458 // Always clean up the worktree before redirecting
6459 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
6460 const msg = err instanceof Error ? err.message : "Merge failed";
6461 return c.redirect(
6462 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
6463 );
6464 }
6465
6466 // Clean up worktree (changes are now in the bare repo via linked worktree)
6467 await Bun.spawn(
6468 ["git", "worktree", "remove", "--force", wt],
6469 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
6470 ).exited.catch(() => {});
e883329Claude6471
a164a6dClaude6472 if (!mergeOk) {
e883329Claude6473 return c.redirect(
a164a6dClaude6474 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude6475 );
6476 }
6477 }
6478
0074234Claude6479 await db
6480 .update(pullRequests)
6481 .set({
6482 state: "merged",
6483 mergedAt: new Date(),
6484 mergedBy: user.id,
6485 updatedAt: new Date(),
6486 })
6487 .where(eq(pullRequests.id, pr.id));
6488
b7b5f75ccanty labs6489 void logActivity({
6490 repositoryId: resolved.repo.id,
6491 userId: user.id,
6492 action: "pr_merge",
6493 targetType: "pull_request",
6494 targetId: String(pr.number),
6495 metadata: { baseBranch: pr.baseBranch, headBranch: pr.headBranch, mergeStrategy },
6496 });
a74f4edccanty labs6497 void fireWebhooks(resolved.repo.id, "pr", {
6498 action: "merged",
6499 number: pr.number,
6500 mergeStrategy,
6501 });
b7b5f75ccanty labs6502
8809b87Claude6503 // Chat notifier — fan out merge event to Slack/Discord/Teams.
6504 import("../lib/chat-notifier")
6505 .then((m) =>
6506 m.notifyChatChannels({
6507 ownerUserId: resolved.repo.ownerId,
6508 repositoryId: resolved.repo.id,
6509 event: {
6510 event: "pr.merged",
6511 repo: `${ownerName}/${repoName}`,
6512 title: `#${pr.number} ${pr.title}`,
6513 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
6514 actor: user.username,
6515 },
6516 })
6517 )
6518 .catch((err) =>
6519 console.warn(`[chat-notifier] PR merge notify failed:`, err)
6520 );
6521
d62fb36Claude6522 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
6523 // and auto-close each matching open issue with a back-link comment. Bounded
6524 // to the same repo for v1 (cross-repo refs ignored). Failures never block
6525 // the merge redirect.
6526 try {
6527 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
6528 const refs = extractClosingRefsMulti([pr.title, pr.body]);
6529 for (const n of refs) {
6530 const [issue] = await db
6531 .select()
6532 .from(issues)
6533 .where(
6534 and(
6535 eq(issues.repositoryId, resolved.repo.id),
6536 eq(issues.number, n)
6537 )
6538 )
6539 .limit(1);
6540 if (!issue || issue.state !== "open") continue;
6541 await db
6542 .update(issues)
6543 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
6544 .where(eq(issues.id, issue.id));
6545 await db.insert(issueComments).values({
6546 issueId: issue.id,
6547 authorId: user.id,
6548 body: `Closed by pull request #${pr.number}.`,
6549 });
6550 }
6551 } catch {
6552 // Never block the merge on close-keyword failures.
6553 }
6554
0074234Claude6555 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6556 }
6557);
6558
6fc53bdClaude6559// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
6560// hasn't run yet on this PR.
6561pulls.post(
6562 "/:owner/:repo/pulls/:number/ready",
6563 softAuth,
6564 requireAuth,
04f6b7fClaude6565 requireRepoAccess("write"),
6fc53bdClaude6566 async (c) => {
6567 const { owner: ownerName, repo: repoName } = c.req.param();
6568 const prNum = parseInt(c.req.param("number"), 10);
6569 const user = c.get("user")!;
6570
6571 const resolved = await resolveRepo(ownerName, repoName);
6572 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6573
6574 const [pr] = await db
6575 .select()
6576 .from(pullRequests)
6577 .where(
6578 and(
6579 eq(pullRequests.repositoryId, resolved.repo.id),
6580 eq(pullRequests.number, prNum)
6581 )
6582 )
6583 .limit(1);
6584 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6585
6586 // Only the author or repo owner can toggle draft state.
6587 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
6588 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6589 }
6590
6591 if (pr.state === "open" && pr.isDraft) {
6592 await db
6593 .update(pullRequests)
6594 .set({ isDraft: false, updatedAt: new Date() })
6595 .where(eq(pullRequests.id, pr.id));
6596
6597 if (isAiReviewEnabled()) {
6598 triggerAiReview(
6599 ownerName,
6600 repoName,
6601 pr.id,
6602 pr.title,
0316dbbClaude6603 pr.body || "",
6fc53bdClaude6604 pr.baseBranch,
6605 pr.headBranch
6606 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
6607 }
6608 }
6609
6610 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6611 }
6612);
6613
6614// Convert a PR back to draft.
6615pulls.post(
6616 "/:owner/:repo/pulls/:number/draft",
6617 softAuth,
6618 requireAuth,
04f6b7fClaude6619 requireRepoAccess("write"),
6fc53bdClaude6620 async (c) => {
6621 const { owner: ownerName, repo: repoName } = c.req.param();
6622 const prNum = parseInt(c.req.param("number"), 10);
6623 const user = c.get("user")!;
6624
6625 const resolved = await resolveRepo(ownerName, repoName);
6626 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6627
6628 const [pr] = await db
6629 .select()
6630 .from(pullRequests)
6631 .where(
6632 and(
6633 eq(pullRequests.repositoryId, resolved.repo.id),
6634 eq(pullRequests.number, prNum)
6635 )
6636 )
6637 .limit(1);
6638 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6639
6640 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
6641 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6642 }
6643
6644 if (pr.state === "open" && !pr.isDraft) {
6645 await db
6646 .update(pullRequests)
6647 .set({ isDraft: true, updatedAt: new Date() })
6648 .where(eq(pullRequests.id, pr.id));
6649 }
6650
6651 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6652 }
6653);
6654
0074234Claude6655// Close PR
6656pulls.post(
6657 "/:owner/:repo/pulls/:number/close",
6658 softAuth,
6659 requireAuth,
04f6b7fClaude6660 requireRepoAccess("write"),
0074234Claude6661 async (c) => {
6662 const { owner: ownerName, repo: repoName } = c.req.param();
6663 const prNum = parseInt(c.req.param("number"), 10);
6664
6665 const resolved = await resolveRepo(ownerName, repoName);
6666 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6667
6668 await db
6669 .update(pullRequests)
6670 .set({
6671 state: "closed",
6672 closedAt: new Date(),
6673 updatedAt: new Date(),
6674 })
6675 .where(
6676 and(
6677 eq(pullRequests.repositoryId, resolved.repo.id),
6678 eq(pullRequests.number, prNum)
6679 )
6680 );
a74f4edccanty labs6681 void fireWebhooks(resolved.repo.id, "pr", {
6682 action: "closed",
6683 number: prNum,
6684 });
0074234Claude6685
6686 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6687 }
6688);
6689
c3e0c07Claude6690// Re-run AI review on demand (e.g. after a force-push). Bypasses the
6691// idempotency marker via { force: true }. Write-access only.
6692pulls.post(
6693 "/:owner/:repo/pulls/:number/ai-rereview",
6694 softAuth,
6695 requireAuth,
6696 requireRepoAccess("write"),
6697 async (c) => {
6698 const { owner: ownerName, repo: repoName } = c.req.param();
6699 const prNum = parseInt(c.req.param("number"), 10);
6700 const resolved = await resolveRepo(ownerName, repoName);
6701 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6702
6703 const [pr] = await db
6704 .select()
6705 .from(pullRequests)
6706 .where(
6707 and(
6708 eq(pullRequests.repositoryId, resolved.repo.id),
6709 eq(pullRequests.number, prNum)
6710 )
6711 )
6712 .limit(1);
6713 if (!pr) {
6714 return c.redirect(`/${ownerName}/${repoName}/pulls`);
6715 }
6716
6717 if (!isAiReviewEnabled()) {
6718 return c.redirect(
6719 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6720 "AI review is not configured (ANTHROPIC_API_KEY)."
6721 )}`
6722 );
6723 }
6724
6725 // Fire-and-forget but with { force: true } to bypass the
6726 // already-reviewed marker. The function still never throws.
6727 triggerAiReview(
6728 ownerName,
6729 repoName,
6730 pr.id,
6731 pr.title || "",
6732 pr.body || "",
6733 pr.baseBranch,
6734 pr.headBranch,
6735 { force: true }
a28cedeClaude6736 ).catch((err) => {
6737 console.warn(
6738 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
6739 err instanceof Error ? err.message : err
6740 );
6741 });
c3e0c07Claude6742
6743 return c.redirect(
6744 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6745 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
6746 )}`
6747 );
6748 }
6749);
6750
1d4ff60Claude6751// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
6752// the PR's head branch carrying just the new test files. Write-access only.
6753// Idempotent — if `ai:added-tests` was previously applied we redirect with
6754// an `info` banner instead of re-firing.
6755pulls.post(
6756 "/:owner/:repo/pulls/:number/generate-tests",
6757 softAuth,
6758 requireAuth,
6759 requireRepoAccess("write"),
6760 async (c) => {
6761 const { owner: ownerName, repo: repoName } = c.req.param();
6762 const prNum = parseInt(c.req.param("number"), 10);
6763 const resolved = await resolveRepo(ownerName, repoName);
6764 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6765
6766 const [pr] = await db
6767 .select()
6768 .from(pullRequests)
6769 .where(
6770 and(
6771 eq(pullRequests.repositoryId, resolved.repo.id),
6772 eq(pullRequests.number, prNum)
6773 )
6774 )
6775 .limit(1);
6776 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6777
6778 if (!isAiReviewEnabled()) {
6779 return c.redirect(
6780 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6781 "AI test generation is not configured (ANTHROPIC_API_KEY)."
6782 )}`
6783 );
6784 }
6785
6786 // Fire-and-forget. The lib never throws.
6787 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
6788 .then((res) => {
6789 if (!res.ok) {
6790 console.warn(
6791 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
6792 );
6793 }
6794 })
6795 .catch((err) => {
6796 console.warn(
6797 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
6798 err instanceof Error ? err.message : err
6799 );
6800 });
6801
6802 return c.redirect(
6803 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6804 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
6805 )}`
6806 );
6807 }
6808);
6809
ace34efClaude6810// ─── Request review ───────────────────────────────────────────────────────────
6811pulls.post(
6812 "/:owner/:repo/pulls/:number/request-review",
6813 softAuth,
6814 requireAuth,
6815 requireRepoAccess("write"),
6816 async (c) => {
6817 const { owner: ownerName, repo: repoName } = c.req.param();
6818 const prNum = parseInt(c.req.param("number"), 10);
6819 const user = c.get("user")!;
6820
6821 const resolved = await resolveRepo(ownerName, repoName);
6822 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6823
6824 const [pr] = await db
6825 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
6826 .from(pullRequests)
6827 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
6828 .limit(1);
6829
6830 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6831
6832 const body = await c.req.formData().catch(() => null);
6833 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
6834
6835 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
6836 return c.redirect(
6837 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
6838 );
6839 }
6840
f4abb8eClaude6841 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
6842 // requesting reviews from arbitrary user IDs outside this repository.
6843 const isOwner = reviewerId === resolved.owner.id;
6844 if (!isOwner) {
6845 const [collab] = await db
6846 .select({ id: repoCollaborators.id })
6847 .from(repoCollaborators)
6848 .where(
6849 and(
6850 eq(repoCollaborators.repositoryId, resolved.repo.id),
6851 eq(repoCollaborators.userId, reviewerId),
6852 isNotNull(repoCollaborators.acceptedAt)
6853 )
6854 )
6855 .limit(1);
6856 if (!collab) {
6857 return c.redirect(
6858 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
6859 );
6860 }
6861 }
6862
ace34efClaude6863 const { requestReview } = await import("../lib/reviewer-suggest");
6864 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
6865
6866 const msg = result.ok
6867 ? "Review requested successfully."
6868 : `Failed to request review: ${result.error ?? "unknown error"}`;
6869
6870 return c.redirect(
6871 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
6872 );
6873 }
6874);
6875
25b1ff7Claude6876// ─── WebSocket presence endpoint ─────────────────────────────────────────────
6877//
6878// GET /:owner/:repo/pulls/:number/presence (WebSocket upgrade)
6879//
6880// Unauthenticated connections are rejected with 401. On connect:
6881// → server sends {type:"init", sessionId, users:[...]}
6882// → server broadcasts {type:"join", user} to all other sessions in the room
6883//
6884// Accepted client messages:
6885// {type:"cursor", line: number} — user hovering a diff line
6886// {type:"typing", line: number, typing: bool} — textarea focus/blur
6887// {type:"ping"} — keep-alive (updates lastSeen)
6888//
6889// The WS `data` payload we store on each socket carries everything needed in
6890// the event handlers so no closure tricks are required.
6891
6892pulls.get(
6893 "/:owner/:repo/pulls/:number/presence",
6894 softAuth,
6895 upgradeWebSocket(async (c) => {
6896 const { owner: ownerName, repo: repoName, number: prNumStr } = c.req.param();
6897 const prNum = parseInt(prNumStr ?? "0", 10);
6898 const user = c.get("user");
6899
6900 // Auth check — no anonymous presence
6901 if (!user) {
6902 // upgradeWebSocket doesn't support returning a non-101 directly;
6903 // we return a dummy handler that immediately closes with 4001.
6904 return {
6905 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6906 ws.close(4001, "Unauthorized");
6907 },
6908 onMessage() {},
6909 onClose() {},
6910 };
6911 }
6912
6913 // Resolve repo to get its numeric id for the room key
6914 const resolved = await resolveRepo(ownerName, repoName);
6915 if (!resolved || isNaN(prNum)) {
6916 return {
6917 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6918 ws.close(4004, "Not found");
6919 },
6920 onMessage() {},
6921 onClose() {},
6922 };
6923 }
6924
6925 const prId = `${resolved.repo.id}:${prNum}`;
6926 const sessionId = `${user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
6927
6928 return {
6929 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6930 // Register and join room
6931 registerSocket(prId, sessionId, {
6932 send: (data: string) => ws.send(data),
6933 readyState: ws.readyState,
6934 });
6935 const presenceUser = joinRoom(prId, sessionId, {
6936 userId: user.id,
6937 username: user.username,
6938 });
6939
6940 // Send init snapshot to the new joiner
6941 const currentUsers = getRoomUsers(prId);
6942 ws.send(
6943 JSON.stringify({
6944 type: "init",
6945 sessionId,
6946 users: currentUsers,
6947 })
6948 );
6949
6950 // Broadcast join to all OTHER sessions
6951 broadcastToRoom(
6952 prId,
6953 {
6954 type: "join",
6955 user: { ...presenceUser, sessionId },
6956 },
6957 sessionId
6958 );
6959 },
6960
6961 onMessage(evt: MessageEvent, _ws: import("hono/ws").WSContext) {
6962 let msg: { type: string; line?: number; typing?: boolean };
6963 try {
6964 msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
6965 } catch {
6966 return;
6967 }
6968
6969 if (msg.type === "ping") {
6970 pingSession(prId, sessionId);
6971 return;
6972 }
6973
6974 if (msg.type === "cursor") {
6975 const line = typeof msg.line === "number" ? msg.line : null;
6976 const updated = updatePresence(prId, sessionId, line, false);
6977 if (updated) {
6978 broadcastToRoom(
6979 prId,
6980 {
6981 type: "cursor",
6982 sessionId,
6983 username: updated.username,
6984 colour: updated.colour,
6985 line,
6986 },
6987 sessionId
6988 );
6989 }
6990 return;
6991 }
6992
6993 if (msg.type === "typing") {
6994 const line = typeof msg.line === "number" ? msg.line : null;
6995 const typing = !!msg.typing;
6996 const updated = updatePresence(prId, sessionId, line, typing);
6997 if (updated) {
6998 broadcastToRoom(
6999 prId,
7000 {
7001 type: "typing",
7002 sessionId,
7003 username: updated.username,
7004 colour: updated.colour,
7005 line,
7006 typing,
7007 },
7008 sessionId
7009 );
7010 }
7011 return;
7012 }
7013 },
7014
7015 onClose() {
7016 leaveRoom(prId, sessionId);
7017 unregisterSocket(prId, sessionId);
7018 broadcastToRoom(prId, { type: "leave", sessionId });
7019 },
7020 };
7021 })
7022);
7023
0074234Claude7024export default pulls;