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.tsxBlame6344 lines · 3 contributors
0074234Claude1/**
2 * Pull request routes — create, list, view, merge, close, comment.
b078860Claude3 *
4 * The list view (`GET /:owner/:repo/pulls`) and detail view
5 * (`GET /:owner/:repo/pulls/:number`) carry the 2026 polish: hero with
6 * gradient title + hairline strip, pill-style state tabs, soft-lift
7 * row cards, conversation thread with AI-review accent border, distinct
8 * gate-check rows, and a gradient-bordered "Merge pull request" button.
9 *
10 * All visual styling is scoped via `.prs-*` class prefixes inside inline
11 * <style> blocks so other surfaces are untouched. No business logic was
12 * changed in this polish pass — AI review triggers, auto-merge wiring,
13 * gate evaluation, and the merge handler are preserved exactly.
0074234Claude14 */
15
16import { Hono } from "hono";
f4abb8eClaude17import { eq, and, desc, asc, sql, inArray, ilike, ne, isNotNull } from "drizzle-orm";
0074234Claude18import { db } from "../db";
19import {
20 pullRequests,
21 prComments,
0a67773Claude22 prReviews,
ec9e3e3Claude23 prReviewRequests,
0074234Claude24 repositories,
25 users,
d62fb36Claude26 issues,
27 issueComments,
f4abb8eClaude28 repoCollaborators,
0074234Claude29} from "../db/schema";
30import { Layout } from "../views/layout";
ea9ed4cClaude31import { RepoHeader } from "../views/components";
cb5a796Claude32import { PendingCommentsBanner } from "../views/pending-comments-banner";
47a7a0aClaude33import { DiffView, type InlineDiffComment } from "../views/diff-view";
6fc53bdClaude34import { ReactionsBar } from "../views/reactions";
35import { summariseReactions } from "../lib/reactions";
24cf2caClaude36import { loadPrTemplate } from "../lib/templates";
0074234Claude37import { renderMarkdown } from "../lib/markdown";
15db0e0Claude38import {
39 parseSlashCommand,
40 executeSlashCommand,
41 detectSlashCmdComment,
42 stripSlashCmdMarker,
43} from "../lib/pr-slash-commands";
b584e52Claude44import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude45import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude46import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude47import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
0074234Claude48import { softAuth, requireAuth } from "../middleware/auth";
49import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude50import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude51import {
52 decideInitialStatus,
53 notifyOwnerOfPendingComment,
54 countPendingForRepo,
55} from "../lib/comment-moderation";
0316dbbClaude56import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
79ed944Claude57import {
58 TRIO_COMMENT_MARKER,
59 TRIO_SUMMARY_MARKER,
67dc4e1Claude60 isTrioReviewEnabled,
79ed944Claude61 type TrioPersona,
62} from "../lib/ai-review-trio";
1d4ff60Claude63import {
64 generateTestsForPr,
65 AI_TESTS_MARKER,
66} from "../lib/ai-test-generator";
0316dbbClaude67import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude68import { generatePrSummary } from "../lib/ai-generators";
69import { isAiAvailable } from "../lib/ai-client";
34e63b9Claude70import { getPatternWarning, type Pattern } from "../lib/pattern-detector";
cc34156Claude71import { getReviewContext, recordPrVisit, type ReviewContext } from "../lib/review-context";
534f04aClaude72import {
73 computePrRiskForPullRequest,
74 getCachedPrRisk,
75 type PrRiskScore,
76} from "../lib/pr-risk";
0316dbbClaude77import { runAllGateChecks } from "../lib/gate";
78import type { GateCheckResult } from "../lib/gate";
79import {
80 matchProtection,
81 countHumanApprovals,
82 listRequiredChecks,
83 passingCheckNames,
84 evaluateProtection,
85} from "../lib/branch-protection";
86import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude87import {
88 listBranches,
89 getRepoPath,
e883329Claude90 resolveRef,
b5dd694Claude91 getBlob,
92 createOrUpdateFileOnBranch,
b558f23Claude93 commitsBetween,
0074234Claude94} from "../git/repository";
b558f23Claude95import type { GitDiffFile, GitCommit } from "../git/repository";
240c477Claude96import { listStatuses } from "../lib/commit-statuses";
97import type { CommitStatus } from "../db/schema";
0074234Claude98import { html } from "hono/html";
4bbacbeClaude99import {
100 getPreviewForBranch,
101 previewStatusLabel,
102} from "../lib/branch-previews";
1e162a8Claude103import {
bb0f894Claude104 Flex,
105 Container,
106 Badge,
107 Button,
108 LinkButton,
109 Form,
110 FormGroup,
111 Input,
112 TextArea,
113 Select,
114 EmptyState,
115 FilterTabs,
116 TabNav,
117 List,
118 ListItem,
119 Text,
120 Alert,
121 MarkdownContent,
122 CommentBox,
123 formatRelative,
124} from "../views/ui";
0074234Claude125
ace34efClaude126import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
74d8c4dClaude127import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
a7460bfClaude128import { BOT_USERNAME } from "../lib/bot-user";
ec9e3e3Claude129import {
130 getCodeownersForRepo,
131 reviewersForChangedFiles,
132} from "../lib/codeowners";
133import { checkMergeEligible } from "../lib/branch-rules";
25b1ff7Claude134import {
135 joinRoom,
136 leaveRoom,
137 updatePresence,
138 pingSession,
139 getRoomUsers,
140 broadcastToRoom,
141 registerSocket,
142 unregisterSocket,
143} from "../lib/pr-presence";
144import { upgradeWebSocket, websocket as presenceWebsocket } from "hono/bun";
145
146export { presenceWebsocket };
1d6db4dClaude147import { getBusFactorWarning, type BusFactorFile } from "../lib/bus-factor";
148import { suggestPrSplit, type SplitSuggestion } from "../lib/pr-splitter";
ace34efClaude149
0074234Claude150const pulls = new Hono<AuthEnv>();
151
b078860Claude152/* ──────────────────────────────────────────────────────────────────────
153 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
154 * into the issue tracker or any other route. Tokens come from layout.tsx
155 * `:root` so light/dark stays consistent if/when light mode lands.
156 * ──────────────────────────────────────────────────────────────────── */
157const PRS_LIST_STYLES = `
158 .prs-hero {
159 position: relative;
160 margin: 0 0 var(--space-5);
161 padding: 22px 26px 24px;
162 background: var(--bg-elevated);
163 border: 1px solid var(--border);
164 border-radius: 16px;
165 overflow: hidden;
166 }
167 .prs-hero::before {
168 content: '';
169 position: absolute; top: 0; left: 0; right: 0;
170 height: 2px;
171 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
172 opacity: 0.7;
173 pointer-events: none;
174 }
175 .prs-hero-inner {
176 position: relative;
177 display: flex;
178 justify-content: space-between;
179 align-items: flex-end;
180 gap: 20px;
181 flex-wrap: wrap;
182 }
183 .prs-hero-text { flex: 1; min-width: 280px; }
184 .prs-hero-eyebrow {
185 font-size: 12px;
186 color: var(--text-muted);
187 text-transform: uppercase;
188 letter-spacing: 0.08em;
189 font-weight: 600;
190 margin-bottom: 8px;
191 }
192 .prs-hero-title {
193 font-family: var(--font-display);
194 font-size: clamp(26px, 3.4vw, 34px);
195 font-weight: 800;
196 letter-spacing: -0.025em;
197 line-height: 1.06;
198 margin: 0 0 8px;
199 color: var(--text-strong);
200 }
201 .prs-hero-title .gradient-text {
202 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
203 -webkit-background-clip: text;
204 background-clip: text;
205 -webkit-text-fill-color: transparent;
206 color: transparent;
207 }
208 .prs-hero-sub {
209 font-size: 14.5px;
210 color: var(--text-muted);
211 margin: 0;
212 line-height: 1.5;
213 max-width: 620px;
214 }
215 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
216 .prs-cta {
217 display: inline-flex; align-items: center; gap: 6px;
218 padding: 10px 16px;
219 border-radius: 10px;
220 font-size: 13.5px;
221 font-weight: 600;
222 color: #fff;
223 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
224 border: 1px solid rgba(140,109,255,0.55);
225 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
226 text-decoration: none;
227 transition: transform 120ms ease, box-shadow 160ms ease;
228 }
229 .prs-cta:hover {
230 transform: translateY(-1px);
231 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
232 color: #fff;
233 }
234
235 .prs-tabs {
236 display: flex; flex-wrap: wrap; gap: 6px;
237 margin: 0 0 18px;
238 padding: 6px;
239 background: var(--bg-secondary);
240 border: 1px solid var(--border);
241 border-radius: 12px;
242 }
243 .prs-tab {
244 display: inline-flex; align-items: center; gap: 8px;
245 padding: 7px 13px;
246 font-size: 13px;
247 font-weight: 500;
248 color: var(--text-muted);
249 border-radius: 8px;
250 text-decoration: none;
251 transition: background 120ms ease, color 120ms ease;
252 }
253 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
254 .prs-tab.is-active {
255 background: var(--bg-elevated);
256 color: var(--text-strong);
257 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
258 }
259 .prs-tab-count {
260 display: inline-flex; align-items: center; justify-content: center;
261 min-width: 22px; padding: 2px 7px;
262 font-size: 11.5px;
263 font-weight: 600;
264 border-radius: 9999px;
265 background: var(--bg-tertiary);
266 color: var(--text-muted);
267 }
268 .prs-tab.is-active .prs-tab-count {
269 background: rgba(140,109,255,0.18);
270 color: var(--text-link);
271 }
272
273 .prs-list { display: flex; flex-direction: column; gap: 10px; }
274 .prs-row {
275 position: relative;
276 display: flex; align-items: flex-start; gap: 14px;
277 padding: 14px 16px;
278 background: var(--bg-elevated);
279 border: 1px solid var(--border);
280 border-radius: 12px;
281 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
282 }
283 .prs-row:hover {
284 transform: translateY(-1px);
285 border-color: var(--border-strong);
286 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
287 }
288 .prs-row-icon {
289 flex: 0 0 auto;
290 width: 26px; height: 26px;
291 display: inline-flex; align-items: center; justify-content: center;
292 border-radius: 9999px;
293 font-size: 13px;
294 margin-top: 2px;
295 }
296 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
297 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
298 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
299 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
300 .prs-row-body { flex: 1; min-width: 0; }
301 .prs-row-title {
302 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
303 font-size: 15px; font-weight: 600;
304 color: var(--text-strong);
305 line-height: 1.35;
306 margin: 0 0 6px;
307 }
308 .prs-row-number {
309 color: var(--text-muted);
310 font-weight: 400;
311 font-size: 14px;
312 }
313 .prs-row-meta {
314 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
315 font-size: 12.5px;
316 color: var(--text-muted);
317 }
318 .prs-branch-chips {
319 display: inline-flex; align-items: center; gap: 6px;
320 font-family: var(--font-mono);
321 font-size: 11.5px;
322 }
323 .prs-branch-chip {
324 padding: 2px 8px;
325 border-radius: 9999px;
326 background: var(--bg-tertiary);
327 border: 1px solid var(--border);
328 color: var(--text);
329 }
330 .prs-branch-arrow {
331 color: var(--text-faint);
332 font-size: 11px;
333 }
334 .prs-row-tags {
335 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
336 margin-left: auto;
337 }
338 .prs-tag {
339 display: inline-flex; align-items: center; gap: 4px;
340 padding: 2px 8px;
341 font-size: 11px;
342 font-weight: 600;
343 border-radius: 9999px;
344 border: 1px solid var(--border);
345 background: var(--bg-secondary);
346 color: var(--text-muted);
347 line-height: 1.6;
348 }
349 .prs-tag.is-draft {
350 color: var(--text-muted);
351 border-color: var(--border-strong);
352 }
353 .prs-tag.is-merged {
354 color: var(--text-link);
355 border-color: rgba(140,109,255,0.45);
356 background: rgba(140,109,255,0.10);
357 }
1aef949Claude358 .prs-tag.is-approved {
359 color: #34d399;
360 border-color: rgba(52,211,153,0.40);
361 background: rgba(52,211,153,0.08);
362 }
363 .prs-tag.is-changes {
364 color: #f87171;
365 border-color: rgba(248,113,113,0.40);
366 background: rgba(248,113,113,0.08);
367 }
b078860Claude368
369 .prs-empty {
ea9ed4cClaude370 position: relative;
371 padding: 56px 32px;
b078860Claude372 text-align: center;
373 border: 1px dashed var(--border);
ea9ed4cClaude374 border-radius: 16px;
375 background: var(--bg-elevated);
b078860Claude376 color: var(--text-muted);
ea9ed4cClaude377 overflow: hidden;
b078860Claude378 }
ea9ed4cClaude379 .prs-empty::before {
380 content: '';
381 position: absolute;
382 inset: -40% -20% auto auto;
383 width: 320px; height: 320px;
384 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
385 filter: blur(70px);
386 opacity: 0.55;
387 pointer-events: none;
388 animation: prsEmptyOrb 16s ease-in-out infinite;
389 }
390 @keyframes prsEmptyOrb {
391 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
392 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
393 }
394 @media (prefers-reduced-motion: reduce) {
395 .prs-empty::before { animation: none; }
396 }
397 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude398 .prs-empty strong {
399 display: block;
400 color: var(--text-strong);
ea9ed4cClaude401 font-family: var(--font-display);
402 font-size: 22px;
403 font-weight: 700;
404 letter-spacing: -0.018em;
405 margin-bottom: 2px;
406 }
407 .prs-empty-sub {
408 font-size: 14.5px;
409 color: var(--text-muted);
410 line-height: 1.55;
411 max-width: 460px;
412 margin: 0 0 18px;
b078860Claude413 }
ea9ed4cClaude414 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude415
416 @media (max-width: 720px) {
417 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
418 .prs-hero-actions { width: 100%; }
419 .prs-row-tags { margin-left: 0; }
420 }
f1dc7c7Claude421
422 /* Additional mobile rules. Additive only. */
423 @media (max-width: 720px) {
424 .prs-hero { padding: 18px 18px 20px; }
425 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
426 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
427 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
428 .prs-row { padding: 12px 14px; gap: 10px; }
429 .prs-row-icon { width: 24px; height: 24px; }
430 }
f5b9ef5Claude431
432 /* ─── Sort controls (PR list) ─── */
433 .prs-sort-row {
434 display: flex;
435 align-items: center;
436 gap: 6px;
437 margin: 0 0 12px;
438 flex-wrap: wrap;
439 }
440 .prs-sort-label {
441 font-size: 12.5px;
442 color: var(--text-muted);
443 font-weight: 600;
444 margin-right: 2px;
445 }
446 .prs-sort-opt {
447 font-size: 12.5px;
448 color: var(--text-muted);
449 text-decoration: none;
450 padding: 3px 10px;
451 border-radius: 9999px;
452 border: 1px solid transparent;
453 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
454 }
455 .prs-sort-opt:hover {
456 background: var(--bg-hover);
457 color: var(--text);
458 }
459 .prs-sort-opt.is-active {
460 background: rgba(140,109,255,0.12);
461 color: var(--text-link);
462 border-color: rgba(140,109,255,0.35);
463 font-weight: 600;
464 }
b078860Claude465`;
466
467/* ──────────────────────────────────────────────────────────────────────
468 * Inline CSS for the detail page. Same `.prs-*` namespace.
469 * ──────────────────────────────────────────────────────────────────── */
470const PRS_DETAIL_STYLES = `
471 .prs-detail-hero {
472 position: relative;
473 margin: 0 0 var(--space-4);
474 padding: 24px 26px;
475 background: var(--bg-elevated);
476 border: 1px solid var(--border);
477 border-radius: 16px;
478 overflow: hidden;
479 }
480 .prs-detail-hero::before {
481 content: '';
482 position: absolute; top: 0; left: 0; right: 0;
483 height: 2px;
484 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
485 opacity: 0.7;
486 pointer-events: none;
487 }
488 .prs-detail-title {
489 font-family: var(--font-display);
490 font-size: clamp(22px, 2.6vw, 28px);
491 font-weight: 700;
492 letter-spacing: -0.022em;
493 line-height: 1.2;
494 color: var(--text-strong);
495 margin: 0 0 12px;
496 }
497 .prs-detail-num {
498 color: var(--text-muted);
499 font-weight: 400;
500 }
501 .prs-state-pill {
502 display: inline-flex; align-items: center; gap: 6px;
503 padding: 6px 12px;
504 border-radius: 9999px;
505 font-size: 12.5px;
506 font-weight: 600;
507 line-height: 1;
508 border: 1px solid transparent;
509 }
510 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
511 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
512 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
513 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
514
74d8c4dClaude515 .prs-size-badge {
516 display: inline-flex;
517 align-items: center;
518 padding: 2px 8px;
519 border-radius: 20px;
520 font-size: 11px;
521 font-weight: 700;
522 letter-spacing: 0.04em;
523 border: 1px solid currentColor;
524 opacity: 0.85;
525 }
526
b078860Claude527 .prs-detail-meta {
528 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
529 font-size: 13px;
530 color: var(--text-muted);
531 }
532 .prs-detail-meta strong { color: var(--text); }
533 .prs-detail-branches {
534 display: inline-flex; align-items: center; gap: 6px;
535 font-family: var(--font-mono);
536 font-size: 12px;
537 }
538 .prs-branch-pill {
539 padding: 3px 9px;
540 border-radius: 9999px;
541 background: var(--bg-tertiary);
542 border: 1px solid var(--border);
543 color: var(--text);
544 }
545 .prs-branch-pill.is-head { color: var(--text-strong); }
546 .prs-branch-arrow-lg {
547 color: var(--accent);
548 font-size: 14px;
549 font-weight: 700;
550 }
0369e77Claude551 .prs-branch-sync {
552 display: inline-flex; align-items: center; gap: 4px;
553 font-size: 11.5px; font-weight: 600;
554 padding: 2px 8px;
555 border-radius: 9999px;
556 border: 1px solid var(--border);
557 background: var(--bg-secondary);
558 color: var(--text-muted);
559 cursor: default;
560 }
561 .prs-branch-sync.is-behind {
562 color: #f87171;
563 border-color: rgba(248,113,113,0.35);
564 background: rgba(248,113,113,0.07);
565 }
566 .prs-branch-sync.is-synced {
567 color: #34d399;
568 border-color: rgba(52,211,153,0.35);
569 background: rgba(52,211,153,0.07);
570 }
b078860Claude571
572 .prs-detail-actions {
573 display: inline-flex; gap: 8px; margin-left: auto;
574 }
575
576 .prs-detail-tabs {
577 display: flex; gap: 4px;
578 margin: 0 0 16px;
579 border-bottom: 1px solid var(--border);
580 }
581 .prs-detail-tab {
582 padding: 10px 14px;
583 font-size: 13.5px;
584 font-weight: 500;
585 color: var(--text-muted);
586 text-decoration: none;
587 border-bottom: 2px solid transparent;
588 transition: color 120ms ease, border-color 120ms ease;
589 margin-bottom: -1px;
590 }
591 .prs-detail-tab:hover { color: var(--text); }
592 .prs-detail-tab.is-active {
593 color: var(--text-strong);
594 border-bottom-color: var(--accent);
595 }
596 .prs-detail-tab-count {
597 display: inline-flex; align-items: center; justify-content: center;
598 min-width: 20px; padding: 0 6px; margin-left: 6px;
599 height: 18px;
600 font-size: 11px;
601 font-weight: 600;
602 border-radius: 9999px;
603 background: var(--bg-tertiary);
604 color: var(--text-muted);
605 }
606
607 /* Gate / check status section */
608 .prs-gate-card {
609 margin-top: 20px;
610 background: var(--bg-elevated);
611 border: 1px solid var(--border);
612 border-radius: 14px;
613 overflow: hidden;
614 }
615 .prs-gate-head {
616 display: flex; align-items: center; gap: 10px;
617 padding: 14px 18px;
618 border-bottom: 1px solid var(--border);
619 }
620 .prs-gate-head h3 {
621 margin: 0;
622 font-size: 14px;
623 font-weight: 600;
624 color: var(--text-strong);
625 }
626 .prs-gate-summary {
627 margin-left: auto;
628 font-size: 12px;
629 color: var(--text-muted);
630 }
631 .prs-gate-row {
632 display: flex; align-items: center; gap: 12px;
633 padding: 12px 18px;
634 border-bottom: 1px solid var(--border-subtle);
635 }
636 .prs-gate-row:last-child { border-bottom: 0; }
637 .prs-gate-icon {
638 flex: 0 0 auto;
639 width: 22px; height: 22px;
640 display: inline-flex; align-items: center; justify-content: center;
641 border-radius: 9999px;
642 font-size: 12px;
643 font-weight: 700;
644 }
645 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
646 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
647 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
648 .prs-gate-name {
649 font-size: 13px;
650 font-weight: 600;
651 color: var(--text);
652 min-width: 140px;
653 }
654 .prs-gate-details {
655 flex: 1; min-width: 0;
656 font-size: 12.5px;
657 color: var(--text-muted);
658 }
659 .prs-gate-pill {
660 flex: 0 0 auto;
661 padding: 3px 10px;
662 border-radius: 9999px;
663 font-size: 11px;
664 font-weight: 600;
665 line-height: 1.5;
666 border: 1px solid transparent;
667 }
668 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
669 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
670 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
671 .prs-gate-footer {
672 padding: 12px 18px;
673 background: var(--bg-secondary);
674 font-size: 12px;
675 color: var(--text-muted);
676 }
677
678 /* Comment cards */
679 .prs-comment {
680 margin-top: 14px;
681 background: var(--bg-elevated);
682 border: 1px solid var(--border);
683 border-radius: 12px;
684 overflow: hidden;
685 }
686 .prs-comment-head {
687 display: flex; align-items: center; gap: 10px;
688 padding: 10px 14px;
689 background: var(--bg-secondary);
690 border-bottom: 1px solid var(--border);
691 font-size: 13px;
692 flex-wrap: wrap;
693 }
694 .prs-comment-head strong { color: var(--text-strong); }
695 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
696 .prs-comment-loc {
697 font-family: var(--font-mono);
698 font-size: 11.5px;
699 color: var(--text-muted);
700 background: var(--bg-tertiary);
701 padding: 2px 8px;
702 border-radius: 6px;
703 }
704 .prs-comment-body { padding: 14px 18px; }
705 .prs-comment.is-ai {
706 border-color: rgba(140,109,255,0.45);
707 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
708 }
709 .prs-comment.is-ai .prs-comment-head {
710 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
711 border-bottom-color: rgba(140,109,255,0.30);
712 }
713 .prs-ai-badge {
714 display: inline-flex; align-items: center; gap: 4px;
715 padding: 2px 9px;
716 font-size: 10.5px;
717 font-weight: 700;
718 letter-spacing: 0.04em;
719 text-transform: uppercase;
720 color: #fff;
721 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
722 border-radius: 9999px;
723 }
a7460bfClaude724 .prs-bot-badge {
725 display: inline-flex; align-items: center; gap: 3px;
726 padding: 1px 7px;
727 font-size: 10px;
728 font-weight: 600;
729 color: var(--fg-muted);
730 background: var(--bg-elevated);
731 border: 1px solid var(--border);
732 border-radius: 9999px;
733 }
b078860Claude734
735 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
736 .prs-files-card {
737 margin-top: 18px;
738 padding: 14px 18px;
739 display: flex; align-items: center; gap: 14px;
740 background: var(--bg-elevated);
741 border: 1px solid var(--border);
742 border-radius: 12px;
743 text-decoration: none;
744 color: inherit;
745 transition: border-color 120ms ease, transform 140ms ease;
746 }
747 .prs-files-card:hover {
748 border-color: rgba(140,109,255,0.45);
749 transform: translateY(-1px);
750 }
751 .prs-files-card-icon {
752 width: 36px; height: 36px;
753 display: inline-flex; align-items: center; justify-content: center;
754 border-radius: 10px;
755 background: rgba(140,109,255,0.12);
756 color: var(--text-link);
757 font-size: 18px;
758 }
759 .prs-files-card-text { flex: 1; min-width: 0; }
760 .prs-files-card-title {
761 font-size: 14px;
762 font-weight: 600;
763 color: var(--text-strong);
764 margin: 0 0 2px;
765 }
766 .prs-files-card-sub {
767 font-size: 12.5px;
768 color: var(--text-muted);
769 margin: 0;
770 }
771 .prs-files-card-cta {
772 font-size: 12.5px;
773 color: var(--text-link);
774 font-weight: 600;
775 }
776
777 /* Merge area */
778 .prs-merge-card {
779 position: relative;
780 margin-top: 22px;
781 padding: 18px;
782 background: var(--bg-elevated);
783 border-radius: 14px;
784 overflow: hidden;
785 }
786 .prs-merge-card::before {
787 content: '';
788 position: absolute; inset: 0;
789 padding: 1px;
790 border-radius: 14px;
791 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
792 -webkit-mask:
793 linear-gradient(#000 0 0) content-box,
794 linear-gradient(#000 0 0);
795 -webkit-mask-composite: xor;
796 mask-composite: exclude;
797 pointer-events: none;
798 }
799 .prs-merge-card.is-closed::before { background: var(--border-strong); }
800 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
801 .prs-merge-head {
802 display: flex; align-items: center; gap: 12px;
803 margin-bottom: 12px;
804 }
805 .prs-merge-head strong {
806 font-family: var(--font-display);
807 font-size: 15px;
808 color: var(--text-strong);
809 font-weight: 700;
810 }
811 .prs-merge-sub {
812 font-size: 13px;
813 color: var(--text-muted);
814 margin: 0 0 12px;
815 }
816 .prs-merge-actions {
817 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
818 }
819 .prs-merge-btn {
820 display: inline-flex; align-items: center; gap: 6px;
821 padding: 9px 16px;
822 border-radius: 10px;
823 font-size: 13.5px;
824 font-weight: 600;
825 color: #fff;
826 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
827 border: 1px solid rgba(52,211,153,0.55);
828 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
829 cursor: pointer;
830 transition: transform 120ms ease, box-shadow 160ms ease;
831 }
832 .prs-merge-btn:hover {
833 transform: translateY(-1px);
834 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
835 }
836 .prs-merge-btn[disabled],
837 .prs-merge-btn.is-disabled {
838 opacity: 0.55;
839 cursor: not-allowed;
840 transform: none;
841 box-shadow: none;
842 }
843 .prs-merge-ready-btn {
844 display: inline-flex; align-items: center; gap: 6px;
845 padding: 9px 16px;
846 border-radius: 10px;
847 font-size: 13.5px;
848 font-weight: 600;
849 color: #fff;
850 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
851 border: 1px solid rgba(140,109,255,0.55);
852 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
853 cursor: pointer;
854 transition: transform 120ms ease, box-shadow 160ms ease;
855 }
856 .prs-merge-ready-btn:hover {
857 transform: translateY(-1px);
858 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
859 }
860 .prs-merge-back-draft {
861 background: none; border: 1px solid var(--border-strong);
862 color: var(--text-muted);
863 padding: 9px 14px; border-radius: 10px;
864 font-size: 13px; cursor: pointer;
865 }
866 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
867
a164a6dClaude868 /* Merge strategy selector */
869 .prs-merge-strategy-wrap {
870 display: inline-flex; align-items: center;
871 background: var(--bg-elevated);
872 border: 1px solid var(--border);
873 border-radius: 10px;
874 overflow: hidden;
875 }
876 .prs-merge-strategy-label {
877 font-size: 11.5px; font-weight: 600;
878 color: var(--text-muted);
879 padding: 0 10px 0 12px;
880 white-space: nowrap;
881 }
882 .prs-merge-strategy-select {
883 background: transparent;
884 border: none;
885 color: var(--text);
886 font-size: 13px;
887 padding: 7px 10px 7px 4px;
888 cursor: pointer;
889 outline: none;
890 appearance: auto;
891 }
892 .prs-merge-strategy-select:focus { outline: 2px solid rgba(140,109,255,0.45); }
893
0a67773Claude894 /* Review summary banner */
895 .prs-review-summary {
896 display: flex; flex-direction: column; gap: 6px;
897 padding: 12px 16px;
898 background: var(--bg-elevated);
899 border: 1px solid var(--border);
900 border-radius: var(--r-md, 8px);
901 margin-bottom: 12px;
902 }
903 .prs-review-row {
904 display: flex; align-items: center; gap: 10px;
905 font-size: 13px;
906 }
907 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
908 .prs-review-approved .prs-review-icon { color: #34d399; }
909 .prs-review-changes .prs-review-icon { color: #f87171; }
ace34efClaude910 .prs-reviewer-avatar {
911 width: 24px; height: 24px; border-radius: 50%;
912 background: var(--accent); color: #fff;
913 display: flex; align-items: center; justify-content: center;
914 font-size: 11px; font-weight: 700; flex-shrink: 0;
915 }
0a67773Claude916
917 /* Review action buttons */
918 .prs-review-approve-btn {
919 display: inline-flex; align-items: center; gap: 5px;
920 padding: 8px 14px; border-radius: 8px; font-size: 13px;
921 font-weight: 600; cursor: pointer;
922 background: rgba(52,211,153,0.12);
923 color: #34d399;
924 border: 1px solid rgba(52,211,153,0.35);
925 transition: background 120ms;
926 }
927 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
928 .prs-review-changes-btn {
929 display: inline-flex; align-items: center; gap: 5px;
930 padding: 8px 14px; border-radius: 8px; font-size: 13px;
931 font-weight: 600; cursor: pointer;
932 background: rgba(248,113,113,0.10);
933 color: #f87171;
934 border: 1px solid rgba(248,113,113,0.30);
935 transition: background 120ms;
936 }
937 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
938
b078860Claude939 /* Inline form helpers */
940 .prs-inline-form { display: inline-flex; }
941
942 /* Comment composer */
943 .prs-composer { margin-top: 22px; }
944 .prs-composer textarea {
945 border-radius: 12px;
946 }
947
948 @media (max-width: 720px) {
949 .prs-detail-actions { margin-left: 0; }
950 .prs-merge-actions { width: 100%; }
951 .prs-merge-actions > * { flex: 1; min-width: 0; }
952 }
f1dc7c7Claude953
954 /* Additional mobile rules. Additive only. */
955 @media (max-width: 720px) {
956 .prs-detail-hero { padding: 18px; }
957 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
958 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
959 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
960 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
961 .prs-gate-name { min-width: 0; }
962 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
963 .prs-gate-summary { margin-left: 0; }
964 .prs-merge-btn,
965 .prs-merge-ready-btn,
966 .prs-merge-back-draft { min-height: 44px; }
967 .prs-comment-body { padding: 12px 14px; }
968 .prs-comment-head { padding: 10px 12px; }
969 .prs-files-card { padding: 12px 14px; }
970 }
3c03977Claude971
972 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
973 .live-pill {
974 display: inline-flex;
975 align-items: center;
976 gap: 8px;
977 padding: 4px 10px 4px 8px;
978 margin-left: 6px;
979 background: var(--bg-elevated);
980 border: 1px solid var(--border);
981 border-radius: 9999px;
982 font-size: 12px;
983 color: var(--text-muted);
984 line-height: 1;
985 vertical-align: middle;
986 }
987 .live-pill.is-busy { color: var(--text); }
988 .live-pill-dot {
989 width: 8px; height: 8px;
990 border-radius: 9999px;
991 background: #34d399;
992 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
993 animation: live-pulse 1.6s ease-in-out infinite;
994 }
995 @keyframes live-pulse {
996 0%, 100% { opacity: 1; }
997 50% { opacity: 0.55; }
998 }
999 .live-avatars {
1000 display: inline-flex;
1001 margin-left: 2px;
1002 }
1003 .live-avatar {
1004 display: inline-flex;
1005 align-items: center;
1006 justify-content: center;
1007 width: 22px; height: 22px;
1008 border-radius: 9999px;
1009 font-size: 10px;
1010 font-weight: 700;
1011 color: #0b1020;
1012 margin-left: -6px;
1013 border: 2px solid var(--bg-elevated);
1014 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
1015 }
1016 .live-avatar:first-child { margin-left: 0; }
1017 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
1018 .live-cursor-host {
1019 position: relative;
1020 }
1021 .live-cursor-overlay {
1022 position: absolute;
1023 inset: 0;
1024 pointer-events: none;
1025 overflow: hidden;
1026 border-radius: inherit;
1027 }
1028 .live-cursor {
1029 position: absolute;
1030 width: 2px;
1031 height: 18px;
1032 border-radius: 2px;
1033 transform: translate(-1px, 0);
1034 transition: transform 80ms linear, opacity 200ms ease;
1035 }
1036 .live-cursor::after {
1037 content: attr(data-label);
1038 position: absolute;
1039 top: -16px;
1040 left: -2px;
1041 font-size: 10px;
1042 line-height: 1;
1043 color: #0b1020;
1044 background: inherit;
1045 padding: 2px 5px;
1046 border-radius: 4px 4px 4px 0;
1047 white-space: nowrap;
1048 font-weight: 600;
1049 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
1050 }
1051 .live-cursor.is-idle { opacity: 0.4; }
1052 .live-edit-tag {
1053 display: inline-block;
1054 margin-left: 6px;
1055 padding: 1px 6px;
1056 font-size: 10px;
1057 font-weight: 600;
1058 letter-spacing: 0.02em;
1059 color: #0b1020;
1060 border-radius: 9999px;
1061 }
15db0e0Claude1062
1063 /* ─── Slash-command pill + composer hint ─── */
1064 .slash-hint {
1065 display: inline-flex;
1066 align-items: center;
1067 gap: 6px;
1068 margin-top: 6px;
1069 padding: 3px 9px;
1070 font-size: 11.5px;
1071 color: var(--text-muted);
1072 background: var(--bg-elevated);
1073 border: 1px dashed var(--border);
1074 border-radius: 9999px;
1075 width: fit-content;
1076 }
1077 .slash-hint code {
1078 background: rgba(110, 168, 255, 0.12);
1079 color: var(--text-strong);
1080 padding: 0 5px;
1081 border-radius: 4px;
1082 font-size: 11px;
1083 }
1084 .slash-pill {
1085 display: grid;
1086 grid-template-columns: auto 1fr auto;
1087 align-items: center;
1088 column-gap: 10px;
1089 row-gap: 6px;
1090 margin: 10px 0;
1091 padding: 10px 14px;
1092 background: linear-gradient(
1093 135deg,
1094 rgba(110, 168, 255, 0.08),
1095 rgba(163, 113, 247, 0.06)
1096 );
1097 border: 1px solid rgba(110, 168, 255, 0.32);
1098 border-left: 3px solid var(--accent, #6ea8ff);
1099 border-radius: var(--radius);
1100 font-size: 13px;
1101 color: var(--text);
1102 }
1103 .slash-pill-icon {
1104 font-size: 14px;
1105 line-height: 1;
1106 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
1107 }
1108 .slash-pill-actor { color: var(--text-muted); }
1109 .slash-pill-actor strong { color: var(--text-strong); }
1110 .slash-pill-cmd {
1111 background: rgba(110, 168, 255, 0.16);
1112 color: var(--text-strong);
1113 padding: 1px 6px;
1114 border-radius: 4px;
1115 font-size: 12.5px;
1116 }
1117 .slash-pill-time {
1118 color: var(--text-muted);
1119 font-size: 12px;
1120 justify-self: end;
1121 }
1122 .slash-pill-body {
1123 grid-column: 1 / -1;
1124 color: var(--text);
1125 font-size: 13px;
1126 line-height: 1.55;
1127 }
1128 .slash-pill-body p:first-child { margin-top: 0; }
1129 .slash-pill-body p:last-child { margin-bottom: 0; }
1130 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
1131 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1132 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
1133 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude1134
1135 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
1136 .preview-prpill {
1137 display: inline-flex; align-items: center; gap: 6px;
1138 padding: 3px 10px;
1139 border-radius: 9999px;
1140 font-family: var(--font-mono);
1141 font-size: 11.5px;
1142 font-weight: 600;
1143 background: rgba(255,255,255,0.04);
1144 color: var(--text-muted);
1145 text-decoration: none;
1146 border: 1px solid var(--border);
1147 }
1148 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
1149 .preview-prpill .preview-prpill-dot {
1150 width: 7px; height: 7px;
1151 border-radius: 9999px;
1152 background: currentColor;
1153 }
1154 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1155 .preview-prpill.is-building .preview-prpill-dot {
1156 animation: previewPrPulse 1.4s ease-in-out infinite;
1157 }
1158 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1159 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1160 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1161 @keyframes previewPrPulse {
1162 0%, 100% { opacity: 1; }
1163 50% { opacity: 0.4; }
1164 }
79ed944Claude1165
1166 /* ─── AI Trio Review — 3-column verdict cards ─── */
1167 .trio-wrap {
1168 margin-top: 18px;
1169 padding: 16px;
1170 background: var(--bg-elevated);
1171 border: 1px solid var(--border);
1172 border-radius: 14px;
1173 }
1174 .trio-header {
1175 display: flex; align-items: center; gap: 10px;
1176 margin: 0 0 12px;
1177 font-size: 13.5px;
1178 color: var(--text);
1179 }
1180 .trio-header strong { color: var(--text-strong); }
1181 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1182 .trio-header-dot {
1183 width: 8px; height: 8px; border-radius: 9999px;
1184 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1185 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1186 }
1187 .trio-grid {
1188 display: grid;
1189 grid-template-columns: repeat(3, minmax(0, 1fr));
1190 gap: 12px;
1191 }
1192 .trio-card {
1193 background: var(--bg-secondary);
1194 border: 1px solid var(--border);
1195 border-radius: 12px;
1196 overflow: hidden;
1197 display: flex; flex-direction: column;
1198 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1199 }
1200 .trio-card-head {
1201 display: flex; align-items: center; gap: 8px;
1202 padding: 10px 12px;
1203 border-bottom: 1px solid var(--border);
1204 background: rgba(255,255,255,0.02);
1205 font-size: 13px;
1206 }
1207 .trio-card-icon {
1208 display: inline-flex; align-items: center; justify-content: center;
1209 width: 22px; height: 22px;
1210 border-radius: 9999px;
1211 font-size: 12px;
1212 background: rgba(255,255,255,0.05);
1213 }
1214 .trio-card-title {
1215 color: var(--text-strong);
1216 font-weight: 600;
1217 letter-spacing: 0.01em;
1218 }
1219 .trio-card-verdict {
1220 margin-left: auto;
1221 font-size: 11px;
1222 font-weight: 700;
1223 letter-spacing: 0.06em;
1224 text-transform: uppercase;
1225 padding: 3px 9px;
1226 border-radius: 9999px;
1227 background: var(--bg-tertiary);
1228 color: var(--text-muted);
1229 border: 1px solid var(--border-strong);
1230 }
1231 .trio-card-body {
1232 padding: 12px 14px;
1233 font-size: 13px;
1234 color: var(--text);
1235 flex: 1;
1236 min-height: 64px;
1237 line-height: 1.55;
1238 }
1239 .trio-card-body p { margin: 0 0 8px; }
1240 .trio-card-body p:last-child { margin-bottom: 0; }
1241 .trio-card-body ul { margin: 0; padding-left: 18px; }
1242 .trio-card-body code {
1243 font-family: var(--font-mono);
1244 font-size: 12px;
1245 background: var(--bg-tertiary);
1246 padding: 1px 6px;
1247 border-radius: 5px;
1248 }
1249 .trio-card-empty {
1250 color: var(--text-muted);
1251 font-style: italic;
1252 font-size: 12.5px;
1253 }
1254
1255 /* Pass state — neutral, no accent. */
1256 .trio-card.is-pass .trio-card-verdict {
1257 color: var(--green);
1258 border-color: rgba(52,211,153,0.35);
1259 background: rgba(52,211,153,0.12);
1260 }
1261
1262 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1263 .trio-card.trio-security.is-fail {
1264 border-color: rgba(248,113,113,0.55);
1265 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1266 }
1267 .trio-card.trio-security.is-fail .trio-card-head {
1268 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1269 border-bottom-color: rgba(248,113,113,0.30);
1270 }
1271 .trio-card.trio-security.is-fail .trio-card-verdict {
1272 color: #fecaca;
1273 border-color: rgba(248,113,113,0.55);
1274 background: rgba(248,113,113,0.20);
1275 }
1276
1277 .trio-card.trio-correctness.is-fail {
1278 border-color: rgba(251,191,36,0.55);
1279 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1280 }
1281 .trio-card.trio-correctness.is-fail .trio-card-head {
1282 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1283 border-bottom-color: rgba(251,191,36,0.30);
1284 }
1285 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1286 color: #fde68a;
1287 border-color: rgba(251,191,36,0.55);
1288 background: rgba(251,191,36,0.20);
1289 }
1290
1291 .trio-card.trio-style.is-fail {
1292 border-color: rgba(96,165,250,0.55);
1293 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1294 }
1295 .trio-card.trio-style.is-fail .trio-card-head {
1296 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1297 border-bottom-color: rgba(96,165,250,0.30);
1298 }
1299 .trio-card.trio-style.is-fail .trio-card-verdict {
1300 color: #bfdbfe;
1301 border-color: rgba(96,165,250,0.55);
1302 background: rgba(96,165,250,0.20);
1303 }
1304
1305 /* Disagreement callout strip — yellow, prominent. */
1306 .trio-disagreement-strip {
1307 display: flex;
1308 gap: 12px;
1309 margin-top: 14px;
1310 padding: 12px 14px;
1311 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1312 border: 1px solid rgba(251,191,36,0.45);
1313 border-radius: 10px;
1314 color: var(--text);
1315 font-size: 13px;
1316 }
1317 .trio-disagreement-icon {
1318 flex: 0 0 auto;
1319 width: 26px; height: 26px;
1320 display: inline-flex; align-items: center; justify-content: center;
1321 border-radius: 9999px;
1322 background: rgba(251,191,36,0.25);
1323 color: #fde68a;
1324 font-size: 14px;
1325 }
1326 .trio-disagreement-body strong {
1327 display: block;
1328 color: #fde68a;
1329 margin: 0 0 4px;
1330 font-weight: 700;
1331 }
1332 .trio-disagreement-list {
1333 margin: 0;
1334 padding-left: 18px;
1335 color: var(--text);
1336 font-size: 12.5px;
1337 line-height: 1.55;
1338 }
1339 .trio-disagreement-list code {
1340 font-family: var(--font-mono);
1341 font-size: 11.5px;
1342 background: var(--bg-tertiary);
1343 padding: 1px 5px;
1344 border-radius: 4px;
1345 }
1346
1347 @media (max-width: 720px) {
1348 .trio-grid { grid-template-columns: 1fr; }
1349 .trio-wrap { padding: 12px; }
1350 }
6d1bbc2Claude1351
1352 /* ─── Task list progress pill ─── */
1353 .prs-tasks-pill {
1354 display: inline-flex; align-items: center; gap: 5px;
1355 font-size: 11.5px; font-weight: 600;
1356 padding: 2px 9px; border-radius: 9999px;
1357 border: 1px solid var(--border);
1358 background: var(--bg-elevated);
1359 color: var(--text-muted);
1360 }
1361 .prs-tasks-pill.is-complete {
1362 color: #34d399;
1363 border-color: rgba(52,211,153,0.40);
1364 background: rgba(52,211,153,0.08);
1365 }
1366 .prs-tasks-progress { display: inline-block; width: 36px; height: 4px; border-radius: 9999px; background: var(--border); overflow: hidden; vertical-align: middle; }
1367 .prs-tasks-progress-bar { height: 100%; border-radius: 9999px; background: #34d399; }
1368
1369 /* ─── Update branch button ─── */
1370 .prs-update-branch-btn {
1371 display: inline-flex; align-items: center; gap: 5px;
1372 padding: 4px 12px; border-radius: 8px; font-size: 12.5px;
1373 font-weight: 600; cursor: pointer;
1374 background: rgba(96,165,250,0.10);
1375 color: #60a5fa;
1376 border: 1px solid rgba(96,165,250,0.30);
1377 transition: background 120ms;
1378 }
1379 .prs-update-branch-btn:hover { background: rgba(96,165,250,0.20); }
1380
1381 /* ─── Linked issues panel ─── */
1382 .prs-linked-issues {
1383 margin-top: 16px;
1384 border: 1px solid var(--border);
1385 border-radius: 12px;
1386 overflow: hidden;
1387 }
1388 .prs-linked-issues-head {
1389 display: flex; align-items: center; justify-content: space-between;
1390 padding: 10px 16px;
1391 background: var(--bg-elevated);
1392 border-bottom: 1px solid var(--border);
1393 font-size: 13px; font-weight: 600; color: var(--text);
1394 }
1395 .prs-linked-issues-count {
1396 font-size: 11px; font-weight: 700;
1397 padding: 1px 7px; border-radius: 9999px;
1398 background: var(--bg-tertiary);
1399 color: var(--text-muted);
1400 }
1401 .prs-linked-issue-row {
1402 display: flex; align-items: center; gap: 10px;
1403 padding: 9px 16px;
1404 border-bottom: 1px solid var(--border);
1405 font-size: 13px;
1406 text-decoration: none; color: inherit;
1407 }
1408 .prs-linked-issue-row:last-child { border-bottom: none; }
1409 .prs-linked-issue-row:hover { background: var(--bg-hover); }
1410 .prs-linked-issue-icon { flex: 0 0 auto; font-size: 14px; }
1411 .prs-linked-issue-icon.is-open { color: #34d399; }
1412 .prs-linked-issue-icon.is-closed { color: #8b949e; }
1413 .prs-linked-issue-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1414 .prs-linked-issue-num { color: var(--text-muted); font-size: 12px; }
1415 .prs-linked-issue-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
1416 .prs-linked-issue-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
1417 .prs-linked-issue-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
b558f23Claude1418
1419 /* ─── Commits tab ─── */
1420 .prs-commits-list { display: flex; flex-direction: column; gap: 0; margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1421 .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; }
1422 .prs-commit-row:last-child { border-bottom: none; }
1423 .prs-commit-row:hover { background: var(--bg-hover); }
1424 .prs-commit-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-top: 6px; }
1425 .prs-commit-body { flex: 1 1 auto; min-width: 0; }
1426 .prs-commit-msg { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
1427 .prs-commit-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
1428 .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; }
1429 .prs-commit-sha:hover { color: var(--accent); }
1430 .prs-commits-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13.5px; }
1431
1432 /* ─── Edit PR title/body ─── */
1433 .prs-edit-title-wrap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1434 .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; }
1435 .prs-edit-btn:hover { color: var(--text); border-color: var(--text-muted); }
1436 .prs-edit-form { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
1437 .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; }
1438 .prs-edit-actions { display: flex; gap: 8px; }
1439 .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; }
1440 .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; }
240c477Claude1441
1442 /* ─── CI status checks ─── */
1443 .prs-ci-card { margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1444 .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); }
1445 .prs-ci-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text); }
1446 .prs-ci-summary { font-size: 12px; color: var(--text-muted); }
1447 .prs-ci-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
1448 .prs-ci-row:last-child { border-bottom: none; }
1449 .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; }
1450 .prs-ci-icon.is-success { background: rgba(52,211,153,0.20); color: #34d399; }
1451 .prs-ci-icon.is-pending { background: rgba(251,191,36,0.20); color: #fbbf24; }
1452 .prs-ci-icon.is-failure, .prs-ci-icon.is-error { background: rgba(248,113,113,0.20); color: #f87171; }
1453 .prs-ci-context { flex: 1 1 auto; font-size: 13px; font-weight: 500; color: var(--text); }
1454 .prs-ci-desc { font-size: 12px; color: var(--text-muted); }
1455 .prs-ci-pill { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 9999px; }
1456 .prs-ci-pill.is-success { color: #34d399; background: rgba(52,211,153,0.10); }
1457 .prs-ci-pill.is-pending { color: #fbbf24; background: rgba(251,191,36,0.10); }
1458 .prs-ci-pill.is-failure, .prs-ci-pill.is-error { color: #f87171; background: rgba(248,113,113,0.10); }
1459 .prs-ci-link { font-size: 12px; color: var(--accent); text-decoration: none; }
1460 .prs-ci-link:hover { text-decoration: underline; }
67dc4e1Claude1461
1462 /* ─── AI Trio verdict pills (header summary) ─── */
1463 .trio-pill {
1464 display: inline-flex; align-items: center; gap: 4px;
1465 padding: 2px 8px;
1466 font-size: 11px;
1467 font-weight: 700;
1468 border-radius: 9999px;
1469 border: 1px solid transparent;
1470 text-decoration: none;
1471 line-height: 1.6;
1472 letter-spacing: 0.01em;
1473 cursor: pointer;
1474 transition: opacity 120ms ease;
1475 }
1476 .trio-pill:hover { opacity: 0.8; }
1477 .trio-pill.is-pass {
1478 color: #34d399;
1479 background: rgba(52,211,153,0.10);
1480 border-color: rgba(52,211,153,0.35);
1481 }
1482 .trio-pill.is-fail {
1483 color: #f87171;
1484 background: rgba(248,113,113,0.10);
1485 border-color: rgba(248,113,113,0.35);
1486 }
1487 .trio-pill.is-pending {
1488 color: var(--text-muted);
1489 background: rgba(255,255,255,0.04);
1490 border-color: var(--border-strong);
1491 }
1492 .trio-pills-wrap {
1493 display: inline-flex; align-items: center; gap: 4px;
1494 }
1d6db4dClaude1495
1496 /* ─── Bus Factor Warning Panel ─── */
1497 .busfactor-panel {
1498 display: flex;
1499 gap: 14px;
1500 align-items: flex-start;
1501 padding: 14px 18px;
1502 margin-bottom: 16px;
1503 border-radius: 12px;
1504 border: 1px solid rgba(245,158,11,0.35);
1505 background: rgba(245,158,11,0.06);
1506 }
1507 .busfactor-critical {
1508 border-color: rgba(239,68,68,0.4);
1509 background: rgba(239,68,68,0.06);
1510 }
1511 .busfactor-high {
1512 border-color: rgba(249,115,22,0.4);
1513 background: rgba(249,115,22,0.06);
1514 }
1515 .busfactor-medium {
1516 border-color: rgba(245,158,11,0.35);
1517 background: rgba(245,158,11,0.06);
1518 }
1519 .busfactor-icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; }
1520 .busfactor-body { flex: 1; min-width: 0; }
1521 .busfactor-body strong { font-size: 14px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1522 .busfactor-body p { font-size: 13px; color: var(--text-muted); margin: 0 0 8px; }
1523 .busfactor-body ul { margin: 0; padding-left: 18px; }
1524 .busfactor-body li { font-size: 12.5px; color: var(--text-muted); margin-bottom: 3px; font-family: var(--font-mono); }
1525 .busfactor-body li strong { font-size: 12.5px; color: var(--text); display: inline; }
1526
1527 /* ─── PR Split Suggestion Panel ─── */
1528 .split-suggestion {
1529 margin-bottom: 16px;
1530 border: 1px solid rgba(140,109,255,0.35);
1531 border-radius: 12px;
1532 overflow: hidden;
1533 }
1534 .split-header {
1535 display: flex;
1536 align-items: center;
1537 gap: 10px;
1538 padding: 12px 18px;
1539 background: rgba(140,109,255,0.06);
1540 flex-wrap: wrap;
1541 }
1542 .split-icon { font-size: 18px; flex-shrink: 0; }
1543 .split-header strong { font-size: 14px; font-weight: 700; color: var(--text-strong); flex: 1; min-width: 200px; }
1544 .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; }
1545 .split-toggle {
1546 background: none;
1547 border: 1px solid rgba(140,109,255,0.45);
1548 color: rgba(140,109,255,0.9);
1549 font-size: 12.5px;
1550 font-weight: 600;
1551 padding: 4px 12px;
1552 border-radius: 8px;
1553 cursor: pointer;
1554 white-space: nowrap;
1555 transition: background 120ms ease;
1556 }
1557 .split-toggle:hover { background: rgba(140,109,255,0.1); }
1558 .split-body {
1559 padding: 16px 18px;
1560 border-top: 1px solid rgba(140,109,255,0.2);
1561 }
1562 .split-intro { font-size: 13.5px; color: var(--text-muted); margin: 0 0 14px; }
1563 .split-pr {
1564 display: flex;
1565 gap: 14px;
1566 align-items: flex-start;
1567 padding: 12px 0;
1568 border-bottom: 1px solid var(--border);
1569 }
1570 .split-pr:last-of-type { border-bottom: none; }
1571 .split-pr-num {
1572 width: 26px; height: 26px;
1573 border-radius: 50%;
1574 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
1575 color: #fff;
1576 font-size: 12px;
1577 font-weight: 800;
1578 display: inline-flex;
1579 align-items: center;
1580 justify-content: center;
1581 flex-shrink: 0;
1582 margin-top: 2px;
1583 }
1584 .split-pr-body { flex: 1; min-width: 0; }
1585 .split-pr-body strong { font-size: 13.5px; font-weight: 700; color: var(--text-strong); display: block; margin-bottom: 4px; }
1586 .split-pr-body p { font-size: 12.5px; color: var(--text-muted); margin: 0 0 6px; }
1587 .split-pr-body code { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); word-break: break-all; }
1588 .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; }
1589 .split-order { font-size: 13px; color: var(--text-muted); margin: 14px 0 0; }
1590 .split-order strong { color: var(--text); }
b078860Claude1591`;
1592
25b1ff7Claude1593/* ──────────────────────────────────────────────────────────────────────
1594 * Figma-style collaborative PR presence — styles for the presence bar
1595 * above the diff and the per-line reviewer cursor pills. All scoped
1596 * with `.presence-*` prefix so they never bleed into other views.
1597 * ──────────────────────────────────────────────────────────────────── */
1598const PRESENCE_STYLES = `
1599 .presence-bar {
1600 display: flex;
1601 align-items: center;
1602 gap: 10px;
1603 padding: 8px 14px;
1604 margin: 0 0 10px;
1605 background: var(--bg-elevated);
1606 border: 1px solid var(--border);
1607 border-radius: 10px;
1608 font-size: 12.5px;
1609 color: var(--text-muted);
1610 min-height: 38px;
1611 }
1612 .presence-bar-label {
1613 font-weight: 600;
1614 color: var(--text-muted);
1615 flex-shrink: 0;
1616 }
1617 .presence-avatars {
1618 display: flex;
1619 align-items: center;
1620 gap: 6px;
1621 flex: 1;
1622 flex-wrap: wrap;
1623 }
1624 .presence-avatar {
1625 display: inline-flex;
1626 align-items: center;
1627 gap: 5px;
1628 padding: 3px 8px 3px 4px;
1629 border-radius: 9999px;
1630 font-size: 12px;
1631 font-weight: 600;
1632 color: #fff;
1633 opacity: 0.92;
1634 transition: opacity 200ms;
1635 }
1636 .presence-avatar-dot {
1637 width: 20px; height: 20px;
1638 border-radius: 9999px;
1639 background: rgba(255,255,255,0.22);
1640 display: inline-flex;
1641 align-items: center;
1642 justify-content: center;
1643 font-size: 10px;
1644 font-weight: 700;
1645 flex-shrink: 0;
1646 }
1647 .presence-count {
1648 font-size: 12px;
1649 color: var(--text-faint);
1650 flex-shrink: 0;
1651 }
1652 /* Per-line reviewer cursor pill — injected by JS into .diff-row */
1653 .presence-line-pill {
1654 position: absolute;
1655 right: 6px;
1656 top: 50%;
1657 transform: translateY(-50%);
1658 display: inline-flex;
1659 align-items: center;
1660 gap: 4px;
1661 padding: 1px 7px;
1662 border-radius: 9999px;
1663 font-size: 10.5px;
1664 font-weight: 600;
1665 color: #fff;
1666 pointer-events: none;
1667 white-space: nowrap;
1668 z-index: 10;
1669 opacity: 0.88;
1670 animation: presence-in 160ms ease;
1671 }
1672 @keyframes presence-in {
1673 from { opacity: 0; transform: translateY(-50%) scale(0.85); }
1674 to { opacity: 0.88; transform: translateY(-50%) scale(1); }
1675 }
1676 .presence-line-pill.is-typing::after {
1677 content: '…';
1678 opacity: 0.7;
1679 }
1680 /* diff rows with a cursor pill need relative positioning */
1681 .diff-row { position: relative; }
1682 /* Toast for join/leave events */
1683 .presence-toast-wrap {
1684 position: fixed;
1685 bottom: 24px;
1686 right: 24px;
1687 display: flex;
1688 flex-direction: column;
1689 gap: 8px;
1690 z-index: 9999;
1691 pointer-events: none;
1692 }
1693 .presence-toast {
1694 padding: 8px 14px;
1695 border-radius: 8px;
1696 background: var(--bg-elevated);
1697 border: 1px solid var(--border);
1698 box-shadow: 0 6px 20px -8px rgba(0,0,0,0.55);
1699 font-size: 13px;
1700 color: var(--text);
1701 opacity: 1;
1702 transition: opacity 400ms;
1703 }
1704 .presence-toast.fading { opacity: 0; }
1705`;
1706
1707
81c73c1Claude1708/**
1709 * Tiny inline JS that drives the "Suggest description with AI" button.
1710 * On click, gathers form values, POSTs JSON to the given endpoint, and
1711 * pipes the response into the #pr-body textarea. All DOM lookups are
1712 * defensive — element absence is a silent no-op.
1713 *
1714 * Built as a string template so it lives next to its server-side caller
1715 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1716 * to avoid </script> breakouts.
1717 */
1718function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1719 const url = JSON.stringify(endpointUrl)
1720 .split("<").join("\\u003C")
1721 .split(">").join("\\u003E")
1722 .split("&").join("\\u0026");
1723 return (
1724 "(function(){try{" +
1725 "var btn=document.getElementById('ai-suggest-desc');" +
1726 "var status=document.getElementById('ai-suggest-status');" +
1727 "var body=document.getElementById('pr-body');" +
1728 "var form=btn&&btn.closest&&btn.closest('form');" +
1729 "if(!btn||!body||!form)return;" +
1730 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1731 "var fd=new FormData(form);" +
1732 "var title=String(fd.get('title')||'').trim();" +
1733 "var base=String(fd.get('base')||'').trim();" +
1734 "var head=String(fd.get('head')||'').trim();" +
1735 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1736 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1737 "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'})" +
1738 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1739 ".then(function(j){btn.disabled=false;" +
1740 "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;}}" +
1741 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1742 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1743 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1744 "});" +
1745 "}catch(e){}})();"
1746 );
1747}
1748
3c03977Claude1749/**
1750 * Live co-editing client. Connects to the per-PR SSE feed and:
1751 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1752 * status colour per user).
1753 * - Renders tinted cursor caret overlays inside #pr-body and every
1754 * `[data-live-field]` element.
1755 * - Broadcasts the local user's cursor position (selectionStart /
1756 * selectionEnd) debounced at 100ms.
1757 * - Broadcasts content patches (`replace` of the whole textarea —
1758 * last-write-wins v1) debounced at 250ms.
1759 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1760 * to the matching local field if untouched.
1761 *
1762 * All endpoint URLs are JSON-escaped via safe replacements so they
1763 * can't break out of the <script> tag.
1764 */
25b1ff7Claude1765
1766/**
1767 * Figma-style collaborative PR presence client (WebSocket).
1768 *
1769 * Connects to `GET /:owner/:repo/pulls/:number/presence` (WebSocket upgrade).
1770 * On connect the server sends `{type:"init", users:[...]}` so the bar renders
1771 * immediately. Subsequent messages from the server drive the presence bar and
1772 * per-line cursor pills in the diff.
1773 *
1774 * Outbound messages:
1775 * {type:"cursor", line: N} — user hovered a diff line
1776 * {type:"typing", line: N, typing: bool} — textarea focus/blur in diff
1777 * {type:"ping"} — keep-alive every 10s
1778 *
1779 * Inbound messages:
1780 * {type:"init", users:[{sessionId,username,colour,line,typing}]}
1781 * {type:"join", user:{sessionId,username,colour,line,typing}}
1782 * {type:"leave", sessionId}
1783 * {type:"cursor", sessionId, username, colour, line}
1784 * {type:"typing", sessionId, username, colour, line, typing}
1785 */
1786function PR_PRESENCE_SCRIPT(owner: string, repo: string, prNum: number): string {
1787 const wsPath = JSON.stringify(`/${owner}/${repo}/pulls/${prNum}/presence`)
1788 .split("<").join("\\u003C")
1789 .split(">").join("\\u003E")
1790 .split("&").join("\\u0026");
1791 return `(function(){
1792try{
1793var wsPath=${wsPath};
1794var proto=location.protocol==='https:'?'wss:':'ws:';
1795var url=proto+'//'+location.host+wsPath;
1796var mySessionId=null;
1797// sessionId -> {username, colour, line, typing}
1798var peers={};
1799var ws=null;
1800var pingTimer=null;
1801var reconnectDelay=1500;
1802var reconnectTimer=null;
1803
1804function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
1805
1806// ── Toast ──────────────────────────────────────────────────────────────
1807var toastWrap=document.getElementById('presence-toasts');
1808function toast(msg){
1809 if(!toastWrap)return;
1810 var t=document.createElement('div');
1811 t.className='presence-toast';
1812 t.textContent=msg;
1813 toastWrap.appendChild(t);
1814 setTimeout(function(){t.classList.add('fading');setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},420);},2500);
1815}
1816
1817// ── Presence bar ───────────────────────────────────────────────────────
1818var avEl=document.getElementById('presence-avatars');
1819var countEl=document.getElementById('presence-count');
1820function renderBar(){
1821 if(!avEl)return;
1822 var ids=Object.keys(peers);
1823 var html='';
1824 for(var i=0;i<ids.length&&i<8;i++){
1825 var p=peers[ids[i]];
1826 var initials=(p.username||'?').slice(0,2).toUpperCase();
1827 html+='<span class="presence-avatar" style="background:'+esc(p.colour)+'" title="'+esc(p.username)+'">';
1828 html+='<span class="presence-avatar-dot">'+esc(initials)+'</span>';
1829 html+=esc(p.username);
1830 html+='</span>';
1831 }
1832 avEl.innerHTML=html;
1833 if(countEl){
1834 var n=ids.length;
1835 countEl.textContent=n===0?'No other reviewers':n===1?'1 reviewer online':n+' reviewers online';
1836 }
1837}
1838
1839// ── Diff cursor pills ──────────────────────────────────────────────────
1840// data-line value is like "12:x:5" or "12:5:x" — pull numeric line only
1841function lineNumFromKey(key){var m=String(key).match(/(\d+)/);return m?parseInt(m[1],10):null;}
1842function findDiffRow(line){return document.querySelector('[data-line]') &&
1843 (function(){var rows=document.querySelectorAll('[data-line]');
1844 for(var i=0;i<rows.length;i++){var n=lineNumFromKey(rows[i].getAttribute('data-line')||'');if(n===line)return rows[i];}
1845 return null;
1846 })();}
1847function removePill(sessionId){var old=document.querySelector('[data-presence-sid="'+sessionId+'"]');if(old&&old.parentNode)old.parentNode.removeChild(old);}
1848function placePill(sessionId,username,colour,line,typing){
1849 removePill(sessionId);
1850 if(line==null)return;
1851 var row=findDiffRow(line);if(!row)return;
1852 var pill=document.createElement('span');
1853 pill.className='presence-line-pill'+(typing?' is-typing':'');
1854 pill.setAttribute('data-presence-sid',sessionId);
1855 pill.style.background=colour||'#8c6dff';
1856 pill.textContent=(username||'?').slice(0,12)+(typing?' typing':'');
1857 row.appendChild(pill);
1858}
1859function clearPeer(sessionId){removePill(sessionId);delete peers[sessionId];}
1860
1861// ── Inbound message handler ────────────────────────────────────────────
1862function onMsg(raw){
1863 var d;try{d=JSON.parse(raw);}catch(e){return;}
1864 if(!d||!d.type)return;
1865 if(d.type==='init'){
1866 mySessionId=d.sessionId||null;
1867 peers={};
1868 (d.users||[]).forEach(function(u){
1869 if(u.sessionId===mySessionId)return;
1870 peers[u.sessionId]={username:u.username,colour:u.colour,line:u.line,typing:u.typing};
1871 placePill(u.sessionId,u.username,u.colour,u.line,u.typing);
1872 });
1873 renderBar();
1874 } else if(d.type==='join'){
1875 if(d.user&&d.user.sessionId!==mySessionId){
1876 peers[d.user.sessionId]={username:d.user.username,colour:d.user.colour,line:d.user.line,typing:d.user.typing};
1877 renderBar();
1878 toast(esc(d.user.username)+' joined the review');
1879 }
1880 } else if(d.type==='leave'){
1881 if(d.sessionId&&d.sessionId!==mySessionId){
1882 var name=peers[d.sessionId]&&peers[d.sessionId].username;
1883 clearPeer(d.sessionId);
1884 renderBar();
1885 if(name)toast(esc(name)+' left the review');
1886 }
1887 } else if(d.type==='cursor'){
1888 if(d.sessionId&&d.sessionId!==mySessionId){
1889 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=false;}
1890 placePill(d.sessionId,d.username,d.colour,d.line,false);
1891 }
1892 } else if(d.type==='typing'){
1893 if(d.sessionId&&d.sessionId!==mySessionId){
1894 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=d.typing;}
1895 placePill(d.sessionId,d.username,d.colour,d.line,d.typing);
1896 }
1897 }
1898}
1899
1900// ── Outbound helpers ───────────────────────────────────────────────────
1901function send(obj){try{if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}catch(e){}}
1902
1903// ── Mouse hover on diff rows ───────────────────────────────────────────
1904var hoverTimer=null;
1905document.addEventListener('mouseover',function(ev){
1906 var row=ev.target&&ev.target.closest&&ev.target.closest('[data-line]');
1907 if(!row)return;
1908 if(hoverTimer)clearTimeout(hoverTimer);
1909 hoverTimer=setTimeout(function(){
1910 var key=row.getAttribute('data-line')||'';
1911 var line=lineNumFromKey(key);
1912 if(line!=null)send({type:'cursor',line:line});
1913 },80);
1914});
1915
1916// ── Typing detection in diff comment textareas ─────────────────────────
1917document.addEventListener('focusin',function(ev){
1918 var ta=ev.target;
1919 if(!ta||ta.tagName!=='TEXTAREA')return;
1920 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1921 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1922 if(line!=null)send({type:'typing',line:line,typing:true});
1923});
1924document.addEventListener('focusout',function(ev){
1925 var ta=ev.target;
1926 if(!ta||ta.tagName!=='TEXTAREA')return;
1927 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1928 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1929 if(line!=null)send({type:'typing',line:line,typing:false});
1930});
1931
1932// ── WebSocket lifecycle ────────────────────────────────────────────────
1933function connect(){
1934 if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=null;}
1935 try{ws=new WebSocket(url);}catch(e){scheduleReconnect();return;}
1936 ws.onopen=function(){
1937 reconnectDelay=1500;
1938 pingTimer=setInterval(function(){send({type:'ping'});},10000);
1939 };
1940 ws.onmessage=function(ev){onMsg(ev.data);};
1941 ws.onclose=function(){
1942 if(pingTimer){clearInterval(pingTimer);pingTimer=null;}
1943 scheduleReconnect();
1944 };
1945 ws.onerror=function(){try{ws.close();}catch(e){}};
1946}
1947function scheduleReconnect(){
1948 reconnectTimer=setTimeout(function(){connect();},reconnectDelay);
1949 reconnectDelay=Math.min(reconnectDelay*2,30000);
1950}
1951
1952connect();
1953}catch(e){}})();`;
1954}
1955
3c03977Claude1956function LIVE_COEDIT_SCRIPT(prId: string): string {
1957 const idJson = JSON.stringify(prId)
1958 .split("<").join("\\u003C")
1959 .split(">").join("\\u003E")
1960 .split("&").join("\\u0026");
1961 return (
1962 "(function(){try{" +
1963 "if(typeof EventSource==='undefined')return;" +
1964 "var prId=" + idJson + ";" +
1965 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1966 "var pill=document.getElementById('live-pill');" +
1967 "var avEl=document.getElementById('live-avatars');" +
1968 "var countEl=document.getElementById('live-count');" +
1969 "var sessionId=null;var myColor=null;" +
1970 "var presence={};" + // sessionId -> {color,status,userId,initials}
1971 "var lastApplied={};" + // field -> last server value (for echo suppression)
1972 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1973 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1974 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1975 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1976 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1977 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1978 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1979 "avEl.innerHTML=html;}}" +
1980 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1981 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1982 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1983 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1984 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1985 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1986 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1987 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1988 "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);}" +
1989 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1990 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1991 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1992 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1993 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1994 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1995 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1996 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1997 "}catch(e){}" +
1998 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1999 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
2000 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
2001 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
2002 "var es;var delay=1000;" +
2003 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
2004 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
2005 "(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){}});" +
2006 "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){}});" +
2007 "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){}});" +
2008 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
2009 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
2010 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
2011 "var patch=d.patch;if(!patch||!patch.field)return;" +
2012 "var ta=fieldEl(patch.field);if(!ta)return;" +
2013 "if(document.activeElement===ta)return;" + // don't trample local typing
2014 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
2015 "}catch(e){}});" +
2016 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
2017 "}connect();" +
2018 "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){}}" +
2019 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
2020 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
2021 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
2022 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
2023 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
2024 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
2025 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2026 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2027 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
2028 "}" +
2029 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
2030 "var live=document.querySelectorAll('[data-live-field]');" +
2031 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
2032 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
2033 "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){}});" +
2034 "}catch(e){}})();"
2035 );
2036}
2037
0074234Claude2038async function resolveRepo(ownerName: string, repoName: string) {
2039 const [owner] = await db
2040 .select()
2041 .from(users)
2042 .where(eq(users.username, ownerName))
2043 .limit(1);
2044 if (!owner) return null;
2045 const [repo] = await db
2046 .select()
2047 .from(repositories)
2048 .where(
2049 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
2050 )
2051 .limit(1);
2052 if (!repo) return null;
2053 return { owner, repo };
2054}
2055
2056// PR Nav helper
2057const PrNav = ({
2058 owner,
2059 repo,
2060 active,
2061}: {
2062 owner: string;
2063 repo: string;
2064 active: "code" | "issues" | "pulls" | "commits";
2065}) => (
bb0f894Claude2066 <TabNav
2067 tabs={[
2068 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
2069 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
2070 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
2071 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
2072 ]}
2073 />
0074234Claude2074);
2075
534f04aClaude2076/**
2077 * Block M3 — pre-merge risk score card. Pure presentational helper.
2078 * Rendered in the conversation tab above the gate checks block. Hidden
2079 * entirely when the PR is closed/merged or there is nothing cached and
2080 * nothing in-flight.
2081 */
2082function PrRiskCard({
2083 risk,
2084 calculating,
2085}: {
2086 risk: PrRiskScore | null;
2087 calculating: boolean;
2088}) {
2089 if (!risk) {
2090 return (
2091 <div
2092 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
2093 >
2094 <strong style="font-size: 13px; color: var(--text)">
2095 Risk score: calculating…
2096 </strong>
2097 <div style="font-size: 12px; margin-top: 4px">
2098 Refresh in a moment to see the pre-merge risk score for this PR.
2099 </div>
2100 </div>
2101 );
2102 }
2103
2104 const palette = riskBandPalette(risk.band);
2105 const label = riskBandLabel(risk.band);
2106
2107 return (
2108 <div
2109 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
2110 >
2111 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
2112 <strong>Risk score:</strong>
2113 <span style={`color:${palette.border};font-weight:600`}>
2114 {palette.icon} {label} ({risk.score}/10)
2115 </span>
2116 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
2117 {risk.commitSha.slice(0, 7)}
2118 </span>
2119 </div>
2120 {risk.aiSummary && (
2121 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
2122 {risk.aiSummary}
2123 </div>
2124 )}
2125 <details style="margin-top:10px">
2126 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
2127 See full signal breakdown
2128 </summary>
2129 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
2130 <li>files changed: {risk.signals.filesChanged}</li>
2131 <li>
2132 lines added/removed: {risk.signals.linesAdded} /{" "}
2133 {risk.signals.linesRemoved}
2134 </li>
2135 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
2136 <li>
2137 schema migration touched:{" "}
2138 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
2139 </li>
2140 <li>
2141 locked / sensitive path touched:{" "}
2142 {risk.signals.lockedPathTouched ? "yes" : "no"}
2143 </li>
2144 <li>
2145 adds new dependency:{" "}
2146 {risk.signals.addsNewDependency ? "yes" : "no"}
2147 </li>
2148 <li>
2149 bumps major dependency:{" "}
2150 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
2151 </li>
2152 <li>
2153 tests added for new code:{" "}
2154 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
2155 </li>
2156 <li>
2157 diff-minus-test ratio:{" "}
2158 {risk.signals.diffMinusTestRatio.toFixed(2)}
2159 </li>
2160 </ul>
2161 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2162 How is this calculated? The score is a transparent sum of
2163 weighted signals — see <code>src/lib/pr-risk.ts</code>
2164 {" "}<code>computePrRiskScore</code>.
2165 </div>
2166 </details>
2167 {calculating && (
2168 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2169 (recomputing for the latest commit — refresh to update)
2170 </div>
2171 )}
2172 </div>
2173 );
2174}
2175
2176function riskBandPalette(band: PrRiskScore["band"]): {
2177 border: string;
2178 icon: string;
2179} {
2180 switch (band) {
2181 case "low":
2182 return { border: "var(--green)", icon: "" };
2183 case "medium":
2184 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
2185 case "high":
2186 return { border: "var(--orange, #db6d28)", icon: "⚠" };
2187 case "critical":
2188 return { border: "var(--red)", icon: "\u{1F6D1}" };
2189 }
2190}
2191
2192function riskBandLabel(band: PrRiskScore["band"]): string {
2193 switch (band) {
2194 case "low":
2195 return "LOW";
2196 case "medium":
2197 return "MEDIUM";
2198 case "high":
2199 return "HIGH";
2200 case "critical":
2201 return "CRITICAL";
2202 }
2203}
2204
422a2d4Claude2205// ---------------------------------------------------------------------------
2206// AI Trio Review — 3-column card grid + disagreement callout.
2207//
2208// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
2209// per run: one per persona (security/correctness/style) plus a top-level
2210// summary. We surface them here as a single grid above the normal
2211// comment stream so reviewers see the verdicts at a glance.
2212// ---------------------------------------------------------------------------
2213
2214const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
2215
2216interface TrioCommentLike {
2217 body: string;
2218}
2219
2220function isTrioComment(body: string | null | undefined): boolean {
2221 if (!body) return false;
2222 return (
2223 body.includes(TRIO_SUMMARY_MARKER) ||
2224 body.includes(TRIO_COMMENT_MARKER.security) ||
2225 body.includes(TRIO_COMMENT_MARKER.correctness) ||
2226 body.includes(TRIO_COMMENT_MARKER.style)
2227 );
2228}
2229
2230function trioPersonaOfComment(body: string): TrioPersona | null {
2231 for (const p of TRIO_PERSONAS) {
2232 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
2233 }
2234 return null;
2235}
2236
2237/**
2238 * Best-effort verdict parse from a persona comment body. The body shape
2239 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
2240 * we only need the "Pass" / "Fail" word from the H2 heading.
2241 */
2242function trioVerdictOfBody(body: string): "pass" | "fail" | null {
2243 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
2244 if (!m) return null;
2245 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
2246}
2247
2248/**
2249 * Parse the disagreement bullet list out of the summary comment so we
2250 * can render it as a polished callout strip. Returns [] when nothing
2251 * matches — the comment author may have edited the marker out.
2252 */
2253function parseDisagreements(summaryBody: string): Array<{
2254 file: string;
2255 failing: string;
2256 passing: string;
2257}> {
2258 const out: Array<{ file: string; failing: string; passing: string }> = [];
2259 // Each disagreement line looks like:
2260 // - `path:42` — security, style say ✗, correctness say ✓
2261 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
2262 let m: RegExpExecArray | null;
2263 while ((m = re.exec(summaryBody)) !== null) {
2264 out.push({
2265 file: m[1].trim(),
2266 failing: m[2].trim().replace(/[,\s]+$/g, ""),
2267 passing: m[3].trim().replace(/[,\s]+$/g, ""),
2268 });
2269 }
2270 return out;
2271}
2272
2273function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
2274 // Find the most recent persona comments + summary. We iterate from
2275 // the end so re-reviews (multiple runs on the same PR) display the
2276 // freshest verdict.
2277 const latest: Partial<Record<TrioPersona, string>> = {};
2278 let summaryBody: string | null = null;
2279 for (let i = comments.length - 1; i >= 0; i--) {
2280 const body = comments[i].body || "";
2281 if (!isTrioComment(body)) continue;
2282 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
2283 summaryBody = body;
2284 continue;
2285 }
2286 const persona = trioPersonaOfComment(body);
2287 if (persona && !latest[persona]) latest[persona] = body;
2288 }
2289 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2290 if (!anyPersona && !summaryBody) return null;
2291
2292 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
2293
2294 return (
67dc4e1Claude2295 <div class="trio-wrap" id="trio-review-section">
422a2d4Claude2296 <div class="trio-header">
2297 <span class="trio-header-dot" aria-hidden="true"></span>
2298 <strong>AI Trio Review</strong>
2299 <span class="trio-header-sub">
2300 Three independent reviewers ran in parallel.
2301 </span>
2302 </div>
2303 <div class="trio-grid">
2304 {TRIO_PERSONAS.map((persona) => {
2305 const body = latest[persona];
2306 const verdict = body ? trioVerdictOfBody(body) : null;
2307 const stateClass =
2308 verdict === "fail"
2309 ? "is-fail"
2310 : verdict === "pass"
2311 ? "is-pass"
2312 : "is-pending";
2313 return (
2314 <div class={`trio-card trio-${persona} ${stateClass}`}>
2315 <div class="trio-card-head">
2316 <span class="trio-card-icon" aria-hidden="true">
2317 {persona === "security"
2318 ? "🛡"
2319 : persona === "correctness"
2320 ? "✓"
2321 : "✎"}
2322 </span>
2323 <strong class="trio-card-title">
2324 {persona[0].toUpperCase() + persona.slice(1)}
2325 </strong>
2326 <span class="trio-card-verdict">
2327 {verdict === "pass"
2328 ? "Pass"
2329 : verdict === "fail"
2330 ? "Fail"
2331 : "Pending"}
2332 </span>
2333 </div>
2334 <div class="trio-card-body">
2335 {body ? (
2336 <MarkdownContent
2337 html={renderMarkdown(stripTrioHeading(body))}
2338 />
2339 ) : (
2340 <span class="trio-card-empty">
2341 Awaiting reviewer output.
2342 </span>
2343 )}
2344 </div>
2345 </div>
2346 );
2347 })}
2348 </div>
2349 {disagreements.length > 0 && (
2350 <div class="trio-disagreement-strip" role="note">
2351 <span class="trio-disagreement-icon" aria-hidden="true">
2352
2353 </span>
2354 <div class="trio-disagreement-body">
2355 <strong>Reviewers disagree — review carefully.</strong>
2356 <ul class="trio-disagreement-list">
2357 {disagreements.map((d) => (
2358 <li>
2359 <code>{d.file}</code> — {d.failing} says ✗,{" "}
2360 {d.passing} says ✓
2361 </li>
2362 ))}
2363 </ul>
2364 </div>
2365 </div>
2366 )}
2367 </div>
2368 );
2369}
2370
2371/**
2372 * Strip the marker comment + first H2 heading from a persona body so
2373 * the card body shows just the findings list (verdict is already in
2374 * the card head). Best-effort — malformed bodies render whole.
2375 */
2376function stripTrioHeading(body: string): string {
2377 return body
2378 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
2379 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
2380 .trim();
2381}
2382
67dc4e1Claude2383/**
2384 * Three small verdict pills rendered inline in the PR header. Each pill
2385 * links to the `#trio-review-section` anchor so clicking scrolls to the
2386 * full card grid. Only shown when `AI_TRIO_REVIEW_ENABLED=1` and at
2387 * least one persona comment exists.
2388 */
2389function TrioVerdictPills({
2390 comments,
2391}: {
2392 comments: TrioCommentLike[];
2393}) {
2394 if (!isTrioReviewEnabled()) return null;
2395
2396 // Find latest persona verdicts (same logic as TrioReviewGrid).
2397 const latest: Partial<Record<TrioPersona, string>> = {};
2398 for (let i = comments.length - 1; i >= 0; i--) {
2399 const body = comments[i].body || "";
2400 if (!isTrioComment(body)) continue;
2401 if (body.includes(TRIO_SUMMARY_MARKER)) continue;
2402 const persona = trioPersonaOfComment(body);
2403 if (persona && !latest[persona]) latest[persona] = body;
2404 }
2405
2406 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2407 if (!anyPersona) return null;
2408
2409 const PERSONA_LABEL: Record<TrioPersona, string> = {
2410 security: "Security",
2411 correctness: "Correctness",
2412 style: "Style",
2413 };
2414 const PERSONA_ICON: Record<TrioPersona, string> = {
2415 security: "🛡",
2416 correctness: "✓",
2417 style: "✎",
2418 };
2419
2420 return (
2421 <span class="trio-pills-wrap" aria-label="AI Trio Review verdicts">
2422 {TRIO_PERSONAS.map((persona) => {
2423 const body = latest[persona];
2424 const verdict = body ? trioVerdictOfBody(body) : null;
2425 const stateClass =
2426 verdict === "pass"
2427 ? "is-pass"
2428 : verdict === "fail"
2429 ? "is-fail"
2430 : "is-pending";
2431 const glyph =
2432 verdict === "pass" ? "✓" : verdict === "fail" ? "✗" : "⟳";
2433 return (
2434 <a
2435 href="#trio-review-section"
2436 class={`trio-pill ${stateClass}`}
2437 title={`AI ${PERSONA_LABEL[persona]} Review — ${verdict === "pass" ? "Pass" : verdict === "fail" ? "Fail" : "Pending"}`}
2438 >
2439 <span aria-hidden="true">{PERSONA_ICON[persona]}</span>
2440 {PERSONA_LABEL[persona]} {glyph}
2441 </a>
2442 );
2443 })}
2444 </span>
2445 );
2446}
2447
0074234Claude2448// List PRs
04f6b7fClaude2449pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2450 const { owner: ownerName, repo: repoName } = c.req.param();
2451 const user = c.get("user");
2452 const state = c.req.query("state") || "open";
d790b49Claude2453 const searchQ = c.req.query("q")?.trim() || "";
80bd7c8Claude2454 const authorFilter = c.req.query("author")?.trim() || "";
f5b9ef5Claude2455 const sortPr = (c.req.query("sort") || "newest").trim();
0074234Claude2456
ea9ed4cClaude2457 // ── Loading skeleton (flag-gated) ──
2458 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
2459 // the user see the page structure before counts + select resolve.
2460 // Behind a flag for now — we don't ship flashes.
2461 if (c.req.query("skeleton") === "1") {
2462 return c.html(
2463 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2464 <RepoHeader owner={ownerName} repo={repoName} />
2465 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2466 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2467 <style
2468 dangerouslySetInnerHTML={{
2469 __html: `
404b398Claude2470 .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; }
2471 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude2472 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
2473 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
2474 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
2475 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
2476 .prs-skel-row { height: 76px; border-radius: 12px; }
2477 `,
2478 }}
2479 />
2480 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
2481 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
2482 <div class="prs-skel-list" aria-hidden="true">
2483 {Array.from({ length: 6 }).map(() => (
2484 <div class="prs-skel prs-skel-row" />
2485 ))}
2486 </div>
2487 <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">
2488 Loading pull requests for {ownerName}/{repoName}…
2489 </span>
2490 </Layout>
2491 );
2492 }
2493
0074234Claude2494 const resolved = await resolveRepo(ownerName, repoName);
2495 if (!resolved) return c.notFound();
2496
6fc53bdClaude2497 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
2498 const stateFilter =
2499 state === "draft"
2500 ? and(
2501 eq(pullRequests.state, "open"),
2502 eq(pullRequests.isDraft, true)
2503 )
2504 : eq(pullRequests.state, state);
2505
0074234Claude2506 const prList = await db
2507 .select({
2508 pr: pullRequests,
2509 author: { username: users.username },
2510 })
2511 .from(pullRequests)
2512 .innerJoin(users, eq(pullRequests.authorId, users.id))
2513 .where(
d790b49Claude2514 and(
2515 eq(pullRequests.repositoryId, resolved.repo.id),
2516 stateFilter,
2517 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
80bd7c8Claude2518 authorFilter ? eq(users.username, authorFilter) : undefined,
d790b49Claude2519 )
0074234Claude2520 )
f5b9ef5Claude2521 .orderBy(
2522 sortPr === "oldest" ? asc(pullRequests.createdAt)
2523 : sortPr === "updated" ? desc(pullRequests.updatedAt)
2524 : desc(pullRequests.createdAt) // newest (default)
2525 );
0074234Claude2526
0369e77Claude2527 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude2528 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude2529 const commentCountMap = new Map<string, number>();
1aef949Claude2530 if (prList.length > 0) {
2531 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude2532 const [reviewRows, commentRows] = await Promise.all([
2533 db
2534 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
2535 .from(prReviews)
2536 .where(inArray(prReviews.pullRequestId, prIds)),
2537 db
2538 .select({
2539 prId: prComments.pullRequestId,
2540 cnt: sql<number>`count(*)::int`,
2541 })
2542 .from(prComments)
2543 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
2544 .groupBy(prComments.pullRequestId),
2545 ]);
1aef949Claude2546 for (const r of reviewRows) {
2547 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
2548 if (r.state === "approved") entry.approved = true;
2549 if (r.state === "changes_requested") entry.changesRequested = true;
2550 reviewMap.set(r.prId, entry);
2551 }
0369e77Claude2552 for (const r of commentRows) {
2553 commentCountMap.set(r.prId, Number(r.cnt));
2554 }
1aef949Claude2555 }
2556
0074234Claude2557 const [counts] = await db
2558 .select({
2559 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude2560 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude2561 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
2562 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
2563 })
2564 .from(pullRequests)
2565 .where(eq(pullRequests.repositoryId, resolved.repo.id));
2566
b078860Claude2567 const openCount = counts?.open ?? 0;
2568 const mergedCount = counts?.merged ?? 0;
2569 const closedCount = counts?.closed ?? 0;
2570 const draftCount = counts?.draft ?? 0;
2571 const allCount = openCount + mergedCount + closedCount;
2572
2573 // "All" is presentational only — the DB query for state='all' matches
2574 // nothing, so we render a friendlier empty state when picked. We do NOT
2575 // change the query logic to keep this commit purely visual.
80bd7c8Claude2576 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude2577 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude2578 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
2579 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
2580 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
2581 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
2582 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude2583 ];
2584 const isAllState = state === "all";
cb5a796Claude2585 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
2586 const prListPendingCount = viewerIsOwnerOnPrList
2587 ? await countPendingForRepo(resolved.repo.id)
2588 : 0;
b078860Claude2589
0074234Claude2590 return c.html(
2591 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2592 <RepoHeader owner={ownerName} repo={repoName} />
2593 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2594 <PendingCommentsBanner
2595 owner={ownerName}
2596 repo={repoName}
2597 count={prListPendingCount}
2598 />
b078860Claude2599 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2600
2601 <div class="prs-hero">
2602 <div class="prs-hero-inner">
2603 <div class="prs-hero-text">
2604 <div class="prs-hero-eyebrow">Pull requests</div>
2605 <h1 class="prs-hero-title">
2606 Review, <span class="gradient-text">merge with AI</span>.
2607 </h1>
2608 <p class="prs-hero-sub">
2609 {openCount === 0 && allCount === 0
2610 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2611 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
2612 </p>
2613 </div>
7a28902Claude2614 <div class="prs-hero-actions">
2615 <a
2616 href={`/${ownerName}/${repoName}/pulls/insights`}
2617 class="prs-cta"
2618 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
2619 >
2620 Insights
2621 </a>
2622 {user && (
b078860Claude2623 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
2624 + New pull request
2625 </a>
7a28902Claude2626 )}
2627 </div>
b078860Claude2628 </div>
2629 </div>
2630
2631 <nav class="prs-tabs" aria-label="Pull request filters">
2632 {tabPills.map((t) => {
2633 const isActive =
2634 state === t.key ||
2635 (t.key === "open" &&
2636 state !== "merged" &&
2637 state !== "closed" &&
2638 state !== "all" &&
2639 state !== "draft");
2640 return (
2641 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2642 <span>{t.label}</span>
2643 <span class="prs-tab-count">{t.count}</span>
2644 </a>
2645 );
2646 })}
2647 </nav>
2648
d790b49Claude2649 <form
2650 method="get"
2651 action={`/${ownerName}/${repoName}/pulls`}
2652 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2653 >
2654 <input type="hidden" name="state" value={state} />
2655 <input
2656 type="search"
2657 name="q"
2658 value={searchQ}
2659 placeholder="Search pull requests…"
2660 class="issues-search-input"
2661 style="flex:1;max-width:380px"
2662 />
80bd7c8Claude2663 <input
2664 type="text"
2665 name="author"
2666 value={authorFilter}
2667 placeholder="Filter by author…"
2668 class="issues-search-input"
2669 style="max-width:200px"
2670 />
d790b49Claude2671 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2672 {(searchQ || authorFilter) && (
d790b49Claude2673 <a
2674 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2675 class="issues-filter-clear"
2676 >
2677 Clear
2678 </a>
2679 )}
2680 </form>
f5b9ef5Claude2681
2682 <div class="prs-sort-row">
2683 <span class="prs-sort-label">Sort:</span>
2684 {(["newest", "oldest", "updated"] as const).map((s) => (
2685 <a
2686 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2687 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2688 >
2689 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2690 </a>
2691 ))}
2692 </div>
2693
0074234Claude2694 {prList.length === 0 ? (
b078860Claude2695 <div class="prs-empty">
ea9ed4cClaude2696 <div class="prs-empty-inner">
2697 <strong>
80bd7c8Claude2698 {searchQ || authorFilter
2699 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2700 : isAllState
2701 ? "Pick a filter above to browse PRs."
2702 : `No ${state} pull requests.`}
ea9ed4cClaude2703 </strong>
2704 <p class="prs-empty-sub">
80bd7c8Claude2705 {searchQ || authorFilter
2706 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2707 : state === "open"
2708 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2709 : isAllState
2710 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2711 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2712 </p>
2713 <div class="prs-empty-cta">
80bd7c8Claude2714 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2715 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2716 + New pull request
2717 </a>
2718 )}
80bd7c8Claude2719 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2720 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2721 View open PRs
2722 </a>
2723 )}
2724 <a href={`/${ownerName}/${repoName}`} class="btn">
2725 Back to code
2726 </a>
2727 </div>
2728 </div>
b078860Claude2729 </div>
0074234Claude2730 ) : (
b078860Claude2731 <div class="prs-list">
2732 {prList.map(({ pr, author }) => {
2733 const stateClass =
2734 pr.state === "open"
2735 ? pr.isDraft
2736 ? "state-draft"
2737 : "state-open"
2738 : pr.state === "merged"
2739 ? "state-merged"
2740 : "state-closed";
2741 const icon =
2742 pr.state === "open"
2743 ? pr.isDraft
2744 ? "◌"
2745 : "○"
2746 : pr.state === "merged"
2747 ? "⮌"
2748 : "✓";
1aef949Claude2749 const rv = reviewMap.get(pr.id);
b078860Claude2750 return (
2751 <a
2752 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2753 class="prs-row"
2754 style="text-decoration:none;color:inherit"
0074234Claude2755 >
b078860Claude2756 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2757 {icon}
0074234Claude2758 </div>
b078860Claude2759 <div class="prs-row-body">
2760 <h3 class="prs-row-title">
2761 <span>{pr.title}</span>
2762 <span class="prs-row-number">#{pr.number}</span>
2763 </h3>
2764 <div class="prs-row-meta">
2765 <span
2766 class="prs-branch-chips"
2767 title={`${pr.headBranch} into ${pr.baseBranch}`}
2768 >
2769 <span class="prs-branch-chip">{pr.headBranch}</span>
2770 <span class="prs-branch-arrow">{"→"}</span>
2771 <span class="prs-branch-chip">{pr.baseBranch}</span>
2772 </span>
2773 <span>
2774 by{" "}
2775 <strong style="color:var(--text)">
2776 {author.username}
2777 </strong>{" "}
2778 {formatRelative(pr.createdAt)}
2779 </span>
2780 <span class="prs-row-tags">
2781 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2782 {pr.state === "merged" && (
2783 <span class="prs-tag is-merged">Merged</span>
2784 )}
1aef949Claude2785 {rv?.approved && !rv.changesRequested && (
2786 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
2787 )}
2788 {rv?.changesRequested && (
2789 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
2790 )}
0369e77Claude2791 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
2792 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
2793 💬 {commentCountMap.get(pr.id)}
2794 </span>
2795 )}
b078860Claude2796 </span>
2797 </div>
0074234Claude2798 </div>
b078860Claude2799 </a>
2800 );
2801 })}
2802 </div>
0074234Claude2803 )}
2804 </Layout>
2805 );
2806});
2807
7a28902Claude2808/* ─────────────────────────────────────────────────────────────────────────
2809 * PR Insights — 90-day analytics for the pull request activity of a repo.
2810 * Route: GET /:owner/:repo/pulls/insights
2811 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
2812 * "insights" is not swallowed by the :number param.
2813 * ───────────────────────────────────────────────────────────────────────── */
2814
2815/** Format a millisecond duration as human-readable string. */
2816function formatMsDuration(ms: number): string {
2817 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
2818 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
2819 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
2820 return `${Math.round(ms / 86_400_000)}d`;
2821}
2822
2823/** Format an ISO week string as "Jan 15". */
2824function formatWeekLabel(isoWeek: string): string {
2825 try {
2826 const d = new Date(isoWeek);
2827 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2828 } catch {
2829 return isoWeek.slice(5, 10);
2830 }
2831}
2832
2833const PR_INSIGHTS_STYLES = `
2834 .pri-page { padding-bottom: 48px; }
2835 .pri-hero {
2836 position: relative;
2837 margin: 0 0 var(--space-5);
2838 padding: 22px 26px 24px;
2839 background: var(--bg-elevated);
2840 border: 1px solid var(--border);
2841 border-radius: 16px;
2842 overflow: hidden;
2843 }
2844 .pri-hero::before {
2845 content: '';
2846 position: absolute; top: 0; left: 0; right: 0;
2847 height: 2px;
2848 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2849 opacity: 0.7;
2850 pointer-events: none;
2851 }
2852 .pri-hero-eyebrow {
2853 font-size: 12px;
2854 color: var(--text-muted);
2855 text-transform: uppercase;
2856 letter-spacing: 0.08em;
2857 font-weight: 600;
2858 margin-bottom: 8px;
2859 }
2860 .pri-hero-title {
2861 font-family: var(--font-display);
2862 font-size: clamp(26px, 3.4vw, 34px);
2863 font-weight: 800;
2864 letter-spacing: -0.025em;
2865 line-height: 1.06;
2866 margin: 0 0 8px;
2867 color: var(--text-strong);
2868 }
2869 .pri-hero-title .gradient-text {
2870 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2871 -webkit-background-clip: text;
2872 background-clip: text;
2873 -webkit-text-fill-color: transparent;
2874 color: transparent;
2875 }
2876 .pri-hero-sub {
2877 font-size: 14.5px;
2878 color: var(--text-muted);
2879 margin: 0;
2880 line-height: 1.5;
2881 }
2882 .pri-section { margin-bottom: 32px; }
2883 .pri-section-title {
2884 font-size: 13px;
2885 font-weight: 700;
2886 text-transform: uppercase;
2887 letter-spacing: 0.06em;
2888 color: var(--text-muted);
2889 margin: 0 0 14px;
2890 }
2891 .pri-cards {
2892 display: grid;
2893 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
2894 gap: 12px;
2895 }
2896 .pri-card {
2897 padding: 16px 18px;
2898 background: var(--bg-elevated);
2899 border: 1px solid var(--border);
2900 border-radius: 12px;
2901 }
2902 .pri-card-label {
2903 font-size: 12px;
2904 font-weight: 600;
2905 color: var(--text-muted);
2906 text-transform: uppercase;
2907 letter-spacing: 0.05em;
2908 margin-bottom: 6px;
2909 }
2910 .pri-card-value {
2911 font-size: 28px;
2912 font-weight: 800;
2913 letter-spacing: -0.04em;
2914 color: var(--text-strong);
2915 line-height: 1;
2916 }
2917 .pri-card-sub {
2918 font-size: 12px;
2919 color: var(--text-muted);
2920 margin-top: 4px;
2921 }
2922 .pri-chart {
2923 display: flex;
2924 align-items: flex-end;
2925 gap: 6px;
2926 height: 120px;
2927 background: var(--bg-elevated);
2928 border: 1px solid var(--border);
2929 border-radius: 12px;
2930 padding: 16px 16px 0;
2931 }
2932 .pri-bar-col {
2933 flex: 1;
2934 display: flex;
2935 flex-direction: column;
2936 align-items: center;
2937 justify-content: flex-end;
2938 height: 100%;
2939 gap: 4px;
2940 }
2941 .pri-bar {
2942 width: 100%;
2943 min-height: 4px;
2944 border-radius: 4px 4px 0 0;
2945 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 100%);
2946 transition: opacity 140ms;
2947 }
2948 .pri-bar:hover { opacity: 0.8; }
2949 .pri-bar-label {
2950 font-size: 10px;
2951 color: var(--text-muted);
2952 text-align: center;
2953 padding-bottom: 8px;
2954 white-space: nowrap;
2955 overflow: hidden;
2956 text-overflow: ellipsis;
2957 max-width: 100%;
2958 }
2959 .pri-table {
2960 width: 100%;
2961 border-collapse: collapse;
2962 font-size: 13.5px;
2963 }
2964 .pri-table th {
2965 text-align: left;
2966 font-size: 12px;
2967 font-weight: 600;
2968 text-transform: uppercase;
2969 letter-spacing: 0.05em;
2970 color: var(--text-muted);
2971 padding: 8px 12px;
2972 border-bottom: 1px solid var(--border);
2973 }
2974 .pri-table td {
2975 padding: 10px 12px;
2976 border-bottom: 1px solid var(--border);
2977 color: var(--text);
2978 }
2979 .pri-table tr:last-child td { border-bottom: none; }
2980 .pri-table-wrap {
2981 background: var(--bg-elevated);
2982 border: 1px solid var(--border);
2983 border-radius: 12px;
2984 overflow: hidden;
2985 }
2986 .pri-age-row {
2987 display: flex;
2988 align-items: center;
2989 gap: 12px;
2990 padding: 10px 0;
2991 border-bottom: 1px solid var(--border);
2992 font-size: 13.5px;
2993 }
2994 .pri-age-row:last-child { border-bottom: none; }
2995 .pri-age-label {
2996 flex: 0 0 80px;
2997 color: var(--text-muted);
2998 font-size: 12.5px;
2999 font-weight: 600;
3000 }
3001 .pri-age-bar-wrap {
3002 flex: 1;
3003 height: 8px;
3004 background: var(--bg-secondary);
3005 border-radius: 9999px;
3006 overflow: hidden;
3007 }
3008 .pri-age-bar {
3009 height: 100%;
3010 border-radius: 9999px;
3011 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
3012 min-width: 4px;
3013 }
3014 .pri-age-count {
3015 flex: 0 0 32px;
3016 text-align: right;
3017 font-weight: 600;
3018 color: var(--text-strong);
3019 font-size: 13px;
3020 }
3021 .pri-sparkline {
3022 display: flex;
3023 align-items: flex-end;
3024 gap: 3px;
3025 height: 40px;
3026 }
3027 .pri-spark-bar {
3028 flex: 1;
3029 min-height: 2px;
3030 border-radius: 2px 2px 0 0;
3031 background: var(--accent, #8c6dff);
3032 opacity: 0.7;
3033 }
3034 .pri-empty {
3035 color: var(--text-muted);
3036 font-size: 14px;
3037 padding: 24px 0;
3038 text-align: center;
3039 }
3040 @media (max-width: 600px) {
3041 .pri-cards { grid-template-columns: repeat(2, 1fr); }
3042 .pri-hero { padding: 18px 18px 20px; }
3043 }
3044`;
3045
3046pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
3047 const { owner: ownerName, repo: repoName } = c.req.param();
3048 const user = c.get("user");
3049
3050 const resolved = await resolveRepo(ownerName, repoName);
3051 if (!resolved) return c.notFound();
3052
3053 const repoId = resolved.repo.id;
3054 const now = Date.now();
3055
3056 // 1. Merged PRs in last 90 days (avg merge time)
3057 const mergedPRs = await db
3058 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
3059 .from(pullRequests)
3060 .where(and(
3061 eq(pullRequests.repositoryId, repoId),
3062 eq(pullRequests.state, "merged"),
3063 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3064 ));
3065
3066 const avgMergeMs = mergedPRs.length > 0
3067 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
3068 : null;
3069
3070 // 2. PR throughput (last 8 weeks)
3071 const weeklyPRs = await db
3072 .select({
3073 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
3074 count: sql<number>`count(*)::int`,
3075 })
3076 .from(pullRequests)
3077 .where(and(
3078 eq(pullRequests.repositoryId, repoId),
3079 sql`${pullRequests.createdAt} > now() - interval '56 days'`
3080 ))
3081 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
3082 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
3083
3084 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
3085
3086 // 3. PR merge rate (last 90 days)
3087 const [rateCounts] = await db
3088 .select({
3089 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
3090 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
3091 })
3092 .from(pullRequests)
3093 .where(and(
3094 eq(pullRequests.repositoryId, repoId),
3095 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3096 ));
3097
3098 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
3099 const mergeRate = totalResolved > 0
3100 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
3101 : null;
3102
3103 // 4. Top reviewers (last 90 days)
3104 const reviewerCounts = await db
3105 .select({
3106 userId: prReviews.reviewerId,
3107 username: users.username,
3108 count: sql<number>`count(*)::int`,
3109 })
3110 .from(prReviews)
3111 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3112 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
3113 .where(and(
3114 eq(pullRequests.repositoryId, repoId),
3115 sql`${prReviews.createdAt} > now() - interval '90 days'`
3116 ))
3117 .groupBy(prReviews.reviewerId, users.username)
3118 .orderBy(desc(sql`count(*)`))
3119 .limit(5);
3120
3121 // 5. Average reviews per merged PR
3122 const [avgReviewRow] = await db
3123 .select({
3124 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
3125 })
3126 .from(pullRequests)
3127 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3128 .where(and(
3129 eq(pullRequests.repositoryId, repoId),
3130 eq(pullRequests.state, "merged"),
3131 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3132 ));
3133
3134 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
3135 ? Math.round(avgReviewRow.avgReviews * 10) / 10
3136 : null;
3137
3138 // 6. Review turnaround — avg time from PR open to first review
3139 const prsWithReviews = await db
3140 .select({
3141 createdAt: pullRequests.createdAt,
3142 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
3143 })
3144 .from(pullRequests)
3145 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3146 .where(and(
3147 eq(pullRequests.repositoryId, repoId),
3148 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3149 ))
3150 .groupBy(pullRequests.id, pullRequests.createdAt);
3151
3152 const avgReviewTurnaroundMs = prsWithReviews.length > 0
3153 ? prsWithReviews.reduce((s, row) => {
3154 const firstMs = new Date(row.firstReview).getTime();
3155 return s + Math.max(0, firstMs - row.createdAt.getTime());
3156 }, 0) / prsWithReviews.length
3157 : null;
3158
3159 // 7. Open PRs by age bucket
3160 const openPRs = await db
3161 .select({ createdAt: pullRequests.createdAt })
3162 .from(pullRequests)
3163 .where(and(
3164 eq(pullRequests.repositoryId, repoId),
3165 eq(pullRequests.state, "open")
3166 ));
3167
3168 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
3169 for (const { createdAt } of openPRs) {
3170 const ageDays = (now - createdAt.getTime()) / 86_400_000;
3171 if (ageDays < 1) ageBuckets.lt1d++;
3172 else if (ageDays < 3) ageBuckets.d1to3++;
3173 else if (ageDays < 7) ageBuckets.d3to7++;
3174 else if (ageDays < 30) ageBuckets.d7to30++;
3175 else ageBuckets.gt30d++;
3176 }
3177 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
3178
3179 // 8. 7-day merge sparkline
3180 const sparklineRows = await db
3181 .select({
3182 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
3183 count: sql<number>`count(*)::int`,
3184 })
3185 .from(pullRequests)
3186 .where(and(
3187 eq(pullRequests.repositoryId, repoId),
3188 eq(pullRequests.state, "merged"),
3189 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
3190 ))
3191 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
3192 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
3193
3194 const sparkMap = new Map<string, number>();
3195 for (const row of sparklineRows) {
3196 sparkMap.set(row.day.slice(0, 10), row.count);
3197 }
3198 const sparkline: number[] = [];
3199 for (let i = 6; i >= 0; i--) {
3200 const d = new Date(now - i * 86_400_000);
3201 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
3202 }
3203 const maxSpark = Math.max(1, ...sparkline);
3204
3205 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
3206 { label: "< 1 day", key: "lt1d" },
3207 { label: "1–3 days", key: "d1to3" },
3208 { label: "3–7 days", key: "d3to7" },
3209 { label: "7–30 days", key: "d7to30" },
3210 { label: "> 30 days", key: "gt30d" },
3211 ];
3212
3213 return c.html(
3214 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
3215 <RepoHeader owner={ownerName} repo={repoName} />
3216 <PrNav owner={ownerName} repo={repoName} active="pulls" />
3217 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
3218
3219 <div class="pri-page">
3220 {/* Hero */}
3221 <div class="pri-hero">
3222 <div class="pri-hero-eyebrow">Pull requests</div>
3223 <h1 class="pri-hero-title">
3224 PR <span class="gradient-text">Insights</span>
3225 </h1>
3226 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
3227 </div>
3228
3229 {/* Stat cards */}
3230 <div class="pri-section">
3231 <div class="pri-section-title">At a glance</div>
3232 <div class="pri-cards">
3233 <div class="pri-card">
3234 <div class="pri-card-label">Avg merge time</div>
3235 <div class="pri-card-value">
3236 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
3237 </div>
3238 <div class="pri-card-sub">last 90 days</div>
3239 </div>
3240 <div class="pri-card">
3241 <div class="pri-card-label">Total merged</div>
3242 <div class="pri-card-value">{mergedPRs.length}</div>
3243 <div class="pri-card-sub">last 90 days</div>
3244 </div>
3245 <div class="pri-card">
3246 <div class="pri-card-label">Open PRs</div>
3247 <div class="pri-card-value">{openPRs.length}</div>
3248 <div class="pri-card-sub">right now</div>
3249 </div>
3250 <div class="pri-card">
3251 <div class="pri-card-label">Merge rate</div>
3252 <div class="pri-card-value">
3253 {mergeRate != null ? `${mergeRate}%` : "—"}
3254 </div>
3255 <div class="pri-card-sub">merged vs closed</div>
3256 </div>
3257 <div class="pri-card">
3258 <div class="pri-card-label">Avg reviews / PR</div>
3259 <div class="pri-card-value">
3260 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
3261 </div>
3262 <div class="pri-card-sub">merged PRs, 90d</div>
3263 </div>
3264 <div class="pri-card">
3265 <div class="pri-card-label">Top reviewer</div>
3266 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
3267 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
3268 </div>
3269 <div class="pri-card-sub">
3270 {reviewerCounts.length > 0
3271 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
3272 : "no reviews yet"}
3273 </div>
3274 </div>
3275 </div>
3276 </div>
3277
3278 {/* Review turnaround */}
3279 <div class="pri-section">
3280 <div class="pri-section-title">Review turnaround</div>
3281 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
3282 <div class="pri-card">
3283 <div class="pri-card-label">Avg time to first review</div>
3284 <div class="pri-card-value">
3285 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
3286 </div>
3287 <div class="pri-card-sub">
3288 {prsWithReviews.length > 0
3289 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
3290 : "no reviewed PRs in 90d"}
3291 </div>
3292 </div>
3293 </div>
3294 </div>
3295
3296 {/* Weekly throughput bar chart */}
3297 <div class="pri-section">
3298 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
3299 {weeklyPRs.length === 0 ? (
3300 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
3301 ) : (
3302 <div class="pri-chart">
3303 {weeklyPRs.map((w) => (
3304 <div class="pri-bar-col">
3305 <div
3306 class="pri-bar"
3307 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
3308 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
3309 />
3310 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
3311 </div>
3312 ))}
3313 </div>
3314 )}
3315 </div>
3316
3317 {/* 7-day merge sparkline */}
3318 <div class="pri-section">
3319 <div class="pri-section-title">Merges this week (daily)</div>
3320 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
3321 <div class="pri-sparkline">
3322 {sparkline.map((v) => (
3323 <div
3324 class="pri-spark-bar"
3325 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
3326 title={`${v} merge${v === 1 ? "" : "s"}`}
3327 />
3328 ))}
3329 </div>
3330 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
3331 <span>7 days ago</span>
3332 <span>Today</span>
3333 </div>
3334 </div>
3335 </div>
3336
3337 {/* Top reviewers table */}
3338 <div class="pri-section">
3339 <div class="pri-section-title">Top reviewers (last 90 days)</div>
3340 {reviewerCounts.length === 0 ? (
3341 <div class="pri-empty">No reviews posted in the last 90 days.</div>
3342 ) : (
3343 <div class="pri-table-wrap">
3344 <table class="pri-table">
3345 <thead>
3346 <tr>
3347 <th>#</th>
3348 <th>Reviewer</th>
3349 <th>Reviews</th>
3350 </tr>
3351 </thead>
3352 <tbody>
3353 {reviewerCounts.map((r, i) => (
3354 <tr>
3355 <td style="color:var(--text-muted)">{i + 1}</td>
3356 <td>
3357 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
3358 {r.username}
3359 </a>
3360 </td>
3361 <td style="font-weight:600">{r.count}</td>
3362 </tr>
3363 ))}
3364 </tbody>
3365 </table>
3366 </div>
3367 )}
3368 </div>
3369
3370 {/* Open PRs by age */}
3371 <div class="pri-section">
3372 <div class="pri-section-title">Open PRs by age</div>
3373 {openPRs.length === 0 ? (
3374 <div class="pri-empty">No open pull requests.</div>
3375 ) : (
3376 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
3377 {ageBucketDefs.map(({ label, key }) => (
3378 <div class="pri-age-row">
3379 <span class="pri-age-label">{label}</span>
3380 <div class="pri-age-bar-wrap">
3381 <div
3382 class="pri-age-bar"
3383 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
3384 />
3385 </div>
3386 <span class="pri-age-count">{ageBuckets[key]}</span>
3387 </div>
3388 ))}
3389 </div>
3390 )}
3391 </div>
3392
3393 {/* Back link */}
3394 <div>
3395 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
3396 {"←"} Back to pull requests
3397 </a>
3398 </div>
3399 </div>
3400 </Layout>
3401 );
3402});
3403
0074234Claude3404// New PR form
3405pulls.get(
3406 "/:owner/:repo/pulls/new",
3407 softAuth,
3408 requireAuth,
04f6b7fClaude3409 requireRepoAccess("write"),
0074234Claude3410 async (c) => {
3411 const { owner: ownerName, repo: repoName } = c.req.param();
3412 const user = c.get("user")!;
3413 const branches = await listBranches(ownerName, repoName);
3414 const error = c.req.query("error");
3415 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude3416 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude3417
3418 return c.html(
3419 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
3420 <RepoHeader owner={ownerName} repo={repoName} />
3421 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude3422 <Container maxWidth={800}>
3423 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude3424 {error && (
bb0f894Claude3425 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude3426 )}
0316dbbClaude3427 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
3428 <Flex gap={12} align="center" style="margin-bottom: 16px">
3429 <Select name="base">
0074234Claude3430 {branches.map((b) => (
3431 <option value={b} selected={b === defaultBase}>
3432 {b}
3433 </option>
3434 ))}
bb0f894Claude3435 </Select>
3436 <Text muted>&larr;</Text>
3437 <Select name="head">
0074234Claude3438 {branches
3439 .filter((b) => b !== defaultBase)
3440 .concat(defaultBase === branches[0] ? [] : [branches[0]])
3441 .map((b) => (
3442 <option value={b}>{b}</option>
3443 ))}
bb0f894Claude3444 </Select>
3445 </Flex>
3446 <FormGroup>
3447 <Input
0074234Claude3448 name="title"
3449 required
3450 placeholder="Title"
bb0f894Claude3451 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]3452 aria-label="Pull request title"
0074234Claude3453 />
bb0f894Claude3454 </FormGroup>
3455 <FormGroup>
3456 <TextArea
0074234Claude3457 name="body"
81c73c1Claude3458 id="pr-body"
0074234Claude3459 rows={8}
3460 placeholder="Description (Markdown supported)"
bb0f894Claude3461 mono
0074234Claude3462 />
bb0f894Claude3463 </FormGroup>
81c73c1Claude3464 <Flex gap={8} align="center">
3465 <Button type="submit" variant="primary">
3466 Create pull request
3467 </Button>
3468 <button
3469 type="button"
3470 id="ai-suggest-desc"
3471 class="btn"
3472 style="font-weight:500"
3473 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
3474 >
3475 Suggest description with AI
3476 </button>
3477 <span
3478 id="ai-suggest-status"
3479 style="color:var(--text-muted);font-size:13px"
3480 />
3481 </Flex>
bb0f894Claude3482 </Form>
81c73c1Claude3483 <script
3484 dangerouslySetInnerHTML={{
3485 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
3486 }}
3487 />
bb0f894Claude3488 </Container>
0074234Claude3489 </Layout>
3490 );
3491 }
3492);
3493
81c73c1Claude3494// AI-suggested PR description — JSON endpoint driven by the form button.
3495// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
3496// 200; the inline script reads `ok` to decide what to do.
3497pulls.post(
3498 "/:owner/:repo/ai/pr-description",
3499 softAuth,
3500 requireAuth,
3501 requireRepoAccess("write"),
3502 async (c) => {
3503 const { owner: ownerName, repo: repoName } = c.req.param();
3504 if (!isAiAvailable()) {
3505 return c.json({
3506 ok: false,
3507 error: "AI is not available — set ANTHROPIC_API_KEY.",
3508 });
3509 }
3510 const body = await c.req.parseBody();
3511 const title = String(body.title || "").trim();
3512 const baseBranch = String(body.base || "").trim();
3513 const headBranch = String(body.head || "").trim();
3514 if (!baseBranch || !headBranch) {
3515 return c.json({ ok: false, error: "Pick base + head branches first." });
3516 }
3517 if (baseBranch === headBranch) {
3518 return c.json({ ok: false, error: "Base and head must differ." });
3519 }
3520
3521 let diff = "";
3522 try {
3523 const cwd = getRepoPath(ownerName, repoName);
3524 const proc = Bun.spawn(
3525 [
3526 "git",
3527 "diff",
3528 `${baseBranch}...${headBranch}`,
3529 "--",
3530 ],
3531 { cwd, stdout: "pipe", stderr: "pipe" }
3532 );
6ea2109Claude3533 // 30s ceiling — without this a pathological diff (huge binary or
3534 // a corrupt ref) hangs the request indefinitely.
3535 const killer = setTimeout(() => proc.kill(), 30_000);
3536 try {
3537 diff = await new Response(proc.stdout).text();
3538 await proc.exited;
3539 } finally {
3540 clearTimeout(killer);
3541 }
81c73c1Claude3542 } catch {
3543 diff = "";
3544 }
3545 if (!diff.trim()) {
3546 return c.json({
3547 ok: false,
3548 error: "No diff between branches — nothing to summarise.",
3549 });
3550 }
3551
3552 let summary = "";
3553 try {
3554 summary = await generatePrSummary(title || "(untitled)", diff);
3555 } catch (err) {
3556 const msg = err instanceof Error ? err.message : "AI request failed.";
3557 return c.json({ ok: false, error: msg });
3558 }
3559 if (!summary.trim()) {
3560 return c.json({ ok: false, error: "AI returned an empty draft." });
3561 }
3562 return c.json({ ok: true, body: summary });
3563 }
3564);
3565
0074234Claude3566// Create PR
3567pulls.post(
3568 "/:owner/:repo/pulls/new",
3569 softAuth,
3570 requireAuth,
04f6b7fClaude3571 requireRepoAccess("write"),
0074234Claude3572 async (c) => {
3573 const { owner: ownerName, repo: repoName } = c.req.param();
3574 const user = c.get("user")!;
3575 const body = await c.req.parseBody();
3576 const title = String(body.title || "").trim();
3577 const prBody = String(body.body || "").trim();
3578 const baseBranch = String(body.base || "main");
3579 const headBranch = String(body.head || "");
3580
3581 if (!title || !headBranch) {
3582 return c.redirect(
3583 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
3584 );
3585 }
3586
3587 if (baseBranch === headBranch) {
3588 return c.redirect(
3589 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
3590 );
3591 }
3592
3593 const resolved = await resolveRepo(ownerName, repoName);
3594 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3595
6fc53bdClaude3596 const isDraft = String(body.draft || "") === "1";
3597
0074234Claude3598 const [pr] = await db
3599 .insert(pullRequests)
3600 .values({
3601 repositoryId: resolved.repo.id,
3602 authorId: user.id,
3603 title,
3604 body: prBody || null,
3605 baseBranch,
3606 headBranch,
6fc53bdClaude3607 isDraft,
0074234Claude3608 })
3609 .returning();
3610
ec9e3e3Claude3611 // CODEOWNERS — auto-request reviewers based on changed files.
3612 // Fire-and-forget; errors never block PR creation.
3613 (async () => {
3614 try {
3615 const repoDir = getRepoPath(ownerName, repoName);
3616 // Get list of changed files between base and head
3617 const diffProc = Bun.spawn(
3618 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
3619 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3620 );
3621 const rawDiff = await new Response(diffProc.stdout).text();
3622 await diffProc.exited;
3623 const changedFiles = rawDiff.trim().split("\n").filter(Boolean);
3624
3625 if (changedFiles.length > 0) {
3626 // Get CODEOWNERS from the default branch of the repo
3627 const rules = await getCodeownersForRepo(
3628 ownerName,
3629 repoName,
3630 resolved.repo.defaultBranch
3631 );
3632 if (rules.length > 0) {
3633 const ownerUsernames = await reviewersForChangedFiles(
3634 resolved.repo.id,
3635 changedFiles
3636 );
3637 // Filter out the PR author
3638 const filteredOwners = ownerUsernames.filter(
3639 (u) => u !== resolved.owner.username
3640 );
3641
3642 if (filteredOwners.length > 0) {
3643 // Look up user IDs for the owner usernames
3644 const reviewerUsers = await db
3645 .select({ id: users.id, username: users.username })
3646 .from(users)
3647 .where(
3648 inArray(
3649 users.username,
3650 filteredOwners
3651 )
3652 );
3653
3654 // Create review request rows (UNIQUE constraint prevents dupes)
3655 if (reviewerUsers.length > 0) {
3656 await db
3657 .insert(prReviewRequests)
3658 .values(
3659 reviewerUsers.map((u) => ({
3660 prId: pr.id,
3661 reviewerId: u.id,
3662 requestedBy: null as string | null,
3663 }))
3664 )
3665 .onConflictDoNothing();
3666
3667 // Add a PR comment announcing the auto-assigned reviewers
3668 const mentionList = reviewerUsers
3669 .map((u) => `@${u.username}`)
3670 .join(", ");
3671 await db.insert(prComments).values({
3672 pullRequestId: pr.id,
3673 authorId: user.id,
3674 body: `AI: Requested review from ${mentionList} based on CODEOWNERS`,
3675 isAiReview: true,
3676 });
3677 }
3678 }
3679 }
3680 }
3681 } catch (err) {
3682 console.warn("[codeowners] auto-assign failed:", err instanceof Error ? err.message : err);
3683 }
3684 })();
3685
6fc53bdClaude3686 // Skip AI review on drafts — it runs again when the PR is marked ready.
3687 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3688 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3689 (err) => console.error("[ai-review] Failed:", err)
3690 );
3691 }
3692
3cbe3d6Claude3693 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3694 triggerPrTriage({
3695 ownerName,
3696 repoName,
3697 repositoryId: resolved.repo.id,
3698 prId: pr.id,
3699 prAuthorId: user.id,
3700 title,
3701 body: prBody,
3702 baseBranch,
3703 headBranch,
3704 }).catch((err) => console.error("[pr-triage] Failed:", err));
3705
1d4ff60Claude3706 // Chat notifier — fan out to Slack/Discord/Teams.
3707 import("../lib/chat-notifier")
3708 .then((m) =>
3709 m.notifyChatChannels({
3710 ownerUserId: resolved.repo.ownerId,
3711 repositoryId: resolved.repo.id,
3712 event: {
3713 event: "pr.opened",
3714 repo: `${ownerName}/${repoName}`,
3715 title: `#${pr.number} ${title}`,
3716 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3717 body: prBody || undefined,
3718 actor: user.username,
3719 },
3720 })
3721 )
3722 .catch((err) =>
3723 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3724 );
3725
9dd96b9Test User3726 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude3727 import("../lib/auto-merge")
3728 .then((m) => m.tryAutoMergeNow(pr.id))
3729 .catch((err) => {
3730 console.warn(
3731 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
3732 err instanceof Error ? err.message : err
3733 );
3734 });
9dd96b9Test User3735
1df50d5Claude3736 // Migration 0077 — PR preview build. Fire-and-forget; skips when
3737 // PREVIEW_DOMAIN is unset or the repo has no preview_build_command.
3738 // Resolve head SHA asynchronously so we don't block the redirect.
3739 resolveRef(ownerName, repoName, headBranch)
3740 .then((headSha) => {
3741 if (!headSha) return;
3742 return import("../lib/preview-builder").then((m) =>
3743 m.buildPreview(pr.id, resolved.repo.id, headSha)
3744 );
3745 })
3746 .catch(() => {});
3747
0074234Claude3748 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
3749 }
3750);
3751
3752// View single PR
04f6b7fClaude3753pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude3754 const { owner: ownerName, repo: repoName } = c.req.param();
3755 const prNum = parseInt(c.req.param("number"), 10);
3756 const user = c.get("user");
3757 const tab = c.req.query("tab") || "conversation";
3758
3759 const resolved = await resolveRepo(ownerName, repoName);
3760 if (!resolved) return c.notFound();
3761
3762 const [pr] = await db
3763 .select()
3764 .from(pullRequests)
3765 .where(
3766 and(
3767 eq(pullRequests.repositoryId, resolved.repo.id),
3768 eq(pullRequests.number, prNum)
3769 )
3770 )
3771 .limit(1);
3772
3773 if (!pr) return c.notFound();
3774
3775 const [author] = await db
3776 .select()
3777 .from(users)
3778 .where(eq(users.id, pr.authorId))
3779 .limit(1);
3780
cb5a796Claude3781 const allCommentsRaw = await db
0074234Claude3782 .select({
3783 comment: prComments,
cb5a796Claude3784 author: { id: users.id, username: users.username },
0074234Claude3785 })
3786 .from(prComments)
3787 .innerJoin(users, eq(prComments.authorId, users.id))
3788 .where(eq(prComments.pullRequestId, pr.id))
3789 .orderBy(asc(prComments.createdAt));
3790
cb5a796Claude3791 // Filter pending/rejected/spam for non-owner, non-author viewers.
3792 // Owner always sees everything; comment author sees their own pending
3793 // with an "Awaiting approval" badge in the render below.
3794 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
3795 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
3796 if (viewerIsRepoOwner) return true;
3797 if (comment.moderationStatus === "approved") return true;
3798 if (
3799 user &&
3800 cAuthor.id === user.id &&
3801 comment.moderationStatus === "pending"
3802 ) {
3803 return true;
3804 }
3805 return false;
3806 });
3807 const prPendingCount = viewerIsRepoOwner
3808 ? await countPendingForRepo(resolved.repo.id)
3809 : 0;
3810
6fc53bdClaude3811 // Reactions for the PR body + each comment, in parallel.
3812 const [prReactions, ...prCommentReactions] = await Promise.all([
3813 summariseReactions("pr", pr.id, user?.id),
3814 ...comments.map((row) =>
3815 summariseReactions("pr_comment", row.comment.id, user?.id)
3816 ),
3817 ]);
3818
0a67773Claude3819 // Formal reviews (Approve / Request Changes)
3820 const reviewRows = await db
3821 .select({
3822 id: prReviews.id,
3823 state: prReviews.state,
3824 body: prReviews.body,
3825 isAi: prReviews.isAi,
3826 createdAt: prReviews.createdAt,
3827 reviewerUsername: users.username,
3828 reviewerId: prReviews.reviewerId,
3829 })
3830 .from(prReviews)
3831 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3832 .where(eq(prReviews.pullRequestId, pr.id))
3833 .orderBy(asc(prReviews.createdAt));
3834 // Most recent review per reviewer determines the current state
3835 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
3836 for (const r of reviewRows) {
3837 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
3838 }
3839 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
3840 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
3841 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
3842
ec9e3e3Claude3843 // Requested reviewers from CODEOWNERS auto-assign (migration 0077).
3844 const requestedReviewerRows = await db
3845 .select({
3846 reviewerUsername: users.username,
3847 reviewerId: prReviewRequests.reviewerId,
3848 createdAt: prReviewRequests.createdAt,
3849 })
3850 .from(prReviewRequests)
3851 .innerJoin(users, eq(prReviewRequests.reviewerId, users.id))
3852 .where(eq(prReviewRequests.prId, pr.id))
3853 .orderBy(asc(prReviewRequests.createdAt))
3854 .catch(() => [] as { reviewerUsername: string; reviewerId: string; createdAt: Date }[]);
3855
ace34efClaude3856 // Suggested reviewers — best-effort, never throws
3857 let reviewerSuggestions: ReviewerCandidate[] = [];
3858 try {
3859 if (user) {
3860 reviewerSuggestions = await suggestReviewers(
3861 ownerName, repoName, pr.headBranch, pr.baseBranch,
3862 pr.authorId, resolved.repo.id
3863 );
3864 }
3865 } catch {
3866 // silent degradation
3867 }
3868
0074234Claude3869 const canManage =
3870 user &&
3871 (user.id === resolved.owner.id || user.id === pr.authorId);
3872
1d4ff60Claude3873 // Has any previous AI-test-generator run already tagged this PR? Used
3874 // both to hide the "Generate tests with AI" button and to short-circuit
3875 // the explicit POST handler.
3876 const hasAiTestsMarker = comments.some(({ comment }) =>
3877 (comment.body || "").includes(AI_TESTS_MARKER)
3878 );
3879
e883329Claude3880 const error = c.req.query("error");
c3e0c07Claude3881 const info = c.req.query("info");
e883329Claude3882
3883 // Get gate check status for open PRs
3884 let gateChecks: GateCheckResult[] = [];
240c477Claude3885 let ciStatuses: CommitStatus[] = [];
e883329Claude3886 if (pr.state === "open") {
3887 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3888 if (headSha) {
3889 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
3890 const aiApproved = aiComments.length === 0 || aiComments.some(
3891 ({ comment }) => comment.body.includes("**Approved**")
3892 );
240c477Claude3893 const [gateResult, fetchedCiStatuses] = await Promise.all([
3894 runAllGateChecks(
3895 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
3896 ),
3897 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
3898 ]);
e883329Claude3899 gateChecks = gateResult.checks;
240c477Claude3900 ciStatuses = fetchedCiStatuses;
e883329Claude3901 }
3902 }
3903
534f04aClaude3904 // Block M3 — pre-merge risk score. Cache-only on the request path so
3905 // the page never waits on Haiku. On a cache miss for an open PR we
3906 // kick off the computation fire-and-forget; the next refresh shows it.
3907 let prRisk: PrRiskScore | null = null;
3908 let prRiskCalculating = false;
3909 if (pr.state === "open") {
3910 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
3911 if (!prRisk) {
3912 prRiskCalculating = true;
a28cedeClaude3913 void computePrRiskForPullRequest(pr.id).catch((err) => {
3914 console.warn(
3915 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
3916 err instanceof Error ? err.message : err
3917 );
3918 });
534f04aClaude3919 }
3920 }
3921
4bbacbeClaude3922 // Migration 0062 — per-branch preview URL. The head branch always
3923 // has a preview row (unless it's the default branch, which never
3924 // happens for an open PR) once it has been pushed at least once.
3925 const preview = await getPreviewForBranch(
3926 (resolved.repo as { id: string }).id,
3927 pr.headBranch
3928 );
3929
0369e77Claude3930 // Branch ahead/behind counts — how many commits head is ahead of base and
3931 // how many commits base has advanced since head branched off.
3932 let branchAhead = 0;
3933 let branchBehind = 0;
3934 if (pr.state === "open") {
3935 try {
3936 const repoDir = getRepoPath(ownerName, repoName);
3937 const [aheadProc, behindProc] = [
3938 Bun.spawn(
3939 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
3940 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3941 ),
3942 Bun.spawn(
3943 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
3944 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3945 ),
3946 ];
3947 const [aheadTxt, behindTxt] = await Promise.all([
3948 new Response(aheadProc.stdout).text(),
3949 new Response(behindProc.stdout).text(),
3950 ]);
3951 await Promise.all([aheadProc.exited, behindProc.exited]);
3952 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
3953 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
3954 } catch { /* non-blocking */ }
3955 }
3956
6d1bbc2Claude3957 // Linked issues — parse closing keywords from PR title+body, look up issues
3958 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
3959 try {
3960 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3961 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3962 if (refs.length > 0) {
3963 linkedIssues = await db
3964 .select({ number: issues.number, title: issues.title, state: issues.state })
3965 .from(issues)
3966 .where(and(
3967 eq(issues.repositoryId, resolved.repo.id),
3968 inArray(issues.number, refs),
3969 ));
3970 }
3971 } catch { /* non-blocking */ }
3972
3973 // Task list progress — count markdown checkboxes in PR body
3974 let taskTotal = 0;
3975 let taskChecked = 0;
3976 if (pr.body) {
3977 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
3978 taskTotal++;
3979 if (m[1].trim() !== "") taskChecked++;
3980 }
3981 }
3982
74d8c4dClaude3983 // M15 — PR size badge (best-effort, non-blocking)
3984 let prSizeInfo: PrSizeInfo | null = null;
3985 try {
3986 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
3987 } catch { /* swallow — purely cosmetic */ }
3988
1d6db4dClaude3989 // Bus factor warning — non-blocking. Get changed files list first.
3990 let busRiskFiles: BusFactorFile[] = [];
3991 let splitSuggestion: SplitSuggestion | null = null;
3992 try {
3993 // Get names of files changed in this PR
3994 const repoDir = getRepoPath(ownerName, repoName);
3995 const nameOnlyProc = Bun.spawn(
3996 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
3997 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3998 );
3999 const nameOnlyRaw = await new Response(nameOnlyProc.stdout).text();
4000 await nameOnlyProc.exited;
4001 const prChangedFiles = nameOnlyRaw.trim().split("\n").filter(Boolean);
4002
4003 // Bus factor — check cache for at-risk files that overlap changed files
4004 [busRiskFiles] = await Promise.all([
4005 getBusFactorWarning(resolved.repo.id, ownerName, repoName, prChangedFiles),
4006 ]);
4007
4008 // PR Split suggestion — only when PR is large (>400 lines)
4009 if (prSizeInfo && prSizeInfo.linesChanged > 400) {
4010 splitSuggestion = await suggestPrSplit(
4011 pr.id,
4012 pr.title,
4013 ownerName,
4014 repoName,
4015 pr.baseBranch,
4016 pr.headBranch
4017 );
4018 }
4019 } catch { /* always degrade gracefully */ }
4020
47a7a0aClaude4021 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude4022 let diffRaw = "";
4023 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude4024 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude4025 if (tab === "files") {
4026 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude4027 // Run the two git diffs in parallel — they're independent reads of
4028 // the same range. Previously sequential, doubling the wall time on
4029 // big PRs (100+ files = 10-30s for no reason).
0074234Claude4030 const proc = Bun.spawn(
4031 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
4032 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4033 );
4034 const statProc = Bun.spawn(
4035 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
4036 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4037 );
6ea2109Claude4038 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
4039 // would otherwise hang the whole request.
4040 const killer = setTimeout(() => {
4041 proc.kill();
4042 statProc.kill();
4043 }, 30_000);
4044 let stat = "";
4045 try {
4046 [diffRaw, stat] = await Promise.all([
4047 new Response(proc.stdout).text(),
4048 new Response(statProc.stdout).text(),
4049 ]);
4050 await Promise.all([proc.exited, statProc.exited]);
4051 } finally {
4052 clearTimeout(killer);
4053 }
0074234Claude4054
4055 diffFiles = stat
4056 .trim()
4057 .split("\n")
4058 .filter(Boolean)
4059 .map((line) => {
4060 const [add, del, filePath] = line.split("\t");
4061 return {
4062 path: filePath,
4063 status: "modified",
4064 additions: add === "-" ? 0 : parseInt(add, 10),
4065 deletions: del === "-" ? 0 : parseInt(del, 10),
4066 patch: "",
4067 };
4068 });
47a7a0aClaude4069
4070 // Fetch inline comments (file+line anchored) for the files tab
4071 const inlineRows = await db
4072 .select({
4073 id: prComments.id,
4074 filePath: prComments.filePath,
4075 lineNumber: prComments.lineNumber,
4076 body: prComments.body,
4077 isAiReview: prComments.isAiReview,
4078 createdAt: prComments.createdAt,
4079 authorUsername: users.username,
4080 })
4081 .from(prComments)
4082 .innerJoin(users, eq(prComments.authorId, users.id))
4083 .where(
4084 and(
4085 eq(prComments.pullRequestId, pr.id),
4086 eq(prComments.moderationStatus, "approved"),
4087 )
4088 )
4089 .orderBy(asc(prComments.createdAt));
4090
4091 diffInlineComments = inlineRows
4092 .filter(r => r.filePath != null && r.lineNumber != null)
4093 .map(r => ({
4094 id: r.id,
4095 filePath: r.filePath!,
4096 lineNumber: r.lineNumber!,
4097 authorUsername: r.authorUsername,
4098 body: renderMarkdown(r.body),
4099 isAiReview: r.isAiReview,
4100 createdAt: r.createdAt.toISOString(),
4101 }));
0074234Claude4102 }
4103
34e63b9Claude4104 // Proactive pattern warning — get changed file paths and check for recurring
4105 // bug patterns. Fire-and-forget safe; returns null on any error or cache miss.
4106 let patternWarning: Pattern | null = null;
4107 try {
4108 const repoDir = getRepoPath(ownerName, repoName);
4109 const nameOnlyProc = Bun.spawn(
4110 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4111 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4112 );
4113 const nameOnlyRaw = await new Response(nameOnlyProc.stdout).text();
4114 await nameOnlyProc.exited;
4115 const prChangedFiles = nameOnlyRaw.trim().split("\n").filter(Boolean);
4116 if (prChangedFiles.length > 0) {
4117 patternWarning = await getPatternWarning(resolved.repo.id, prChangedFiles);
4118 }
4119 } catch {
4120 // Non-blocking — swallow
4121 }
4122
b078860Claude4123 // ─── Derived visual state ───
4124 const stateKey =
4125 pr.state === "open"
4126 ? pr.isDraft
4127 ? "draft"
4128 : "open"
4129 : pr.state;
4130 const stateLabel =
4131 stateKey === "open"
4132 ? "Open"
4133 : stateKey === "draft"
4134 ? "Draft"
4135 : stateKey === "merged"
4136 ? "Merged"
4137 : "Closed";
4138 const stateIcon =
4139 stateKey === "open"
4140 ? "○"
4141 : stateKey === "draft"
4142 ? "◌"
4143 : stateKey === "merged"
4144 ? "⮌"
4145 : "✓";
4146 const commentCount = comments.length;
4147 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
4148 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
4149 const mergeBlocked =
4150 gateChecks.length > 0 &&
4151 gateChecks.some(
4152 (c) => !c.passed && c.name !== "Merge check"
4153 );
4154
b558f23Claude4155 // Commits tab — list commits included in this PR (base..head range)
4156 let prCommits: GitCommit[] = [];
4157 if (tab === "commits") {
4158 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
4159 }
4160
cc34156Claude4161 // Review context restore — compute BEFORE recording the visit so the
4162 // previous timestamp is available for the delta calculation.
4163 let reviewCtx: ReviewContext | null = null;
4164 if (user) {
4165 reviewCtx = await getReviewContext(pr.id, user.id, {
4166 ownerName,
4167 repoName,
4168 baseBranch: pr.baseBranch,
4169 headBranch: pr.headBranch,
4170 });
4171 // Fire-and-forget: record the visit AFTER computing context
4172 void recordPrVisit(pr.id, user.id);
4173 }
4174
0074234Claude4175 return c.html(
4176 <Layout
4177 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
4178 user={user}
4179 >
4180 <RepoHeader owner={ownerName} repo={repoName} />
4181 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude4182 <PendingCommentsBanner
4183 owner={ownerName}
4184 repo={repoName}
4185 count={prPendingCount}
4186 />
b078860Claude4187 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude4188 <div
4189 id="live-comment-banner"
4190 class="alert"
4191 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
4192 >
4193 <strong class="js-live-count">0</strong> new comment(s) —{" "}
4194 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
4195 reload to view
4196 </a>
4197 </div>
4198 <script
4199 dangerouslySetInnerHTML={{
4200 __html: liveCommentBannerScript({
4201 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
4202 bannerElementId: "live-comment-banner",
4203 }),
4204 }}
4205 />
b078860Claude4206
cc34156Claude4207 {/* Review context restore banner — shown when returning after changes */}
4208 {reviewCtx && (
4209 <div
4210 class="context-restore-banner"
4211 id="review-context"
4212 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"
4213 >
4214 <span class="context-icon" style="font-size:18px;flex-shrink:0;margin-top:2px" aria-hidden="true">{"↩"}</span>
4215 <div style="flex:1;min-width:0">
4216 <strong style="font-size:13.5px;color:var(--text-strong,#111)">Welcome back</strong>
4217 <p style="margin:4px 0 0;font-size:13px;color:var(--text,#333);line-height:1.5">{reviewCtx.summary}</p>
4218 <small style="font-size:11.5px;color:var(--text-muted,#777)">
4219 Last visited {formatRelative(new Date(reviewCtx.lastVisitedAt))}
4220 {reviewCtx.commitsSince > 0 && ` · ${reviewCtx.commitsSince} new commit${reviewCtx.commitsSince === 1 ? "" : "s"}`}
4221 {reviewCtx.newComments > 0 && ` · ${reviewCtx.newComments} new comment${reviewCtx.newComments === 1 ? "" : "s"}`}
4222 </small>
4223 {reviewCtx.suggestedStartLine && (
4224 <p style="margin:6px 0 0;font-size:12px;color:var(--accent,#0070f3)">
4225 Start at: <code style="font-size:11px">{reviewCtx.suggestedStartLine}</code>
4226 </p>
4227 )}
4228 </div>
4229 <button
4230 type="button"
4231 onclick="this.closest('.context-restore-banner').remove()"
4232 style="flex-shrink:0;background:none;border:none;cursor:pointer;font-size:18px;color:var(--text-muted,#777);padding:0;line-height:1"
4233 aria-label="Dismiss"
4234 >
4235 {"×"}
4236 </button>
4237 </div>
4238 )}
4239
b078860Claude4240 <div class="prs-detail-hero">
b558f23Claude4241 <div class="prs-edit-title-wrap">
4242 <h1 class="prs-detail-title" id="pr-title-display">
4243 {pr.title}{" "}
4244 <span class="prs-detail-num">#{pr.number}</span>
4245 </h1>
4246 {canManage && pr.state === "open" && (
4247 <button
4248 type="button"
4249 class="prs-edit-btn"
4250 id="pr-edit-toggle"
4251 onclick={`
4252 document.getElementById('pr-title-display').style.display='none';
4253 document.getElementById('pr-edit-toggle').style.display='none';
4254 document.getElementById('pr-edit-form').style.display='flex';
4255 document.getElementById('pr-title-input').focus();
4256 `}
4257 >
4258 Edit
4259 </button>
4260 )}
4261 </div>
4262 {canManage && pr.state === "open" && (
4263 <form
4264 id="pr-edit-form"
4265 method="post"
4266 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
4267 class="prs-edit-form"
4268 style="display:none"
4269 >
4270 <input
4271 id="pr-title-input"
4272 type="text"
4273 name="title"
4274 value={pr.title}
4275 required
4276 maxlength={256}
4277 placeholder="Pull request title"
4278 />
4279 <div class="prs-edit-actions">
4280 <button type="submit" class="prs-edit-save-btn">Save</button>
4281 <button
4282 type="button"
4283 class="prs-edit-cancel-btn"
4284 onclick={`
4285 document.getElementById('pr-edit-form').style.display='none';
4286 document.getElementById('pr-title-display').style.display='';
4287 document.getElementById('pr-edit-toggle').style.display='';
4288 `}
4289 >
4290 Cancel
4291 </button>
4292 </div>
4293 </form>
4294 )}
b078860Claude4295 <div class="prs-detail-meta">
4296 <span class={`prs-state-pill state-${stateKey}`}>
4297 <span aria-hidden="true">{stateIcon}</span>
4298 <span>{stateLabel}</span>
4299 </span>
74d8c4dClaude4300 {prSizeInfo && (
4301 <span
4302 class="prs-size-badge"
4303 style={`color:${prSizeInfo.color};background:${prSizeInfo.bgColor}`}
4304 title={`${prSizeInfo.linesChanged} lines changed (+${prSizeInfo.added} −${prSizeInfo.deleted})`}
4305 >
4306 {prSizeInfo.label}
4307 </span>
4308 )}
67dc4e1Claude4309 <TrioVerdictPills
4310 comments={comments.map(({ comment }) => comment)}
4311 />
b078860Claude4312 <span>
4313 <strong>{author?.username}</strong> wants to merge
4314 </span>
4315 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
4316 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
4317 <span class="prs-branch-arrow-lg">{"→"}</span>
4318 <span class="prs-branch-pill">{pr.baseBranch}</span>
4319 </span>
0369e77Claude4320 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
4321 <span
4322 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
4323 title={branchBehind > 0
4324 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
4325 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
4326 >
4327 {branchAhead > 0 ? `↑${branchAhead}` : ""}
4328 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
4329 {branchBehind > 0 ? `↓${branchBehind}` : ""}
4330 </span>
4331 )}
b078860Claude4332 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude4333 {taskTotal > 0 && (
4334 <span
4335 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
4336 title={`${taskChecked} of ${taskTotal} tasks completed`}
4337 >
4338 <span class="prs-tasks-progress" aria-hidden="true">
4339 <span
4340 class="prs-tasks-progress-bar"
4341 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
4342 ></span>
4343 </span>
4344 {taskChecked}/{taskTotal} tasks
4345 </span>
4346 )}
4347 {canManage && pr.state === "open" && branchBehind > 0 && (
4348 <form
4349 method="post"
4350 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
4351 class="prs-inline-form"
4352 >
4353 <button
4354 type="submit"
4355 class="prs-update-branch-btn"
4356 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
4357 >
4358 ↑ Update branch
4359 </button>
4360 </form>
4361 )}
3c03977Claude4362 <span
4363 id="live-pill"
4364 class="live-pill"
4365 title="People editing this PR right now"
4366 >
4367 <span class="live-pill-dot" aria-hidden="true"></span>
4368 <span>
4369 Live: <strong id="live-count">0</strong> editing
4370 </span>
4371 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
4372 </span>
4bbacbeClaude4373 {preview && (
4374 <a
4375 class={`preview-prpill is-${preview.status}`}
4376 href={
4377 preview.status === "ready"
4378 ? preview.previewUrl
4379 : `/${ownerName}/${repoName}/previews`
4380 }
4381 target={preview.status === "ready" ? "_blank" : undefined}
4382 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
4383 title={`Preview · ${previewStatusLabel(preview.status)}`}
4384 >
4385 <span class="preview-prpill-dot" aria-hidden="true"></span>
4386 <span>Preview: </span>
4387 <span>{previewStatusLabel(preview.status)}</span>
4388 </a>
4389 )}
b078860Claude4390 {canManage && pr.state === "open" && pr.isDraft && (
4391 <form
4392 method="post"
4393 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4394 class="prs-inline-form prs-detail-actions"
4395 >
4396 <button type="submit" class="prs-merge-ready-btn">
4397 Ready for review
4398 </button>
4399 </form>
4400 )}
4401 </div>
4402 </div>
3c03977Claude4403 <script
4404 dangerouslySetInnerHTML={{
4405 __html: LIVE_COEDIT_SCRIPT(pr.id),
4406 }}
4407 />
829a046Claude4408 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude4409 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude4410 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude4411
25b1ff7Claude4412 {/* Presence styles + bar (shown only on the files tab so cursor pills work) */}
4413 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES }} />
4414 {/* Toast container — always present for join/leave toasts */}
4415 <div id="presence-toasts" class="presence-toast-wrap" aria-live="polite" />
4416 {user && (
4417 <>
4418 <div class="presence-bar" id="presence-bar">
4419 <span class="presence-bar-label">Live reviewers</span>
4420 <div class="presence-avatars" id="presence-avatars" />
4421 <span class="presence-count" id="presence-count">Loading…</span>
4422 </div>
4423 <script
4424 dangerouslySetInnerHTML={{
4425 __html: PR_PRESENCE_SCRIPT(ownerName, repoName, pr.number),
4426 }}
4427 />
4428 </>
4429 )}
4430
b078860Claude4431 <nav class="prs-detail-tabs" aria-label="Pull request sections">
4432 <a
4433 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
4434 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
4435 >
4436 Conversation
4437 <span class="prs-detail-tab-count">{commentCount}</span>
4438 </a>
b558f23Claude4439 <a
4440 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
4441 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
4442 >
4443 Commits
4444 {branchAhead > 0 && (
4445 <span class="prs-detail-tab-count">{branchAhead}</span>
4446 )}
4447 </a>
b078860Claude4448 <a
4449 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
4450 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4451 >
4452 Files changed
4453 {diffFiles.length > 0 && (
4454 <span class="prs-detail-tab-count">{diffFiles.length}</span>
4455 )}
4456 </a>
4457 </nav>
4458
34e63b9Claude4459 {/* Proactive pattern warning — shown when a known recurring bug pattern
4460 overlaps with the files changed in this PR. */}
4461 {patternWarning && (
4462 <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">
4463 <span style="font-size:15px;margin-right:6px" aria-hidden="true">⚠️</span>
4464 <strong>Recurring pattern detected: {patternWarning.title}</strong>
4465 <span style="color:var(--fg-muted)">
4466 {" — "}
4467 This area has had {patternWarning.occurrences} similar fix
4468 {patternWarning.occurrences === 1 ? "" : "es"}.
4469 {patternWarning.rootCauseHypothesis && (
4470 <> Root cause may be in <code style="font-size:12px">{patternWarning.suggestedFile}</code>.</>
4471 )}
4472 </span>
4473 </div>
4474 )}
4475
b558f23Claude4476 {tab === "commits" ? (
4477 <div class="prs-commits-list">
4478 {prCommits.length === 0 ? (
4479 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
4480 ) : (
4481 prCommits.map((commit) => (
4482 <div class="prs-commit-row">
4483 <span class="prs-commit-dot" aria-hidden="true"></span>
4484 <div class="prs-commit-body">
4485 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
4486 <div class="prs-commit-meta">
4487 <strong>{commit.author}</strong> committed{" "}
4488 {formatRelative(new Date(commit.date))}
4489 </div>
4490 </div>
4491 <a
4492 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
4493 class="prs-commit-sha"
4494 title="View commit"
4495 >
4496 {commit.sha.slice(0, 7)}
4497 </a>
4498 </div>
4499 ))
4500 )}
4501 </div>
4502 ) : tab === "files" ? (
1d6db4dClaude4503 <>
4504 {/* PR Split Suggestion — shown when PR has >400 changed lines */}
4505 {splitSuggestion && (
4506 <div class="split-suggestion" id="pr-split-banner">
4507 <div class="split-header">
4508 <span class="split-icon" aria-hidden="true">✂️</span>
4509 <strong>This PR may be too large to review effectively</strong>
4510 <span class="split-stat">
4511 {splitSuggestion.totalLines} lines · {splitSuggestion.totalFiles} files
4512 </span>
4513 <button
4514 class="split-toggle"
4515 type="button"
4516 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';"
4517 >
4518 Show split suggestion
4519 </button>
4520 </div>
4521 <div class="split-body" id="pr-split-body" hidden>
4522 <p class="split-intro">
4523 AI suggests splitting into {splitSuggestion.suggestedPrs.length} PRs:
4524 </p>
4525 {splitSuggestion.suggestedPrs.map((sp, i) => (
4526 <div class="split-pr">
4527 <div class="split-pr-num">{i + 1}</div>
4528 <div class="split-pr-body">
4529 <strong>{sp.title}</strong>
4530 <p>{sp.rationale}</p>
4531 <code>{sp.files.join(", ")}</code>
4532 <span class="split-lines">~{sp.estimatedLines} lines</span>
4533 </div>
4534 </div>
4535 ))}
4536 {splitSuggestion.mergeOrder.length > 0 && (
4537 <p class="split-order">
4538 Suggested merge order:{" "}
4539 <strong>{splitSuggestion.mergeOrder.join(" → ")}</strong>
4540 </p>
4541 )}
4542 </div>
4543 </div>
4544 )}
4545
4546 {/* Bus Factor Warning — shown when changed files overlap at-risk files */}
4547 {busRiskFiles.length > 0 && (() => {
4548 const topRisk = busRiskFiles.some((f) => f.risk === "critical")
4549 ? "critical"
4550 : busRiskFiles.some((f) => f.risk === "high")
4551 ? "high"
4552 : "medium";
4553 return (
4554 <div class={`busfactor-panel busfactor-${topRisk}`}>
4555 <span class="busfactor-icon" aria-hidden="true">⚠️</span>
4556 <div class="busfactor-body">
4557 <strong>Knowledge concentration warning</strong>
4558 <p>
4559 {busRiskFiles.length} file{busRiskFiles.length !== 1 ? "s" : ""} in
4560 this PR {busRiskFiles.length !== 1 ? "are" : "is"} primarily
4561 maintained by one person. Consider pairing on this review.
4562 </p>
4563 <ul>
4564 {busRiskFiles.map((f) => (
4565 <li>
4566 <code>{f.path}</code> —{" "}
4567 <strong>{f.primaryAuthorPct}%</strong> by {f.primaryAuthor}
4568 </li>
4569 ))}
4570 </ul>
4571 </div>
4572 </div>
4573 );
4574 })()}
4575
4576 <DiffView
4577 raw={diffRaw}
4578 files={diffFiles}
4579 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
4580 inlineComments={diffInlineComments}
4581 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
4582 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
4583 />
4584 </>
b078860Claude4585 ) : (
4586 <>
4587 {pr.body && (
4588 <CommentBox
4589 author={author?.username ?? "unknown"}
4590 date={pr.createdAt}
4591 body={renderMarkdown(pr.body)}
4592 />
4593 )}
4594
422a2d4Claude4595 {/* Block H — AI trio review (security/correctness/style). When
4596 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
4597 hoisted into a 3-column card grid above the normal comment
4598 stream so reviewers see verdicts at a glance. Disagreements
4599 are surfaced as a yellow callout. */}
4600 <TrioReviewGrid
4601 comments={comments.map(({ comment }) => comment)}
4602 />
4603
15db0e0Claude4604 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude4605 // Skip trio comments — already rendered in TrioReviewGrid above.
4606 if (isTrioComment(comment.body)) return null;
15db0e0Claude4607 const slashCmd = detectSlashCmdComment(comment.body);
4608 if (slashCmd) {
4609 const visible = stripSlashCmdMarker(comment.body);
4610 return (
4611 <div class={`slash-pill slash-cmd-${slashCmd}`}>
4612 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
4613 <span class="slash-pill-actor">
4614 <strong>{commentAuthor.username}</strong>
4615 {" ran "}
4616 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude4617 </span>
15db0e0Claude4618 <span class="slash-pill-time">
4619 {formatRelative(comment.createdAt)}
4620 </span>
4621 <div class="slash-pill-body">
4622 <MarkdownContent html={renderMarkdown(visible)} />
4623 </div>
4624 </div>
4625 );
4626 }
cb5a796Claude4627 const isPending = comment.moderationStatus === "pending";
15db0e0Claude4628 return (
cb5a796Claude4629 <div
4630 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
4631 >
15db0e0Claude4632 <div class="prs-comment-head">
4633 <strong>{commentAuthor.username}</strong>
a7460bfClaude4634 {commentAuthor.username === BOT_USERNAME && (
4635 <span class="prs-bot-badge">&#x1F916; bot</span>
4636 )}
15db0e0Claude4637 {comment.isAiReview && (
4638 <span class="prs-ai-badge">AI Review</span>
4639 )}
cb5a796Claude4640 {isPending && (
4641 <span
4642 class="modq-pending-badge"
4643 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
4644 >
4645 Awaiting approval
4646 </span>
4647 )}
15db0e0Claude4648 <span class="prs-comment-time">
4649 commented {formatRelative(comment.createdAt)}
4650 </span>
4651 {comment.filePath && (
4652 <span class="prs-comment-loc">
4653 {comment.filePath}
4654 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
4655 </span>
4656 )}
4657 </div>
4658 <div class="prs-comment-body">
4659 <MarkdownContent html={renderMarkdown(comment.body)} />
4660 </div>
0074234Claude4661 </div>
15db0e0Claude4662 );
4663 })}
0074234Claude4664
b078860Claude4665 {/* Quick link to the Files changed tab when there's a diff to look at. */}
4666 {pr.state !== "merged" && (
4667 <a
4668 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4669 class="prs-files-card"
4670 >
4671 <span class="prs-files-card-icon" aria-hidden="true">
4672 {"▤"}
4673 </span>
4674 <div class="prs-files-card-text">
4675 <p class="prs-files-card-title">Files changed</p>
4676 <p class="prs-files-card-sub">
4677 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
4678 </p>
e883329Claude4679 </div>
b078860Claude4680 <span class="prs-files-card-cta">View diff {"→"}</span>
4681 </a>
4682 )}
4683
6d1bbc2Claude4684 {linkedIssues.length > 0 && (
4685 <div class="prs-linked-issues">
4686 <div class="prs-linked-issues-head">
4687 <span>Closing issues</span>
4688 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
4689 </div>
4690 {linkedIssues.map((issue) => (
4691 <a
4692 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
4693 class="prs-linked-issue-row"
4694 >
4695 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
4696 {issue.state === "open" ? "○" : "✓"}
4697 </span>
4698 <span class="prs-linked-issue-title">{issue.title}</span>
4699 <span class="prs-linked-issue-num">#{issue.number}</span>
4700 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
4701 {issue.state}
4702 </span>
4703 </a>
4704 ))}
4705 </div>
4706 )}
4707
b078860Claude4708 {error && (
4709 <div
4710 class="auth-error"
4711 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)"
4712 >
4713 {decodeURIComponent(error)}
4714 </div>
4715 )}
4716
4717 {info && (
4718 <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)">
4719 {decodeURIComponent(info)}
4720 </div>
4721 )}
e883329Claude4722
b078860Claude4723 {pr.state === "open" && (prRisk || prRiskCalculating) && (
4724 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
4725 )}
4726
ec9e3e3Claude4727 {/* ─── Requested reviewers (CODEOWNERS auto-assign, migration 0077) ─── */}
4728 {requestedReviewerRows.length > 0 && (
4729 <div class="prs-review-summary" style="margin-top:14px">
4730 <div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-weight:700;margin-bottom:4px">
4731 Review requested
4732 </div>
4733 {requestedReviewerRows.map((rr) => {
4734 const hasReviewed = latestReviewByReviewer.has(rr.reviewerId);
4735 const review = latestReviewByReviewer.get(rr.reviewerId);
4736 const statusIcon = !hasReviewed ? "⏳" : review?.state === "approved" ? "✓" : "✗";
4737 const statusColor = !hasReviewed
4738 ? "var(--text-muted)"
4739 : review?.state === "approved"
4740 ? "#34d399"
4741 : "#f87171";
4742 return (
4743 <div class="prs-review-row" style={`gap:8px`}>
4744 <span class="prs-reviewer-avatar">
4745 {rr.reviewerUsername.slice(0, 1).toUpperCase()}
4746 </span>
4747 <a href={`/${rr.reviewerUsername}`}
4748 style="flex:1;font-size:13px;color:var(--text);font-weight:600;text-decoration:none">
4749 {rr.reviewerUsername}
4750 </a>
4751 <span style={`font-size:12px;font-weight:600;color:${statusColor}`}>
4752 {statusIcon} {!hasReviewed ? "Pending" : review?.state === "approved" ? "Approved" : "Changes requested"}
4753 </span>
4754 </div>
4755 );
4756 })}
4757 </div>
4758 )}
4759
0a67773Claude4760 {/* ─── Review summary ─────────────────────────────────── */}
4761 {(approvals.length > 0 || changesRequested.length > 0) && (
4762 <div class="prs-review-summary">
4763 {approvals.length > 0 && (
4764 <div class="prs-review-row prs-review-approved">
4765 <span class="prs-review-icon">✓</span>
4766 <span>
4767 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4768 approved this pull request
4769 </span>
4770 </div>
4771 )}
4772 {changesRequested.length > 0 && (
4773 <div class="prs-review-row prs-review-changes">
4774 <span class="prs-review-icon">✗</span>
4775 <span>
4776 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4777 requested changes
4778 </span>
4779 </div>
4780 )}
4781 </div>
4782 )}
4783
ace34efClaude4784 {/* Suggested reviewers */}
4785 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
4786 <div class="prs-review-summary" style="margin-top:12px">
4787 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
4788 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
4789 Suggested reviewers
4790 </span>
4791 {reviewerSuggestions.map((r) => (
4792 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
4793 style="display:flex;align-items:center;gap:8px;width:100%">
4794 <input type="hidden" name="reviewerId" value={r.userId} />
4795 <span class="prs-reviewer-avatar">
4796 {r.username.slice(0, 1).toUpperCase()}
4797 </span>
4798 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
4799 {r.username}
4800 </a>
4801 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
4802 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
4803 Request
4804 </button>
4805 </form>
4806 ))}
4807 </div>
4808 </div>
4809 )}
4810
b078860Claude4811 {pr.state === "open" && gateChecks.length > 0 && (
4812 <div class="prs-gate-card">
4813 <div class="prs-gate-head">
4814 <h3>Gate checks</h3>
4815 <span class="prs-gate-summary">
4816 {gatesAllPassed
4817 ? `All ${gateChecks.length} checks passed`
4818 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
4819 </span>
c3e0c07Claude4820 </div>
b078860Claude4821 {gateChecks.map((check) => {
4822 const isAi = /ai.*review/i.test(check.name);
4823 const isSkip = check.skipped === true;
4824 const statusClass = isSkip
4825 ? "is-skip"
4826 : check.passed
4827 ? "is-pass"
4828 : "is-fail";
4829 const statusGlyph = isSkip
4830 ? "—"
4831 : check.passed
4832 ? "✓"
4833 : "✗";
4834 const statusLabel = isSkip
4835 ? "Skipped"
4836 : check.passed
4837 ? "Passed"
4838 : "Failing";
4839 return (
4840 <div
4841 class="prs-gate-row"
4842 style={
4843 isAi
4844 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
4845 : ""
4846 }
4847 >
4848 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
4849 {statusGlyph}
4850 </span>
4851 <span class="prs-gate-name">
4852 {check.name}
4853 {isAi && (
4854 <span
4855 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,#8c6dff 0%,#36c5d6 130%);border-radius:9999px;vertical-align:middle"
4856 >
4857 AI
4858 </span>
4859 )}
4860 </span>
4861 <span class="prs-gate-details">{check.details}</span>
4862 <span class={`prs-gate-pill ${statusClass}`}>
4863 {statusLabel}
e883329Claude4864 </span>
4865 </div>
b078860Claude4866 );
4867 })}
4868 <div class="prs-gate-footer">
4869 {gatesAllPassed
4870 ? "All checks passed — ready to merge."
4871 : gateChecks.some(
4872 (c) => !c.passed && c.name === "Merge check"
4873 )
4874 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
4875 : "Some checks failed — resolve issues before merging."}
4876 {aiReviewCount > 0 && (
4877 <>
4878 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
4879 </>
4880 )}
4881 </div>
4882 </div>
4883 )}
4884
240c477Claude4885 {pr.state === "open" && ciStatuses.length > 0 && (
4886 <div class="prs-ci-card">
4887 <div class="prs-ci-head">
4888 <h3>CI checks</h3>
4889 <span class="prs-ci-summary">
4890 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
4891 </span>
4892 </div>
4893 {ciStatuses.map((status) => {
4894 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
4895 return (
4896 <div class="prs-ci-row">
4897 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
4898 <span class="prs-ci-context">{status.context}</span>
4899 {status.description && (
4900 <span class="prs-ci-desc">{status.description}</span>
4901 )}
4902 <span class={`prs-ci-pill is-${status.state}`}>
4903 {status.state}
4904 </span>
4905 {status.targetUrl && (
4906 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
4907 )}
4908 </div>
4909 );
4910 })}
4911 </div>
4912 )}
4913
b078860Claude4914 {/* ─── Merge area / state-aware action card ─────────────── */}
4915 {user && pr.state === "open" && (
4916 <div
4917 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
4918 >
4919 <div class="prs-merge-head">
4920 <strong>
4921 {pr.isDraft
4922 ? "Draft — ready for review?"
4923 : mergeBlocked
4924 ? "Merge blocked"
4925 : "Ready to merge"}
4926 </strong>
e883329Claude4927 </div>
b078860Claude4928 <p class="prs-merge-sub">
4929 {pr.isDraft
4930 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
4931 : mergeBlocked
4932 ? "Resolve the failing gate checks above before this PR can land."
4933 : gateChecks.length > 0
4934 ? gatesAllPassed
4935 ? "All gates green. Merge will fast-forward into the base branch."
4936 : "Conflicts will be auto-resolved by GlueCron AI on merge."
4937 : "Run gate checks by refreshing once your branch has a recent commit."}
4938 </p>
4939 <Form
4940 method="post"
4941 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
4942 >
4943 <FormGroup>
3c03977Claude4944 <div class="live-cursor-host" style="position:relative">
4945 <textarea
4946 name="body"
4947 id="pr-comment-body"
4948 data-live-field="comment_new"
6cd2f0eClaude4949 data-md-preview=""
3c03977Claude4950 rows={5}
4951 required
4952 placeholder="Leave a comment... (Markdown supported)"
4953 style="font-family:var(--font-mono);font-size:13px;width:100%"
4954 ></textarea>
4955 </div>
15db0e0Claude4956 <span class="slash-hint" title="Type a slash-command as the first line">
4957 Type <code>/</code> for commands —{" "}
4958 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
4959 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
4960 </span>
b078860Claude4961 </FormGroup>
4962 <div class="prs-merge-actions">
4963 <Button type="submit" variant="primary">
4964 Comment
4965 </Button>
0a67773Claude4966 {user && user.id !== pr.authorId && pr.state === "open" && (
4967 <>
4968 <button
4969 type="submit"
4970 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4971 name="review_state"
4972 value="approved"
4973 class="prs-review-approve-btn"
4974 title="Approve this pull request"
4975 >
4976 ✓ Approve
4977 </button>
4978 <button
4979 type="submit"
4980 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4981 name="review_state"
4982 value="changes_requested"
4983 class="prs-review-changes-btn"
4984 title="Request changes before merging"
4985 >
4986 ✗ Request changes
4987 </button>
4988 </>
4989 )}
b078860Claude4990 {canManage && (
4991 <>
4992 {pr.isDraft ? (
4993 <button
4994 type="submit"
4995 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4996 formnovalidate
4997 class="prs-merge-ready-btn"
4998 >
4999 Ready for review
5000 </button>
5001 ) : (
a164a6dClaude5002 <>
5003 <div class="prs-merge-strategy-wrap">
5004 <span class="prs-merge-strategy-label">Strategy</span>
5005 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
5006 <option value="merge">Merge commit</option>
5007 <option value="squash">Squash and merge</option>
5008 <option value="ff">Fast-forward</option>
5009 </select>
5010 </div>
5011 <button
5012 type="submit"
5013 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
5014 formnovalidate
5015 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
5016 title={
5017 mergeBlocked
5018 ? "Failing gate checks must be resolved before this PR can merge."
5019 : "Merge pull request"
5020 }
5021 >
5022 {"✔"} Merge pull request
5023 </button>
5024 </>
b078860Claude5025 )}
5026 {!pr.isDraft && (
5027 <button
0074234Claude5028 type="submit"
b078860Claude5029 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
5030 formnovalidate
5031 class="prs-merge-back-draft"
5032 title="Convert back to draft"
0074234Claude5033 >
b078860Claude5034 Convert to draft
5035 </button>
5036 )}
5037 {isAiReviewEnabled() && (
5038 <button
5039 type="submit"
5040 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
5041 formnovalidate
5042 class="btn"
5043 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
5044 >
5045 Re-run AI review
5046 </button>
5047 )}
1d4ff60Claude5048 {isAiReviewEnabled() && !hasAiTestsMarker && (
5049 <button
5050 type="submit"
5051 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
5052 formnovalidate
5053 class="btn"
5054 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."
5055 >
5056 Generate tests with AI
5057 </button>
5058 )}
b078860Claude5059 <Button
5060 type="submit"
5061 variant="danger"
5062 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
5063 >
5064 Close
5065 </Button>
5066 </>
5067 )}
5068 </div>
5069 </Form>
5070 </div>
5071 )}
5072
5073 {/* Read-only footers for non-open states. */}
5074 {pr.state === "merged" && (
5075 <div class="prs-merge-card is-merged">
5076 <div class="prs-merge-head">
5077 <strong>{"⮌"} Merged</strong>
0074234Claude5078 </div>
b078860Claude5079 <p class="prs-merge-sub">
5080 This pull request was merged into{" "}
5081 <code>{pr.baseBranch}</code>.
5082 </p>
5083 </div>
5084 )}
5085 {pr.state === "closed" && (
5086 <div class="prs-merge-card is-closed">
5087 <div class="prs-merge-head">
5088 <strong>{"✕"} Closed without merging</strong>
5089 </div>
5090 <p class="prs-merge-sub">
5091 This pull request was closed and not merged.
5092 </p>
5093 </div>
5094 )}
5095 </>
5096 )}
0074234Claude5097 </Layout>
5098 );
5099});
5100
6d1bbc2Claude5101// Update branch — merge base into head so the PR branch is up to date.
5102// Uses a git worktree so the bare repo stays clean. Write access required.
5103pulls.post(
5104 "/:owner/:repo/pulls/:number/update-branch",
5105 softAuth,
5106 requireAuth,
5107 requireRepoAccess("write"),
5108 async (c) => {
5109 const { owner: ownerName, repo: repoName } = c.req.param();
5110 const prNum = parseInt(c.req.param("number"), 10);
5111 const user = c.get("user")!;
5112 const resolved = await resolveRepo(ownerName, repoName);
5113 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5114
5115 const [pr] = await db
5116 .select()
5117 .from(pullRequests)
5118 .where(and(
5119 eq(pullRequests.repositoryId, resolved.repo.id),
5120 eq(pullRequests.number, prNum),
5121 ))
5122 .limit(1);
5123 if (!pr || pr.state !== "open") {
5124 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5125 }
5126
5127 const repoDir = getRepoPath(ownerName, repoName);
5128 const wt = `${repoDir}/_update_wt_${Date.now()}`;
5129 const gitEnv = {
5130 ...process.env,
5131 GIT_AUTHOR_NAME: user.displayName || user.username,
5132 GIT_AUTHOR_EMAIL: user.email,
5133 GIT_COMMITTER_NAME: user.displayName || user.username,
5134 GIT_COMMITTER_EMAIL: user.email,
5135 };
5136
5137 const addWt = Bun.spawn(
5138 ["git", "worktree", "add", wt, pr.headBranch],
5139 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5140 );
5141 if (await addWt.exited !== 0) {
5142 return c.redirect(
5143 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
5144 );
5145 }
5146
5147 let ok = false;
5148 try {
5149 const mergeProc = Bun.spawn(
5150 ["git", "merge", "--no-edit", pr.baseBranch],
5151 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
5152 );
5153 if (await mergeProc.exited === 0) {
5154 ok = true;
5155 } else {
5156 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5157 }
5158 } catch {
5159 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5160 }
5161
5162 await Bun.spawn(
5163 ["git", "worktree", "remove", "--force", wt],
5164 { cwd: repoDir }
5165 ).exited.catch(() => {});
5166
5167 if (ok) {
5168 return c.redirect(
5169 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
5170 );
5171 }
5172 return c.redirect(
5173 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
5174 );
5175 }
5176);
5177
b558f23Claude5178// Edit PR title (and optionally body). Owner or author only.
5179pulls.post(
5180 "/:owner/:repo/pulls/:number/edit",
5181 softAuth,
5182 requireAuth,
5183 requireRepoAccess("write"),
5184 async (c) => {
5185 const { owner: ownerName, repo: repoName } = c.req.param();
5186 const prNum = parseInt(c.req.param("number"), 10);
5187 const user = c.get("user")!;
5188 const resolved = await resolveRepo(ownerName, repoName);
5189 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5190
5191 const [pr] = await db
5192 .select()
5193 .from(pullRequests)
5194 .where(and(
5195 eq(pullRequests.repositoryId, resolved.repo.id),
5196 eq(pullRequests.number, prNum),
5197 ))
5198 .limit(1);
5199 if (!pr || pr.state !== "open") {
5200 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5201 }
5202 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
5203 if (!canEdit) {
5204 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5205 }
5206
5207 const body = await c.req.parseBody();
5208 const newTitle = String(body.title || "").trim().slice(0, 256);
5209 if (!newTitle) {
5210 return c.redirect(
5211 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
5212 );
5213 }
5214
5215 await db
5216 .update(pullRequests)
5217 .set({ title: newTitle, updatedAt: new Date() })
5218 .where(eq(pullRequests.id, pr.id));
5219
5220 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
5221 }
5222);
5223
cb5a796Claude5224// Add comment to PR.
5225//
5226// Permission model mirrors `issues.tsx`: any logged-in user with read
5227// access can submit; `decideInitialStatus` routes non-collaborators
5228// through the moderation queue. Slash commands only fire when the
5229// comment is auto-approved — we don't want a banned/pending comment to
5230// silently trigger AI work on the PR.
0074234Claude5231pulls.post(
5232 "/:owner/:repo/pulls/:number/comment",
5233 softAuth,
5234 requireAuth,
cb5a796Claude5235 requireRepoAccess("read"),
0074234Claude5236 async (c) => {
5237 const { owner: ownerName, repo: repoName } = c.req.param();
5238 const prNum = parseInt(c.req.param("number"), 10);
5239 const user = c.get("user")!;
5240 const body = await c.req.parseBody();
5241 const commentBody = String(body.body || "").trim();
47a7a0aClaude5242 const filePathRaw = String(body.file_path || "").trim();
5243 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
5244 const inlineFilePath = filePathRaw || undefined;
5245 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude5246
5247 if (!commentBody) {
5248 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5249 }
5250
5251 const resolved = await resolveRepo(ownerName, repoName);
5252 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5253
5254 const [pr] = await db
5255 .select()
5256 .from(pullRequests)
5257 .where(
5258 and(
5259 eq(pullRequests.repositoryId, resolved.repo.id),
5260 eq(pullRequests.number, prNum)
5261 )
5262 )
5263 .limit(1);
5264
5265 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5266
cb5a796Claude5267 const decision = await decideInitialStatus({
5268 commenterUserId: user.id,
5269 repositoryId: resolved.repo.id,
5270 kind: "pr",
5271 threadId: pr.id,
5272 });
5273
d4ac5c3Claude5274 const [inserted] = await db
5275 .insert(prComments)
5276 .values({
5277 pullRequestId: pr.id,
5278 authorId: user.id,
5279 body: commentBody,
cb5a796Claude5280 moderationStatus: decision.status,
47a7a0aClaude5281 filePath: inlineFilePath,
5282 lineNumber: inlineLineNumber,
d4ac5c3Claude5283 })
5284 .returning();
5285
cb5a796Claude5286 // Live update: only when the comment is actually visible.
5287 if (inserted && decision.status === "approved") {
d4ac5c3Claude5288 try {
5289 const { publish } = await import("../lib/sse");
5290 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
5291 event: "pr-comment",
5292 data: {
5293 pullRequestId: pr.id,
5294 commentId: inserted.id,
5295 authorId: user.id,
5296 authorUsername: user.username,
5297 },
5298 });
5299 } catch {
5300 /* SSE is best-effort */
5301 }
b7ecb14Claude5302 // Notify the PR author — fire-and-forget, never blocks the response.
5303 if (pr.authorId && pr.authorId !== user.id) {
5304 void import("../lib/notify").then(({ createNotification }) =>
5305 createNotification({
5306 userId: pr.authorId,
5307 type: "pr_comment",
5308 title: `New comment on "${pr.title}"`,
5309 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
5310 url: `/${ownerName}/${repoName}/pulls/${prNum}`,
5311 repoId: resolved.repo.id,
5312 })
5313 ).catch(() => { /* never block the response */ });
5314 }
d4ac5c3Claude5315 }
0074234Claude5316
cb5a796Claude5317 if (decision.status === "pending") {
5318 void notifyOwnerOfPendingComment({
5319 repositoryId: resolved.repo.id,
5320 commenterUsername: user.username,
5321 kind: "pr",
5322 threadNumber: prNum,
5323 ownerUsername: ownerName,
5324 repoName,
5325 });
5326 return c.redirect(
5327 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5328 );
5329 }
5330 if (decision.status === "rejected") {
5331 // Silent ban path — same UX as 'pending' so we don't leak the gate.
5332 return c.redirect(
5333 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5334 );
5335 }
5336
15db0e0Claude5337 // Slash-command handoff. We always store the original comment above
5338 // first so free-form text that happens to start with `/` is preserved
5339 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude5340 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude5341 const parsed = parseSlashCommand(commentBody);
5342 if (parsed) {
5343 try {
5344 const result = await executeSlashCommand({
5345 command: parsed.command,
5346 args: parsed.args,
5347 prId: pr.id,
5348 userId: user.id,
5349 repositoryId: resolved.repo.id,
5350 });
5351 await db.insert(prComments).values({
5352 pullRequestId: pr.id,
5353 authorId: user.id,
5354 body: result.body,
5355 });
5356 } catch (err) {
5357 // Defence-in-depth — executeSlashCommand promises not to throw,
5358 // but if it ever does we want the PR thread to know.
5359 await db
5360 .insert(prComments)
5361 .values({
5362 pullRequestId: pr.id,
5363 authorId: user.id,
5364 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
5365 })
5366 .catch(() => {});
5367 }
5368 }
5369
47a7a0aClaude5370 // Inline comments go back to the files tab; conversation comments to the conversation tab
5371 const redirectTab = inlineFilePath ? "?tab=files" : "";
5372 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude5373 }
5374);
5375
b5dd694Claude5376// Apply a suggestion from a PR comment — commits the suggested code to the
5377// head branch on behalf of the logged-in user.
5378pulls.post(
5379 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
5380 softAuth,
5381 requireAuth,
5382 requireRepoAccess("read"),
5383 async (c) => {
5384 const { owner: ownerName, repo: repoName } = c.req.param();
5385 const prNum = parseInt(c.req.param("number"), 10);
5386 const commentId = c.req.param("commentId"); // UUID
5387 const user = c.get("user")!;
5388
5389 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
5390
5391 const resolved = await resolveRepo(ownerName, repoName);
5392 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5393
5394 const [pr] = await db
5395 .select()
5396 .from(pullRequests)
5397 .where(
5398 and(
5399 eq(pullRequests.repositoryId, resolved.repo.id),
5400 eq(pullRequests.number, prNum)
5401 )
5402 )
5403 .limit(1);
5404
5405 if (!pr || pr.state !== "open") {
5406 return c.redirect(`${backUrl}&error=pr_not_open`);
5407 }
5408
5409 // Only PR author or repo owner may apply suggestions.
5410 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
5411 return c.redirect(`${backUrl}&error=forbidden`);
5412 }
5413
5414 // Load the comment.
5415 const [comment] = await db
5416 .select()
5417 .from(prComments)
5418 .where(
5419 and(
5420 eq(prComments.id, commentId),
5421 eq(prComments.pullRequestId, pr.id)
5422 )
5423 )
5424 .limit(1);
5425
5426 if (!comment) {
5427 return c.redirect(`${backUrl}&error=comment_not_found`);
5428 }
5429
5430 // Parse suggestion block from comment body.
5431 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
5432 if (!m) {
5433 return c.redirect(`${backUrl}&error=no_suggestion`);
5434 }
5435 const suggestionCode = m[1];
5436
5437 // Get the commenter's details for the commit message co-author line.
5438 const [commenter] = await db
5439 .select()
5440 .from(users)
5441 .where(eq(users.id, comment.authorId))
5442 .limit(1);
5443
5444 // Fetch current file content from head branch.
5445 if (!comment.filePath) {
5446 return c.redirect(`${backUrl}&error=file_not_found`);
5447 }
5448 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
5449 if (!blob) {
5450 return c.redirect(`${backUrl}&error=file_not_found`);
5451 }
5452
5453 // Apply the patch — replace the target line(s) with suggestion lines.
5454 const lines = blob.content.split('\n');
5455 const lineIdx = (comment.lineNumber ?? 1) - 1;
5456 if (lineIdx < 0 || lineIdx >= lines.length) {
5457 return c.redirect(`${backUrl}&error=line_out_of_range`);
5458 }
5459 const suggestionLines = suggestionCode.split('\n');
5460 lines.splice(lineIdx, 1, ...suggestionLines);
5461 const newContent = lines.join('\n');
5462
5463 // Commit the change.
5464 const coAuthorLine = commenter
5465 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
5466 : "";
5467 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
5468
5469 const result = await createOrUpdateFileOnBranch({
5470 owner: ownerName,
5471 name: repoName,
5472 branch: pr.headBranch,
5473 filePath: comment.filePath,
5474 bytes: new TextEncoder().encode(newContent),
5475 message: commitMessage,
5476 authorName: user.username,
5477 authorEmail: `${user.username}@users.noreply.gluecron.com`,
5478 });
5479
5480 if ("error" in result) {
5481 return c.redirect(`${backUrl}&error=apply_failed`);
5482 }
5483
5484 // Post a follow-up comment noting the suggestion was applied.
5485 await db.insert(prComments).values({
5486 pullRequestId: pr.id,
5487 authorId: user.id,
5488 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
5489 });
5490
5491 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5492 }
5493);
5494
0a67773Claude5495// Formal review — Approve / Request Changes / Comment
5496pulls.post(
5497 "/:owner/:repo/pulls/:number/review",
5498 softAuth,
5499 requireAuth,
5500 requireRepoAccess("read"),
5501 async (c) => {
5502 const { owner: ownerName, repo: repoName } = c.req.param();
5503 const prNum = parseInt(c.req.param("number"), 10);
5504 const user = c.get("user")!;
5505 const body = await c.req.parseBody();
5506 const reviewBody = String(body.body || "").trim();
5507 const reviewState = String(body.review_state || "commented");
5508
5509 const validStates = ["approved", "changes_requested", "commented"];
5510 if (!validStates.includes(reviewState)) {
5511 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5512 }
5513
5514 const resolved = await resolveRepo(ownerName, repoName);
5515 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5516
5517 const [pr] = await db
5518 .select()
5519 .from(pullRequests)
5520 .where(
5521 and(
5522 eq(pullRequests.repositoryId, resolved.repo.id),
5523 eq(pullRequests.number, prNum)
5524 )
5525 )
5526 .limit(1);
5527 if (!pr || pr.state !== "open") {
5528 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5529 }
5530 // Authors can't review their own PR
5531 if (pr.authorId === user.id) {
5532 return c.redirect(
5533 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
5534 );
5535 }
5536
5537 await db.insert(prReviews).values({
5538 pullRequestId: pr.id,
5539 reviewerId: user.id,
5540 state: reviewState,
5541 body: reviewBody || null,
5542 });
5543
5544 const stateLabel =
5545 reviewState === "approved" ? "Approved"
5546 : reviewState === "changes_requested" ? "Changes requested"
5547 : "Commented";
5548 return c.redirect(
5549 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
5550 );
5551 }
5552);
5553
e883329Claude5554// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude5555// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
5556// but we keep it at "write" for v1 so trusted collaborators can ship.
5557// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
5558// surface. Branch-protection rules (evaluated below) are the current mechanism
5559// for locking down merges further on specific branches.
0074234Claude5560pulls.post(
5561 "/:owner/:repo/pulls/:number/merge",
5562 softAuth,
5563 requireAuth,
04f6b7fClaude5564 requireRepoAccess("write"),
0074234Claude5565 async (c) => {
5566 const { owner: ownerName, repo: repoName } = c.req.param();
5567 const prNum = parseInt(c.req.param("number"), 10);
5568 const user = c.get("user")!;
5569
a164a6dClaude5570 // Read merge strategy from form (default: merge commit)
5571 let mergeStrategy = "merge";
5572 try {
5573 const body = await c.req.parseBody();
5574 const s = body.merge_strategy;
5575 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
5576 } catch { /* ignore parse errors — default to merge commit */ }
5577
0074234Claude5578 const resolved = await resolveRepo(ownerName, repoName);
5579 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5580
5581 const [pr] = await db
5582 .select()
5583 .from(pullRequests)
5584 .where(
5585 and(
5586 eq(pullRequests.repositoryId, resolved.repo.id),
5587 eq(pullRequests.number, prNum)
5588 )
5589 )
5590 .limit(1);
5591
5592 if (!pr || pr.state !== "open") {
5593 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5594 }
5595
6fc53bdClaude5596 // Draft PRs cannot be merged — must be marked ready first.
5597 if (pr.isDraft) {
5598 return c.redirect(
5599 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5600 "This PR is a draft. Mark it as ready for review before merging."
5601 )}`
5602 );
5603 }
5604
ec9e3e3Claude5605 // Required reviews check — branch-protection `required_approvals` gate.
5606 // Evaluated before running expensive gate checks so the feedback is fast.
5607 {
5608 const eligibility = await checkMergeEligible(pr.id, resolved.repo.id, pr.baseBranch);
5609 if (!eligibility.eligible && eligibility.reason) {
5610 return c.redirect(
5611 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(eligibility.reason)}`
5612 );
5613 }
5614 }
5615
e883329Claude5616 // Resolve head SHA
5617 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
5618 if (!headSha) {
5619 return c.redirect(
5620 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
5621 );
5622 }
5623
5624 // Check if AI review approved this PR
5625 const aiComments = await db
5626 .select()
5627 .from(prComments)
5628 .where(
5629 and(
5630 eq(prComments.pullRequestId, pr.id),
5631 eq(prComments.isAiReview, true)
5632 )
5633 );
5634 const aiApproved = aiComments.length === 0 || aiComments.some(
5635 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude5636 );
e883329Claude5637
5638 // Run all green gate checks (GateTest + mergeability + AI review)
5639 const gateResult = await runAllGateChecks(
5640 ownerName,
5641 repoName,
5642 pr.baseBranch,
5643 pr.headBranch,
5644 headSha,
5645 aiApproved
0074234Claude5646 );
5647
e883329Claude5648 // If GateTest or AI review failed (hard blocks), reject the merge
5649 const hardFailures = gateResult.checks.filter(
5650 (check) => !check.passed && check.name !== "Merge check"
5651 );
5652 if (hardFailures.length > 0) {
5653 const errorMsg = hardFailures
5654 .map((f) => `${f.name}: ${f.details}`)
5655 .join("; ");
0074234Claude5656 return c.redirect(
e883329Claude5657 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude5658 );
5659 }
5660
1e162a8Claude5661 // D5 — Branch-protection enforcement. Looks up the matching rule for the
5662 // base branch and blocks the merge if requireAiApproval / requireGreenGates
5663 // / requireHumanReview / requiredApprovals are not satisfied. Independent
5664 // of repo-global settings, so owners can lock specific branches down
5665 // further than the repo default.
5666 const protectionRule = await matchProtection(
5667 resolved.repo.id,
5668 pr.baseBranch
5669 );
5670 if (protectionRule) {
5671 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude5672 const required = await listRequiredChecks(protectionRule.id);
5673 const passingNames = required.length > 0
5674 ? await passingCheckNames(resolved.repo.id, headSha)
5675 : [];
5676 const decision = evaluateProtection(
5677 protectionRule,
5678 {
5679 aiApproved,
5680 humanApprovalCount: humanApprovals,
5681 gateResultGreen: hardFailures.length === 0,
5682 hasFailedGates: hardFailures.length > 0,
5683 passingCheckNames: passingNames,
5684 },
5685 required.map((r) => r.checkName)
5686 );
1e162a8Claude5687 if (!decision.allowed) {
5688 return c.redirect(
5689 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5690 decision.reasons.join(" ")
5691 )}`
5692 );
5693 }
5694 }
5695
e883329Claude5696 // Attempt the merge — with auto conflict resolution if needed
5697 const repoDir = getRepoPath(ownerName, repoName);
5698 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
5699 const hasConflicts = mergeCheck && !mergeCheck.passed;
5700
5701 if (hasConflicts && isAiReviewEnabled()) {
5702 // Use Claude to auto-resolve conflicts
5703 const mergeResult = await mergeWithAutoResolve(
5704 ownerName,
5705 repoName,
5706 pr.baseBranch,
5707 pr.headBranch,
5708 `Merge pull request #${pr.number}: ${pr.title}`
5709 );
5710
5711 if (!mergeResult.success) {
5712 return c.redirect(
5713 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
5714 );
5715 }
5716
5717 // Post a comment about the auto-resolution
5718 if (mergeResult.resolvedFiles.length > 0) {
5719 await db.insert(prComments).values({
5720 pullRequestId: pr.id,
5721 authorId: user.id,
5722 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
5723 isAiReview: true,
5724 });
5725 }
5726 } else {
a164a6dClaude5727 // Worktree-based merge: supports merge-commit, squash, and fast-forward
5728 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
5729 const gitEnv = {
5730 ...process.env,
5731 GIT_AUTHOR_NAME: user.displayName || user.username,
5732 GIT_AUTHOR_EMAIL: user.email,
5733 GIT_COMMITTER_NAME: user.displayName || user.username,
5734 GIT_COMMITTER_EMAIL: user.email,
5735 };
5736
5737 // Create linked worktree on the base branch
5738 const addWt = Bun.spawn(
5739 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude5740 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5741 );
a164a6dClaude5742 if (await addWt.exited !== 0) {
5743 return c.redirect(
5744 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
5745 );
5746 }
5747
5748 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
5749 let mergeOk = false;
5750
5751 try {
5752 if (mergeStrategy === "squash") {
5753 // Squash: stage all changes without committing
5754 const squashProc = Bun.spawn(
5755 ["git", "merge", "--squash", headSha],
5756 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5757 );
5758 if (await squashProc.exited !== 0) {
5759 const errTxt = await new Response(squashProc.stderr).text();
5760 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
5761 }
5762 // Commit the squashed changes
5763 const commitProc = Bun.spawn(
5764 ["git", "commit", "-m", commitMsg],
5765 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5766 );
5767 if (await commitProc.exited !== 0) {
5768 const errTxt = await new Response(commitProc.stderr).text();
5769 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
5770 }
5771 mergeOk = true;
5772 } else if (mergeStrategy === "ff") {
5773 // Fast-forward only — fail if FF is not possible
5774 const ffProc = Bun.spawn(
5775 ["git", "merge", "--ff-only", headSha],
5776 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5777 );
5778 if (await ffProc.exited !== 0) {
5779 const errTxt = await new Response(ffProc.stderr).text();
5780 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
5781 }
5782 mergeOk = true;
5783 } else {
5784 // Default: merge commit (--no-ff always creates a merge commit)
5785 const mergeProc = Bun.spawn(
5786 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
5787 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5788 );
5789 if (await mergeProc.exited !== 0) {
5790 const errTxt = await new Response(mergeProc.stderr).text();
5791 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
5792 }
5793 mergeOk = true;
5794 }
5795 } catch (err) {
5796 // Always clean up the worktree before redirecting
5797 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
5798 const msg = err instanceof Error ? err.message : "Merge failed";
5799 return c.redirect(
5800 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
5801 );
5802 }
5803
5804 // Clean up worktree (changes are now in the bare repo via linked worktree)
5805 await Bun.spawn(
5806 ["git", "worktree", "remove", "--force", wt],
5807 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5808 ).exited.catch(() => {});
e883329Claude5809
a164a6dClaude5810 if (!mergeOk) {
e883329Claude5811 return c.redirect(
a164a6dClaude5812 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude5813 );
5814 }
5815 }
5816
0074234Claude5817 await db
5818 .update(pullRequests)
5819 .set({
5820 state: "merged",
5821 mergedAt: new Date(),
5822 mergedBy: user.id,
5823 updatedAt: new Date(),
5824 })
5825 .where(eq(pullRequests.id, pr.id));
5826
8809b87Claude5827 // Chat notifier — fan out merge event to Slack/Discord/Teams.
5828 import("../lib/chat-notifier")
5829 .then((m) =>
5830 m.notifyChatChannels({
5831 ownerUserId: resolved.repo.ownerId,
5832 repositoryId: resolved.repo.id,
5833 event: {
5834 event: "pr.merged",
5835 repo: `${ownerName}/${repoName}`,
5836 title: `#${pr.number} ${pr.title}`,
5837 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
5838 actor: user.username,
5839 },
5840 })
5841 )
5842 .catch((err) =>
5843 console.warn(`[chat-notifier] PR merge notify failed:`, err)
5844 );
5845
d62fb36Claude5846 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
5847 // and auto-close each matching open issue with a back-link comment. Bounded
5848 // to the same repo for v1 (cross-repo refs ignored). Failures never block
5849 // the merge redirect.
5850 try {
5851 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
5852 const refs = extractClosingRefsMulti([pr.title, pr.body]);
5853 for (const n of refs) {
5854 const [issue] = await db
5855 .select()
5856 .from(issues)
5857 .where(
5858 and(
5859 eq(issues.repositoryId, resolved.repo.id),
5860 eq(issues.number, n)
5861 )
5862 )
5863 .limit(1);
5864 if (!issue || issue.state !== "open") continue;
5865 await db
5866 .update(issues)
5867 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
5868 .where(eq(issues.id, issue.id));
5869 await db.insert(issueComments).values({
5870 issueId: issue.id,
5871 authorId: user.id,
5872 body: `Closed by pull request #${pr.number}.`,
5873 });
5874 }
5875 } catch {
5876 // Never block the merge on close-keyword failures.
5877 }
5878
0074234Claude5879 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5880 }
5881);
5882
6fc53bdClaude5883// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
5884// hasn't run yet on this PR.
5885pulls.post(
5886 "/:owner/:repo/pulls/:number/ready",
5887 softAuth,
5888 requireAuth,
04f6b7fClaude5889 requireRepoAccess("write"),
6fc53bdClaude5890 async (c) => {
5891 const { owner: ownerName, repo: repoName } = c.req.param();
5892 const prNum = parseInt(c.req.param("number"), 10);
5893 const user = c.get("user")!;
5894
5895 const resolved = await resolveRepo(ownerName, repoName);
5896 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5897
5898 const [pr] = await db
5899 .select()
5900 .from(pullRequests)
5901 .where(
5902 and(
5903 eq(pullRequests.repositoryId, resolved.repo.id),
5904 eq(pullRequests.number, prNum)
5905 )
5906 )
5907 .limit(1);
5908 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5909
5910 // Only the author or repo owner can toggle draft state.
5911 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5912 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5913 }
5914
5915 if (pr.state === "open" && pr.isDraft) {
5916 await db
5917 .update(pullRequests)
5918 .set({ isDraft: false, updatedAt: new Date() })
5919 .where(eq(pullRequests.id, pr.id));
5920
5921 if (isAiReviewEnabled()) {
5922 triggerAiReview(
5923 ownerName,
5924 repoName,
5925 pr.id,
5926 pr.title,
0316dbbClaude5927 pr.body || "",
6fc53bdClaude5928 pr.baseBranch,
5929 pr.headBranch
5930 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
5931 }
5932 }
5933
5934 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5935 }
5936);
5937
5938// Convert a PR back to draft.
5939pulls.post(
5940 "/:owner/:repo/pulls/:number/draft",
5941 softAuth,
5942 requireAuth,
04f6b7fClaude5943 requireRepoAccess("write"),
6fc53bdClaude5944 async (c) => {
5945 const { owner: ownerName, repo: repoName } = c.req.param();
5946 const prNum = parseInt(c.req.param("number"), 10);
5947 const user = c.get("user")!;
5948
5949 const resolved = await resolveRepo(ownerName, repoName);
5950 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5951
5952 const [pr] = await db
5953 .select()
5954 .from(pullRequests)
5955 .where(
5956 and(
5957 eq(pullRequests.repositoryId, resolved.repo.id),
5958 eq(pullRequests.number, prNum)
5959 )
5960 )
5961 .limit(1);
5962 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5963
5964 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5965 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5966 }
5967
5968 if (pr.state === "open" && !pr.isDraft) {
5969 await db
5970 .update(pullRequests)
5971 .set({ isDraft: true, updatedAt: new Date() })
5972 .where(eq(pullRequests.id, pr.id));
5973 }
5974
5975 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5976 }
5977);
5978
0074234Claude5979// Close PR
5980pulls.post(
5981 "/:owner/:repo/pulls/:number/close",
5982 softAuth,
5983 requireAuth,
04f6b7fClaude5984 requireRepoAccess("write"),
0074234Claude5985 async (c) => {
5986 const { owner: ownerName, repo: repoName } = c.req.param();
5987 const prNum = parseInt(c.req.param("number"), 10);
5988
5989 const resolved = await resolveRepo(ownerName, repoName);
5990 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5991
5992 await db
5993 .update(pullRequests)
5994 .set({
5995 state: "closed",
5996 closedAt: new Date(),
5997 updatedAt: new Date(),
5998 })
5999 .where(
6000 and(
6001 eq(pullRequests.repositoryId, resolved.repo.id),
6002 eq(pullRequests.number, prNum)
6003 )
6004 );
6005
6006 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6007 }
6008);
6009
c3e0c07Claude6010// Re-run AI review on demand (e.g. after a force-push). Bypasses the
6011// idempotency marker via { force: true }. Write-access only.
6012pulls.post(
6013 "/:owner/:repo/pulls/:number/ai-rereview",
6014 softAuth,
6015 requireAuth,
6016 requireRepoAccess("write"),
6017 async (c) => {
6018 const { owner: ownerName, repo: repoName } = c.req.param();
6019 const prNum = parseInt(c.req.param("number"), 10);
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 if (!pr) {
6034 return c.redirect(`/${ownerName}/${repoName}/pulls`);
6035 }
6036
6037 if (!isAiReviewEnabled()) {
6038 return c.redirect(
6039 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6040 "AI review is not configured (ANTHROPIC_API_KEY)."
6041 )}`
6042 );
6043 }
6044
6045 // Fire-and-forget but with { force: true } to bypass the
6046 // already-reviewed marker. The function still never throws.
6047 triggerAiReview(
6048 ownerName,
6049 repoName,
6050 pr.id,
6051 pr.title || "",
6052 pr.body || "",
6053 pr.baseBranch,
6054 pr.headBranch,
6055 { force: true }
a28cedeClaude6056 ).catch((err) => {
6057 console.warn(
6058 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
6059 err instanceof Error ? err.message : err
6060 );
6061 });
c3e0c07Claude6062
6063 return c.redirect(
6064 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6065 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
6066 )}`
6067 );
6068 }
6069);
6070
1d4ff60Claude6071// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
6072// the PR's head branch carrying just the new test files. Write-access only.
6073// Idempotent — if `ai:added-tests` was previously applied we redirect with
6074// an `info` banner instead of re-firing.
6075pulls.post(
6076 "/:owner/:repo/pulls/:number/generate-tests",
6077 softAuth,
6078 requireAuth,
6079 requireRepoAccess("write"),
6080 async (c) => {
6081 const { owner: ownerName, repo: repoName } = c.req.param();
6082 const prNum = parseInt(c.req.param("number"), 10);
6083 const resolved = await resolveRepo(ownerName, repoName);
6084 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6085
6086 const [pr] = await db
6087 .select()
6088 .from(pullRequests)
6089 .where(
6090 and(
6091 eq(pullRequests.repositoryId, resolved.repo.id),
6092 eq(pullRequests.number, prNum)
6093 )
6094 )
6095 .limit(1);
6096 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6097
6098 if (!isAiReviewEnabled()) {
6099 return c.redirect(
6100 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6101 "AI test generation is not configured (ANTHROPIC_API_KEY)."
6102 )}`
6103 );
6104 }
6105
6106 // Fire-and-forget. The lib never throws.
6107 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
6108 .then((res) => {
6109 if (!res.ok) {
6110 console.warn(
6111 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
6112 );
6113 }
6114 })
6115 .catch((err) => {
6116 console.warn(
6117 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
6118 err instanceof Error ? err.message : err
6119 );
6120 });
6121
6122 return c.redirect(
6123 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6124 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
6125 )}`
6126 );
6127 }
6128);
6129
ace34efClaude6130// ─── Request review ───────────────────────────────────────────────────────────
6131pulls.post(
6132 "/:owner/:repo/pulls/:number/request-review",
6133 softAuth,
6134 requireAuth,
6135 requireRepoAccess("write"),
6136 async (c) => {
6137 const { owner: ownerName, repo: repoName } = c.req.param();
6138 const prNum = parseInt(c.req.param("number"), 10);
6139 const user = c.get("user")!;
6140
6141 const resolved = await resolveRepo(ownerName, repoName);
6142 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6143
6144 const [pr] = await db
6145 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
6146 .from(pullRequests)
6147 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
6148 .limit(1);
6149
6150 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6151
6152 const body = await c.req.formData().catch(() => null);
6153 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
6154
6155 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
6156 return c.redirect(
6157 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
6158 );
6159 }
6160
f4abb8eClaude6161 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
6162 // requesting reviews from arbitrary user IDs outside this repository.
6163 const isOwner = reviewerId === resolved.owner.id;
6164 if (!isOwner) {
6165 const [collab] = await db
6166 .select({ id: repoCollaborators.id })
6167 .from(repoCollaborators)
6168 .where(
6169 and(
6170 eq(repoCollaborators.repositoryId, resolved.repo.id),
6171 eq(repoCollaborators.userId, reviewerId),
6172 isNotNull(repoCollaborators.acceptedAt)
6173 )
6174 )
6175 .limit(1);
6176 if (!collab) {
6177 return c.redirect(
6178 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
6179 );
6180 }
6181 }
6182
ace34efClaude6183 const { requestReview } = await import("../lib/reviewer-suggest");
6184 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
6185
6186 const msg = result.ok
6187 ? "Review requested successfully."
6188 : `Failed to request review: ${result.error ?? "unknown error"}`;
6189
6190 return c.redirect(
6191 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
6192 );
6193 }
6194);
6195
25b1ff7Claude6196// ─── WebSocket presence endpoint ─────────────────────────────────────────────
6197//
6198// GET /:owner/:repo/pulls/:number/presence (WebSocket upgrade)
6199//
6200// Unauthenticated connections are rejected with 401. On connect:
6201// → server sends {type:"init", sessionId, users:[...]}
6202// → server broadcasts {type:"join", user} to all other sessions in the room
6203//
6204// Accepted client messages:
6205// {type:"cursor", line: number} — user hovering a diff line
6206// {type:"typing", line: number, typing: bool} — textarea focus/blur
6207// {type:"ping"} — keep-alive (updates lastSeen)
6208//
6209// The WS `data` payload we store on each socket carries everything needed in
6210// the event handlers so no closure tricks are required.
6211
6212pulls.get(
6213 "/:owner/:repo/pulls/:number/presence",
6214 softAuth,
6215 upgradeWebSocket(async (c) => {
6216 const { owner: ownerName, repo: repoName, number: prNumStr } = c.req.param();
6217 const prNum = parseInt(prNumStr ?? "0", 10);
6218 const user = c.get("user");
6219
6220 // Auth check — no anonymous presence
6221 if (!user) {
6222 // upgradeWebSocket doesn't support returning a non-101 directly;
6223 // we return a dummy handler that immediately closes with 4001.
6224 return {
6225 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6226 ws.close(4001, "Unauthorized");
6227 },
6228 onMessage() {},
6229 onClose() {},
6230 };
6231 }
6232
6233 // Resolve repo to get its numeric id for the room key
6234 const resolved = await resolveRepo(ownerName, repoName);
6235 if (!resolved || isNaN(prNum)) {
6236 return {
6237 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6238 ws.close(4004, "Not found");
6239 },
6240 onMessage() {},
6241 onClose() {},
6242 };
6243 }
6244
6245 const prId = `${resolved.repo.id}:${prNum}`;
6246 const sessionId = `${user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
6247
6248 return {
6249 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6250 // Register and join room
6251 registerSocket(prId, sessionId, {
6252 send: (data: string) => ws.send(data),
6253 readyState: ws.readyState,
6254 });
6255 const presenceUser = joinRoom(prId, sessionId, {
6256 userId: user.id,
6257 username: user.username,
6258 });
6259
6260 // Send init snapshot to the new joiner
6261 const currentUsers = getRoomUsers(prId);
6262 ws.send(
6263 JSON.stringify({
6264 type: "init",
6265 sessionId,
6266 users: currentUsers,
6267 })
6268 );
6269
6270 // Broadcast join to all OTHER sessions
6271 broadcastToRoom(
6272 prId,
6273 {
6274 type: "join",
6275 user: { ...presenceUser, sessionId },
6276 },
6277 sessionId
6278 );
6279 },
6280
6281 onMessage(evt: MessageEvent, _ws: import("hono/ws").WSContext) {
6282 let msg: { type: string; line?: number; typing?: boolean };
6283 try {
6284 msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
6285 } catch {
6286 return;
6287 }
6288
6289 if (msg.type === "ping") {
6290 pingSession(prId, sessionId);
6291 return;
6292 }
6293
6294 if (msg.type === "cursor") {
6295 const line = typeof msg.line === "number" ? msg.line : null;
6296 const updated = updatePresence(prId, sessionId, line, false);
6297 if (updated) {
6298 broadcastToRoom(
6299 prId,
6300 {
6301 type: "cursor",
6302 sessionId,
6303 username: updated.username,
6304 colour: updated.colour,
6305 line,
6306 },
6307 sessionId
6308 );
6309 }
6310 return;
6311 }
6312
6313 if (msg.type === "typing") {
6314 const line = typeof msg.line === "number" ? msg.line : null;
6315 const typing = !!msg.typing;
6316 const updated = updatePresence(prId, sessionId, line, typing);
6317 if (updated) {
6318 broadcastToRoom(
6319 prId,
6320 {
6321 type: "typing",
6322 sessionId,
6323 username: updated.username,
6324 colour: updated.colour,
6325 line,
6326 typing,
6327 },
6328 sessionId
6329 );
6330 }
6331 return;
6332 }
6333 },
6334
6335 onClose() {
6336 leaveRoom(prId, sessionId);
6337 unregisterSocket(prId, sessionId);
6338 broadcastToRoom(prId, { type: "leave", sessionId });
6339 },
6340 };
6341 })
6342);
6343
0074234Claude6344export default pulls;