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.tsxBlame5406 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,
0074234Claude23 repositories,
24 users,
d62fb36Claude25 issues,
26 issueComments,
f4abb8eClaude27 repoCollaborators,
0074234Claude28} from "../db/schema";
29import { Layout } from "../views/layout";
ea9ed4cClaude30import { RepoHeader } from "../views/components";
cb5a796Claude31import { PendingCommentsBanner } from "../views/pending-comments-banner";
47a7a0aClaude32import { DiffView, type InlineDiffComment } from "../views/diff-view";
6fc53bdClaude33import { ReactionsBar } from "../views/reactions";
34import { summariseReactions } from "../lib/reactions";
24cf2caClaude35import { loadPrTemplate } from "../lib/templates";
0074234Claude36import { renderMarkdown } from "../lib/markdown";
15db0e0Claude37import {
38 parseSlashCommand,
39 executeSlashCommand,
40 detectSlashCmdComment,
41 stripSlashCmdMarker,
42} from "../lib/pr-slash-commands";
b584e52Claude43import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude44import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude45import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude46import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
0074234Claude47import { softAuth, requireAuth } from "../middleware/auth";
48import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude49import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude50import {
51 decideInitialStatus,
52 notifyOwnerOfPendingComment,
53 countPendingForRepo,
54} from "../lib/comment-moderation";
0316dbbClaude55import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
79ed944Claude56import {
57 TRIO_COMMENT_MARKER,
58 TRIO_SUMMARY_MARKER,
67dc4e1Claude59 isTrioReviewEnabled,
79ed944Claude60 type TrioPersona,
61} from "../lib/ai-review-trio";
1d4ff60Claude62import {
63 generateTestsForPr,
64 AI_TESTS_MARKER,
65} from "../lib/ai-test-generator";
0316dbbClaude66import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude67import { generatePrSummary } from "../lib/ai-generators";
68import { isAiAvailable } from "../lib/ai-client";
534f04aClaude69import {
70 computePrRiskForPullRequest,
71 getCachedPrRisk,
72 type PrRiskScore,
73} from "../lib/pr-risk";
0316dbbClaude74import { runAllGateChecks } from "../lib/gate";
75import type { GateCheckResult } from "../lib/gate";
76import {
77 matchProtection,
78 countHumanApprovals,
79 listRequiredChecks,
80 passingCheckNames,
81 evaluateProtection,
82} from "../lib/branch-protection";
83import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude84import {
85 listBranches,
86 getRepoPath,
e883329Claude87 resolveRef,
b5dd694Claude88 getBlob,
89 createOrUpdateFileOnBranch,
b558f23Claude90 commitsBetween,
0074234Claude91} from "../git/repository";
b558f23Claude92import type { GitDiffFile, GitCommit } from "../git/repository";
240c477Claude93import { listStatuses } from "../lib/commit-statuses";
94import type { CommitStatus } from "../db/schema";
0074234Claude95import { html } from "hono/html";
4bbacbeClaude96import {
97 getPreviewForBranch,
98 previewStatusLabel,
99} from "../lib/branch-previews";
1e162a8Claude100import {
bb0f894Claude101 Flex,
102 Container,
103 Badge,
104 Button,
105 LinkButton,
106 Form,
107 FormGroup,
108 Input,
109 TextArea,
110 Select,
111 EmptyState,
112 FilterTabs,
113 TabNav,
114 List,
115 ListItem,
116 Text,
117 Alert,
118 MarkdownContent,
119 CommentBox,
120 formatRelative,
121} from "../views/ui";
0074234Claude122
ace34efClaude123import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
74d8c4dClaude124import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
a7460bfClaude125import { BOT_USERNAME } from "../lib/bot-user";
ace34efClaude126
0074234Claude127const pulls = new Hono<AuthEnv>();
128
b078860Claude129/* ──────────────────────────────────────────────────────────────────────
130 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
131 * into the issue tracker or any other route. Tokens come from layout.tsx
132 * `:root` so light/dark stays consistent if/when light mode lands.
133 * ──────────────────────────────────────────────────────────────────── */
134const PRS_LIST_STYLES = `
135 .prs-hero {
136 position: relative;
137 margin: 0 0 var(--space-5);
138 padding: 22px 26px 24px;
139 background: var(--bg-elevated);
140 border: 1px solid var(--border);
141 border-radius: 16px;
142 overflow: hidden;
143 }
144 .prs-hero::before {
145 content: '';
146 position: absolute; top: 0; left: 0; right: 0;
147 height: 2px;
148 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
149 opacity: 0.7;
150 pointer-events: none;
151 }
152 .prs-hero-inner {
153 position: relative;
154 display: flex;
155 justify-content: space-between;
156 align-items: flex-end;
157 gap: 20px;
158 flex-wrap: wrap;
159 }
160 .prs-hero-text { flex: 1; min-width: 280px; }
161 .prs-hero-eyebrow {
162 font-size: 12px;
163 color: var(--text-muted);
164 text-transform: uppercase;
165 letter-spacing: 0.08em;
166 font-weight: 600;
167 margin-bottom: 8px;
168 }
169 .prs-hero-title {
170 font-family: var(--font-display);
171 font-size: clamp(26px, 3.4vw, 34px);
172 font-weight: 800;
173 letter-spacing: -0.025em;
174 line-height: 1.06;
175 margin: 0 0 8px;
176 color: var(--text-strong);
177 }
178 .prs-hero-title .gradient-text {
179 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
180 -webkit-background-clip: text;
181 background-clip: text;
182 -webkit-text-fill-color: transparent;
183 color: transparent;
184 }
185 .prs-hero-sub {
186 font-size: 14.5px;
187 color: var(--text-muted);
188 margin: 0;
189 line-height: 1.5;
190 max-width: 620px;
191 }
192 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
193 .prs-cta {
194 display: inline-flex; align-items: center; gap: 6px;
195 padding: 10px 16px;
196 border-radius: 10px;
197 font-size: 13.5px;
198 font-weight: 600;
199 color: #fff;
200 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
201 border: 1px solid rgba(140,109,255,0.55);
202 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
203 text-decoration: none;
204 transition: transform 120ms ease, box-shadow 160ms ease;
205 }
206 .prs-cta:hover {
207 transform: translateY(-1px);
208 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
209 color: #fff;
210 }
211
212 .prs-tabs {
213 display: flex; flex-wrap: wrap; gap: 6px;
214 margin: 0 0 18px;
215 padding: 6px;
216 background: var(--bg-secondary);
217 border: 1px solid var(--border);
218 border-radius: 12px;
219 }
220 .prs-tab {
221 display: inline-flex; align-items: center; gap: 8px;
222 padding: 7px 13px;
223 font-size: 13px;
224 font-weight: 500;
225 color: var(--text-muted);
226 border-radius: 8px;
227 text-decoration: none;
228 transition: background 120ms ease, color 120ms ease;
229 }
230 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
231 .prs-tab.is-active {
232 background: var(--bg-elevated);
233 color: var(--text-strong);
234 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
235 }
236 .prs-tab-count {
237 display: inline-flex; align-items: center; justify-content: center;
238 min-width: 22px; padding: 2px 7px;
239 font-size: 11.5px;
240 font-weight: 600;
241 border-radius: 9999px;
242 background: var(--bg-tertiary);
243 color: var(--text-muted);
244 }
245 .prs-tab.is-active .prs-tab-count {
246 background: rgba(140,109,255,0.18);
247 color: var(--text-link);
248 }
249
250 .prs-list { display: flex; flex-direction: column; gap: 10px; }
251 .prs-row {
252 position: relative;
253 display: flex; align-items: flex-start; gap: 14px;
254 padding: 14px 16px;
255 background: var(--bg-elevated);
256 border: 1px solid var(--border);
257 border-radius: 12px;
258 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
259 }
260 .prs-row:hover {
261 transform: translateY(-1px);
262 border-color: var(--border-strong);
263 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
264 }
265 .prs-row-icon {
266 flex: 0 0 auto;
267 width: 26px; height: 26px;
268 display: inline-flex; align-items: center; justify-content: center;
269 border-radius: 9999px;
270 font-size: 13px;
271 margin-top: 2px;
272 }
273 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
274 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
275 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
276 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
277 .prs-row-body { flex: 1; min-width: 0; }
278 .prs-row-title {
279 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
280 font-size: 15px; font-weight: 600;
281 color: var(--text-strong);
282 line-height: 1.35;
283 margin: 0 0 6px;
284 }
285 .prs-row-number {
286 color: var(--text-muted);
287 font-weight: 400;
288 font-size: 14px;
289 }
290 .prs-row-meta {
291 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
292 font-size: 12.5px;
293 color: var(--text-muted);
294 }
295 .prs-branch-chips {
296 display: inline-flex; align-items: center; gap: 6px;
297 font-family: var(--font-mono);
298 font-size: 11.5px;
299 }
300 .prs-branch-chip {
301 padding: 2px 8px;
302 border-radius: 9999px;
303 background: var(--bg-tertiary);
304 border: 1px solid var(--border);
305 color: var(--text);
306 }
307 .prs-branch-arrow {
308 color: var(--text-faint);
309 font-size: 11px;
310 }
311 .prs-row-tags {
312 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
313 margin-left: auto;
314 }
315 .prs-tag {
316 display: inline-flex; align-items: center; gap: 4px;
317 padding: 2px 8px;
318 font-size: 11px;
319 font-weight: 600;
320 border-radius: 9999px;
321 border: 1px solid var(--border);
322 background: var(--bg-secondary);
323 color: var(--text-muted);
324 line-height: 1.6;
325 }
326 .prs-tag.is-draft {
327 color: var(--text-muted);
328 border-color: var(--border-strong);
329 }
330 .prs-tag.is-merged {
331 color: var(--text-link);
332 border-color: rgba(140,109,255,0.45);
333 background: rgba(140,109,255,0.10);
334 }
1aef949Claude335 .prs-tag.is-approved {
336 color: #34d399;
337 border-color: rgba(52,211,153,0.40);
338 background: rgba(52,211,153,0.08);
339 }
340 .prs-tag.is-changes {
341 color: #f87171;
342 border-color: rgba(248,113,113,0.40);
343 background: rgba(248,113,113,0.08);
344 }
b078860Claude345
346 .prs-empty {
ea9ed4cClaude347 position: relative;
348 padding: 56px 32px;
b078860Claude349 text-align: center;
350 border: 1px dashed var(--border);
ea9ed4cClaude351 border-radius: 16px;
352 background: var(--bg-elevated);
b078860Claude353 color: var(--text-muted);
ea9ed4cClaude354 overflow: hidden;
b078860Claude355 }
ea9ed4cClaude356 .prs-empty::before {
357 content: '';
358 position: absolute;
359 inset: -40% -20% auto auto;
360 width: 320px; height: 320px;
361 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
362 filter: blur(70px);
363 opacity: 0.55;
364 pointer-events: none;
365 animation: prsEmptyOrb 16s ease-in-out infinite;
366 }
367 @keyframes prsEmptyOrb {
368 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
369 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
370 }
371 @media (prefers-reduced-motion: reduce) {
372 .prs-empty::before { animation: none; }
373 }
374 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude375 .prs-empty strong {
376 display: block;
377 color: var(--text-strong);
ea9ed4cClaude378 font-family: var(--font-display);
379 font-size: 22px;
380 font-weight: 700;
381 letter-spacing: -0.018em;
382 margin-bottom: 2px;
383 }
384 .prs-empty-sub {
385 font-size: 14.5px;
386 color: var(--text-muted);
387 line-height: 1.55;
388 max-width: 460px;
389 margin: 0 0 18px;
b078860Claude390 }
ea9ed4cClaude391 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude392
393 @media (max-width: 720px) {
394 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
395 .prs-hero-actions { width: 100%; }
396 .prs-row-tags { margin-left: 0; }
397 }
f1dc7c7Claude398
399 /* Additional mobile rules. Additive only. */
400 @media (max-width: 720px) {
401 .prs-hero { padding: 18px 18px 20px; }
402 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
403 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
404 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
405 .prs-row { padding: 12px 14px; gap: 10px; }
406 .prs-row-icon { width: 24px; height: 24px; }
407 }
f5b9ef5Claude408
409 /* ─── Sort controls (PR list) ─── */
410 .prs-sort-row {
411 display: flex;
412 align-items: center;
413 gap: 6px;
414 margin: 0 0 12px;
415 flex-wrap: wrap;
416 }
417 .prs-sort-label {
418 font-size: 12.5px;
419 color: var(--text-muted);
420 font-weight: 600;
421 margin-right: 2px;
422 }
423 .prs-sort-opt {
424 font-size: 12.5px;
425 color: var(--text-muted);
426 text-decoration: none;
427 padding: 3px 10px;
428 border-radius: 9999px;
429 border: 1px solid transparent;
430 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
431 }
432 .prs-sort-opt:hover {
433 background: var(--bg-hover);
434 color: var(--text);
435 }
436 .prs-sort-opt.is-active {
437 background: rgba(140,109,255,0.12);
438 color: var(--text-link);
439 border-color: rgba(140,109,255,0.35);
440 font-weight: 600;
441 }
b078860Claude442`;
443
444/* ──────────────────────────────────────────────────────────────────────
445 * Inline CSS for the detail page. Same `.prs-*` namespace.
446 * ──────────────────────────────────────────────────────────────────── */
447const PRS_DETAIL_STYLES = `
448 .prs-detail-hero {
449 position: relative;
450 margin: 0 0 var(--space-4);
451 padding: 24px 26px;
452 background: var(--bg-elevated);
453 border: 1px solid var(--border);
454 border-radius: 16px;
455 overflow: hidden;
456 }
457 .prs-detail-hero::before {
458 content: '';
459 position: absolute; top: 0; left: 0; right: 0;
460 height: 2px;
461 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
462 opacity: 0.7;
463 pointer-events: none;
464 }
465 .prs-detail-title {
466 font-family: var(--font-display);
467 font-size: clamp(22px, 2.6vw, 28px);
468 font-weight: 700;
469 letter-spacing: -0.022em;
470 line-height: 1.2;
471 color: var(--text-strong);
472 margin: 0 0 12px;
473 }
474 .prs-detail-num {
475 color: var(--text-muted);
476 font-weight: 400;
477 }
478 .prs-state-pill {
479 display: inline-flex; align-items: center; gap: 6px;
480 padding: 6px 12px;
481 border-radius: 9999px;
482 font-size: 12.5px;
483 font-weight: 600;
484 line-height: 1;
485 border: 1px solid transparent;
486 }
487 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
488 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
489 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
490 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
491
74d8c4dClaude492 .prs-size-badge {
493 display: inline-flex;
494 align-items: center;
495 padding: 2px 8px;
496 border-radius: 20px;
497 font-size: 11px;
498 font-weight: 700;
499 letter-spacing: 0.04em;
500 border: 1px solid currentColor;
501 opacity: 0.85;
502 }
503
b078860Claude504 .prs-detail-meta {
505 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
506 font-size: 13px;
507 color: var(--text-muted);
508 }
509 .prs-detail-meta strong { color: var(--text); }
510 .prs-detail-branches {
511 display: inline-flex; align-items: center; gap: 6px;
512 font-family: var(--font-mono);
513 font-size: 12px;
514 }
515 .prs-branch-pill {
516 padding: 3px 9px;
517 border-radius: 9999px;
518 background: var(--bg-tertiary);
519 border: 1px solid var(--border);
520 color: var(--text);
521 }
522 .prs-branch-pill.is-head { color: var(--text-strong); }
523 .prs-branch-arrow-lg {
524 color: var(--accent);
525 font-size: 14px;
526 font-weight: 700;
527 }
0369e77Claude528 .prs-branch-sync {
529 display: inline-flex; align-items: center; gap: 4px;
530 font-size: 11.5px; font-weight: 600;
531 padding: 2px 8px;
532 border-radius: 9999px;
533 border: 1px solid var(--border);
534 background: var(--bg-secondary);
535 color: var(--text-muted);
536 cursor: default;
537 }
538 .prs-branch-sync.is-behind {
539 color: #f87171;
540 border-color: rgba(248,113,113,0.35);
541 background: rgba(248,113,113,0.07);
542 }
543 .prs-branch-sync.is-synced {
544 color: #34d399;
545 border-color: rgba(52,211,153,0.35);
546 background: rgba(52,211,153,0.07);
547 }
b078860Claude548
549 .prs-detail-actions {
550 display: inline-flex; gap: 8px; margin-left: auto;
551 }
552
553 .prs-detail-tabs {
554 display: flex; gap: 4px;
555 margin: 0 0 16px;
556 border-bottom: 1px solid var(--border);
557 }
558 .prs-detail-tab {
559 padding: 10px 14px;
560 font-size: 13.5px;
561 font-weight: 500;
562 color: var(--text-muted);
563 text-decoration: none;
564 border-bottom: 2px solid transparent;
565 transition: color 120ms ease, border-color 120ms ease;
566 margin-bottom: -1px;
567 }
568 .prs-detail-tab:hover { color: var(--text); }
569 .prs-detail-tab.is-active {
570 color: var(--text-strong);
571 border-bottom-color: var(--accent);
572 }
573 .prs-detail-tab-count {
574 display: inline-flex; align-items: center; justify-content: center;
575 min-width: 20px; padding: 0 6px; margin-left: 6px;
576 height: 18px;
577 font-size: 11px;
578 font-weight: 600;
579 border-radius: 9999px;
580 background: var(--bg-tertiary);
581 color: var(--text-muted);
582 }
583
584 /* Gate / check status section */
585 .prs-gate-card {
586 margin-top: 20px;
587 background: var(--bg-elevated);
588 border: 1px solid var(--border);
589 border-radius: 14px;
590 overflow: hidden;
591 }
592 .prs-gate-head {
593 display: flex; align-items: center; gap: 10px;
594 padding: 14px 18px;
595 border-bottom: 1px solid var(--border);
596 }
597 .prs-gate-head h3 {
598 margin: 0;
599 font-size: 14px;
600 font-weight: 600;
601 color: var(--text-strong);
602 }
603 .prs-gate-summary {
604 margin-left: auto;
605 font-size: 12px;
606 color: var(--text-muted);
607 }
608 .prs-gate-row {
609 display: flex; align-items: center; gap: 12px;
610 padding: 12px 18px;
611 border-bottom: 1px solid var(--border-subtle);
612 }
613 .prs-gate-row:last-child { border-bottom: 0; }
614 .prs-gate-icon {
615 flex: 0 0 auto;
616 width: 22px; height: 22px;
617 display: inline-flex; align-items: center; justify-content: center;
618 border-radius: 9999px;
619 font-size: 12px;
620 font-weight: 700;
621 }
622 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
623 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
624 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
625 .prs-gate-name {
626 font-size: 13px;
627 font-weight: 600;
628 color: var(--text);
629 min-width: 140px;
630 }
631 .prs-gate-details {
632 flex: 1; min-width: 0;
633 font-size: 12.5px;
634 color: var(--text-muted);
635 }
636 .prs-gate-pill {
637 flex: 0 0 auto;
638 padding: 3px 10px;
639 border-radius: 9999px;
640 font-size: 11px;
641 font-weight: 600;
642 line-height: 1.5;
643 border: 1px solid transparent;
644 }
645 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
646 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
647 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
648 .prs-gate-footer {
649 padding: 12px 18px;
650 background: var(--bg-secondary);
651 font-size: 12px;
652 color: var(--text-muted);
653 }
654
655 /* Comment cards */
656 .prs-comment {
657 margin-top: 14px;
658 background: var(--bg-elevated);
659 border: 1px solid var(--border);
660 border-radius: 12px;
661 overflow: hidden;
662 }
663 .prs-comment-head {
664 display: flex; align-items: center; gap: 10px;
665 padding: 10px 14px;
666 background: var(--bg-secondary);
667 border-bottom: 1px solid var(--border);
668 font-size: 13px;
669 flex-wrap: wrap;
670 }
671 .prs-comment-head strong { color: var(--text-strong); }
672 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
673 .prs-comment-loc {
674 font-family: var(--font-mono);
675 font-size: 11.5px;
676 color: var(--text-muted);
677 background: var(--bg-tertiary);
678 padding: 2px 8px;
679 border-radius: 6px;
680 }
681 .prs-comment-body { padding: 14px 18px; }
682 .prs-comment.is-ai {
683 border-color: rgba(140,109,255,0.45);
684 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
685 }
686 .prs-comment.is-ai .prs-comment-head {
687 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
688 border-bottom-color: rgba(140,109,255,0.30);
689 }
690 .prs-ai-badge {
691 display: inline-flex; align-items: center; gap: 4px;
692 padding: 2px 9px;
693 font-size: 10.5px;
694 font-weight: 700;
695 letter-spacing: 0.04em;
696 text-transform: uppercase;
697 color: #fff;
698 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
699 border-radius: 9999px;
700 }
a7460bfClaude701 .prs-bot-badge {
702 display: inline-flex; align-items: center; gap: 3px;
703 padding: 1px 7px;
704 font-size: 10px;
705 font-weight: 600;
706 color: var(--fg-muted);
707 background: var(--bg-elevated);
708 border: 1px solid var(--border);
709 border-radius: 9999px;
710 }
b078860Claude711
712 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
713 .prs-files-card {
714 margin-top: 18px;
715 padding: 14px 18px;
716 display: flex; align-items: center; gap: 14px;
717 background: var(--bg-elevated);
718 border: 1px solid var(--border);
719 border-radius: 12px;
720 text-decoration: none;
721 color: inherit;
722 transition: border-color 120ms ease, transform 140ms ease;
723 }
724 .prs-files-card:hover {
725 border-color: rgba(140,109,255,0.45);
726 transform: translateY(-1px);
727 }
728 .prs-files-card-icon {
729 width: 36px; height: 36px;
730 display: inline-flex; align-items: center; justify-content: center;
731 border-radius: 10px;
732 background: rgba(140,109,255,0.12);
733 color: var(--text-link);
734 font-size: 18px;
735 }
736 .prs-files-card-text { flex: 1; min-width: 0; }
737 .prs-files-card-title {
738 font-size: 14px;
739 font-weight: 600;
740 color: var(--text-strong);
741 margin: 0 0 2px;
742 }
743 .prs-files-card-sub {
744 font-size: 12.5px;
745 color: var(--text-muted);
746 margin: 0;
747 }
748 .prs-files-card-cta {
749 font-size: 12.5px;
750 color: var(--text-link);
751 font-weight: 600;
752 }
753
754 /* Merge area */
755 .prs-merge-card {
756 position: relative;
757 margin-top: 22px;
758 padding: 18px;
759 background: var(--bg-elevated);
760 border-radius: 14px;
761 overflow: hidden;
762 }
763 .prs-merge-card::before {
764 content: '';
765 position: absolute; inset: 0;
766 padding: 1px;
767 border-radius: 14px;
768 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
769 -webkit-mask:
770 linear-gradient(#000 0 0) content-box,
771 linear-gradient(#000 0 0);
772 -webkit-mask-composite: xor;
773 mask-composite: exclude;
774 pointer-events: none;
775 }
776 .prs-merge-card.is-closed::before { background: var(--border-strong); }
777 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
778 .prs-merge-head {
779 display: flex; align-items: center; gap: 12px;
780 margin-bottom: 12px;
781 }
782 .prs-merge-head strong {
783 font-family: var(--font-display);
784 font-size: 15px;
785 color: var(--text-strong);
786 font-weight: 700;
787 }
788 .prs-merge-sub {
789 font-size: 13px;
790 color: var(--text-muted);
791 margin: 0 0 12px;
792 }
793 .prs-merge-actions {
794 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
795 }
796 .prs-merge-btn {
797 display: inline-flex; align-items: center; gap: 6px;
798 padding: 9px 16px;
799 border-radius: 10px;
800 font-size: 13.5px;
801 font-weight: 600;
802 color: #fff;
803 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
804 border: 1px solid rgba(52,211,153,0.55);
805 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
806 cursor: pointer;
807 transition: transform 120ms ease, box-shadow 160ms ease;
808 }
809 .prs-merge-btn:hover {
810 transform: translateY(-1px);
811 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
812 }
813 .prs-merge-btn[disabled],
814 .prs-merge-btn.is-disabled {
815 opacity: 0.55;
816 cursor: not-allowed;
817 transform: none;
818 box-shadow: none;
819 }
820 .prs-merge-ready-btn {
821 display: inline-flex; align-items: center; gap: 6px;
822 padding: 9px 16px;
823 border-radius: 10px;
824 font-size: 13.5px;
825 font-weight: 600;
826 color: #fff;
827 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
828 border: 1px solid rgba(140,109,255,0.55);
829 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
830 cursor: pointer;
831 transition: transform 120ms ease, box-shadow 160ms ease;
832 }
833 .prs-merge-ready-btn:hover {
834 transform: translateY(-1px);
835 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
836 }
837 .prs-merge-back-draft {
838 background: none; border: 1px solid var(--border-strong);
839 color: var(--text-muted);
840 padding: 9px 14px; border-radius: 10px;
841 font-size: 13px; cursor: pointer;
842 }
843 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
844
a164a6dClaude845 /* Merge strategy selector */
846 .prs-merge-strategy-wrap {
847 display: inline-flex; align-items: center;
848 background: var(--bg-elevated);
849 border: 1px solid var(--border);
850 border-radius: 10px;
851 overflow: hidden;
852 }
853 .prs-merge-strategy-label {
854 font-size: 11.5px; font-weight: 600;
855 color: var(--text-muted);
856 padding: 0 10px 0 12px;
857 white-space: nowrap;
858 }
859 .prs-merge-strategy-select {
860 background: transparent;
861 border: none;
862 color: var(--text);
863 font-size: 13px;
864 padding: 7px 10px 7px 4px;
865 cursor: pointer;
866 outline: none;
867 appearance: auto;
868 }
869 .prs-merge-strategy-select:focus { outline: 2px solid rgba(140,109,255,0.45); }
870
0a67773Claude871 /* Review summary banner */
872 .prs-review-summary {
873 display: flex; flex-direction: column; gap: 6px;
874 padding: 12px 16px;
875 background: var(--bg-elevated);
876 border: 1px solid var(--border);
877 border-radius: var(--r-md, 8px);
878 margin-bottom: 12px;
879 }
880 .prs-review-row {
881 display: flex; align-items: center; gap: 10px;
882 font-size: 13px;
883 }
884 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
885 .prs-review-approved .prs-review-icon { color: #34d399; }
886 .prs-review-changes .prs-review-icon { color: #f87171; }
ace34efClaude887 .prs-reviewer-avatar {
888 width: 24px; height: 24px; border-radius: 50%;
889 background: var(--accent); color: #fff;
890 display: flex; align-items: center; justify-content: center;
891 font-size: 11px; font-weight: 700; flex-shrink: 0;
892 }
0a67773Claude893
894 /* Review action buttons */
895 .prs-review-approve-btn {
896 display: inline-flex; align-items: center; gap: 5px;
897 padding: 8px 14px; border-radius: 8px; font-size: 13px;
898 font-weight: 600; cursor: pointer;
899 background: rgba(52,211,153,0.12);
900 color: #34d399;
901 border: 1px solid rgba(52,211,153,0.35);
902 transition: background 120ms;
903 }
904 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
905 .prs-review-changes-btn {
906 display: inline-flex; align-items: center; gap: 5px;
907 padding: 8px 14px; border-radius: 8px; font-size: 13px;
908 font-weight: 600; cursor: pointer;
909 background: rgba(248,113,113,0.10);
910 color: #f87171;
911 border: 1px solid rgba(248,113,113,0.30);
912 transition: background 120ms;
913 }
914 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
915
b078860Claude916 /* Inline form helpers */
917 .prs-inline-form { display: inline-flex; }
918
919 /* Comment composer */
920 .prs-composer { margin-top: 22px; }
921 .prs-composer textarea {
922 border-radius: 12px;
923 }
924
925 @media (max-width: 720px) {
926 .prs-detail-actions { margin-left: 0; }
927 .prs-merge-actions { width: 100%; }
928 .prs-merge-actions > * { flex: 1; min-width: 0; }
929 }
f1dc7c7Claude930
931 /* Additional mobile rules. Additive only. */
932 @media (max-width: 720px) {
933 .prs-detail-hero { padding: 18px; }
934 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
935 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
936 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
937 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
938 .prs-gate-name { min-width: 0; }
939 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
940 .prs-gate-summary { margin-left: 0; }
941 .prs-merge-btn,
942 .prs-merge-ready-btn,
943 .prs-merge-back-draft { min-height: 44px; }
944 .prs-comment-body { padding: 12px 14px; }
945 .prs-comment-head { padding: 10px 12px; }
946 .prs-files-card { padding: 12px 14px; }
947 }
3c03977Claude948
949 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
950 .live-pill {
951 display: inline-flex;
952 align-items: center;
953 gap: 8px;
954 padding: 4px 10px 4px 8px;
955 margin-left: 6px;
956 background: var(--bg-elevated);
957 border: 1px solid var(--border);
958 border-radius: 9999px;
959 font-size: 12px;
960 color: var(--text-muted);
961 line-height: 1;
962 vertical-align: middle;
963 }
964 .live-pill.is-busy { color: var(--text); }
965 .live-pill-dot {
966 width: 8px; height: 8px;
967 border-radius: 9999px;
968 background: #34d399;
969 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
970 animation: live-pulse 1.6s ease-in-out infinite;
971 }
972 @keyframes live-pulse {
973 0%, 100% { opacity: 1; }
974 50% { opacity: 0.55; }
975 }
976 .live-avatars {
977 display: inline-flex;
978 margin-left: 2px;
979 }
980 .live-avatar {
981 display: inline-flex;
982 align-items: center;
983 justify-content: center;
984 width: 22px; height: 22px;
985 border-radius: 9999px;
986 font-size: 10px;
987 font-weight: 700;
988 color: #0b1020;
989 margin-left: -6px;
990 border: 2px solid var(--bg-elevated);
991 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
992 }
993 .live-avatar:first-child { margin-left: 0; }
994 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
995 .live-cursor-host {
996 position: relative;
997 }
998 .live-cursor-overlay {
999 position: absolute;
1000 inset: 0;
1001 pointer-events: none;
1002 overflow: hidden;
1003 border-radius: inherit;
1004 }
1005 .live-cursor {
1006 position: absolute;
1007 width: 2px;
1008 height: 18px;
1009 border-radius: 2px;
1010 transform: translate(-1px, 0);
1011 transition: transform 80ms linear, opacity 200ms ease;
1012 }
1013 .live-cursor::after {
1014 content: attr(data-label);
1015 position: absolute;
1016 top: -16px;
1017 left: -2px;
1018 font-size: 10px;
1019 line-height: 1;
1020 color: #0b1020;
1021 background: inherit;
1022 padding: 2px 5px;
1023 border-radius: 4px 4px 4px 0;
1024 white-space: nowrap;
1025 font-weight: 600;
1026 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
1027 }
1028 .live-cursor.is-idle { opacity: 0.4; }
1029 .live-edit-tag {
1030 display: inline-block;
1031 margin-left: 6px;
1032 padding: 1px 6px;
1033 font-size: 10px;
1034 font-weight: 600;
1035 letter-spacing: 0.02em;
1036 color: #0b1020;
1037 border-radius: 9999px;
1038 }
15db0e0Claude1039
1040 /* ─── Slash-command pill + composer hint ─── */
1041 .slash-hint {
1042 display: inline-flex;
1043 align-items: center;
1044 gap: 6px;
1045 margin-top: 6px;
1046 padding: 3px 9px;
1047 font-size: 11.5px;
1048 color: var(--text-muted);
1049 background: var(--bg-elevated);
1050 border: 1px dashed var(--border);
1051 border-radius: 9999px;
1052 width: fit-content;
1053 }
1054 .slash-hint code {
1055 background: rgba(110, 168, 255, 0.12);
1056 color: var(--text-strong);
1057 padding: 0 5px;
1058 border-radius: 4px;
1059 font-size: 11px;
1060 }
1061 .slash-pill {
1062 display: grid;
1063 grid-template-columns: auto 1fr auto;
1064 align-items: center;
1065 column-gap: 10px;
1066 row-gap: 6px;
1067 margin: 10px 0;
1068 padding: 10px 14px;
1069 background: linear-gradient(
1070 135deg,
1071 rgba(110, 168, 255, 0.08),
1072 rgba(163, 113, 247, 0.06)
1073 );
1074 border: 1px solid rgba(110, 168, 255, 0.32);
1075 border-left: 3px solid var(--accent, #6ea8ff);
1076 border-radius: var(--radius);
1077 font-size: 13px;
1078 color: var(--text);
1079 }
1080 .slash-pill-icon {
1081 font-size: 14px;
1082 line-height: 1;
1083 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
1084 }
1085 .slash-pill-actor { color: var(--text-muted); }
1086 .slash-pill-actor strong { color: var(--text-strong); }
1087 .slash-pill-cmd {
1088 background: rgba(110, 168, 255, 0.16);
1089 color: var(--text-strong);
1090 padding: 1px 6px;
1091 border-radius: 4px;
1092 font-size: 12.5px;
1093 }
1094 .slash-pill-time {
1095 color: var(--text-muted);
1096 font-size: 12px;
1097 justify-self: end;
1098 }
1099 .slash-pill-body {
1100 grid-column: 1 / -1;
1101 color: var(--text);
1102 font-size: 13px;
1103 line-height: 1.55;
1104 }
1105 .slash-pill-body p:first-child { margin-top: 0; }
1106 .slash-pill-body p:last-child { margin-bottom: 0; }
1107 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
1108 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1109 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
1110 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude1111
1112 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
1113 .preview-prpill {
1114 display: inline-flex; align-items: center; gap: 6px;
1115 padding: 3px 10px;
1116 border-radius: 9999px;
1117 font-family: var(--font-mono);
1118 font-size: 11.5px;
1119 font-weight: 600;
1120 background: rgba(255,255,255,0.04);
1121 color: var(--text-muted);
1122 text-decoration: none;
1123 border: 1px solid var(--border);
1124 }
1125 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
1126 .preview-prpill .preview-prpill-dot {
1127 width: 7px; height: 7px;
1128 border-radius: 9999px;
1129 background: currentColor;
1130 }
1131 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1132 .preview-prpill.is-building .preview-prpill-dot {
1133 animation: previewPrPulse 1.4s ease-in-out infinite;
1134 }
1135 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1136 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1137 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1138 @keyframes previewPrPulse {
1139 0%, 100% { opacity: 1; }
1140 50% { opacity: 0.4; }
1141 }
79ed944Claude1142
1143 /* ─── AI Trio Review — 3-column verdict cards ─── */
1144 .trio-wrap {
1145 margin-top: 18px;
1146 padding: 16px;
1147 background: var(--bg-elevated);
1148 border: 1px solid var(--border);
1149 border-radius: 14px;
1150 }
1151 .trio-header {
1152 display: flex; align-items: center; gap: 10px;
1153 margin: 0 0 12px;
1154 font-size: 13.5px;
1155 color: var(--text);
1156 }
1157 .trio-header strong { color: var(--text-strong); }
1158 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1159 .trio-header-dot {
1160 width: 8px; height: 8px; border-radius: 9999px;
1161 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1162 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1163 }
1164 .trio-grid {
1165 display: grid;
1166 grid-template-columns: repeat(3, minmax(0, 1fr));
1167 gap: 12px;
1168 }
1169 .trio-card {
1170 background: var(--bg-secondary);
1171 border: 1px solid var(--border);
1172 border-radius: 12px;
1173 overflow: hidden;
1174 display: flex; flex-direction: column;
1175 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1176 }
1177 .trio-card-head {
1178 display: flex; align-items: center; gap: 8px;
1179 padding: 10px 12px;
1180 border-bottom: 1px solid var(--border);
1181 background: rgba(255,255,255,0.02);
1182 font-size: 13px;
1183 }
1184 .trio-card-icon {
1185 display: inline-flex; align-items: center; justify-content: center;
1186 width: 22px; height: 22px;
1187 border-radius: 9999px;
1188 font-size: 12px;
1189 background: rgba(255,255,255,0.05);
1190 }
1191 .trio-card-title {
1192 color: var(--text-strong);
1193 font-weight: 600;
1194 letter-spacing: 0.01em;
1195 }
1196 .trio-card-verdict {
1197 margin-left: auto;
1198 font-size: 11px;
1199 font-weight: 700;
1200 letter-spacing: 0.06em;
1201 text-transform: uppercase;
1202 padding: 3px 9px;
1203 border-radius: 9999px;
1204 background: var(--bg-tertiary);
1205 color: var(--text-muted);
1206 border: 1px solid var(--border-strong);
1207 }
1208 .trio-card-body {
1209 padding: 12px 14px;
1210 font-size: 13px;
1211 color: var(--text);
1212 flex: 1;
1213 min-height: 64px;
1214 line-height: 1.55;
1215 }
1216 .trio-card-body p { margin: 0 0 8px; }
1217 .trio-card-body p:last-child { margin-bottom: 0; }
1218 .trio-card-body ul { margin: 0; padding-left: 18px; }
1219 .trio-card-body code {
1220 font-family: var(--font-mono);
1221 font-size: 12px;
1222 background: var(--bg-tertiary);
1223 padding: 1px 6px;
1224 border-radius: 5px;
1225 }
1226 .trio-card-empty {
1227 color: var(--text-muted);
1228 font-style: italic;
1229 font-size: 12.5px;
1230 }
1231
1232 /* Pass state — neutral, no accent. */
1233 .trio-card.is-pass .trio-card-verdict {
1234 color: var(--green);
1235 border-color: rgba(52,211,153,0.35);
1236 background: rgba(52,211,153,0.12);
1237 }
1238
1239 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1240 .trio-card.trio-security.is-fail {
1241 border-color: rgba(248,113,113,0.55);
1242 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1243 }
1244 .trio-card.trio-security.is-fail .trio-card-head {
1245 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1246 border-bottom-color: rgba(248,113,113,0.30);
1247 }
1248 .trio-card.trio-security.is-fail .trio-card-verdict {
1249 color: #fecaca;
1250 border-color: rgba(248,113,113,0.55);
1251 background: rgba(248,113,113,0.20);
1252 }
1253
1254 .trio-card.trio-correctness.is-fail {
1255 border-color: rgba(251,191,36,0.55);
1256 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1257 }
1258 .trio-card.trio-correctness.is-fail .trio-card-head {
1259 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1260 border-bottom-color: rgba(251,191,36,0.30);
1261 }
1262 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1263 color: #fde68a;
1264 border-color: rgba(251,191,36,0.55);
1265 background: rgba(251,191,36,0.20);
1266 }
1267
1268 .trio-card.trio-style.is-fail {
1269 border-color: rgba(96,165,250,0.55);
1270 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1271 }
1272 .trio-card.trio-style.is-fail .trio-card-head {
1273 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1274 border-bottom-color: rgba(96,165,250,0.30);
1275 }
1276 .trio-card.trio-style.is-fail .trio-card-verdict {
1277 color: #bfdbfe;
1278 border-color: rgba(96,165,250,0.55);
1279 background: rgba(96,165,250,0.20);
1280 }
1281
1282 /* Disagreement callout strip — yellow, prominent. */
1283 .trio-disagreement-strip {
1284 display: flex;
1285 gap: 12px;
1286 margin-top: 14px;
1287 padding: 12px 14px;
1288 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1289 border: 1px solid rgba(251,191,36,0.45);
1290 border-radius: 10px;
1291 color: var(--text);
1292 font-size: 13px;
1293 }
1294 .trio-disagreement-icon {
1295 flex: 0 0 auto;
1296 width: 26px; height: 26px;
1297 display: inline-flex; align-items: center; justify-content: center;
1298 border-radius: 9999px;
1299 background: rgba(251,191,36,0.25);
1300 color: #fde68a;
1301 font-size: 14px;
1302 }
1303 .trio-disagreement-body strong {
1304 display: block;
1305 color: #fde68a;
1306 margin: 0 0 4px;
1307 font-weight: 700;
1308 }
1309 .trio-disagreement-list {
1310 margin: 0;
1311 padding-left: 18px;
1312 color: var(--text);
1313 font-size: 12.5px;
1314 line-height: 1.55;
1315 }
1316 .trio-disagreement-list code {
1317 font-family: var(--font-mono);
1318 font-size: 11.5px;
1319 background: var(--bg-tertiary);
1320 padding: 1px 5px;
1321 border-radius: 4px;
1322 }
1323
1324 @media (max-width: 720px) {
1325 .trio-grid { grid-template-columns: 1fr; }
1326 .trio-wrap { padding: 12px; }
1327 }
6d1bbc2Claude1328
1329 /* ─── Task list progress pill ─── */
1330 .prs-tasks-pill {
1331 display: inline-flex; align-items: center; gap: 5px;
1332 font-size: 11.5px; font-weight: 600;
1333 padding: 2px 9px; border-radius: 9999px;
1334 border: 1px solid var(--border);
1335 background: var(--bg-elevated);
1336 color: var(--text-muted);
1337 }
1338 .prs-tasks-pill.is-complete {
1339 color: #34d399;
1340 border-color: rgba(52,211,153,0.40);
1341 background: rgba(52,211,153,0.08);
1342 }
1343 .prs-tasks-progress { display: inline-block; width: 36px; height: 4px; border-radius: 9999px; background: var(--border); overflow: hidden; vertical-align: middle; }
1344 .prs-tasks-progress-bar { height: 100%; border-radius: 9999px; background: #34d399; }
1345
1346 /* ─── Update branch button ─── */
1347 .prs-update-branch-btn {
1348 display: inline-flex; align-items: center; gap: 5px;
1349 padding: 4px 12px; border-radius: 8px; font-size: 12.5px;
1350 font-weight: 600; cursor: pointer;
1351 background: rgba(96,165,250,0.10);
1352 color: #60a5fa;
1353 border: 1px solid rgba(96,165,250,0.30);
1354 transition: background 120ms;
1355 }
1356 .prs-update-branch-btn:hover { background: rgba(96,165,250,0.20); }
1357
1358 /* ─── Linked issues panel ─── */
1359 .prs-linked-issues {
1360 margin-top: 16px;
1361 border: 1px solid var(--border);
1362 border-radius: 12px;
1363 overflow: hidden;
1364 }
1365 .prs-linked-issues-head {
1366 display: flex; align-items: center; justify-content: space-between;
1367 padding: 10px 16px;
1368 background: var(--bg-elevated);
1369 border-bottom: 1px solid var(--border);
1370 font-size: 13px; font-weight: 600; color: var(--text);
1371 }
1372 .prs-linked-issues-count {
1373 font-size: 11px; font-weight: 700;
1374 padding: 1px 7px; border-radius: 9999px;
1375 background: var(--bg-tertiary);
1376 color: var(--text-muted);
1377 }
1378 .prs-linked-issue-row {
1379 display: flex; align-items: center; gap: 10px;
1380 padding: 9px 16px;
1381 border-bottom: 1px solid var(--border);
1382 font-size: 13px;
1383 text-decoration: none; color: inherit;
1384 }
1385 .prs-linked-issue-row:last-child { border-bottom: none; }
1386 .prs-linked-issue-row:hover { background: var(--bg-hover); }
1387 .prs-linked-issue-icon { flex: 0 0 auto; font-size: 14px; }
1388 .prs-linked-issue-icon.is-open { color: #34d399; }
1389 .prs-linked-issue-icon.is-closed { color: #8b949e; }
1390 .prs-linked-issue-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1391 .prs-linked-issue-num { color: var(--text-muted); font-size: 12px; }
1392 .prs-linked-issue-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
1393 .prs-linked-issue-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
1394 .prs-linked-issue-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
b558f23Claude1395
1396 /* ─── Commits tab ─── */
1397 .prs-commits-list { display: flex; flex-direction: column; gap: 0; margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1398 .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; }
1399 .prs-commit-row:last-child { border-bottom: none; }
1400 .prs-commit-row:hover { background: var(--bg-hover); }
1401 .prs-commit-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-top: 6px; }
1402 .prs-commit-body { flex: 1 1 auto; min-width: 0; }
1403 .prs-commit-msg { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
1404 .prs-commit-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
1405 .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; }
1406 .prs-commit-sha:hover { color: var(--accent); }
1407 .prs-commits-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13.5px; }
1408
1409 /* ─── Edit PR title/body ─── */
1410 .prs-edit-title-wrap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1411 .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; }
1412 .prs-edit-btn:hover { color: var(--text); border-color: var(--text-muted); }
1413 .prs-edit-form { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
1414 .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; }
1415 .prs-edit-actions { display: flex; gap: 8px; }
1416 .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; }
1417 .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; }
240c477Claude1418
1419 /* ─── CI status checks ─── */
1420 .prs-ci-card { margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1421 .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); }
1422 .prs-ci-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text); }
1423 .prs-ci-summary { font-size: 12px; color: var(--text-muted); }
1424 .prs-ci-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
1425 .prs-ci-row:last-child { border-bottom: none; }
1426 .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; }
1427 .prs-ci-icon.is-success { background: rgba(52,211,153,0.20); color: #34d399; }
1428 .prs-ci-icon.is-pending { background: rgba(251,191,36,0.20); color: #fbbf24; }
1429 .prs-ci-icon.is-failure, .prs-ci-icon.is-error { background: rgba(248,113,113,0.20); color: #f87171; }
1430 .prs-ci-context { flex: 1 1 auto; font-size: 13px; font-weight: 500; color: var(--text); }
1431 .prs-ci-desc { font-size: 12px; color: var(--text-muted); }
1432 .prs-ci-pill { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 9999px; }
1433 .prs-ci-pill.is-success { color: #34d399; background: rgba(52,211,153,0.10); }
1434 .prs-ci-pill.is-pending { color: #fbbf24; background: rgba(251,191,36,0.10); }
1435 .prs-ci-pill.is-failure, .prs-ci-pill.is-error { color: #f87171; background: rgba(248,113,113,0.10); }
1436 .prs-ci-link { font-size: 12px; color: var(--accent); text-decoration: none; }
1437 .prs-ci-link:hover { text-decoration: underline; }
67dc4e1Claude1438
1439 /* ─── AI Trio verdict pills (header summary) ─── */
1440 .trio-pill {
1441 display: inline-flex; align-items: center; gap: 4px;
1442 padding: 2px 8px;
1443 font-size: 11px;
1444 font-weight: 700;
1445 border-radius: 9999px;
1446 border: 1px solid transparent;
1447 text-decoration: none;
1448 line-height: 1.6;
1449 letter-spacing: 0.01em;
1450 cursor: pointer;
1451 transition: opacity 120ms ease;
1452 }
1453 .trio-pill:hover { opacity: 0.8; }
1454 .trio-pill.is-pass {
1455 color: #34d399;
1456 background: rgba(52,211,153,0.10);
1457 border-color: rgba(52,211,153,0.35);
1458 }
1459 .trio-pill.is-fail {
1460 color: #f87171;
1461 background: rgba(248,113,113,0.10);
1462 border-color: rgba(248,113,113,0.35);
1463 }
1464 .trio-pill.is-pending {
1465 color: var(--text-muted);
1466 background: rgba(255,255,255,0.04);
1467 border-color: var(--border-strong);
1468 }
1469 .trio-pills-wrap {
1470 display: inline-flex; align-items: center; gap: 4px;
1471 }
b078860Claude1472`;
1473
81c73c1Claude1474/**
1475 * Tiny inline JS that drives the "Suggest description with AI" button.
1476 * On click, gathers form values, POSTs JSON to the given endpoint, and
1477 * pipes the response into the #pr-body textarea. All DOM lookups are
1478 * defensive — element absence is a silent no-op.
1479 *
1480 * Built as a string template so it lives next to its server-side caller
1481 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1482 * to avoid </script> breakouts.
1483 */
1484function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1485 const url = JSON.stringify(endpointUrl)
1486 .split("<").join("\\u003C")
1487 .split(">").join("\\u003E")
1488 .split("&").join("\\u0026");
1489 return (
1490 "(function(){try{" +
1491 "var btn=document.getElementById('ai-suggest-desc');" +
1492 "var status=document.getElementById('ai-suggest-status');" +
1493 "var body=document.getElementById('pr-body');" +
1494 "var form=btn&&btn.closest&&btn.closest('form');" +
1495 "if(!btn||!body||!form)return;" +
1496 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1497 "var fd=new FormData(form);" +
1498 "var title=String(fd.get('title')||'').trim();" +
1499 "var base=String(fd.get('base')||'').trim();" +
1500 "var head=String(fd.get('head')||'').trim();" +
1501 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1502 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1503 "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'})" +
1504 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1505 ".then(function(j){btn.disabled=false;" +
1506 "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;}}" +
1507 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1508 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1509 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1510 "});" +
1511 "}catch(e){}})();"
1512 );
1513}
1514
3c03977Claude1515/**
1516 * Live co-editing client. Connects to the per-PR SSE feed and:
1517 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1518 * status colour per user).
1519 * - Renders tinted cursor caret overlays inside #pr-body and every
1520 * `[data-live-field]` element.
1521 * - Broadcasts the local user's cursor position (selectionStart /
1522 * selectionEnd) debounced at 100ms.
1523 * - Broadcasts content patches (`replace` of the whole textarea —
1524 * last-write-wins v1) debounced at 250ms.
1525 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1526 * to the matching local field if untouched.
1527 *
1528 * All endpoint URLs are JSON-escaped via safe replacements so they
1529 * can't break out of the <script> tag.
1530 */
1531function LIVE_COEDIT_SCRIPT(prId: string): string {
1532 const idJson = JSON.stringify(prId)
1533 .split("<").join("\\u003C")
1534 .split(">").join("\\u003E")
1535 .split("&").join("\\u0026");
1536 return (
1537 "(function(){try{" +
1538 "if(typeof EventSource==='undefined')return;" +
1539 "var prId=" + idJson + ";" +
1540 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1541 "var pill=document.getElementById('live-pill');" +
1542 "var avEl=document.getElementById('live-avatars');" +
1543 "var countEl=document.getElementById('live-count');" +
1544 "var sessionId=null;var myColor=null;" +
1545 "var presence={};" + // sessionId -> {color,status,userId,initials}
1546 "var lastApplied={};" + // field -> last server value (for echo suppression)
1547 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1548 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1549 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1550 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1551 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1552 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1553 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1554 "avEl.innerHTML=html;}}" +
1555 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1556 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1557 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1558 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1559 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1560 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1561 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1562 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1563 "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);}" +
1564 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1565 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1566 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1567 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1568 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1569 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1570 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1571 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1572 "}catch(e){}" +
1573 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1574 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1575 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1576 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1577 "var es;var delay=1000;" +
1578 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1579 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1580 "(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){}});" +
1581 "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){}});" +
1582 "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){}});" +
1583 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1584 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1585 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1586 "var patch=d.patch;if(!patch||!patch.field)return;" +
1587 "var ta=fieldEl(patch.field);if(!ta)return;" +
1588 "if(document.activeElement===ta)return;" + // don't trample local typing
1589 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1590 "}catch(e){}});" +
1591 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1592 "}connect();" +
1593 "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){}}" +
1594 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1595 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1596 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1597 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1598 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1599 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1600 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1601 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1602 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1603 "}" +
1604 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1605 "var live=document.querySelectorAll('[data-live-field]');" +
1606 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1607 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1608 "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){}});" +
1609 "}catch(e){}})();"
1610 );
1611}
1612
0074234Claude1613async function resolveRepo(ownerName: string, repoName: string) {
1614 const [owner] = await db
1615 .select()
1616 .from(users)
1617 .where(eq(users.username, ownerName))
1618 .limit(1);
1619 if (!owner) return null;
1620 const [repo] = await db
1621 .select()
1622 .from(repositories)
1623 .where(
1624 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1625 )
1626 .limit(1);
1627 if (!repo) return null;
1628 return { owner, repo };
1629}
1630
1631// PR Nav helper
1632const PrNav = ({
1633 owner,
1634 repo,
1635 active,
1636}: {
1637 owner: string;
1638 repo: string;
1639 active: "code" | "issues" | "pulls" | "commits";
1640}) => (
bb0f894Claude1641 <TabNav
1642 tabs={[
1643 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1644 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1645 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1646 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1647 ]}
1648 />
0074234Claude1649);
1650
534f04aClaude1651/**
1652 * Block M3 — pre-merge risk score card. Pure presentational helper.
1653 * Rendered in the conversation tab above the gate checks block. Hidden
1654 * entirely when the PR is closed/merged or there is nothing cached and
1655 * nothing in-flight.
1656 */
1657function PrRiskCard({
1658 risk,
1659 calculating,
1660}: {
1661 risk: PrRiskScore | null;
1662 calculating: boolean;
1663}) {
1664 if (!risk) {
1665 return (
1666 <div
1667 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1668 >
1669 <strong style="font-size: 13px; color: var(--text)">
1670 Risk score: calculating…
1671 </strong>
1672 <div style="font-size: 12px; margin-top: 4px">
1673 Refresh in a moment to see the pre-merge risk score for this PR.
1674 </div>
1675 </div>
1676 );
1677 }
1678
1679 const palette = riskBandPalette(risk.band);
1680 const label = riskBandLabel(risk.band);
1681
1682 return (
1683 <div
1684 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1685 >
1686 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1687 <strong>Risk score:</strong>
1688 <span style={`color:${palette.border};font-weight:600`}>
1689 {palette.icon} {label} ({risk.score}/10)
1690 </span>
1691 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1692 {risk.commitSha.slice(0, 7)}
1693 </span>
1694 </div>
1695 {risk.aiSummary && (
1696 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1697 {risk.aiSummary}
1698 </div>
1699 )}
1700 <details style="margin-top:10px">
1701 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1702 See full signal breakdown
1703 </summary>
1704 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1705 <li>files changed: {risk.signals.filesChanged}</li>
1706 <li>
1707 lines added/removed: {risk.signals.linesAdded} /{" "}
1708 {risk.signals.linesRemoved}
1709 </li>
1710 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1711 <li>
1712 schema migration touched:{" "}
1713 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1714 </li>
1715 <li>
1716 locked / sensitive path touched:{" "}
1717 {risk.signals.lockedPathTouched ? "yes" : "no"}
1718 </li>
1719 <li>
1720 adds new dependency:{" "}
1721 {risk.signals.addsNewDependency ? "yes" : "no"}
1722 </li>
1723 <li>
1724 bumps major dependency:{" "}
1725 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1726 </li>
1727 <li>
1728 tests added for new code:{" "}
1729 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1730 </li>
1731 <li>
1732 diff-minus-test ratio:{" "}
1733 {risk.signals.diffMinusTestRatio.toFixed(2)}
1734 </li>
1735 </ul>
1736 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1737 How is this calculated? The score is a transparent sum of
1738 weighted signals — see <code>src/lib/pr-risk.ts</code>
1739 {" "}<code>computePrRiskScore</code>.
1740 </div>
1741 </details>
1742 {calculating && (
1743 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1744 (recomputing for the latest commit — refresh to update)
1745 </div>
1746 )}
1747 </div>
1748 );
1749}
1750
1751function riskBandPalette(band: PrRiskScore["band"]): {
1752 border: string;
1753 icon: string;
1754} {
1755 switch (band) {
1756 case "low":
1757 return { border: "var(--green)", icon: "" };
1758 case "medium":
1759 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1760 case "high":
1761 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1762 case "critical":
1763 return { border: "var(--red)", icon: "\u{1F6D1}" };
1764 }
1765}
1766
1767function riskBandLabel(band: PrRiskScore["band"]): string {
1768 switch (band) {
1769 case "low":
1770 return "LOW";
1771 case "medium":
1772 return "MEDIUM";
1773 case "high":
1774 return "HIGH";
1775 case "critical":
1776 return "CRITICAL";
1777 }
1778}
1779
422a2d4Claude1780// ---------------------------------------------------------------------------
1781// AI Trio Review — 3-column card grid + disagreement callout.
1782//
1783// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
1784// per run: one per persona (security/correctness/style) plus a top-level
1785// summary. We surface them here as a single grid above the normal
1786// comment stream so reviewers see the verdicts at a glance.
1787// ---------------------------------------------------------------------------
1788
1789const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
1790
1791interface TrioCommentLike {
1792 body: string;
1793}
1794
1795function isTrioComment(body: string | null | undefined): boolean {
1796 if (!body) return false;
1797 return (
1798 body.includes(TRIO_SUMMARY_MARKER) ||
1799 body.includes(TRIO_COMMENT_MARKER.security) ||
1800 body.includes(TRIO_COMMENT_MARKER.correctness) ||
1801 body.includes(TRIO_COMMENT_MARKER.style)
1802 );
1803}
1804
1805function trioPersonaOfComment(body: string): TrioPersona | null {
1806 for (const p of TRIO_PERSONAS) {
1807 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
1808 }
1809 return null;
1810}
1811
1812/**
1813 * Best-effort verdict parse from a persona comment body. The body shape
1814 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
1815 * we only need the "Pass" / "Fail" word from the H2 heading.
1816 */
1817function trioVerdictOfBody(body: string): "pass" | "fail" | null {
1818 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
1819 if (!m) return null;
1820 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
1821}
1822
1823/**
1824 * Parse the disagreement bullet list out of the summary comment so we
1825 * can render it as a polished callout strip. Returns [] when nothing
1826 * matches — the comment author may have edited the marker out.
1827 */
1828function parseDisagreements(summaryBody: string): Array<{
1829 file: string;
1830 failing: string;
1831 passing: string;
1832}> {
1833 const out: Array<{ file: string; failing: string; passing: string }> = [];
1834 // Each disagreement line looks like:
1835 // - `path:42` — security, style say ✗, correctness say ✓
1836 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
1837 let m: RegExpExecArray | null;
1838 while ((m = re.exec(summaryBody)) !== null) {
1839 out.push({
1840 file: m[1].trim(),
1841 failing: m[2].trim().replace(/[,\s]+$/g, ""),
1842 passing: m[3].trim().replace(/[,\s]+$/g, ""),
1843 });
1844 }
1845 return out;
1846}
1847
1848function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
1849 // Find the most recent persona comments + summary. We iterate from
1850 // the end so re-reviews (multiple runs on the same PR) display the
1851 // freshest verdict.
1852 const latest: Partial<Record<TrioPersona, string>> = {};
1853 let summaryBody: string | null = null;
1854 for (let i = comments.length - 1; i >= 0; i--) {
1855 const body = comments[i].body || "";
1856 if (!isTrioComment(body)) continue;
1857 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
1858 summaryBody = body;
1859 continue;
1860 }
1861 const persona = trioPersonaOfComment(body);
1862 if (persona && !latest[persona]) latest[persona] = body;
1863 }
1864 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1865 if (!anyPersona && !summaryBody) return null;
1866
1867 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
1868
1869 return (
67dc4e1Claude1870 <div class="trio-wrap" id="trio-review-section">
422a2d4Claude1871 <div class="trio-header">
1872 <span class="trio-header-dot" aria-hidden="true"></span>
1873 <strong>AI Trio Review</strong>
1874 <span class="trio-header-sub">
1875 Three independent reviewers ran in parallel.
1876 </span>
1877 </div>
1878 <div class="trio-grid">
1879 {TRIO_PERSONAS.map((persona) => {
1880 const body = latest[persona];
1881 const verdict = body ? trioVerdictOfBody(body) : null;
1882 const stateClass =
1883 verdict === "fail"
1884 ? "is-fail"
1885 : verdict === "pass"
1886 ? "is-pass"
1887 : "is-pending";
1888 return (
1889 <div class={`trio-card trio-${persona} ${stateClass}`}>
1890 <div class="trio-card-head">
1891 <span class="trio-card-icon" aria-hidden="true">
1892 {persona === "security"
1893 ? "🛡"
1894 : persona === "correctness"
1895 ? "✓"
1896 : "✎"}
1897 </span>
1898 <strong class="trio-card-title">
1899 {persona[0].toUpperCase() + persona.slice(1)}
1900 </strong>
1901 <span class="trio-card-verdict">
1902 {verdict === "pass"
1903 ? "Pass"
1904 : verdict === "fail"
1905 ? "Fail"
1906 : "Pending"}
1907 </span>
1908 </div>
1909 <div class="trio-card-body">
1910 {body ? (
1911 <MarkdownContent
1912 html={renderMarkdown(stripTrioHeading(body))}
1913 />
1914 ) : (
1915 <span class="trio-card-empty">
1916 Awaiting reviewer output.
1917 </span>
1918 )}
1919 </div>
1920 </div>
1921 );
1922 })}
1923 </div>
1924 {disagreements.length > 0 && (
1925 <div class="trio-disagreement-strip" role="note">
1926 <span class="trio-disagreement-icon" aria-hidden="true">
1927
1928 </span>
1929 <div class="trio-disagreement-body">
1930 <strong>Reviewers disagree — review carefully.</strong>
1931 <ul class="trio-disagreement-list">
1932 {disagreements.map((d) => (
1933 <li>
1934 <code>{d.file}</code> — {d.failing} says ✗,{" "}
1935 {d.passing} says ✓
1936 </li>
1937 ))}
1938 </ul>
1939 </div>
1940 </div>
1941 )}
1942 </div>
1943 );
1944}
1945
1946/**
1947 * Strip the marker comment + first H2 heading from a persona body so
1948 * the card body shows just the findings list (verdict is already in
1949 * the card head). Best-effort — malformed bodies render whole.
1950 */
1951function stripTrioHeading(body: string): string {
1952 return body
1953 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
1954 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
1955 .trim();
1956}
1957
67dc4e1Claude1958/**
1959 * Three small verdict pills rendered inline in the PR header. Each pill
1960 * links to the `#trio-review-section` anchor so clicking scrolls to the
1961 * full card grid. Only shown when `AI_TRIO_REVIEW_ENABLED=1` and at
1962 * least one persona comment exists.
1963 */
1964function TrioVerdictPills({
1965 comments,
1966}: {
1967 comments: TrioCommentLike[];
1968}) {
1969 if (!isTrioReviewEnabled()) return null;
1970
1971 // Find latest persona verdicts (same logic as TrioReviewGrid).
1972 const latest: Partial<Record<TrioPersona, string>> = {};
1973 for (let i = comments.length - 1; i >= 0; i--) {
1974 const body = comments[i].body || "";
1975 if (!isTrioComment(body)) continue;
1976 if (body.includes(TRIO_SUMMARY_MARKER)) continue;
1977 const persona = trioPersonaOfComment(body);
1978 if (persona && !latest[persona]) latest[persona] = body;
1979 }
1980
1981 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1982 if (!anyPersona) return null;
1983
1984 const PERSONA_LABEL: Record<TrioPersona, string> = {
1985 security: "Security",
1986 correctness: "Correctness",
1987 style: "Style",
1988 };
1989 const PERSONA_ICON: Record<TrioPersona, string> = {
1990 security: "🛡",
1991 correctness: "✓",
1992 style: "✎",
1993 };
1994
1995 return (
1996 <span class="trio-pills-wrap" aria-label="AI Trio Review verdicts">
1997 {TRIO_PERSONAS.map((persona) => {
1998 const body = latest[persona];
1999 const verdict = body ? trioVerdictOfBody(body) : null;
2000 const stateClass =
2001 verdict === "pass"
2002 ? "is-pass"
2003 : verdict === "fail"
2004 ? "is-fail"
2005 : "is-pending";
2006 const glyph =
2007 verdict === "pass" ? "✓" : verdict === "fail" ? "✗" : "⟳";
2008 return (
2009 <a
2010 href="#trio-review-section"
2011 class={`trio-pill ${stateClass}`}
2012 title={`AI ${PERSONA_LABEL[persona]} Review — ${verdict === "pass" ? "Pass" : verdict === "fail" ? "Fail" : "Pending"}`}
2013 >
2014 <span aria-hidden="true">{PERSONA_ICON[persona]}</span>
2015 {PERSONA_LABEL[persona]} {glyph}
2016 </a>
2017 );
2018 })}
2019 </span>
2020 );
2021}
2022
0074234Claude2023// List PRs
04f6b7fClaude2024pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2025 const { owner: ownerName, repo: repoName } = c.req.param();
2026 const user = c.get("user");
2027 const state = c.req.query("state") || "open";
d790b49Claude2028 const searchQ = c.req.query("q")?.trim() || "";
80bd7c8Claude2029 const authorFilter = c.req.query("author")?.trim() || "";
f5b9ef5Claude2030 const sortPr = (c.req.query("sort") || "newest").trim();
0074234Claude2031
ea9ed4cClaude2032 // ── Loading skeleton (flag-gated) ──
2033 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
2034 // the user see the page structure before counts + select resolve.
2035 // Behind a flag for now — we don't ship flashes.
2036 if (c.req.query("skeleton") === "1") {
2037 return c.html(
2038 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2039 <RepoHeader owner={ownerName} repo={repoName} />
2040 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2041 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2042 <style
2043 dangerouslySetInnerHTML={{
2044 __html: `
404b398Claude2045 .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; }
2046 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude2047 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
2048 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
2049 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
2050 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
2051 .prs-skel-row { height: 76px; border-radius: 12px; }
2052 `,
2053 }}
2054 />
2055 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
2056 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
2057 <div class="prs-skel-list" aria-hidden="true">
2058 {Array.from({ length: 6 }).map(() => (
2059 <div class="prs-skel prs-skel-row" />
2060 ))}
2061 </div>
2062 <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">
2063 Loading pull requests for {ownerName}/{repoName}…
2064 </span>
2065 </Layout>
2066 );
2067 }
2068
0074234Claude2069 const resolved = await resolveRepo(ownerName, repoName);
2070 if (!resolved) return c.notFound();
2071
6fc53bdClaude2072 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
2073 const stateFilter =
2074 state === "draft"
2075 ? and(
2076 eq(pullRequests.state, "open"),
2077 eq(pullRequests.isDraft, true)
2078 )
2079 : eq(pullRequests.state, state);
2080
0074234Claude2081 const prList = await db
2082 .select({
2083 pr: pullRequests,
2084 author: { username: users.username },
2085 })
2086 .from(pullRequests)
2087 .innerJoin(users, eq(pullRequests.authorId, users.id))
2088 .where(
d790b49Claude2089 and(
2090 eq(pullRequests.repositoryId, resolved.repo.id),
2091 stateFilter,
2092 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
80bd7c8Claude2093 authorFilter ? eq(users.username, authorFilter) : undefined,
d790b49Claude2094 )
0074234Claude2095 )
f5b9ef5Claude2096 .orderBy(
2097 sortPr === "oldest" ? asc(pullRequests.createdAt)
2098 : sortPr === "updated" ? desc(pullRequests.updatedAt)
2099 : desc(pullRequests.createdAt) // newest (default)
2100 );
0074234Claude2101
0369e77Claude2102 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude2103 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude2104 const commentCountMap = new Map<string, number>();
1aef949Claude2105 if (prList.length > 0) {
2106 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude2107 const [reviewRows, commentRows] = await Promise.all([
2108 db
2109 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
2110 .from(prReviews)
2111 .where(inArray(prReviews.pullRequestId, prIds)),
2112 db
2113 .select({
2114 prId: prComments.pullRequestId,
2115 cnt: sql<number>`count(*)::int`,
2116 })
2117 .from(prComments)
2118 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
2119 .groupBy(prComments.pullRequestId),
2120 ]);
1aef949Claude2121 for (const r of reviewRows) {
2122 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
2123 if (r.state === "approved") entry.approved = true;
2124 if (r.state === "changes_requested") entry.changesRequested = true;
2125 reviewMap.set(r.prId, entry);
2126 }
0369e77Claude2127 for (const r of commentRows) {
2128 commentCountMap.set(r.prId, Number(r.cnt));
2129 }
1aef949Claude2130 }
2131
0074234Claude2132 const [counts] = await db
2133 .select({
2134 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude2135 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude2136 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
2137 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
2138 })
2139 .from(pullRequests)
2140 .where(eq(pullRequests.repositoryId, resolved.repo.id));
2141
b078860Claude2142 const openCount = counts?.open ?? 0;
2143 const mergedCount = counts?.merged ?? 0;
2144 const closedCount = counts?.closed ?? 0;
2145 const draftCount = counts?.draft ?? 0;
2146 const allCount = openCount + mergedCount + closedCount;
2147
2148 // "All" is presentational only — the DB query for state='all' matches
2149 // nothing, so we render a friendlier empty state when picked. We do NOT
2150 // change the query logic to keep this commit purely visual.
80bd7c8Claude2151 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude2152 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude2153 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
2154 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
2155 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
2156 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
2157 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude2158 ];
2159 const isAllState = state === "all";
cb5a796Claude2160 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
2161 const prListPendingCount = viewerIsOwnerOnPrList
2162 ? await countPendingForRepo(resolved.repo.id)
2163 : 0;
b078860Claude2164
0074234Claude2165 return c.html(
2166 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2167 <RepoHeader owner={ownerName} repo={repoName} />
2168 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2169 <PendingCommentsBanner
2170 owner={ownerName}
2171 repo={repoName}
2172 count={prListPendingCount}
2173 />
b078860Claude2174 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2175
2176 <div class="prs-hero">
2177 <div class="prs-hero-inner">
2178 <div class="prs-hero-text">
2179 <div class="prs-hero-eyebrow">Pull requests</div>
2180 <h1 class="prs-hero-title">
2181 Review, <span class="gradient-text">merge with AI</span>.
2182 </h1>
2183 <p class="prs-hero-sub">
2184 {openCount === 0 && allCount === 0
2185 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2186 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
2187 </p>
2188 </div>
7a28902Claude2189 <div class="prs-hero-actions">
2190 <a
2191 href={`/${ownerName}/${repoName}/pulls/insights`}
2192 class="prs-cta"
2193 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
2194 >
2195 Insights
2196 </a>
2197 {user && (
b078860Claude2198 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
2199 + New pull request
2200 </a>
7a28902Claude2201 )}
2202 </div>
b078860Claude2203 </div>
2204 </div>
2205
2206 <nav class="prs-tabs" aria-label="Pull request filters">
2207 {tabPills.map((t) => {
2208 const isActive =
2209 state === t.key ||
2210 (t.key === "open" &&
2211 state !== "merged" &&
2212 state !== "closed" &&
2213 state !== "all" &&
2214 state !== "draft");
2215 return (
2216 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2217 <span>{t.label}</span>
2218 <span class="prs-tab-count">{t.count}</span>
2219 </a>
2220 );
2221 })}
2222 </nav>
2223
d790b49Claude2224 <form
2225 method="get"
2226 action={`/${ownerName}/${repoName}/pulls`}
2227 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2228 >
2229 <input type="hidden" name="state" value={state} />
2230 <input
2231 type="search"
2232 name="q"
2233 value={searchQ}
2234 placeholder="Search pull requests…"
2235 class="issues-search-input"
2236 style="flex:1;max-width:380px"
2237 />
80bd7c8Claude2238 <input
2239 type="text"
2240 name="author"
2241 value={authorFilter}
2242 placeholder="Filter by author…"
2243 class="issues-search-input"
2244 style="max-width:200px"
2245 />
d790b49Claude2246 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2247 {(searchQ || authorFilter) && (
d790b49Claude2248 <a
2249 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2250 class="issues-filter-clear"
2251 >
2252 Clear
2253 </a>
2254 )}
2255 </form>
f5b9ef5Claude2256
2257 <div class="prs-sort-row">
2258 <span class="prs-sort-label">Sort:</span>
2259 {(["newest", "oldest", "updated"] as const).map((s) => (
2260 <a
2261 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2262 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2263 >
2264 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2265 </a>
2266 ))}
2267 </div>
2268
0074234Claude2269 {prList.length === 0 ? (
b078860Claude2270 <div class="prs-empty">
ea9ed4cClaude2271 <div class="prs-empty-inner">
2272 <strong>
80bd7c8Claude2273 {searchQ || authorFilter
2274 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2275 : isAllState
2276 ? "Pick a filter above to browse PRs."
2277 : `No ${state} pull requests.`}
ea9ed4cClaude2278 </strong>
2279 <p class="prs-empty-sub">
80bd7c8Claude2280 {searchQ || authorFilter
2281 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2282 : state === "open"
2283 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2284 : isAllState
2285 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2286 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2287 </p>
2288 <div class="prs-empty-cta">
80bd7c8Claude2289 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2290 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2291 + New pull request
2292 </a>
2293 )}
80bd7c8Claude2294 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2295 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2296 View open PRs
2297 </a>
2298 )}
2299 <a href={`/${ownerName}/${repoName}`} class="btn">
2300 Back to code
2301 </a>
2302 </div>
2303 </div>
b078860Claude2304 </div>
0074234Claude2305 ) : (
b078860Claude2306 <div class="prs-list">
2307 {prList.map(({ pr, author }) => {
2308 const stateClass =
2309 pr.state === "open"
2310 ? pr.isDraft
2311 ? "state-draft"
2312 : "state-open"
2313 : pr.state === "merged"
2314 ? "state-merged"
2315 : "state-closed";
2316 const icon =
2317 pr.state === "open"
2318 ? pr.isDraft
2319 ? "◌"
2320 : "○"
2321 : pr.state === "merged"
2322 ? "⮌"
2323 : "✓";
1aef949Claude2324 const rv = reviewMap.get(pr.id);
b078860Claude2325 return (
2326 <a
2327 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2328 class="prs-row"
2329 style="text-decoration:none;color:inherit"
0074234Claude2330 >
b078860Claude2331 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2332 {icon}
0074234Claude2333 </div>
b078860Claude2334 <div class="prs-row-body">
2335 <h3 class="prs-row-title">
2336 <span>{pr.title}</span>
2337 <span class="prs-row-number">#{pr.number}</span>
2338 </h3>
2339 <div class="prs-row-meta">
2340 <span
2341 class="prs-branch-chips"
2342 title={`${pr.headBranch} into ${pr.baseBranch}`}
2343 >
2344 <span class="prs-branch-chip">{pr.headBranch}</span>
2345 <span class="prs-branch-arrow">{"→"}</span>
2346 <span class="prs-branch-chip">{pr.baseBranch}</span>
2347 </span>
2348 <span>
2349 by{" "}
2350 <strong style="color:var(--text)">
2351 {author.username}
2352 </strong>{" "}
2353 {formatRelative(pr.createdAt)}
2354 </span>
2355 <span class="prs-row-tags">
2356 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2357 {pr.state === "merged" && (
2358 <span class="prs-tag is-merged">Merged</span>
2359 )}
1aef949Claude2360 {rv?.approved && !rv.changesRequested && (
2361 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
2362 )}
2363 {rv?.changesRequested && (
2364 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
2365 )}
0369e77Claude2366 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
2367 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
2368 💬 {commentCountMap.get(pr.id)}
2369 </span>
2370 )}
b078860Claude2371 </span>
2372 </div>
0074234Claude2373 </div>
b078860Claude2374 </a>
2375 );
2376 })}
2377 </div>
0074234Claude2378 )}
2379 </Layout>
2380 );
2381});
2382
7a28902Claude2383/* ─────────────────────────────────────────────────────────────────────────
2384 * PR Insights — 90-day analytics for the pull request activity of a repo.
2385 * Route: GET /:owner/:repo/pulls/insights
2386 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
2387 * "insights" is not swallowed by the :number param.
2388 * ───────────────────────────────────────────────────────────────────────── */
2389
2390/** Format a millisecond duration as human-readable string. */
2391function formatMsDuration(ms: number): string {
2392 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
2393 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
2394 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
2395 return `${Math.round(ms / 86_400_000)}d`;
2396}
2397
2398/** Format an ISO week string as "Jan 15". */
2399function formatWeekLabel(isoWeek: string): string {
2400 try {
2401 const d = new Date(isoWeek);
2402 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2403 } catch {
2404 return isoWeek.slice(5, 10);
2405 }
2406}
2407
2408const PR_INSIGHTS_STYLES = `
2409 .pri-page { padding-bottom: 48px; }
2410 .pri-hero {
2411 position: relative;
2412 margin: 0 0 var(--space-5);
2413 padding: 22px 26px 24px;
2414 background: var(--bg-elevated);
2415 border: 1px solid var(--border);
2416 border-radius: 16px;
2417 overflow: hidden;
2418 }
2419 .pri-hero::before {
2420 content: '';
2421 position: absolute; top: 0; left: 0; right: 0;
2422 height: 2px;
2423 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2424 opacity: 0.7;
2425 pointer-events: none;
2426 }
2427 .pri-hero-eyebrow {
2428 font-size: 12px;
2429 color: var(--text-muted);
2430 text-transform: uppercase;
2431 letter-spacing: 0.08em;
2432 font-weight: 600;
2433 margin-bottom: 8px;
2434 }
2435 .pri-hero-title {
2436 font-family: var(--font-display);
2437 font-size: clamp(26px, 3.4vw, 34px);
2438 font-weight: 800;
2439 letter-spacing: -0.025em;
2440 line-height: 1.06;
2441 margin: 0 0 8px;
2442 color: var(--text-strong);
2443 }
2444 .pri-hero-title .gradient-text {
2445 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2446 -webkit-background-clip: text;
2447 background-clip: text;
2448 -webkit-text-fill-color: transparent;
2449 color: transparent;
2450 }
2451 .pri-hero-sub {
2452 font-size: 14.5px;
2453 color: var(--text-muted);
2454 margin: 0;
2455 line-height: 1.5;
2456 }
2457 .pri-section { margin-bottom: 32px; }
2458 .pri-section-title {
2459 font-size: 13px;
2460 font-weight: 700;
2461 text-transform: uppercase;
2462 letter-spacing: 0.06em;
2463 color: var(--text-muted);
2464 margin: 0 0 14px;
2465 }
2466 .pri-cards {
2467 display: grid;
2468 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
2469 gap: 12px;
2470 }
2471 .pri-card {
2472 padding: 16px 18px;
2473 background: var(--bg-elevated);
2474 border: 1px solid var(--border);
2475 border-radius: 12px;
2476 }
2477 .pri-card-label {
2478 font-size: 12px;
2479 font-weight: 600;
2480 color: var(--text-muted);
2481 text-transform: uppercase;
2482 letter-spacing: 0.05em;
2483 margin-bottom: 6px;
2484 }
2485 .pri-card-value {
2486 font-size: 28px;
2487 font-weight: 800;
2488 letter-spacing: -0.04em;
2489 color: var(--text-strong);
2490 line-height: 1;
2491 }
2492 .pri-card-sub {
2493 font-size: 12px;
2494 color: var(--text-muted);
2495 margin-top: 4px;
2496 }
2497 .pri-chart {
2498 display: flex;
2499 align-items: flex-end;
2500 gap: 6px;
2501 height: 120px;
2502 background: var(--bg-elevated);
2503 border: 1px solid var(--border);
2504 border-radius: 12px;
2505 padding: 16px 16px 0;
2506 }
2507 .pri-bar-col {
2508 flex: 1;
2509 display: flex;
2510 flex-direction: column;
2511 align-items: center;
2512 justify-content: flex-end;
2513 height: 100%;
2514 gap: 4px;
2515 }
2516 .pri-bar {
2517 width: 100%;
2518 min-height: 4px;
2519 border-radius: 4px 4px 0 0;
2520 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 100%);
2521 transition: opacity 140ms;
2522 }
2523 .pri-bar:hover { opacity: 0.8; }
2524 .pri-bar-label {
2525 font-size: 10px;
2526 color: var(--text-muted);
2527 text-align: center;
2528 padding-bottom: 8px;
2529 white-space: nowrap;
2530 overflow: hidden;
2531 text-overflow: ellipsis;
2532 max-width: 100%;
2533 }
2534 .pri-table {
2535 width: 100%;
2536 border-collapse: collapse;
2537 font-size: 13.5px;
2538 }
2539 .pri-table th {
2540 text-align: left;
2541 font-size: 12px;
2542 font-weight: 600;
2543 text-transform: uppercase;
2544 letter-spacing: 0.05em;
2545 color: var(--text-muted);
2546 padding: 8px 12px;
2547 border-bottom: 1px solid var(--border);
2548 }
2549 .pri-table td {
2550 padding: 10px 12px;
2551 border-bottom: 1px solid var(--border);
2552 color: var(--text);
2553 }
2554 .pri-table tr:last-child td { border-bottom: none; }
2555 .pri-table-wrap {
2556 background: var(--bg-elevated);
2557 border: 1px solid var(--border);
2558 border-radius: 12px;
2559 overflow: hidden;
2560 }
2561 .pri-age-row {
2562 display: flex;
2563 align-items: center;
2564 gap: 12px;
2565 padding: 10px 0;
2566 border-bottom: 1px solid var(--border);
2567 font-size: 13.5px;
2568 }
2569 .pri-age-row:last-child { border-bottom: none; }
2570 .pri-age-label {
2571 flex: 0 0 80px;
2572 color: var(--text-muted);
2573 font-size: 12.5px;
2574 font-weight: 600;
2575 }
2576 .pri-age-bar-wrap {
2577 flex: 1;
2578 height: 8px;
2579 background: var(--bg-secondary);
2580 border-radius: 9999px;
2581 overflow: hidden;
2582 }
2583 .pri-age-bar {
2584 height: 100%;
2585 border-radius: 9999px;
2586 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
2587 min-width: 4px;
2588 }
2589 .pri-age-count {
2590 flex: 0 0 32px;
2591 text-align: right;
2592 font-weight: 600;
2593 color: var(--text-strong);
2594 font-size: 13px;
2595 }
2596 .pri-sparkline {
2597 display: flex;
2598 align-items: flex-end;
2599 gap: 3px;
2600 height: 40px;
2601 }
2602 .pri-spark-bar {
2603 flex: 1;
2604 min-height: 2px;
2605 border-radius: 2px 2px 0 0;
2606 background: var(--accent, #8c6dff);
2607 opacity: 0.7;
2608 }
2609 .pri-empty {
2610 color: var(--text-muted);
2611 font-size: 14px;
2612 padding: 24px 0;
2613 text-align: center;
2614 }
2615 @media (max-width: 600px) {
2616 .pri-cards { grid-template-columns: repeat(2, 1fr); }
2617 .pri-hero { padding: 18px 18px 20px; }
2618 }
2619`;
2620
2621pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
2622 const { owner: ownerName, repo: repoName } = c.req.param();
2623 const user = c.get("user");
2624
2625 const resolved = await resolveRepo(ownerName, repoName);
2626 if (!resolved) return c.notFound();
2627
2628 const repoId = resolved.repo.id;
2629 const now = Date.now();
2630
2631 // 1. Merged PRs in last 90 days (avg merge time)
2632 const mergedPRs = await db
2633 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
2634 .from(pullRequests)
2635 .where(and(
2636 eq(pullRequests.repositoryId, repoId),
2637 eq(pullRequests.state, "merged"),
2638 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2639 ));
2640
2641 const avgMergeMs = mergedPRs.length > 0
2642 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
2643 : null;
2644
2645 // 2. PR throughput (last 8 weeks)
2646 const weeklyPRs = await db
2647 .select({
2648 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
2649 count: sql<number>`count(*)::int`,
2650 })
2651 .from(pullRequests)
2652 .where(and(
2653 eq(pullRequests.repositoryId, repoId),
2654 sql`${pullRequests.createdAt} > now() - interval '56 days'`
2655 ))
2656 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
2657 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
2658
2659 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
2660
2661 // 3. PR merge rate (last 90 days)
2662 const [rateCounts] = await db
2663 .select({
2664 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
2665 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
2666 })
2667 .from(pullRequests)
2668 .where(and(
2669 eq(pullRequests.repositoryId, repoId),
2670 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2671 ));
2672
2673 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
2674 const mergeRate = totalResolved > 0
2675 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
2676 : null;
2677
2678 // 4. Top reviewers (last 90 days)
2679 const reviewerCounts = await db
2680 .select({
2681 userId: prReviews.reviewerId,
2682 username: users.username,
2683 count: sql<number>`count(*)::int`,
2684 })
2685 .from(prReviews)
2686 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2687 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
2688 .where(and(
2689 eq(pullRequests.repositoryId, repoId),
2690 sql`${prReviews.createdAt} > now() - interval '90 days'`
2691 ))
2692 .groupBy(prReviews.reviewerId, users.username)
2693 .orderBy(desc(sql`count(*)`))
2694 .limit(5);
2695
2696 // 5. Average reviews per merged PR
2697 const [avgReviewRow] = await db
2698 .select({
2699 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
2700 })
2701 .from(pullRequests)
2702 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2703 .where(and(
2704 eq(pullRequests.repositoryId, repoId),
2705 eq(pullRequests.state, "merged"),
2706 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2707 ));
2708
2709 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
2710 ? Math.round(avgReviewRow.avgReviews * 10) / 10
2711 : null;
2712
2713 // 6. Review turnaround — avg time from PR open to first review
2714 const prsWithReviews = await db
2715 .select({
2716 createdAt: pullRequests.createdAt,
2717 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
2718 })
2719 .from(pullRequests)
2720 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2721 .where(and(
2722 eq(pullRequests.repositoryId, repoId),
2723 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2724 ))
2725 .groupBy(pullRequests.id, pullRequests.createdAt);
2726
2727 const avgReviewTurnaroundMs = prsWithReviews.length > 0
2728 ? prsWithReviews.reduce((s, row) => {
2729 const firstMs = new Date(row.firstReview).getTime();
2730 return s + Math.max(0, firstMs - row.createdAt.getTime());
2731 }, 0) / prsWithReviews.length
2732 : null;
2733
2734 // 7. Open PRs by age bucket
2735 const openPRs = await db
2736 .select({ createdAt: pullRequests.createdAt })
2737 .from(pullRequests)
2738 .where(and(
2739 eq(pullRequests.repositoryId, repoId),
2740 eq(pullRequests.state, "open")
2741 ));
2742
2743 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
2744 for (const { createdAt } of openPRs) {
2745 const ageDays = (now - createdAt.getTime()) / 86_400_000;
2746 if (ageDays < 1) ageBuckets.lt1d++;
2747 else if (ageDays < 3) ageBuckets.d1to3++;
2748 else if (ageDays < 7) ageBuckets.d3to7++;
2749 else if (ageDays < 30) ageBuckets.d7to30++;
2750 else ageBuckets.gt30d++;
2751 }
2752 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
2753
2754 // 8. 7-day merge sparkline
2755 const sparklineRows = await db
2756 .select({
2757 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
2758 count: sql<number>`count(*)::int`,
2759 })
2760 .from(pullRequests)
2761 .where(and(
2762 eq(pullRequests.repositoryId, repoId),
2763 eq(pullRequests.state, "merged"),
2764 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
2765 ))
2766 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
2767 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
2768
2769 const sparkMap = new Map<string, number>();
2770 for (const row of sparklineRows) {
2771 sparkMap.set(row.day.slice(0, 10), row.count);
2772 }
2773 const sparkline: number[] = [];
2774 for (let i = 6; i >= 0; i--) {
2775 const d = new Date(now - i * 86_400_000);
2776 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
2777 }
2778 const maxSpark = Math.max(1, ...sparkline);
2779
2780 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
2781 { label: "< 1 day", key: "lt1d" },
2782 { label: "1–3 days", key: "d1to3" },
2783 { label: "3–7 days", key: "d3to7" },
2784 { label: "7–30 days", key: "d7to30" },
2785 { label: "> 30 days", key: "gt30d" },
2786 ];
2787
2788 return c.html(
2789 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
2790 <RepoHeader owner={ownerName} repo={repoName} />
2791 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2792 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
2793
2794 <div class="pri-page">
2795 {/* Hero */}
2796 <div class="pri-hero">
2797 <div class="pri-hero-eyebrow">Pull requests</div>
2798 <h1 class="pri-hero-title">
2799 PR <span class="gradient-text">Insights</span>
2800 </h1>
2801 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
2802 </div>
2803
2804 {/* Stat cards */}
2805 <div class="pri-section">
2806 <div class="pri-section-title">At a glance</div>
2807 <div class="pri-cards">
2808 <div class="pri-card">
2809 <div class="pri-card-label">Avg merge time</div>
2810 <div class="pri-card-value">
2811 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
2812 </div>
2813 <div class="pri-card-sub">last 90 days</div>
2814 </div>
2815 <div class="pri-card">
2816 <div class="pri-card-label">Total merged</div>
2817 <div class="pri-card-value">{mergedPRs.length}</div>
2818 <div class="pri-card-sub">last 90 days</div>
2819 </div>
2820 <div class="pri-card">
2821 <div class="pri-card-label">Open PRs</div>
2822 <div class="pri-card-value">{openPRs.length}</div>
2823 <div class="pri-card-sub">right now</div>
2824 </div>
2825 <div class="pri-card">
2826 <div class="pri-card-label">Merge rate</div>
2827 <div class="pri-card-value">
2828 {mergeRate != null ? `${mergeRate}%` : "—"}
2829 </div>
2830 <div class="pri-card-sub">merged vs closed</div>
2831 </div>
2832 <div class="pri-card">
2833 <div class="pri-card-label">Avg reviews / PR</div>
2834 <div class="pri-card-value">
2835 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
2836 </div>
2837 <div class="pri-card-sub">merged PRs, 90d</div>
2838 </div>
2839 <div class="pri-card">
2840 <div class="pri-card-label">Top reviewer</div>
2841 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
2842 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
2843 </div>
2844 <div class="pri-card-sub">
2845 {reviewerCounts.length > 0
2846 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
2847 : "no reviews yet"}
2848 </div>
2849 </div>
2850 </div>
2851 </div>
2852
2853 {/* Review turnaround */}
2854 <div class="pri-section">
2855 <div class="pri-section-title">Review turnaround</div>
2856 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
2857 <div class="pri-card">
2858 <div class="pri-card-label">Avg time to first review</div>
2859 <div class="pri-card-value">
2860 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
2861 </div>
2862 <div class="pri-card-sub">
2863 {prsWithReviews.length > 0
2864 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
2865 : "no reviewed PRs in 90d"}
2866 </div>
2867 </div>
2868 </div>
2869 </div>
2870
2871 {/* Weekly throughput bar chart */}
2872 <div class="pri-section">
2873 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
2874 {weeklyPRs.length === 0 ? (
2875 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
2876 ) : (
2877 <div class="pri-chart">
2878 {weeklyPRs.map((w) => (
2879 <div class="pri-bar-col">
2880 <div
2881 class="pri-bar"
2882 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
2883 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
2884 />
2885 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
2886 </div>
2887 ))}
2888 </div>
2889 )}
2890 </div>
2891
2892 {/* 7-day merge sparkline */}
2893 <div class="pri-section">
2894 <div class="pri-section-title">Merges this week (daily)</div>
2895 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
2896 <div class="pri-sparkline">
2897 {sparkline.map((v) => (
2898 <div
2899 class="pri-spark-bar"
2900 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
2901 title={`${v} merge${v === 1 ? "" : "s"}`}
2902 />
2903 ))}
2904 </div>
2905 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
2906 <span>7 days ago</span>
2907 <span>Today</span>
2908 </div>
2909 </div>
2910 </div>
2911
2912 {/* Top reviewers table */}
2913 <div class="pri-section">
2914 <div class="pri-section-title">Top reviewers (last 90 days)</div>
2915 {reviewerCounts.length === 0 ? (
2916 <div class="pri-empty">No reviews posted in the last 90 days.</div>
2917 ) : (
2918 <div class="pri-table-wrap">
2919 <table class="pri-table">
2920 <thead>
2921 <tr>
2922 <th>#</th>
2923 <th>Reviewer</th>
2924 <th>Reviews</th>
2925 </tr>
2926 </thead>
2927 <tbody>
2928 {reviewerCounts.map((r, i) => (
2929 <tr>
2930 <td style="color:var(--text-muted)">{i + 1}</td>
2931 <td>
2932 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
2933 {r.username}
2934 </a>
2935 </td>
2936 <td style="font-weight:600">{r.count}</td>
2937 </tr>
2938 ))}
2939 </tbody>
2940 </table>
2941 </div>
2942 )}
2943 </div>
2944
2945 {/* Open PRs by age */}
2946 <div class="pri-section">
2947 <div class="pri-section-title">Open PRs by age</div>
2948 {openPRs.length === 0 ? (
2949 <div class="pri-empty">No open pull requests.</div>
2950 ) : (
2951 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
2952 {ageBucketDefs.map(({ label, key }) => (
2953 <div class="pri-age-row">
2954 <span class="pri-age-label">{label}</span>
2955 <div class="pri-age-bar-wrap">
2956 <div
2957 class="pri-age-bar"
2958 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
2959 />
2960 </div>
2961 <span class="pri-age-count">{ageBuckets[key]}</span>
2962 </div>
2963 ))}
2964 </div>
2965 )}
2966 </div>
2967
2968 {/* Back link */}
2969 <div>
2970 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
2971 {"←"} Back to pull requests
2972 </a>
2973 </div>
2974 </div>
2975 </Layout>
2976 );
2977});
2978
0074234Claude2979// New PR form
2980pulls.get(
2981 "/:owner/:repo/pulls/new",
2982 softAuth,
2983 requireAuth,
04f6b7fClaude2984 requireRepoAccess("write"),
0074234Claude2985 async (c) => {
2986 const { owner: ownerName, repo: repoName } = c.req.param();
2987 const user = c.get("user")!;
2988 const branches = await listBranches(ownerName, repoName);
2989 const error = c.req.query("error");
2990 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude2991 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude2992
2993 return c.html(
2994 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
2995 <RepoHeader owner={ownerName} repo={repoName} />
2996 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude2997 <Container maxWidth={800}>
2998 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude2999 {error && (
bb0f894Claude3000 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude3001 )}
0316dbbClaude3002 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
3003 <Flex gap={12} align="center" style="margin-bottom: 16px">
3004 <Select name="base">
0074234Claude3005 {branches.map((b) => (
3006 <option value={b} selected={b === defaultBase}>
3007 {b}
3008 </option>
3009 ))}
bb0f894Claude3010 </Select>
3011 <Text muted>&larr;</Text>
3012 <Select name="head">
0074234Claude3013 {branches
3014 .filter((b) => b !== defaultBase)
3015 .concat(defaultBase === branches[0] ? [] : [branches[0]])
3016 .map((b) => (
3017 <option value={b}>{b}</option>
3018 ))}
bb0f894Claude3019 </Select>
3020 </Flex>
3021 <FormGroup>
3022 <Input
0074234Claude3023 name="title"
3024 required
3025 placeholder="Title"
bb0f894Claude3026 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]3027 aria-label="Pull request title"
0074234Claude3028 />
bb0f894Claude3029 </FormGroup>
3030 <FormGroup>
3031 <TextArea
0074234Claude3032 name="body"
81c73c1Claude3033 id="pr-body"
0074234Claude3034 rows={8}
3035 placeholder="Description (Markdown supported)"
bb0f894Claude3036 mono
0074234Claude3037 />
bb0f894Claude3038 </FormGroup>
81c73c1Claude3039 <Flex gap={8} align="center">
3040 <Button type="submit" variant="primary">
3041 Create pull request
3042 </Button>
3043 <button
3044 type="button"
3045 id="ai-suggest-desc"
3046 class="btn"
3047 style="font-weight:500"
3048 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
3049 >
3050 Suggest description with AI
3051 </button>
3052 <span
3053 id="ai-suggest-status"
3054 style="color:var(--text-muted);font-size:13px"
3055 />
3056 </Flex>
bb0f894Claude3057 </Form>
81c73c1Claude3058 <script
3059 dangerouslySetInnerHTML={{
3060 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
3061 }}
3062 />
bb0f894Claude3063 </Container>
0074234Claude3064 </Layout>
3065 );
3066 }
3067);
3068
81c73c1Claude3069// AI-suggested PR description — JSON endpoint driven by the form button.
3070// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
3071// 200; the inline script reads `ok` to decide what to do.
3072pulls.post(
3073 "/:owner/:repo/ai/pr-description",
3074 softAuth,
3075 requireAuth,
3076 requireRepoAccess("write"),
3077 async (c) => {
3078 const { owner: ownerName, repo: repoName } = c.req.param();
3079 if (!isAiAvailable()) {
3080 return c.json({
3081 ok: false,
3082 error: "AI is not available — set ANTHROPIC_API_KEY.",
3083 });
3084 }
3085 const body = await c.req.parseBody();
3086 const title = String(body.title || "").trim();
3087 const baseBranch = String(body.base || "").trim();
3088 const headBranch = String(body.head || "").trim();
3089 if (!baseBranch || !headBranch) {
3090 return c.json({ ok: false, error: "Pick base + head branches first." });
3091 }
3092 if (baseBranch === headBranch) {
3093 return c.json({ ok: false, error: "Base and head must differ." });
3094 }
3095
3096 let diff = "";
3097 try {
3098 const cwd = getRepoPath(ownerName, repoName);
3099 const proc = Bun.spawn(
3100 [
3101 "git",
3102 "diff",
3103 `${baseBranch}...${headBranch}`,
3104 "--",
3105 ],
3106 { cwd, stdout: "pipe", stderr: "pipe" }
3107 );
6ea2109Claude3108 // 30s ceiling — without this a pathological diff (huge binary or
3109 // a corrupt ref) hangs the request indefinitely.
3110 const killer = setTimeout(() => proc.kill(), 30_000);
3111 try {
3112 diff = await new Response(proc.stdout).text();
3113 await proc.exited;
3114 } finally {
3115 clearTimeout(killer);
3116 }
81c73c1Claude3117 } catch {
3118 diff = "";
3119 }
3120 if (!diff.trim()) {
3121 return c.json({
3122 ok: false,
3123 error: "No diff between branches — nothing to summarise.",
3124 });
3125 }
3126
3127 let summary = "";
3128 try {
3129 summary = await generatePrSummary(title || "(untitled)", diff);
3130 } catch (err) {
3131 const msg = err instanceof Error ? err.message : "AI request failed.";
3132 return c.json({ ok: false, error: msg });
3133 }
3134 if (!summary.trim()) {
3135 return c.json({ ok: false, error: "AI returned an empty draft." });
3136 }
3137 return c.json({ ok: true, body: summary });
3138 }
3139);
3140
0074234Claude3141// Create PR
3142pulls.post(
3143 "/:owner/:repo/pulls/new",
3144 softAuth,
3145 requireAuth,
04f6b7fClaude3146 requireRepoAccess("write"),
0074234Claude3147 async (c) => {
3148 const { owner: ownerName, repo: repoName } = c.req.param();
3149 const user = c.get("user")!;
3150 const body = await c.req.parseBody();
3151 const title = String(body.title || "").trim();
3152 const prBody = String(body.body || "").trim();
3153 const baseBranch = String(body.base || "main");
3154 const headBranch = String(body.head || "");
3155
3156 if (!title || !headBranch) {
3157 return c.redirect(
3158 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
3159 );
3160 }
3161
3162 if (baseBranch === headBranch) {
3163 return c.redirect(
3164 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
3165 );
3166 }
3167
3168 const resolved = await resolveRepo(ownerName, repoName);
3169 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3170
6fc53bdClaude3171 const isDraft = String(body.draft || "") === "1";
3172
0074234Claude3173 const [pr] = await db
3174 .insert(pullRequests)
3175 .values({
3176 repositoryId: resolved.repo.id,
3177 authorId: user.id,
3178 title,
3179 body: prBody || null,
3180 baseBranch,
3181 headBranch,
6fc53bdClaude3182 isDraft,
0074234Claude3183 })
3184 .returning();
3185
6fc53bdClaude3186 // Skip AI review on drafts — it runs again when the PR is marked ready.
3187 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3188 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3189 (err) => console.error("[ai-review] Failed:", err)
3190 );
3191 }
3192
3cbe3d6Claude3193 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3194 triggerPrTriage({
3195 ownerName,
3196 repoName,
3197 repositoryId: resolved.repo.id,
3198 prId: pr.id,
3199 prAuthorId: user.id,
3200 title,
3201 body: prBody,
3202 baseBranch,
3203 headBranch,
3204 }).catch((err) => console.error("[pr-triage] Failed:", err));
3205
1d4ff60Claude3206 // Chat notifier — fan out to Slack/Discord/Teams.
3207 import("../lib/chat-notifier")
3208 .then((m) =>
3209 m.notifyChatChannels({
3210 ownerUserId: resolved.repo.ownerId,
3211 repositoryId: resolved.repo.id,
3212 event: {
3213 event: "pr.opened",
3214 repo: `${ownerName}/${repoName}`,
3215 title: `#${pr.number} ${title}`,
3216 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3217 body: prBody || undefined,
3218 actor: user.username,
3219 },
3220 })
3221 )
3222 .catch((err) =>
3223 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3224 );
3225
9dd96b9Test User3226 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude3227 import("../lib/auto-merge")
3228 .then((m) => m.tryAutoMergeNow(pr.id))
3229 .catch((err) => {
3230 console.warn(
3231 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
3232 err instanceof Error ? err.message : err
3233 );
3234 });
9dd96b9Test User3235
0074234Claude3236 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
3237 }
3238);
3239
3240// View single PR
04f6b7fClaude3241pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude3242 const { owner: ownerName, repo: repoName } = c.req.param();
3243 const prNum = parseInt(c.req.param("number"), 10);
3244 const user = c.get("user");
3245 const tab = c.req.query("tab") || "conversation";
3246
3247 const resolved = await resolveRepo(ownerName, repoName);
3248 if (!resolved) return c.notFound();
3249
3250 const [pr] = await db
3251 .select()
3252 .from(pullRequests)
3253 .where(
3254 and(
3255 eq(pullRequests.repositoryId, resolved.repo.id),
3256 eq(pullRequests.number, prNum)
3257 )
3258 )
3259 .limit(1);
3260
3261 if (!pr) return c.notFound();
3262
3263 const [author] = await db
3264 .select()
3265 .from(users)
3266 .where(eq(users.id, pr.authorId))
3267 .limit(1);
3268
cb5a796Claude3269 const allCommentsRaw = await db
0074234Claude3270 .select({
3271 comment: prComments,
cb5a796Claude3272 author: { id: users.id, username: users.username },
0074234Claude3273 })
3274 .from(prComments)
3275 .innerJoin(users, eq(prComments.authorId, users.id))
3276 .where(eq(prComments.pullRequestId, pr.id))
3277 .orderBy(asc(prComments.createdAt));
3278
cb5a796Claude3279 // Filter pending/rejected/spam for non-owner, non-author viewers.
3280 // Owner always sees everything; comment author sees their own pending
3281 // with an "Awaiting approval" badge in the render below.
3282 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
3283 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
3284 if (viewerIsRepoOwner) return true;
3285 if (comment.moderationStatus === "approved") return true;
3286 if (
3287 user &&
3288 cAuthor.id === user.id &&
3289 comment.moderationStatus === "pending"
3290 ) {
3291 return true;
3292 }
3293 return false;
3294 });
3295 const prPendingCount = viewerIsRepoOwner
3296 ? await countPendingForRepo(resolved.repo.id)
3297 : 0;
3298
6fc53bdClaude3299 // Reactions for the PR body + each comment, in parallel.
3300 const [prReactions, ...prCommentReactions] = await Promise.all([
3301 summariseReactions("pr", pr.id, user?.id),
3302 ...comments.map((row) =>
3303 summariseReactions("pr_comment", row.comment.id, user?.id)
3304 ),
3305 ]);
3306
0a67773Claude3307 // Formal reviews (Approve / Request Changes)
3308 const reviewRows = await db
3309 .select({
3310 id: prReviews.id,
3311 state: prReviews.state,
3312 body: prReviews.body,
3313 isAi: prReviews.isAi,
3314 createdAt: prReviews.createdAt,
3315 reviewerUsername: users.username,
3316 reviewerId: prReviews.reviewerId,
3317 })
3318 .from(prReviews)
3319 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3320 .where(eq(prReviews.pullRequestId, pr.id))
3321 .orderBy(asc(prReviews.createdAt));
3322 // Most recent review per reviewer determines the current state
3323 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
3324 for (const r of reviewRows) {
3325 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
3326 }
3327 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
3328 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
3329 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
3330
ace34efClaude3331 // Suggested reviewers — best-effort, never throws
3332 let reviewerSuggestions: ReviewerCandidate[] = [];
3333 try {
3334 if (user) {
3335 reviewerSuggestions = await suggestReviewers(
3336 ownerName, repoName, pr.headBranch, pr.baseBranch,
3337 pr.authorId, resolved.repo.id
3338 );
3339 }
3340 } catch {
3341 // silent degradation
3342 }
3343
0074234Claude3344 const canManage =
3345 user &&
3346 (user.id === resolved.owner.id || user.id === pr.authorId);
3347
1d4ff60Claude3348 // Has any previous AI-test-generator run already tagged this PR? Used
3349 // both to hide the "Generate tests with AI" button and to short-circuit
3350 // the explicit POST handler.
3351 const hasAiTestsMarker = comments.some(({ comment }) =>
3352 (comment.body || "").includes(AI_TESTS_MARKER)
3353 );
3354
e883329Claude3355 const error = c.req.query("error");
c3e0c07Claude3356 const info = c.req.query("info");
e883329Claude3357
3358 // Get gate check status for open PRs
3359 let gateChecks: GateCheckResult[] = [];
240c477Claude3360 let ciStatuses: CommitStatus[] = [];
e883329Claude3361 if (pr.state === "open") {
3362 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3363 if (headSha) {
3364 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
3365 const aiApproved = aiComments.length === 0 || aiComments.some(
3366 ({ comment }) => comment.body.includes("**Approved**")
3367 );
240c477Claude3368 const [gateResult, fetchedCiStatuses] = await Promise.all([
3369 runAllGateChecks(
3370 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
3371 ),
3372 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
3373 ]);
e883329Claude3374 gateChecks = gateResult.checks;
240c477Claude3375 ciStatuses = fetchedCiStatuses;
e883329Claude3376 }
3377 }
3378
534f04aClaude3379 // Block M3 — pre-merge risk score. Cache-only on the request path so
3380 // the page never waits on Haiku. On a cache miss for an open PR we
3381 // kick off the computation fire-and-forget; the next refresh shows it.
3382 let prRisk: PrRiskScore | null = null;
3383 let prRiskCalculating = false;
3384 if (pr.state === "open") {
3385 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
3386 if (!prRisk) {
3387 prRiskCalculating = true;
a28cedeClaude3388 void computePrRiskForPullRequest(pr.id).catch((err) => {
3389 console.warn(
3390 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
3391 err instanceof Error ? err.message : err
3392 );
3393 });
534f04aClaude3394 }
3395 }
3396
4bbacbeClaude3397 // Migration 0062 — per-branch preview URL. The head branch always
3398 // has a preview row (unless it's the default branch, which never
3399 // happens for an open PR) once it has been pushed at least once.
3400 const preview = await getPreviewForBranch(
3401 (resolved.repo as { id: string }).id,
3402 pr.headBranch
3403 );
3404
0369e77Claude3405 // Branch ahead/behind counts — how many commits head is ahead of base and
3406 // how many commits base has advanced since head branched off.
3407 let branchAhead = 0;
3408 let branchBehind = 0;
3409 if (pr.state === "open") {
3410 try {
3411 const repoDir = getRepoPath(ownerName, repoName);
3412 const [aheadProc, behindProc] = [
3413 Bun.spawn(
3414 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
3415 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3416 ),
3417 Bun.spawn(
3418 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
3419 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3420 ),
3421 ];
3422 const [aheadTxt, behindTxt] = await Promise.all([
3423 new Response(aheadProc.stdout).text(),
3424 new Response(behindProc.stdout).text(),
3425 ]);
3426 await Promise.all([aheadProc.exited, behindProc.exited]);
3427 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
3428 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
3429 } catch { /* non-blocking */ }
3430 }
3431
6d1bbc2Claude3432 // Linked issues — parse closing keywords from PR title+body, look up issues
3433 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
3434 try {
3435 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3436 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3437 if (refs.length > 0) {
3438 linkedIssues = await db
3439 .select({ number: issues.number, title: issues.title, state: issues.state })
3440 .from(issues)
3441 .where(and(
3442 eq(issues.repositoryId, resolved.repo.id),
3443 inArray(issues.number, refs),
3444 ));
3445 }
3446 } catch { /* non-blocking */ }
3447
3448 // Task list progress — count markdown checkboxes in PR body
3449 let taskTotal = 0;
3450 let taskChecked = 0;
3451 if (pr.body) {
3452 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
3453 taskTotal++;
3454 if (m[1].trim() !== "") taskChecked++;
3455 }
3456 }
3457
74d8c4dClaude3458 // M15 — PR size badge (best-effort, non-blocking)
3459 let prSizeInfo: PrSizeInfo | null = null;
3460 try {
3461 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
3462 } catch { /* swallow — purely cosmetic */ }
3463
47a7a0aClaude3464 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude3465 let diffRaw = "";
3466 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude3467 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude3468 if (tab === "files") {
3469 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude3470 // Run the two git diffs in parallel — they're independent reads of
3471 // the same range. Previously sequential, doubling the wall time on
3472 // big PRs (100+ files = 10-30s for no reason).
0074234Claude3473 const proc = Bun.spawn(
3474 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
3475 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3476 );
3477 const statProc = Bun.spawn(
3478 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
3479 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3480 );
6ea2109Claude3481 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
3482 // would otherwise hang the whole request.
3483 const killer = setTimeout(() => {
3484 proc.kill();
3485 statProc.kill();
3486 }, 30_000);
3487 let stat = "";
3488 try {
3489 [diffRaw, stat] = await Promise.all([
3490 new Response(proc.stdout).text(),
3491 new Response(statProc.stdout).text(),
3492 ]);
3493 await Promise.all([proc.exited, statProc.exited]);
3494 } finally {
3495 clearTimeout(killer);
3496 }
0074234Claude3497
3498 diffFiles = stat
3499 .trim()
3500 .split("\n")
3501 .filter(Boolean)
3502 .map((line) => {
3503 const [add, del, filePath] = line.split("\t");
3504 return {
3505 path: filePath,
3506 status: "modified",
3507 additions: add === "-" ? 0 : parseInt(add, 10),
3508 deletions: del === "-" ? 0 : parseInt(del, 10),
3509 patch: "",
3510 };
3511 });
47a7a0aClaude3512
3513 // Fetch inline comments (file+line anchored) for the files tab
3514 const inlineRows = await db
3515 .select({
3516 id: prComments.id,
3517 filePath: prComments.filePath,
3518 lineNumber: prComments.lineNumber,
3519 body: prComments.body,
3520 isAiReview: prComments.isAiReview,
3521 createdAt: prComments.createdAt,
3522 authorUsername: users.username,
3523 })
3524 .from(prComments)
3525 .innerJoin(users, eq(prComments.authorId, users.id))
3526 .where(
3527 and(
3528 eq(prComments.pullRequestId, pr.id),
3529 eq(prComments.moderationStatus, "approved"),
3530 )
3531 )
3532 .orderBy(asc(prComments.createdAt));
3533
3534 diffInlineComments = inlineRows
3535 .filter(r => r.filePath != null && r.lineNumber != null)
3536 .map(r => ({
3537 id: r.id,
3538 filePath: r.filePath!,
3539 lineNumber: r.lineNumber!,
3540 authorUsername: r.authorUsername,
3541 body: renderMarkdown(r.body),
3542 isAiReview: r.isAiReview,
3543 createdAt: r.createdAt.toISOString(),
3544 }));
0074234Claude3545 }
3546
b078860Claude3547 // ─── Derived visual state ───
3548 const stateKey =
3549 pr.state === "open"
3550 ? pr.isDraft
3551 ? "draft"
3552 : "open"
3553 : pr.state;
3554 const stateLabel =
3555 stateKey === "open"
3556 ? "Open"
3557 : stateKey === "draft"
3558 ? "Draft"
3559 : stateKey === "merged"
3560 ? "Merged"
3561 : "Closed";
3562 const stateIcon =
3563 stateKey === "open"
3564 ? "○"
3565 : stateKey === "draft"
3566 ? "◌"
3567 : stateKey === "merged"
3568 ? "⮌"
3569 : "✓";
3570 const commentCount = comments.length;
3571 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
3572 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
3573 const mergeBlocked =
3574 gateChecks.length > 0 &&
3575 gateChecks.some(
3576 (c) => !c.passed && c.name !== "Merge check"
3577 );
3578
b558f23Claude3579 // Commits tab — list commits included in this PR (base..head range)
3580 let prCommits: GitCommit[] = [];
3581 if (tab === "commits") {
3582 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
3583 }
3584
0074234Claude3585 return c.html(
3586 <Layout
3587 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
3588 user={user}
3589 >
3590 <RepoHeader owner={ownerName} repo={repoName} />
3591 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude3592 <PendingCommentsBanner
3593 owner={ownerName}
3594 repo={repoName}
3595 count={prPendingCount}
3596 />
b078860Claude3597 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude3598 <div
3599 id="live-comment-banner"
3600 class="alert"
3601 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
3602 >
3603 <strong class="js-live-count">0</strong> new comment(s) —{" "}
3604 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
3605 reload to view
3606 </a>
3607 </div>
3608 <script
3609 dangerouslySetInnerHTML={{
3610 __html: liveCommentBannerScript({
3611 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
3612 bannerElementId: "live-comment-banner",
3613 }),
3614 }}
3615 />
b078860Claude3616
3617 <div class="prs-detail-hero">
b558f23Claude3618 <div class="prs-edit-title-wrap">
3619 <h1 class="prs-detail-title" id="pr-title-display">
3620 {pr.title}{" "}
3621 <span class="prs-detail-num">#{pr.number}</span>
3622 </h1>
3623 {canManage && pr.state === "open" && (
3624 <button
3625 type="button"
3626 class="prs-edit-btn"
3627 id="pr-edit-toggle"
3628 onclick={`
3629 document.getElementById('pr-title-display').style.display='none';
3630 document.getElementById('pr-edit-toggle').style.display='none';
3631 document.getElementById('pr-edit-form').style.display='flex';
3632 document.getElementById('pr-title-input').focus();
3633 `}
3634 >
3635 Edit
3636 </button>
3637 )}
3638 </div>
3639 {canManage && pr.state === "open" && (
3640 <form
3641 id="pr-edit-form"
3642 method="post"
3643 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
3644 class="prs-edit-form"
3645 style="display:none"
3646 >
3647 <input
3648 id="pr-title-input"
3649 type="text"
3650 name="title"
3651 value={pr.title}
3652 required
3653 maxlength={256}
3654 placeholder="Pull request title"
3655 />
3656 <div class="prs-edit-actions">
3657 <button type="submit" class="prs-edit-save-btn">Save</button>
3658 <button
3659 type="button"
3660 class="prs-edit-cancel-btn"
3661 onclick={`
3662 document.getElementById('pr-edit-form').style.display='none';
3663 document.getElementById('pr-title-display').style.display='';
3664 document.getElementById('pr-edit-toggle').style.display='';
3665 `}
3666 >
3667 Cancel
3668 </button>
3669 </div>
3670 </form>
3671 )}
b078860Claude3672 <div class="prs-detail-meta">
3673 <span class={`prs-state-pill state-${stateKey}`}>
3674 <span aria-hidden="true">{stateIcon}</span>
3675 <span>{stateLabel}</span>
3676 </span>
74d8c4dClaude3677 {prSizeInfo && (
3678 <span
3679 class="prs-size-badge"
3680 style={`color:${prSizeInfo.color};background:${prSizeInfo.bgColor}`}
3681 title={`${prSizeInfo.linesChanged} lines changed (+${prSizeInfo.added} −${prSizeInfo.deleted})`}
3682 >
3683 {prSizeInfo.label}
3684 </span>
3685 )}
67dc4e1Claude3686 <TrioVerdictPills
3687 comments={comments.map(({ comment }) => comment)}
3688 />
b078860Claude3689 <span>
3690 <strong>{author?.username}</strong> wants to merge
3691 </span>
3692 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
3693 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
3694 <span class="prs-branch-arrow-lg">{"→"}</span>
3695 <span class="prs-branch-pill">{pr.baseBranch}</span>
3696 </span>
0369e77Claude3697 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
3698 <span
3699 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
3700 title={branchBehind > 0
3701 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
3702 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
3703 >
3704 {branchAhead > 0 ? `↑${branchAhead}` : ""}
3705 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
3706 {branchBehind > 0 ? `↓${branchBehind}` : ""}
3707 </span>
3708 )}
b078860Claude3709 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude3710 {taskTotal > 0 && (
3711 <span
3712 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
3713 title={`${taskChecked} of ${taskTotal} tasks completed`}
3714 >
3715 <span class="prs-tasks-progress" aria-hidden="true">
3716 <span
3717 class="prs-tasks-progress-bar"
3718 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
3719 ></span>
3720 </span>
3721 {taskChecked}/{taskTotal} tasks
3722 </span>
3723 )}
3724 {canManage && pr.state === "open" && branchBehind > 0 && (
3725 <form
3726 method="post"
3727 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
3728 class="prs-inline-form"
3729 >
3730 <button
3731 type="submit"
3732 class="prs-update-branch-btn"
3733 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
3734 >
3735 ↑ Update branch
3736 </button>
3737 </form>
3738 )}
3c03977Claude3739 <span
3740 id="live-pill"
3741 class="live-pill"
3742 title="People editing this PR right now"
3743 >
3744 <span class="live-pill-dot" aria-hidden="true"></span>
3745 <span>
3746 Live: <strong id="live-count">0</strong> editing
3747 </span>
3748 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
3749 </span>
4bbacbeClaude3750 {preview && (
3751 <a
3752 class={`preview-prpill is-${preview.status}`}
3753 href={
3754 preview.status === "ready"
3755 ? preview.previewUrl
3756 : `/${ownerName}/${repoName}/previews`
3757 }
3758 target={preview.status === "ready" ? "_blank" : undefined}
3759 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
3760 title={`Preview · ${previewStatusLabel(preview.status)}`}
3761 >
3762 <span class="preview-prpill-dot" aria-hidden="true"></span>
3763 <span>Preview: </span>
3764 <span>{previewStatusLabel(preview.status)}</span>
3765 </a>
3766 )}
b078860Claude3767 {canManage && pr.state === "open" && pr.isDraft && (
3768 <form
3769 method="post"
3770 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3771 class="prs-inline-form prs-detail-actions"
3772 >
3773 <button type="submit" class="prs-merge-ready-btn">
3774 Ready for review
3775 </button>
3776 </form>
3777 )}
3778 </div>
3779 </div>
3c03977Claude3780 <script
3781 dangerouslySetInnerHTML={{
3782 __html: LIVE_COEDIT_SCRIPT(pr.id),
3783 }}
3784 />
829a046Claude3785 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude3786 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude3787 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude3788
b078860Claude3789 <nav class="prs-detail-tabs" aria-label="Pull request sections">
3790 <a
3791 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
3792 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
3793 >
3794 Conversation
3795 <span class="prs-detail-tab-count">{commentCount}</span>
3796 </a>
b558f23Claude3797 <a
3798 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
3799 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
3800 >
3801 Commits
3802 {branchAhead > 0 && (
3803 <span class="prs-detail-tab-count">{branchAhead}</span>
3804 )}
3805 </a>
b078860Claude3806 <a
3807 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
3808 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3809 >
3810 Files changed
3811 {diffFiles.length > 0 && (
3812 <span class="prs-detail-tab-count">{diffFiles.length}</span>
3813 )}
3814 </a>
3815 </nav>
3816
b558f23Claude3817 {tab === "commits" ? (
3818 <div class="prs-commits-list">
3819 {prCommits.length === 0 ? (
3820 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
3821 ) : (
3822 prCommits.map((commit) => (
3823 <div class="prs-commit-row">
3824 <span class="prs-commit-dot" aria-hidden="true"></span>
3825 <div class="prs-commit-body">
3826 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
3827 <div class="prs-commit-meta">
3828 <strong>{commit.author}</strong> committed{" "}
3829 {formatRelative(new Date(commit.date))}
3830 </div>
3831 </div>
3832 <a
3833 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
3834 class="prs-commit-sha"
3835 title="View commit"
3836 >
3837 {commit.sha.slice(0, 7)}
3838 </a>
3839 </div>
3840 ))
3841 )}
3842 </div>
3843 ) : tab === "files" ? (
ea9ed4cClaude3844 <DiffView
3845 raw={diffRaw}
3846 files={diffFiles}
3847 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
47a7a0aClaude3848 inlineComments={diffInlineComments}
3849 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
b5dd694Claude3850 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
ea9ed4cClaude3851 />
b078860Claude3852 ) : (
3853 <>
3854 {pr.body && (
3855 <CommentBox
3856 author={author?.username ?? "unknown"}
3857 date={pr.createdAt}
3858 body={renderMarkdown(pr.body)}
3859 />
3860 )}
3861
422a2d4Claude3862 {/* Block H — AI trio review (security/correctness/style). When
3863 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
3864 hoisted into a 3-column card grid above the normal comment
3865 stream so reviewers see verdicts at a glance. Disagreements
3866 are surfaced as a yellow callout. */}
3867 <TrioReviewGrid
3868 comments={comments.map(({ comment }) => comment)}
3869 />
3870
15db0e0Claude3871 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude3872 // Skip trio comments — already rendered in TrioReviewGrid above.
3873 if (isTrioComment(comment.body)) return null;
15db0e0Claude3874 const slashCmd = detectSlashCmdComment(comment.body);
3875 if (slashCmd) {
3876 const visible = stripSlashCmdMarker(comment.body);
3877 return (
3878 <div class={`slash-pill slash-cmd-${slashCmd}`}>
3879 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
3880 <span class="slash-pill-actor">
3881 <strong>{commentAuthor.username}</strong>
3882 {" ran "}
3883 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude3884 </span>
15db0e0Claude3885 <span class="slash-pill-time">
3886 {formatRelative(comment.createdAt)}
3887 </span>
3888 <div class="slash-pill-body">
3889 <MarkdownContent html={renderMarkdown(visible)} />
3890 </div>
3891 </div>
3892 );
3893 }
cb5a796Claude3894 const isPending = comment.moderationStatus === "pending";
15db0e0Claude3895 return (
cb5a796Claude3896 <div
3897 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
3898 >
15db0e0Claude3899 <div class="prs-comment-head">
3900 <strong>{commentAuthor.username}</strong>
a7460bfClaude3901 {commentAuthor.username === BOT_USERNAME && (
3902 <span class="prs-bot-badge">&#x1F916; bot</span>
3903 )}
15db0e0Claude3904 {comment.isAiReview && (
3905 <span class="prs-ai-badge">AI Review</span>
3906 )}
cb5a796Claude3907 {isPending && (
3908 <span
3909 class="modq-pending-badge"
3910 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
3911 >
3912 Awaiting approval
3913 </span>
3914 )}
15db0e0Claude3915 <span class="prs-comment-time">
3916 commented {formatRelative(comment.createdAt)}
3917 </span>
3918 {comment.filePath && (
3919 <span class="prs-comment-loc">
3920 {comment.filePath}
3921 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
3922 </span>
3923 )}
3924 </div>
3925 <div class="prs-comment-body">
3926 <MarkdownContent html={renderMarkdown(comment.body)} />
3927 </div>
0074234Claude3928 </div>
15db0e0Claude3929 );
3930 })}
0074234Claude3931
b078860Claude3932 {/* Quick link to the Files changed tab when there's a diff to look at. */}
3933 {pr.state !== "merged" && (
3934 <a
3935 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3936 class="prs-files-card"
3937 >
3938 <span class="prs-files-card-icon" aria-hidden="true">
3939 {"▤"}
3940 </span>
3941 <div class="prs-files-card-text">
3942 <p class="prs-files-card-title">Files changed</p>
3943 <p class="prs-files-card-sub">
3944 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
3945 </p>
e883329Claude3946 </div>
b078860Claude3947 <span class="prs-files-card-cta">View diff {"→"}</span>
3948 </a>
3949 )}
3950
6d1bbc2Claude3951 {linkedIssues.length > 0 && (
3952 <div class="prs-linked-issues">
3953 <div class="prs-linked-issues-head">
3954 <span>Closing issues</span>
3955 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
3956 </div>
3957 {linkedIssues.map((issue) => (
3958 <a
3959 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
3960 class="prs-linked-issue-row"
3961 >
3962 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
3963 {issue.state === "open" ? "○" : "✓"}
3964 </span>
3965 <span class="prs-linked-issue-title">{issue.title}</span>
3966 <span class="prs-linked-issue-num">#{issue.number}</span>
3967 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
3968 {issue.state}
3969 </span>
3970 </a>
3971 ))}
3972 </div>
3973 )}
3974
b078860Claude3975 {error && (
3976 <div
3977 class="auth-error"
3978 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)"
3979 >
3980 {decodeURIComponent(error)}
3981 </div>
3982 )}
3983
3984 {info && (
3985 <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)">
3986 {decodeURIComponent(info)}
3987 </div>
3988 )}
e883329Claude3989
b078860Claude3990 {pr.state === "open" && (prRisk || prRiskCalculating) && (
3991 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
3992 )}
3993
0a67773Claude3994 {/* ─── Review summary ─────────────────────────────────── */}
3995 {(approvals.length > 0 || changesRequested.length > 0) && (
3996 <div class="prs-review-summary">
3997 {approvals.length > 0 && (
3998 <div class="prs-review-row prs-review-approved">
3999 <span class="prs-review-icon">✓</span>
4000 <span>
4001 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4002 approved this pull request
4003 </span>
4004 </div>
4005 )}
4006 {changesRequested.length > 0 && (
4007 <div class="prs-review-row prs-review-changes">
4008 <span class="prs-review-icon">✗</span>
4009 <span>
4010 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4011 requested changes
4012 </span>
4013 </div>
4014 )}
4015 </div>
4016 )}
4017
ace34efClaude4018 {/* Suggested reviewers */}
4019 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
4020 <div class="prs-review-summary" style="margin-top:12px">
4021 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
4022 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
4023 Suggested reviewers
4024 </span>
4025 {reviewerSuggestions.map((r) => (
4026 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
4027 style="display:flex;align-items:center;gap:8px;width:100%">
4028 <input type="hidden" name="reviewerId" value={r.userId} />
4029 <span class="prs-reviewer-avatar">
4030 {r.username.slice(0, 1).toUpperCase()}
4031 </span>
4032 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
4033 {r.username}
4034 </a>
4035 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
4036 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
4037 Request
4038 </button>
4039 </form>
4040 ))}
4041 </div>
4042 </div>
4043 )}
4044
b078860Claude4045 {pr.state === "open" && gateChecks.length > 0 && (
4046 <div class="prs-gate-card">
4047 <div class="prs-gate-head">
4048 <h3>Gate checks</h3>
4049 <span class="prs-gate-summary">
4050 {gatesAllPassed
4051 ? `All ${gateChecks.length} checks passed`
4052 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
4053 </span>
c3e0c07Claude4054 </div>
b078860Claude4055 {gateChecks.map((check) => {
4056 const isAi = /ai.*review/i.test(check.name);
4057 const isSkip = check.skipped === true;
4058 const statusClass = isSkip
4059 ? "is-skip"
4060 : check.passed
4061 ? "is-pass"
4062 : "is-fail";
4063 const statusGlyph = isSkip
4064 ? "—"
4065 : check.passed
4066 ? "✓"
4067 : "✗";
4068 const statusLabel = isSkip
4069 ? "Skipped"
4070 : check.passed
4071 ? "Passed"
4072 : "Failing";
4073 return (
4074 <div
4075 class="prs-gate-row"
4076 style={
4077 isAi
4078 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
4079 : ""
4080 }
4081 >
4082 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
4083 {statusGlyph}
4084 </span>
4085 <span class="prs-gate-name">
4086 {check.name}
4087 {isAi && (
4088 <span
4089 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"
4090 >
4091 AI
4092 </span>
4093 )}
4094 </span>
4095 <span class="prs-gate-details">{check.details}</span>
4096 <span class={`prs-gate-pill ${statusClass}`}>
4097 {statusLabel}
e883329Claude4098 </span>
4099 </div>
b078860Claude4100 );
4101 })}
4102 <div class="prs-gate-footer">
4103 {gatesAllPassed
4104 ? "All checks passed — ready to merge."
4105 : gateChecks.some(
4106 (c) => !c.passed && c.name === "Merge check"
4107 )
4108 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
4109 : "Some checks failed — resolve issues before merging."}
4110 {aiReviewCount > 0 && (
4111 <>
4112 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
4113 </>
4114 )}
4115 </div>
4116 </div>
4117 )}
4118
240c477Claude4119 {pr.state === "open" && ciStatuses.length > 0 && (
4120 <div class="prs-ci-card">
4121 <div class="prs-ci-head">
4122 <h3>CI checks</h3>
4123 <span class="prs-ci-summary">
4124 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
4125 </span>
4126 </div>
4127 {ciStatuses.map((status) => {
4128 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
4129 return (
4130 <div class="prs-ci-row">
4131 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
4132 <span class="prs-ci-context">{status.context}</span>
4133 {status.description && (
4134 <span class="prs-ci-desc">{status.description}</span>
4135 )}
4136 <span class={`prs-ci-pill is-${status.state}`}>
4137 {status.state}
4138 </span>
4139 {status.targetUrl && (
4140 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
4141 )}
4142 </div>
4143 );
4144 })}
4145 </div>
4146 )}
4147
b078860Claude4148 {/* ─── Merge area / state-aware action card ─────────────── */}
4149 {user && pr.state === "open" && (
4150 <div
4151 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
4152 >
4153 <div class="prs-merge-head">
4154 <strong>
4155 {pr.isDraft
4156 ? "Draft — ready for review?"
4157 : mergeBlocked
4158 ? "Merge blocked"
4159 : "Ready to merge"}
4160 </strong>
e883329Claude4161 </div>
b078860Claude4162 <p class="prs-merge-sub">
4163 {pr.isDraft
4164 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
4165 : mergeBlocked
4166 ? "Resolve the failing gate checks above before this PR can land."
4167 : gateChecks.length > 0
4168 ? gatesAllPassed
4169 ? "All gates green. Merge will fast-forward into the base branch."
4170 : "Conflicts will be auto-resolved by GlueCron AI on merge."
4171 : "Run gate checks by refreshing once your branch has a recent commit."}
4172 </p>
4173 <Form
4174 method="post"
4175 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
4176 >
4177 <FormGroup>
3c03977Claude4178 <div class="live-cursor-host" style="position:relative">
4179 <textarea
4180 name="body"
4181 id="pr-comment-body"
4182 data-live-field="comment_new"
6cd2f0eClaude4183 data-md-preview=""
3c03977Claude4184 rows={5}
4185 required
4186 placeholder="Leave a comment... (Markdown supported)"
4187 style="font-family:var(--font-mono);font-size:13px;width:100%"
4188 ></textarea>
4189 </div>
15db0e0Claude4190 <span class="slash-hint" title="Type a slash-command as the first line">
4191 Type <code>/</code> for commands —{" "}
4192 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
4193 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
4194 </span>
b078860Claude4195 </FormGroup>
4196 <div class="prs-merge-actions">
4197 <Button type="submit" variant="primary">
4198 Comment
4199 </Button>
0a67773Claude4200 {user && user.id !== pr.authorId && pr.state === "open" && (
4201 <>
4202 <button
4203 type="submit"
4204 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4205 name="review_state"
4206 value="approved"
4207 class="prs-review-approve-btn"
4208 title="Approve this pull request"
4209 >
4210 ✓ Approve
4211 </button>
4212 <button
4213 type="submit"
4214 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4215 name="review_state"
4216 value="changes_requested"
4217 class="prs-review-changes-btn"
4218 title="Request changes before merging"
4219 >
4220 ✗ Request changes
4221 </button>
4222 </>
4223 )}
b078860Claude4224 {canManage && (
4225 <>
4226 {pr.isDraft ? (
4227 <button
4228 type="submit"
4229 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4230 formnovalidate
4231 class="prs-merge-ready-btn"
4232 >
4233 Ready for review
4234 </button>
4235 ) : (
a164a6dClaude4236 <>
4237 <div class="prs-merge-strategy-wrap">
4238 <span class="prs-merge-strategy-label">Strategy</span>
4239 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
4240 <option value="merge">Merge commit</option>
4241 <option value="squash">Squash and merge</option>
4242 <option value="ff">Fast-forward</option>
4243 </select>
4244 </div>
4245 <button
4246 type="submit"
4247 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
4248 formnovalidate
4249 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
4250 title={
4251 mergeBlocked
4252 ? "Failing gate checks must be resolved before this PR can merge."
4253 : "Merge pull request"
4254 }
4255 >
4256 {"✔"} Merge pull request
4257 </button>
4258 </>
b078860Claude4259 )}
4260 {!pr.isDraft && (
4261 <button
0074234Claude4262 type="submit"
b078860Claude4263 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
4264 formnovalidate
4265 class="prs-merge-back-draft"
4266 title="Convert back to draft"
0074234Claude4267 >
b078860Claude4268 Convert to draft
4269 </button>
4270 )}
4271 {isAiReviewEnabled() && (
4272 <button
4273 type="submit"
4274 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
4275 formnovalidate
4276 class="btn"
4277 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
4278 >
4279 Re-run AI review
4280 </button>
4281 )}
1d4ff60Claude4282 {isAiReviewEnabled() && !hasAiTestsMarker && (
4283 <button
4284 type="submit"
4285 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
4286 formnovalidate
4287 class="btn"
4288 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."
4289 >
4290 Generate tests with AI
4291 </button>
4292 )}
b078860Claude4293 <Button
4294 type="submit"
4295 variant="danger"
4296 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
4297 >
4298 Close
4299 </Button>
4300 </>
4301 )}
4302 </div>
4303 </Form>
4304 </div>
4305 )}
4306
4307 {/* Read-only footers for non-open states. */}
4308 {pr.state === "merged" && (
4309 <div class="prs-merge-card is-merged">
4310 <div class="prs-merge-head">
4311 <strong>{"⮌"} Merged</strong>
0074234Claude4312 </div>
b078860Claude4313 <p class="prs-merge-sub">
4314 This pull request was merged into{" "}
4315 <code>{pr.baseBranch}</code>.
4316 </p>
4317 </div>
4318 )}
4319 {pr.state === "closed" && (
4320 <div class="prs-merge-card is-closed">
4321 <div class="prs-merge-head">
4322 <strong>{"✕"} Closed without merging</strong>
4323 </div>
4324 <p class="prs-merge-sub">
4325 This pull request was closed and not merged.
4326 </p>
4327 </div>
4328 )}
4329 </>
4330 )}
0074234Claude4331 </Layout>
4332 );
4333});
4334
6d1bbc2Claude4335// Update branch — merge base into head so the PR branch is up to date.
4336// Uses a git worktree so the bare repo stays clean. Write access required.
4337pulls.post(
4338 "/:owner/:repo/pulls/:number/update-branch",
4339 softAuth,
4340 requireAuth,
4341 requireRepoAccess("write"),
4342 async (c) => {
4343 const { owner: ownerName, repo: repoName } = c.req.param();
4344 const prNum = parseInt(c.req.param("number"), 10);
4345 const user = c.get("user")!;
4346 const resolved = await resolveRepo(ownerName, repoName);
4347 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4348
4349 const [pr] = await db
4350 .select()
4351 .from(pullRequests)
4352 .where(and(
4353 eq(pullRequests.repositoryId, resolved.repo.id),
4354 eq(pullRequests.number, prNum),
4355 ))
4356 .limit(1);
4357 if (!pr || pr.state !== "open") {
4358 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4359 }
4360
4361 const repoDir = getRepoPath(ownerName, repoName);
4362 const wt = `${repoDir}/_update_wt_${Date.now()}`;
4363 const gitEnv = {
4364 ...process.env,
4365 GIT_AUTHOR_NAME: user.displayName || user.username,
4366 GIT_AUTHOR_EMAIL: user.email,
4367 GIT_COMMITTER_NAME: user.displayName || user.username,
4368 GIT_COMMITTER_EMAIL: user.email,
4369 };
4370
4371 const addWt = Bun.spawn(
4372 ["git", "worktree", "add", wt, pr.headBranch],
4373 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4374 );
4375 if (await addWt.exited !== 0) {
4376 return c.redirect(
4377 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
4378 );
4379 }
4380
4381 let ok = false;
4382 try {
4383 const mergeProc = Bun.spawn(
4384 ["git", "merge", "--no-edit", pr.baseBranch],
4385 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
4386 );
4387 if (await mergeProc.exited === 0) {
4388 ok = true;
4389 } else {
4390 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4391 }
4392 } catch {
4393 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4394 }
4395
4396 await Bun.spawn(
4397 ["git", "worktree", "remove", "--force", wt],
4398 { cwd: repoDir }
4399 ).exited.catch(() => {});
4400
4401 if (ok) {
4402 return c.redirect(
4403 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
4404 );
4405 }
4406 return c.redirect(
4407 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
4408 );
4409 }
4410);
4411
b558f23Claude4412// Edit PR title (and optionally body). Owner or author only.
4413pulls.post(
4414 "/:owner/:repo/pulls/:number/edit",
4415 softAuth,
4416 requireAuth,
4417 requireRepoAccess("write"),
4418 async (c) => {
4419 const { owner: ownerName, repo: repoName } = c.req.param();
4420 const prNum = parseInt(c.req.param("number"), 10);
4421 const user = c.get("user")!;
4422 const resolved = await resolveRepo(ownerName, repoName);
4423 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4424
4425 const [pr] = await db
4426 .select()
4427 .from(pullRequests)
4428 .where(and(
4429 eq(pullRequests.repositoryId, resolved.repo.id),
4430 eq(pullRequests.number, prNum),
4431 ))
4432 .limit(1);
4433 if (!pr || pr.state !== "open") {
4434 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4435 }
4436 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
4437 if (!canEdit) {
4438 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4439 }
4440
4441 const body = await c.req.parseBody();
4442 const newTitle = String(body.title || "").trim().slice(0, 256);
4443 if (!newTitle) {
4444 return c.redirect(
4445 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
4446 );
4447 }
4448
4449 await db
4450 .update(pullRequests)
4451 .set({ title: newTitle, updatedAt: new Date() })
4452 .where(eq(pullRequests.id, pr.id));
4453
4454 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
4455 }
4456);
4457
cb5a796Claude4458// Add comment to PR.
4459//
4460// Permission model mirrors `issues.tsx`: any logged-in user with read
4461// access can submit; `decideInitialStatus` routes non-collaborators
4462// through the moderation queue. Slash commands only fire when the
4463// comment is auto-approved — we don't want a banned/pending comment to
4464// silently trigger AI work on the PR.
0074234Claude4465pulls.post(
4466 "/:owner/:repo/pulls/:number/comment",
4467 softAuth,
4468 requireAuth,
cb5a796Claude4469 requireRepoAccess("read"),
0074234Claude4470 async (c) => {
4471 const { owner: ownerName, repo: repoName } = c.req.param();
4472 const prNum = parseInt(c.req.param("number"), 10);
4473 const user = c.get("user")!;
4474 const body = await c.req.parseBody();
4475 const commentBody = String(body.body || "").trim();
47a7a0aClaude4476 const filePathRaw = String(body.file_path || "").trim();
4477 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
4478 const inlineFilePath = filePathRaw || undefined;
4479 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude4480
4481 if (!commentBody) {
4482 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4483 }
4484
4485 const resolved = await resolveRepo(ownerName, repoName);
4486 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4487
4488 const [pr] = await db
4489 .select()
4490 .from(pullRequests)
4491 .where(
4492 and(
4493 eq(pullRequests.repositoryId, resolved.repo.id),
4494 eq(pullRequests.number, prNum)
4495 )
4496 )
4497 .limit(1);
4498
4499 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4500
cb5a796Claude4501 const decision = await decideInitialStatus({
4502 commenterUserId: user.id,
4503 repositoryId: resolved.repo.id,
4504 kind: "pr",
4505 threadId: pr.id,
4506 });
4507
d4ac5c3Claude4508 const [inserted] = await db
4509 .insert(prComments)
4510 .values({
4511 pullRequestId: pr.id,
4512 authorId: user.id,
4513 body: commentBody,
cb5a796Claude4514 moderationStatus: decision.status,
47a7a0aClaude4515 filePath: inlineFilePath,
4516 lineNumber: inlineLineNumber,
d4ac5c3Claude4517 })
4518 .returning();
4519
cb5a796Claude4520 // Live update: only when the comment is actually visible.
4521 if (inserted && decision.status === "approved") {
d4ac5c3Claude4522 try {
4523 const { publish } = await import("../lib/sse");
4524 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
4525 event: "pr-comment",
4526 data: {
4527 pullRequestId: pr.id,
4528 commentId: inserted.id,
4529 authorId: user.id,
4530 authorUsername: user.username,
4531 },
4532 });
4533 } catch {
4534 /* SSE is best-effort */
4535 }
4536 }
0074234Claude4537
cb5a796Claude4538 if (decision.status === "pending") {
4539 void notifyOwnerOfPendingComment({
4540 repositoryId: resolved.repo.id,
4541 commenterUsername: user.username,
4542 kind: "pr",
4543 threadNumber: prNum,
4544 ownerUsername: ownerName,
4545 repoName,
4546 });
4547 return c.redirect(
4548 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4549 );
4550 }
4551 if (decision.status === "rejected") {
4552 // Silent ban path — same UX as 'pending' so we don't leak the gate.
4553 return c.redirect(
4554 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4555 );
4556 }
4557
15db0e0Claude4558 // Slash-command handoff. We always store the original comment above
4559 // first so free-form text that happens to start with `/` is preserved
4560 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude4561 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude4562 const parsed = parseSlashCommand(commentBody);
4563 if (parsed) {
4564 try {
4565 const result = await executeSlashCommand({
4566 command: parsed.command,
4567 args: parsed.args,
4568 prId: pr.id,
4569 userId: user.id,
4570 repositoryId: resolved.repo.id,
4571 });
4572 await db.insert(prComments).values({
4573 pullRequestId: pr.id,
4574 authorId: user.id,
4575 body: result.body,
4576 });
4577 } catch (err) {
4578 // Defence-in-depth — executeSlashCommand promises not to throw,
4579 // but if it ever does we want the PR thread to know.
4580 await db
4581 .insert(prComments)
4582 .values({
4583 pullRequestId: pr.id,
4584 authorId: user.id,
4585 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
4586 })
4587 .catch(() => {});
4588 }
4589 }
4590
47a7a0aClaude4591 // Inline comments go back to the files tab; conversation comments to the conversation tab
4592 const redirectTab = inlineFilePath ? "?tab=files" : "";
4593 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude4594 }
4595);
4596
b5dd694Claude4597// Apply a suggestion from a PR comment — commits the suggested code to the
4598// head branch on behalf of the logged-in user.
4599pulls.post(
4600 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
4601 softAuth,
4602 requireAuth,
4603 requireRepoAccess("read"),
4604 async (c) => {
4605 const { owner: ownerName, repo: repoName } = c.req.param();
4606 const prNum = parseInt(c.req.param("number"), 10);
4607 const commentId = c.req.param("commentId"); // UUID
4608 const user = c.get("user")!;
4609
4610 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
4611
4612 const resolved = await resolveRepo(ownerName, repoName);
4613 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4614
4615 const [pr] = await db
4616 .select()
4617 .from(pullRequests)
4618 .where(
4619 and(
4620 eq(pullRequests.repositoryId, resolved.repo.id),
4621 eq(pullRequests.number, prNum)
4622 )
4623 )
4624 .limit(1);
4625
4626 if (!pr || pr.state !== "open") {
4627 return c.redirect(`${backUrl}&error=pr_not_open`);
4628 }
4629
4630 // Only PR author or repo owner may apply suggestions.
4631 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
4632 return c.redirect(`${backUrl}&error=forbidden`);
4633 }
4634
4635 // Load the comment.
4636 const [comment] = await db
4637 .select()
4638 .from(prComments)
4639 .where(
4640 and(
4641 eq(prComments.id, commentId),
4642 eq(prComments.pullRequestId, pr.id)
4643 )
4644 )
4645 .limit(1);
4646
4647 if (!comment) {
4648 return c.redirect(`${backUrl}&error=comment_not_found`);
4649 }
4650
4651 // Parse suggestion block from comment body.
4652 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
4653 if (!m) {
4654 return c.redirect(`${backUrl}&error=no_suggestion`);
4655 }
4656 const suggestionCode = m[1];
4657
4658 // Get the commenter's details for the commit message co-author line.
4659 const [commenter] = await db
4660 .select()
4661 .from(users)
4662 .where(eq(users.id, comment.authorId))
4663 .limit(1);
4664
4665 // Fetch current file content from head branch.
4666 if (!comment.filePath) {
4667 return c.redirect(`${backUrl}&error=file_not_found`);
4668 }
4669 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
4670 if (!blob) {
4671 return c.redirect(`${backUrl}&error=file_not_found`);
4672 }
4673
4674 // Apply the patch — replace the target line(s) with suggestion lines.
4675 const lines = blob.content.split('\n');
4676 const lineIdx = (comment.lineNumber ?? 1) - 1;
4677 if (lineIdx < 0 || lineIdx >= lines.length) {
4678 return c.redirect(`${backUrl}&error=line_out_of_range`);
4679 }
4680 const suggestionLines = suggestionCode.split('\n');
4681 lines.splice(lineIdx, 1, ...suggestionLines);
4682 const newContent = lines.join('\n');
4683
4684 // Commit the change.
4685 const coAuthorLine = commenter
4686 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
4687 : "";
4688 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
4689
4690 const result = await createOrUpdateFileOnBranch({
4691 owner: ownerName,
4692 name: repoName,
4693 branch: pr.headBranch,
4694 filePath: comment.filePath,
4695 bytes: new TextEncoder().encode(newContent),
4696 message: commitMessage,
4697 authorName: user.username,
4698 authorEmail: `${user.username}@users.noreply.gluecron.com`,
4699 });
4700
4701 if ("error" in result) {
4702 return c.redirect(`${backUrl}&error=apply_failed`);
4703 }
4704
4705 // Post a follow-up comment noting the suggestion was applied.
4706 await db.insert(prComments).values({
4707 pullRequestId: pr.id,
4708 authorId: user.id,
4709 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
4710 });
4711
4712 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
4713 }
4714);
4715
0a67773Claude4716// Formal review — Approve / Request Changes / Comment
4717pulls.post(
4718 "/:owner/:repo/pulls/:number/review",
4719 softAuth,
4720 requireAuth,
4721 requireRepoAccess("read"),
4722 async (c) => {
4723 const { owner: ownerName, repo: repoName } = c.req.param();
4724 const prNum = parseInt(c.req.param("number"), 10);
4725 const user = c.get("user")!;
4726 const body = await c.req.parseBody();
4727 const reviewBody = String(body.body || "").trim();
4728 const reviewState = String(body.review_state || "commented");
4729
4730 const validStates = ["approved", "changes_requested", "commented"];
4731 if (!validStates.includes(reviewState)) {
4732 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4733 }
4734
4735 const resolved = await resolveRepo(ownerName, repoName);
4736 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4737
4738 const [pr] = await db
4739 .select()
4740 .from(pullRequests)
4741 .where(
4742 and(
4743 eq(pullRequests.repositoryId, resolved.repo.id),
4744 eq(pullRequests.number, prNum)
4745 )
4746 )
4747 .limit(1);
4748 if (!pr || pr.state !== "open") {
4749 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4750 }
4751 // Authors can't review their own PR
4752 if (pr.authorId === user.id) {
4753 return c.redirect(
4754 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
4755 );
4756 }
4757
4758 await db.insert(prReviews).values({
4759 pullRequestId: pr.id,
4760 reviewerId: user.id,
4761 state: reviewState,
4762 body: reviewBody || null,
4763 });
4764
4765 const stateLabel =
4766 reviewState === "approved" ? "Approved"
4767 : reviewState === "changes_requested" ? "Changes requested"
4768 : "Commented";
4769 return c.redirect(
4770 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
4771 );
4772 }
4773);
4774
e883329Claude4775// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude4776// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
4777// but we keep it at "write" for v1 so trusted collaborators can ship.
4778// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
4779// surface. Branch-protection rules (evaluated below) are the current mechanism
4780// for locking down merges further on specific branches.
0074234Claude4781pulls.post(
4782 "/:owner/:repo/pulls/:number/merge",
4783 softAuth,
4784 requireAuth,
04f6b7fClaude4785 requireRepoAccess("write"),
0074234Claude4786 async (c) => {
4787 const { owner: ownerName, repo: repoName } = c.req.param();
4788 const prNum = parseInt(c.req.param("number"), 10);
4789 const user = c.get("user")!;
4790
a164a6dClaude4791 // Read merge strategy from form (default: merge commit)
4792 let mergeStrategy = "merge";
4793 try {
4794 const body = await c.req.parseBody();
4795 const s = body.merge_strategy;
4796 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
4797 } catch { /* ignore parse errors — default to merge commit */ }
4798
0074234Claude4799 const resolved = await resolveRepo(ownerName, repoName);
4800 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4801
4802 const [pr] = await db
4803 .select()
4804 .from(pullRequests)
4805 .where(
4806 and(
4807 eq(pullRequests.repositoryId, resolved.repo.id),
4808 eq(pullRequests.number, prNum)
4809 )
4810 )
4811 .limit(1);
4812
4813 if (!pr || pr.state !== "open") {
4814 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4815 }
4816
6fc53bdClaude4817 // Draft PRs cannot be merged — must be marked ready first.
4818 if (pr.isDraft) {
4819 return c.redirect(
4820 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4821 "This PR is a draft. Mark it as ready for review before merging."
4822 )}`
4823 );
4824 }
4825
e883329Claude4826 // Resolve head SHA
4827 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4828 if (!headSha) {
4829 return c.redirect(
4830 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
4831 );
4832 }
4833
4834 // Check if AI review approved this PR
4835 const aiComments = await db
4836 .select()
4837 .from(prComments)
4838 .where(
4839 and(
4840 eq(prComments.pullRequestId, pr.id),
4841 eq(prComments.isAiReview, true)
4842 )
4843 );
4844 const aiApproved = aiComments.length === 0 || aiComments.some(
4845 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude4846 );
e883329Claude4847
4848 // Run all green gate checks (GateTest + mergeability + AI review)
4849 const gateResult = await runAllGateChecks(
4850 ownerName,
4851 repoName,
4852 pr.baseBranch,
4853 pr.headBranch,
4854 headSha,
4855 aiApproved
0074234Claude4856 );
4857
e883329Claude4858 // If GateTest or AI review failed (hard blocks), reject the merge
4859 const hardFailures = gateResult.checks.filter(
4860 (check) => !check.passed && check.name !== "Merge check"
4861 );
4862 if (hardFailures.length > 0) {
4863 const errorMsg = hardFailures
4864 .map((f) => `${f.name}: ${f.details}`)
4865 .join("; ");
0074234Claude4866 return c.redirect(
e883329Claude4867 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude4868 );
4869 }
4870
1e162a8Claude4871 // D5 — Branch-protection enforcement. Looks up the matching rule for the
4872 // base branch and blocks the merge if requireAiApproval / requireGreenGates
4873 // / requireHumanReview / requiredApprovals are not satisfied. Independent
4874 // of repo-global settings, so owners can lock specific branches down
4875 // further than the repo default.
4876 const protectionRule = await matchProtection(
4877 resolved.repo.id,
4878 pr.baseBranch
4879 );
4880 if (protectionRule) {
4881 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude4882 const required = await listRequiredChecks(protectionRule.id);
4883 const passingNames = required.length > 0
4884 ? await passingCheckNames(resolved.repo.id, headSha)
4885 : [];
4886 const decision = evaluateProtection(
4887 protectionRule,
4888 {
4889 aiApproved,
4890 humanApprovalCount: humanApprovals,
4891 gateResultGreen: hardFailures.length === 0,
4892 hasFailedGates: hardFailures.length > 0,
4893 passingCheckNames: passingNames,
4894 },
4895 required.map((r) => r.checkName)
4896 );
1e162a8Claude4897 if (!decision.allowed) {
4898 return c.redirect(
4899 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4900 decision.reasons.join(" ")
4901 )}`
4902 );
4903 }
4904 }
4905
e883329Claude4906 // Attempt the merge — with auto conflict resolution if needed
4907 const repoDir = getRepoPath(ownerName, repoName);
4908 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
4909 const hasConflicts = mergeCheck && !mergeCheck.passed;
4910
4911 if (hasConflicts && isAiReviewEnabled()) {
4912 // Use Claude to auto-resolve conflicts
4913 const mergeResult = await mergeWithAutoResolve(
4914 ownerName,
4915 repoName,
4916 pr.baseBranch,
4917 pr.headBranch,
4918 `Merge pull request #${pr.number}: ${pr.title}`
4919 );
4920
4921 if (!mergeResult.success) {
4922 return c.redirect(
4923 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
4924 );
4925 }
4926
4927 // Post a comment about the auto-resolution
4928 if (mergeResult.resolvedFiles.length > 0) {
4929 await db.insert(prComments).values({
4930 pullRequestId: pr.id,
4931 authorId: user.id,
4932 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
4933 isAiReview: true,
4934 });
4935 }
4936 } else {
a164a6dClaude4937 // Worktree-based merge: supports merge-commit, squash, and fast-forward
4938 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
4939 const gitEnv = {
4940 ...process.env,
4941 GIT_AUTHOR_NAME: user.displayName || user.username,
4942 GIT_AUTHOR_EMAIL: user.email,
4943 GIT_COMMITTER_NAME: user.displayName || user.username,
4944 GIT_COMMITTER_EMAIL: user.email,
4945 };
4946
4947 // Create linked worktree on the base branch
4948 const addWt = Bun.spawn(
4949 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude4950 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4951 );
a164a6dClaude4952 if (await addWt.exited !== 0) {
4953 return c.redirect(
4954 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
4955 );
4956 }
4957
4958 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
4959 let mergeOk = false;
4960
4961 try {
4962 if (mergeStrategy === "squash") {
4963 // Squash: stage all changes without committing
4964 const squashProc = Bun.spawn(
4965 ["git", "merge", "--squash", headSha],
4966 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4967 );
4968 if (await squashProc.exited !== 0) {
4969 const errTxt = await new Response(squashProc.stderr).text();
4970 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
4971 }
4972 // Commit the squashed changes
4973 const commitProc = Bun.spawn(
4974 ["git", "commit", "-m", commitMsg],
4975 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4976 );
4977 if (await commitProc.exited !== 0) {
4978 const errTxt = await new Response(commitProc.stderr).text();
4979 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
4980 }
4981 mergeOk = true;
4982 } else if (mergeStrategy === "ff") {
4983 // Fast-forward only — fail if FF is not possible
4984 const ffProc = Bun.spawn(
4985 ["git", "merge", "--ff-only", headSha],
4986 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4987 );
4988 if (await ffProc.exited !== 0) {
4989 const errTxt = await new Response(ffProc.stderr).text();
4990 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
4991 }
4992 mergeOk = true;
4993 } else {
4994 // Default: merge commit (--no-ff always creates a merge commit)
4995 const mergeProc = Bun.spawn(
4996 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
4997 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4998 );
4999 if (await mergeProc.exited !== 0) {
5000 const errTxt = await new Response(mergeProc.stderr).text();
5001 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
5002 }
5003 mergeOk = true;
5004 }
5005 } catch (err) {
5006 // Always clean up the worktree before redirecting
5007 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
5008 const msg = err instanceof Error ? err.message : "Merge failed";
5009 return c.redirect(
5010 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
5011 );
5012 }
5013
5014 // Clean up worktree (changes are now in the bare repo via linked worktree)
5015 await Bun.spawn(
5016 ["git", "worktree", "remove", "--force", wt],
5017 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5018 ).exited.catch(() => {});
e883329Claude5019
a164a6dClaude5020 if (!mergeOk) {
e883329Claude5021 return c.redirect(
a164a6dClaude5022 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude5023 );
5024 }
5025 }
5026
0074234Claude5027 await db
5028 .update(pullRequests)
5029 .set({
5030 state: "merged",
5031 mergedAt: new Date(),
5032 mergedBy: user.id,
5033 updatedAt: new Date(),
5034 })
5035 .where(eq(pullRequests.id, pr.id));
5036
8809b87Claude5037 // Chat notifier — fan out merge event to Slack/Discord/Teams.
5038 import("../lib/chat-notifier")
5039 .then((m) =>
5040 m.notifyChatChannels({
5041 ownerUserId: resolved.repo.ownerId,
5042 repositoryId: resolved.repo.id,
5043 event: {
5044 event: "pr.merged",
5045 repo: `${ownerName}/${repoName}`,
5046 title: `#${pr.number} ${pr.title}`,
5047 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
5048 actor: user.username,
5049 },
5050 })
5051 )
5052 .catch((err) =>
5053 console.warn(`[chat-notifier] PR merge notify failed:`, err)
5054 );
5055
d62fb36Claude5056 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
5057 // and auto-close each matching open issue with a back-link comment. Bounded
5058 // to the same repo for v1 (cross-repo refs ignored). Failures never block
5059 // the merge redirect.
5060 try {
5061 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
5062 const refs = extractClosingRefsMulti([pr.title, pr.body]);
5063 for (const n of refs) {
5064 const [issue] = await db
5065 .select()
5066 .from(issues)
5067 .where(
5068 and(
5069 eq(issues.repositoryId, resolved.repo.id),
5070 eq(issues.number, n)
5071 )
5072 )
5073 .limit(1);
5074 if (!issue || issue.state !== "open") continue;
5075 await db
5076 .update(issues)
5077 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
5078 .where(eq(issues.id, issue.id));
5079 await db.insert(issueComments).values({
5080 issueId: issue.id,
5081 authorId: user.id,
5082 body: `Closed by pull request #${pr.number}.`,
5083 });
5084 }
5085 } catch {
5086 // Never block the merge on close-keyword failures.
5087 }
5088
0074234Claude5089 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5090 }
5091);
5092
6fc53bdClaude5093// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
5094// hasn't run yet on this PR.
5095pulls.post(
5096 "/:owner/:repo/pulls/:number/ready",
5097 softAuth,
5098 requireAuth,
04f6b7fClaude5099 requireRepoAccess("write"),
6fc53bdClaude5100 async (c) => {
5101 const { owner: ownerName, repo: repoName } = c.req.param();
5102 const prNum = parseInt(c.req.param("number"), 10);
5103 const user = c.get("user")!;
5104
5105 const resolved = await resolveRepo(ownerName, repoName);
5106 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5107
5108 const [pr] = await db
5109 .select()
5110 .from(pullRequests)
5111 .where(
5112 and(
5113 eq(pullRequests.repositoryId, resolved.repo.id),
5114 eq(pullRequests.number, prNum)
5115 )
5116 )
5117 .limit(1);
5118 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5119
5120 // Only the author or repo owner can toggle draft state.
5121 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5122 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5123 }
5124
5125 if (pr.state === "open" && pr.isDraft) {
5126 await db
5127 .update(pullRequests)
5128 .set({ isDraft: false, updatedAt: new Date() })
5129 .where(eq(pullRequests.id, pr.id));
5130
5131 if (isAiReviewEnabled()) {
5132 triggerAiReview(
5133 ownerName,
5134 repoName,
5135 pr.id,
5136 pr.title,
0316dbbClaude5137 pr.body || "",
6fc53bdClaude5138 pr.baseBranch,
5139 pr.headBranch
5140 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
5141 }
5142 }
5143
5144 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5145 }
5146);
5147
5148// Convert a PR back to draft.
5149pulls.post(
5150 "/:owner/:repo/pulls/:number/draft",
5151 softAuth,
5152 requireAuth,
04f6b7fClaude5153 requireRepoAccess("write"),
6fc53bdClaude5154 async (c) => {
5155 const { owner: ownerName, repo: repoName } = c.req.param();
5156 const prNum = parseInt(c.req.param("number"), 10);
5157 const user = c.get("user")!;
5158
5159 const resolved = await resolveRepo(ownerName, repoName);
5160 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5161
5162 const [pr] = await db
5163 .select()
5164 .from(pullRequests)
5165 .where(
5166 and(
5167 eq(pullRequests.repositoryId, resolved.repo.id),
5168 eq(pullRequests.number, prNum)
5169 )
5170 )
5171 .limit(1);
5172 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5173
5174 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5175 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5176 }
5177
5178 if (pr.state === "open" && !pr.isDraft) {
5179 await db
5180 .update(pullRequests)
5181 .set({ isDraft: true, updatedAt: new Date() })
5182 .where(eq(pullRequests.id, pr.id));
5183 }
5184
5185 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5186 }
5187);
5188
0074234Claude5189// Close PR
5190pulls.post(
5191 "/:owner/:repo/pulls/:number/close",
5192 softAuth,
5193 requireAuth,
04f6b7fClaude5194 requireRepoAccess("write"),
0074234Claude5195 async (c) => {
5196 const { owner: ownerName, repo: repoName } = c.req.param();
5197 const prNum = parseInt(c.req.param("number"), 10);
5198
5199 const resolved = await resolveRepo(ownerName, repoName);
5200 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5201
5202 await db
5203 .update(pullRequests)
5204 .set({
5205 state: "closed",
5206 closedAt: new Date(),
5207 updatedAt: new Date(),
5208 })
5209 .where(
5210 and(
5211 eq(pullRequests.repositoryId, resolved.repo.id),
5212 eq(pullRequests.number, prNum)
5213 )
5214 );
5215
5216 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5217 }
5218);
5219
c3e0c07Claude5220// Re-run AI review on demand (e.g. after a force-push). Bypasses the
5221// idempotency marker via { force: true }. Write-access only.
5222pulls.post(
5223 "/:owner/:repo/pulls/:number/ai-rereview",
5224 softAuth,
5225 requireAuth,
5226 requireRepoAccess("write"),
5227 async (c) => {
5228 const { owner: ownerName, repo: repoName } = c.req.param();
5229 const prNum = parseInt(c.req.param("number"), 10);
5230 const resolved = await resolveRepo(ownerName, repoName);
5231 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5232
5233 const [pr] = await db
5234 .select()
5235 .from(pullRequests)
5236 .where(
5237 and(
5238 eq(pullRequests.repositoryId, resolved.repo.id),
5239 eq(pullRequests.number, prNum)
5240 )
5241 )
5242 .limit(1);
5243 if (!pr) {
5244 return c.redirect(`/${ownerName}/${repoName}/pulls`);
5245 }
5246
5247 if (!isAiReviewEnabled()) {
5248 return c.redirect(
5249 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5250 "AI review is not configured (ANTHROPIC_API_KEY)."
5251 )}`
5252 );
5253 }
5254
5255 // Fire-and-forget but with { force: true } to bypass the
5256 // already-reviewed marker. The function still never throws.
5257 triggerAiReview(
5258 ownerName,
5259 repoName,
5260 pr.id,
5261 pr.title || "",
5262 pr.body || "",
5263 pr.baseBranch,
5264 pr.headBranch,
5265 { force: true }
a28cedeClaude5266 ).catch((err) => {
5267 console.warn(
5268 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
5269 err instanceof Error ? err.message : err
5270 );
5271 });
c3e0c07Claude5272
5273 return c.redirect(
5274 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5275 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
5276 )}`
5277 );
5278 }
5279);
5280
1d4ff60Claude5281// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
5282// the PR's head branch carrying just the new test files. Write-access only.
5283// Idempotent — if `ai:added-tests` was previously applied we redirect with
5284// an `info` banner instead of re-firing.
5285pulls.post(
5286 "/:owner/:repo/pulls/:number/generate-tests",
5287 softAuth,
5288 requireAuth,
5289 requireRepoAccess("write"),
5290 async (c) => {
5291 const { owner: ownerName, repo: repoName } = c.req.param();
5292 const prNum = parseInt(c.req.param("number"), 10);
5293 const resolved = await resolveRepo(ownerName, repoName);
5294 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5295
5296 const [pr] = await db
5297 .select()
5298 .from(pullRequests)
5299 .where(
5300 and(
5301 eq(pullRequests.repositoryId, resolved.repo.id),
5302 eq(pullRequests.number, prNum)
5303 )
5304 )
5305 .limit(1);
5306 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5307
5308 if (!isAiReviewEnabled()) {
5309 return c.redirect(
5310 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5311 "AI test generation is not configured (ANTHROPIC_API_KEY)."
5312 )}`
5313 );
5314 }
5315
5316 // Fire-and-forget. The lib never throws.
5317 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
5318 .then((res) => {
5319 if (!res.ok) {
5320 console.warn(
5321 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
5322 );
5323 }
5324 })
5325 .catch((err) => {
5326 console.warn(
5327 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
5328 err instanceof Error ? err.message : err
5329 );
5330 });
5331
5332 return c.redirect(
5333 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5334 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
5335 )}`
5336 );
5337 }
5338);
5339
ace34efClaude5340// ─── Request review ───────────────────────────────────────────────────────────
5341pulls.post(
5342 "/:owner/:repo/pulls/:number/request-review",
5343 softAuth,
5344 requireAuth,
5345 requireRepoAccess("write"),
5346 async (c) => {
5347 const { owner: ownerName, repo: repoName } = c.req.param();
5348 const prNum = parseInt(c.req.param("number"), 10);
5349 const user = c.get("user")!;
5350
5351 const resolved = await resolveRepo(ownerName, repoName);
5352 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5353
5354 const [pr] = await db
5355 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
5356 .from(pullRequests)
5357 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5358 .limit(1);
5359
5360 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5361
5362 const body = await c.req.formData().catch(() => null);
5363 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
5364
5365 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
5366 return c.redirect(
5367 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
5368 );
5369 }
5370
f4abb8eClaude5371 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
5372 // requesting reviews from arbitrary user IDs outside this repository.
5373 const isOwner = reviewerId === resolved.owner.id;
5374 if (!isOwner) {
5375 const [collab] = await db
5376 .select({ id: repoCollaborators.id })
5377 .from(repoCollaborators)
5378 .where(
5379 and(
5380 eq(repoCollaborators.repositoryId, resolved.repo.id),
5381 eq(repoCollaborators.userId, reviewerId),
5382 isNotNull(repoCollaborators.acceptedAt)
5383 )
5384 )
5385 .limit(1);
5386 if (!collab) {
5387 return c.redirect(
5388 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
5389 );
5390 }
5391 }
5392
ace34efClaude5393 const { requestReview } = await import("../lib/reviewer-suggest");
5394 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
5395
5396 const msg = result.ok
5397 ? "Review requested successfully."
5398 : `Failed to request review: ${result.error ?? "unknown error"}`;
5399
5400 return c.redirect(
5401 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
5402 );
5403 }
5404);
5405
0074234Claude5406export default pulls;