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