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