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.tsxBlame5431 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 }
b7ecb14Claude4548 // Notify the PR author — fire-and-forget, never blocks the response.
4549 if (pr.authorId && pr.authorId !== user.id) {
4550 void import("../lib/notify").then(({ createNotification }) =>
4551 createNotification({
4552 userId: pr.authorId,
4553 type: "pr_comment",
4554 title: `New comment on "${pr.title}"`,
4555 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
4556 url: `/${ownerName}/${repoName}/pulls/${prNum}`,
4557 repoId: resolved.repo.id,
4558 })
4559 ).catch(() => { /* never block the response */ });
4560 }
d4ac5c3Claude4561 }
0074234Claude4562
cb5a796Claude4563 if (decision.status === "pending") {
4564 void notifyOwnerOfPendingComment({
4565 repositoryId: resolved.repo.id,
4566 commenterUsername: user.username,
4567 kind: "pr",
4568 threadNumber: prNum,
4569 ownerUsername: ownerName,
4570 repoName,
4571 });
4572 return c.redirect(
4573 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4574 );
4575 }
4576 if (decision.status === "rejected") {
4577 // Silent ban path — same UX as 'pending' so we don't leak the gate.
4578 return c.redirect(
4579 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4580 );
4581 }
4582
15db0e0Claude4583 // Slash-command handoff. We always store the original comment above
4584 // first so free-form text that happens to start with `/` is preserved
4585 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude4586 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude4587 const parsed = parseSlashCommand(commentBody);
4588 if (parsed) {
4589 try {
4590 const result = await executeSlashCommand({
4591 command: parsed.command,
4592 args: parsed.args,
4593 prId: pr.id,
4594 userId: user.id,
4595 repositoryId: resolved.repo.id,
4596 });
4597 await db.insert(prComments).values({
4598 pullRequestId: pr.id,
4599 authorId: user.id,
4600 body: result.body,
4601 });
4602 } catch (err) {
4603 // Defence-in-depth — executeSlashCommand promises not to throw,
4604 // but if it ever does we want the PR thread to know.
4605 await db
4606 .insert(prComments)
4607 .values({
4608 pullRequestId: pr.id,
4609 authorId: user.id,
4610 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
4611 })
4612 .catch(() => {});
4613 }
4614 }
4615
47a7a0aClaude4616 // Inline comments go back to the files tab; conversation comments to the conversation tab
4617 const redirectTab = inlineFilePath ? "?tab=files" : "";
4618 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude4619 }
4620);
4621
b5dd694Claude4622// Apply a suggestion from a PR comment — commits the suggested code to the
4623// head branch on behalf of the logged-in user.
4624pulls.post(
4625 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
4626 softAuth,
4627 requireAuth,
4628 requireRepoAccess("read"),
4629 async (c) => {
4630 const { owner: ownerName, repo: repoName } = c.req.param();
4631 const prNum = parseInt(c.req.param("number"), 10);
4632 const commentId = c.req.param("commentId"); // UUID
4633 const user = c.get("user")!;
4634
4635 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
4636
4637 const resolved = await resolveRepo(ownerName, repoName);
4638 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4639
4640 const [pr] = await db
4641 .select()
4642 .from(pullRequests)
4643 .where(
4644 and(
4645 eq(pullRequests.repositoryId, resolved.repo.id),
4646 eq(pullRequests.number, prNum)
4647 )
4648 )
4649 .limit(1);
4650
4651 if (!pr || pr.state !== "open") {
4652 return c.redirect(`${backUrl}&error=pr_not_open`);
4653 }
4654
4655 // Only PR author or repo owner may apply suggestions.
4656 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
4657 return c.redirect(`${backUrl}&error=forbidden`);
4658 }
4659
4660 // Load the comment.
4661 const [comment] = await db
4662 .select()
4663 .from(prComments)
4664 .where(
4665 and(
4666 eq(prComments.id, commentId),
4667 eq(prComments.pullRequestId, pr.id)
4668 )
4669 )
4670 .limit(1);
4671
4672 if (!comment) {
4673 return c.redirect(`${backUrl}&error=comment_not_found`);
4674 }
4675
4676 // Parse suggestion block from comment body.
4677 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
4678 if (!m) {
4679 return c.redirect(`${backUrl}&error=no_suggestion`);
4680 }
4681 const suggestionCode = m[1];
4682
4683 // Get the commenter's details for the commit message co-author line.
4684 const [commenter] = await db
4685 .select()
4686 .from(users)
4687 .where(eq(users.id, comment.authorId))
4688 .limit(1);
4689
4690 // Fetch current file content from head branch.
4691 if (!comment.filePath) {
4692 return c.redirect(`${backUrl}&error=file_not_found`);
4693 }
4694 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
4695 if (!blob) {
4696 return c.redirect(`${backUrl}&error=file_not_found`);
4697 }
4698
4699 // Apply the patch — replace the target line(s) with suggestion lines.
4700 const lines = blob.content.split('\n');
4701 const lineIdx = (comment.lineNumber ?? 1) - 1;
4702 if (lineIdx < 0 || lineIdx >= lines.length) {
4703 return c.redirect(`${backUrl}&error=line_out_of_range`);
4704 }
4705 const suggestionLines = suggestionCode.split('\n');
4706 lines.splice(lineIdx, 1, ...suggestionLines);
4707 const newContent = lines.join('\n');
4708
4709 // Commit the change.
4710 const coAuthorLine = commenter
4711 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
4712 : "";
4713 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
4714
4715 const result = await createOrUpdateFileOnBranch({
4716 owner: ownerName,
4717 name: repoName,
4718 branch: pr.headBranch,
4719 filePath: comment.filePath,
4720 bytes: new TextEncoder().encode(newContent),
4721 message: commitMessage,
4722 authorName: user.username,
4723 authorEmail: `${user.username}@users.noreply.gluecron.com`,
4724 });
4725
4726 if ("error" in result) {
4727 return c.redirect(`${backUrl}&error=apply_failed`);
4728 }
4729
4730 // Post a follow-up comment noting the suggestion was applied.
4731 await db.insert(prComments).values({
4732 pullRequestId: pr.id,
4733 authorId: user.id,
4734 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
4735 });
4736
4737 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
4738 }
4739);
4740
0a67773Claude4741// Formal review — Approve / Request Changes / Comment
4742pulls.post(
4743 "/:owner/:repo/pulls/:number/review",
4744 softAuth,
4745 requireAuth,
4746 requireRepoAccess("read"),
4747 async (c) => {
4748 const { owner: ownerName, repo: repoName } = c.req.param();
4749 const prNum = parseInt(c.req.param("number"), 10);
4750 const user = c.get("user")!;
4751 const body = await c.req.parseBody();
4752 const reviewBody = String(body.body || "").trim();
4753 const reviewState = String(body.review_state || "commented");
4754
4755 const validStates = ["approved", "changes_requested", "commented"];
4756 if (!validStates.includes(reviewState)) {
4757 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4758 }
4759
4760 const resolved = await resolveRepo(ownerName, repoName);
4761 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4762
4763 const [pr] = await db
4764 .select()
4765 .from(pullRequests)
4766 .where(
4767 and(
4768 eq(pullRequests.repositoryId, resolved.repo.id),
4769 eq(pullRequests.number, prNum)
4770 )
4771 )
4772 .limit(1);
4773 if (!pr || pr.state !== "open") {
4774 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4775 }
4776 // Authors can't review their own PR
4777 if (pr.authorId === user.id) {
4778 return c.redirect(
4779 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
4780 );
4781 }
4782
4783 await db.insert(prReviews).values({
4784 pullRequestId: pr.id,
4785 reviewerId: user.id,
4786 state: reviewState,
4787 body: reviewBody || null,
4788 });
4789
4790 const stateLabel =
4791 reviewState === "approved" ? "Approved"
4792 : reviewState === "changes_requested" ? "Changes requested"
4793 : "Commented";
4794 return c.redirect(
4795 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
4796 );
4797 }
4798);
4799
e883329Claude4800// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude4801// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
4802// but we keep it at "write" for v1 so trusted collaborators can ship.
4803// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
4804// surface. Branch-protection rules (evaluated below) are the current mechanism
4805// for locking down merges further on specific branches.
0074234Claude4806pulls.post(
4807 "/:owner/:repo/pulls/:number/merge",
4808 softAuth,
4809 requireAuth,
04f6b7fClaude4810 requireRepoAccess("write"),
0074234Claude4811 async (c) => {
4812 const { owner: ownerName, repo: repoName } = c.req.param();
4813 const prNum = parseInt(c.req.param("number"), 10);
4814 const user = c.get("user")!;
4815
a164a6dClaude4816 // Read merge strategy from form (default: merge commit)
4817 let mergeStrategy = "merge";
4818 try {
4819 const body = await c.req.parseBody();
4820 const s = body.merge_strategy;
4821 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
4822 } catch { /* ignore parse errors — default to merge commit */ }
4823
0074234Claude4824 const resolved = await resolveRepo(ownerName, repoName);
4825 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4826
4827 const [pr] = await db
4828 .select()
4829 .from(pullRequests)
4830 .where(
4831 and(
4832 eq(pullRequests.repositoryId, resolved.repo.id),
4833 eq(pullRequests.number, prNum)
4834 )
4835 )
4836 .limit(1);
4837
4838 if (!pr || pr.state !== "open") {
4839 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4840 }
4841
6fc53bdClaude4842 // Draft PRs cannot be merged — must be marked ready first.
4843 if (pr.isDraft) {
4844 return c.redirect(
4845 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4846 "This PR is a draft. Mark it as ready for review before merging."
4847 )}`
4848 );
4849 }
4850
e883329Claude4851 // Resolve head SHA
4852 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4853 if (!headSha) {
4854 return c.redirect(
4855 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
4856 );
4857 }
4858
4859 // Check if AI review approved this PR
4860 const aiComments = await db
4861 .select()
4862 .from(prComments)
4863 .where(
4864 and(
4865 eq(prComments.pullRequestId, pr.id),
4866 eq(prComments.isAiReview, true)
4867 )
4868 );
4869 const aiApproved = aiComments.length === 0 || aiComments.some(
4870 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude4871 );
e883329Claude4872
4873 // Run all green gate checks (GateTest + mergeability + AI review)
4874 const gateResult = await runAllGateChecks(
4875 ownerName,
4876 repoName,
4877 pr.baseBranch,
4878 pr.headBranch,
4879 headSha,
4880 aiApproved
0074234Claude4881 );
4882
e883329Claude4883 // If GateTest or AI review failed (hard blocks), reject the merge
4884 const hardFailures = gateResult.checks.filter(
4885 (check) => !check.passed && check.name !== "Merge check"
4886 );
4887 if (hardFailures.length > 0) {
4888 const errorMsg = hardFailures
4889 .map((f) => `${f.name}: ${f.details}`)
4890 .join("; ");
0074234Claude4891 return c.redirect(
e883329Claude4892 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude4893 );
4894 }
4895
1e162a8Claude4896 // D5 — Branch-protection enforcement. Looks up the matching rule for the
4897 // base branch and blocks the merge if requireAiApproval / requireGreenGates
4898 // / requireHumanReview / requiredApprovals are not satisfied. Independent
4899 // of repo-global settings, so owners can lock specific branches down
4900 // further than the repo default.
4901 const protectionRule = await matchProtection(
4902 resolved.repo.id,
4903 pr.baseBranch
4904 );
4905 if (protectionRule) {
4906 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude4907 const required = await listRequiredChecks(protectionRule.id);
4908 const passingNames = required.length > 0
4909 ? await passingCheckNames(resolved.repo.id, headSha)
4910 : [];
4911 const decision = evaluateProtection(
4912 protectionRule,
4913 {
4914 aiApproved,
4915 humanApprovalCount: humanApprovals,
4916 gateResultGreen: hardFailures.length === 0,
4917 hasFailedGates: hardFailures.length > 0,
4918 passingCheckNames: passingNames,
4919 },
4920 required.map((r) => r.checkName)
4921 );
1e162a8Claude4922 if (!decision.allowed) {
4923 return c.redirect(
4924 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4925 decision.reasons.join(" ")
4926 )}`
4927 );
4928 }
4929 }
4930
e883329Claude4931 // Attempt the merge — with auto conflict resolution if needed
4932 const repoDir = getRepoPath(ownerName, repoName);
4933 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
4934 const hasConflicts = mergeCheck && !mergeCheck.passed;
4935
4936 if (hasConflicts && isAiReviewEnabled()) {
4937 // Use Claude to auto-resolve conflicts
4938 const mergeResult = await mergeWithAutoResolve(
4939 ownerName,
4940 repoName,
4941 pr.baseBranch,
4942 pr.headBranch,
4943 `Merge pull request #${pr.number}: ${pr.title}`
4944 );
4945
4946 if (!mergeResult.success) {
4947 return c.redirect(
4948 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
4949 );
4950 }
4951
4952 // Post a comment about the auto-resolution
4953 if (mergeResult.resolvedFiles.length > 0) {
4954 await db.insert(prComments).values({
4955 pullRequestId: pr.id,
4956 authorId: user.id,
4957 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
4958 isAiReview: true,
4959 });
4960 }
4961 } else {
a164a6dClaude4962 // Worktree-based merge: supports merge-commit, squash, and fast-forward
4963 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
4964 const gitEnv = {
4965 ...process.env,
4966 GIT_AUTHOR_NAME: user.displayName || user.username,
4967 GIT_AUTHOR_EMAIL: user.email,
4968 GIT_COMMITTER_NAME: user.displayName || user.username,
4969 GIT_COMMITTER_EMAIL: user.email,
4970 };
4971
4972 // Create linked worktree on the base branch
4973 const addWt = Bun.spawn(
4974 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude4975 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4976 );
a164a6dClaude4977 if (await addWt.exited !== 0) {
4978 return c.redirect(
4979 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
4980 );
4981 }
4982
4983 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
4984 let mergeOk = false;
4985
4986 try {
4987 if (mergeStrategy === "squash") {
4988 // Squash: stage all changes without committing
4989 const squashProc = Bun.spawn(
4990 ["git", "merge", "--squash", headSha],
4991 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4992 );
4993 if (await squashProc.exited !== 0) {
4994 const errTxt = await new Response(squashProc.stderr).text();
4995 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
4996 }
4997 // Commit the squashed changes
4998 const commitProc = Bun.spawn(
4999 ["git", "commit", "-m", commitMsg],
5000 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5001 );
5002 if (await commitProc.exited !== 0) {
5003 const errTxt = await new Response(commitProc.stderr).text();
5004 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
5005 }
5006 mergeOk = true;
5007 } else if (mergeStrategy === "ff") {
5008 // Fast-forward only — fail if FF is not possible
5009 const ffProc = Bun.spawn(
5010 ["git", "merge", "--ff-only", headSha],
5011 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5012 );
5013 if (await ffProc.exited !== 0) {
5014 const errTxt = await new Response(ffProc.stderr).text();
5015 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
5016 }
5017 mergeOk = true;
5018 } else {
5019 // Default: merge commit (--no-ff always creates a merge commit)
5020 const mergeProc = Bun.spawn(
5021 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
5022 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5023 );
5024 if (await mergeProc.exited !== 0) {
5025 const errTxt = await new Response(mergeProc.stderr).text();
5026 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
5027 }
5028 mergeOk = true;
5029 }
5030 } catch (err) {
5031 // Always clean up the worktree before redirecting
5032 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
5033 const msg = err instanceof Error ? err.message : "Merge failed";
5034 return c.redirect(
5035 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
5036 );
5037 }
5038
5039 // Clean up worktree (changes are now in the bare repo via linked worktree)
5040 await Bun.spawn(
5041 ["git", "worktree", "remove", "--force", wt],
5042 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5043 ).exited.catch(() => {});
e883329Claude5044
a164a6dClaude5045 if (!mergeOk) {
e883329Claude5046 return c.redirect(
a164a6dClaude5047 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude5048 );
5049 }
5050 }
5051
0074234Claude5052 await db
5053 .update(pullRequests)
5054 .set({
5055 state: "merged",
5056 mergedAt: new Date(),
5057 mergedBy: user.id,
5058 updatedAt: new Date(),
5059 })
5060 .where(eq(pullRequests.id, pr.id));
5061
8809b87Claude5062 // Chat notifier — fan out merge event to Slack/Discord/Teams.
5063 import("../lib/chat-notifier")
5064 .then((m) =>
5065 m.notifyChatChannels({
5066 ownerUserId: resolved.repo.ownerId,
5067 repositoryId: resolved.repo.id,
5068 event: {
5069 event: "pr.merged",
5070 repo: `${ownerName}/${repoName}`,
5071 title: `#${pr.number} ${pr.title}`,
5072 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
5073 actor: user.username,
5074 },
5075 })
5076 )
5077 .catch((err) =>
5078 console.warn(`[chat-notifier] PR merge notify failed:`, err)
5079 );
5080
d62fb36Claude5081 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
5082 // and auto-close each matching open issue with a back-link comment. Bounded
5083 // to the same repo for v1 (cross-repo refs ignored). Failures never block
5084 // the merge redirect.
5085 try {
5086 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
5087 const refs = extractClosingRefsMulti([pr.title, pr.body]);
5088 for (const n of refs) {
5089 const [issue] = await db
5090 .select()
5091 .from(issues)
5092 .where(
5093 and(
5094 eq(issues.repositoryId, resolved.repo.id),
5095 eq(issues.number, n)
5096 )
5097 )
5098 .limit(1);
5099 if (!issue || issue.state !== "open") continue;
5100 await db
5101 .update(issues)
5102 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
5103 .where(eq(issues.id, issue.id));
5104 await db.insert(issueComments).values({
5105 issueId: issue.id,
5106 authorId: user.id,
5107 body: `Closed by pull request #${pr.number}.`,
5108 });
5109 }
5110 } catch {
5111 // Never block the merge on close-keyword failures.
5112 }
5113
0074234Claude5114 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5115 }
5116);
5117
6fc53bdClaude5118// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
5119// hasn't run yet on this PR.
5120pulls.post(
5121 "/:owner/:repo/pulls/:number/ready",
5122 softAuth,
5123 requireAuth,
04f6b7fClaude5124 requireRepoAccess("write"),
6fc53bdClaude5125 async (c) => {
5126 const { owner: ownerName, repo: repoName } = c.req.param();
5127 const prNum = parseInt(c.req.param("number"), 10);
5128 const user = c.get("user")!;
5129
5130 const resolved = await resolveRepo(ownerName, repoName);
5131 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5132
5133 const [pr] = await db
5134 .select()
5135 .from(pullRequests)
5136 .where(
5137 and(
5138 eq(pullRequests.repositoryId, resolved.repo.id),
5139 eq(pullRequests.number, prNum)
5140 )
5141 )
5142 .limit(1);
5143 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5144
5145 // Only the author or repo owner can toggle draft state.
5146 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5147 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5148 }
5149
5150 if (pr.state === "open" && pr.isDraft) {
5151 await db
5152 .update(pullRequests)
5153 .set({ isDraft: false, updatedAt: new Date() })
5154 .where(eq(pullRequests.id, pr.id));
5155
5156 if (isAiReviewEnabled()) {
5157 triggerAiReview(
5158 ownerName,
5159 repoName,
5160 pr.id,
5161 pr.title,
0316dbbClaude5162 pr.body || "",
6fc53bdClaude5163 pr.baseBranch,
5164 pr.headBranch
5165 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
5166 }
5167 }
5168
5169 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5170 }
5171);
5172
5173// Convert a PR back to draft.
5174pulls.post(
5175 "/:owner/:repo/pulls/:number/draft",
5176 softAuth,
5177 requireAuth,
04f6b7fClaude5178 requireRepoAccess("write"),
6fc53bdClaude5179 async (c) => {
5180 const { owner: ownerName, repo: repoName } = c.req.param();
5181 const prNum = parseInt(c.req.param("number"), 10);
5182 const user = c.get("user")!;
5183
5184 const resolved = await resolveRepo(ownerName, repoName);
5185 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5186
5187 const [pr] = await db
5188 .select()
5189 .from(pullRequests)
5190 .where(
5191 and(
5192 eq(pullRequests.repositoryId, resolved.repo.id),
5193 eq(pullRequests.number, prNum)
5194 )
5195 )
5196 .limit(1);
5197 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5198
5199 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5200 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5201 }
5202
5203 if (pr.state === "open" && !pr.isDraft) {
5204 await db
5205 .update(pullRequests)
5206 .set({ isDraft: true, updatedAt: new Date() })
5207 .where(eq(pullRequests.id, pr.id));
5208 }
5209
5210 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5211 }
5212);
5213
0074234Claude5214// Close PR
5215pulls.post(
5216 "/:owner/:repo/pulls/:number/close",
5217 softAuth,
5218 requireAuth,
04f6b7fClaude5219 requireRepoAccess("write"),
0074234Claude5220 async (c) => {
5221 const { owner: ownerName, repo: repoName } = c.req.param();
5222 const prNum = parseInt(c.req.param("number"), 10);
5223
5224 const resolved = await resolveRepo(ownerName, repoName);
5225 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5226
5227 await db
5228 .update(pullRequests)
5229 .set({
5230 state: "closed",
5231 closedAt: new Date(),
5232 updatedAt: new Date(),
5233 })
5234 .where(
5235 and(
5236 eq(pullRequests.repositoryId, resolved.repo.id),
5237 eq(pullRequests.number, prNum)
5238 )
5239 );
5240
5241 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5242 }
5243);
5244
c3e0c07Claude5245// Re-run AI review on demand (e.g. after a force-push). Bypasses the
5246// idempotency marker via { force: true }. Write-access only.
5247pulls.post(
5248 "/:owner/:repo/pulls/:number/ai-rereview",
5249 softAuth,
5250 requireAuth,
5251 requireRepoAccess("write"),
5252 async (c) => {
5253 const { owner: ownerName, repo: repoName } = c.req.param();
5254 const prNum = parseInt(c.req.param("number"), 10);
5255 const resolved = await resolveRepo(ownerName, repoName);
5256 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5257
5258 const [pr] = await db
5259 .select()
5260 .from(pullRequests)
5261 .where(
5262 and(
5263 eq(pullRequests.repositoryId, resolved.repo.id),
5264 eq(pullRequests.number, prNum)
5265 )
5266 )
5267 .limit(1);
5268 if (!pr) {
5269 return c.redirect(`/${ownerName}/${repoName}/pulls`);
5270 }
5271
5272 if (!isAiReviewEnabled()) {
5273 return c.redirect(
5274 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5275 "AI review is not configured (ANTHROPIC_API_KEY)."
5276 )}`
5277 );
5278 }
5279
5280 // Fire-and-forget but with { force: true } to bypass the
5281 // already-reviewed marker. The function still never throws.
5282 triggerAiReview(
5283 ownerName,
5284 repoName,
5285 pr.id,
5286 pr.title || "",
5287 pr.body || "",
5288 pr.baseBranch,
5289 pr.headBranch,
5290 { force: true }
a28cedeClaude5291 ).catch((err) => {
5292 console.warn(
5293 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
5294 err instanceof Error ? err.message : err
5295 );
5296 });
c3e0c07Claude5297
5298 return c.redirect(
5299 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5300 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
5301 )}`
5302 );
5303 }
5304);
5305
1d4ff60Claude5306// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
5307// the PR's head branch carrying just the new test files. Write-access only.
5308// Idempotent — if `ai:added-tests` was previously applied we redirect with
5309// an `info` banner instead of re-firing.
5310pulls.post(
5311 "/:owner/:repo/pulls/:number/generate-tests",
5312 softAuth,
5313 requireAuth,
5314 requireRepoAccess("write"),
5315 async (c) => {
5316 const { owner: ownerName, repo: repoName } = c.req.param();
5317 const prNum = parseInt(c.req.param("number"), 10);
5318 const resolved = await resolveRepo(ownerName, repoName);
5319 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5320
5321 const [pr] = await db
5322 .select()
5323 .from(pullRequests)
5324 .where(
5325 and(
5326 eq(pullRequests.repositoryId, resolved.repo.id),
5327 eq(pullRequests.number, prNum)
5328 )
5329 )
5330 .limit(1);
5331 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5332
5333 if (!isAiReviewEnabled()) {
5334 return c.redirect(
5335 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5336 "AI test generation is not configured (ANTHROPIC_API_KEY)."
5337 )}`
5338 );
5339 }
5340
5341 // Fire-and-forget. The lib never throws.
5342 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
5343 .then((res) => {
5344 if (!res.ok) {
5345 console.warn(
5346 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
5347 );
5348 }
5349 })
5350 .catch((err) => {
5351 console.warn(
5352 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
5353 err instanceof Error ? err.message : err
5354 );
5355 });
5356
5357 return c.redirect(
5358 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5359 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
5360 )}`
5361 );
5362 }
5363);
5364
ace34efClaude5365// ─── Request review ───────────────────────────────────────────────────────────
5366pulls.post(
5367 "/:owner/:repo/pulls/:number/request-review",
5368 softAuth,
5369 requireAuth,
5370 requireRepoAccess("write"),
5371 async (c) => {
5372 const { owner: ownerName, repo: repoName } = c.req.param();
5373 const prNum = parseInt(c.req.param("number"), 10);
5374 const user = c.get("user")!;
5375
5376 const resolved = await resolveRepo(ownerName, repoName);
5377 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5378
5379 const [pr] = await db
5380 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
5381 .from(pullRequests)
5382 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5383 .limit(1);
5384
5385 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5386
5387 const body = await c.req.formData().catch(() => null);
5388 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
5389
5390 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
5391 return c.redirect(
5392 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
5393 );
5394 }
5395
f4abb8eClaude5396 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
5397 // requesting reviews from arbitrary user IDs outside this repository.
5398 const isOwner = reviewerId === resolved.owner.id;
5399 if (!isOwner) {
5400 const [collab] = await db
5401 .select({ id: repoCollaborators.id })
5402 .from(repoCollaborators)
5403 .where(
5404 and(
5405 eq(repoCollaborators.repositoryId, resolved.repo.id),
5406 eq(repoCollaborators.userId, reviewerId),
5407 isNotNull(repoCollaborators.acceptedAt)
5408 )
5409 )
5410 .limit(1);
5411 if (!collab) {
5412 return c.redirect(
5413 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
5414 );
5415 }
5416 }
5417
ace34efClaude5418 const { requestReview } = await import("../lib/reviewer-suggest");
5419 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
5420
5421 const msg = result.ok
5422 ? "Review requested successfully."
5423 : `Failed to request review: ${result.error ?? "unknown error"}`;
5424
5425 return c.redirect(
5426 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
5427 );
5428 }
5429);
5430
0074234Claude5431export default pulls;