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