Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

pulls.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

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