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