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.tsxBlame5513 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 )}
9c5223fClaude4343 {/* Keyboard hint bar — shown at the bottom of PR pages */}
4344 <div class="kbd-hints" aria-label="Keyboard shortcuts for this pull request">
4345 <kbd>c</kbd> comment &middot; <kbd>e</kbd> edit title &middot; <kbd>m</kbd> merge &middot; <kbd>a</kbd> approve &middot; <kbd>r</kbd> request changes &middot; <kbd>?</kbd> shortcuts
4346 </div>
4347 <style dangerouslySetInnerHTML={{ __html: `
4348 .kbd-hints {
4349 position: fixed;
4350 bottom: 0;
4351 left: 0;
4352 right: 0;
4353 z-index: 90;
4354 padding: 6px 24px;
4355 background: var(--bg-secondary);
4356 border-top: 1px solid var(--border);
4357 font-size: 12px;
4358 color: var(--text-muted);
4359 display: flex;
4360 align-items: center;
4361 gap: 8px;
4362 flex-wrap: wrap;
4363 }
4364 .kbd-hints kbd {
4365 font-family: var(--font-mono);
4366 font-size: 10px;
4367 background: var(--bg-elevated);
4368 border: 1px solid var(--border);
4369 border-bottom-width: 2px;
4370 border-radius: 4px;
4371 padding: 1px 5px;
4372 color: var(--text);
4373 line-height: 1.5;
4374 }
4375 /* Padding so the page footer doesn't overlap the hint bar */
4376 main { padding-bottom: 40px; }
4377 ` }} />
4378 {/* Repo context commands for command palette */}
4379 <script
4380 id="cmdk-repo-context"
4381 dangerouslySetInnerHTML={{
4382 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
4383 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
4384 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
4385 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
4386 { label: `View commits — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/commits`, kw: "history log" },
4387 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
4388 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
4389 ])};`,
4390 }}
4391 />
4392 {/* PR keyboard shortcuts script */}
4393 <script dangerouslySetInnerHTML={{ __html: `
4394 (function(){
4395 var commentBox = document.querySelector('textarea[name="body"]');
4396 var mergeBtn = document.querySelector('[data-merge-btn], .prs-merge-btn, button[form*="merge"], form[action*="/merge"] button[type="submit"]');
4397 var editBtn = document.getElementById('pr-edit-toggle');
4398 var approveUrl = ${JSON.stringify(`/${ownerName}/${repoName}/pulls/${pr.number}/review`)};
4399
4400 function isTyping(t){
4401 t = t || {};
4402 var tag = (t.tagName || '').toLowerCase();
4403 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
4404 }
4405
4406 document.addEventListener('keydown', function(e){
4407 if (isTyping(e.target)) return;
4408 if (e.metaKey || e.ctrlKey || e.altKey) return;
4409 if (e.key === 'c') {
4410 e.preventDefault();
4411 if (commentBox) { commentBox.focus(); commentBox.scrollIntoView({block:'center'}); }
4412 }
4413 if (e.key === 'e') {
4414 e.preventDefault();
4415 if (editBtn) { editBtn.click(); }
4416 }
4417 if (e.key === 'm') {
4418 e.preventDefault();
4419 var mBtn = document.querySelector('.prs-merge-btn, form[action*="/merge"] button[type="submit"]');
4420 if (mBtn) { mBtn.focus(); mBtn.scrollIntoView({block:'center'}); }
4421 }
4422 if (e.key === 'a') {
4423 e.preventDefault();
4424 // Navigate to approve review page
4425 window.location.href = approveUrl + '?action=approve';
4426 }
4427 if (e.key === 'r') {
4428 e.preventDefault();
4429 window.location.href = approveUrl + '?action=request_changes';
4430 }
4431 if (e.key === 'Escape') {
4432 var focused = document.activeElement;
4433 if (focused) focused.blur();
4434 }
4435 });
4436 })();
4437 ` }} />
0074234Claude4438 </Layout>
4439 );
4440});
4441
6d1bbc2Claude4442// Update branch — merge base into head so the PR branch is up to date.
4443// Uses a git worktree so the bare repo stays clean. Write access required.
4444pulls.post(
4445 "/:owner/:repo/pulls/:number/update-branch",
4446 softAuth,
4447 requireAuth,
4448 requireRepoAccess("write"),
4449 async (c) => {
4450 const { owner: ownerName, repo: repoName } = c.req.param();
4451 const prNum = parseInt(c.req.param("number"), 10);
4452 const user = c.get("user")!;
4453 const resolved = await resolveRepo(ownerName, repoName);
4454 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4455
4456 const [pr] = await db
4457 .select()
4458 .from(pullRequests)
4459 .where(and(
4460 eq(pullRequests.repositoryId, resolved.repo.id),
4461 eq(pullRequests.number, prNum),
4462 ))
4463 .limit(1);
4464 if (!pr || pr.state !== "open") {
4465 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4466 }
4467
4468 const repoDir = getRepoPath(ownerName, repoName);
4469 const wt = `${repoDir}/_update_wt_${Date.now()}`;
4470 const gitEnv = {
4471 ...process.env,
4472 GIT_AUTHOR_NAME: user.displayName || user.username,
4473 GIT_AUTHOR_EMAIL: user.email,
4474 GIT_COMMITTER_NAME: user.displayName || user.username,
4475 GIT_COMMITTER_EMAIL: user.email,
4476 };
4477
4478 const addWt = Bun.spawn(
4479 ["git", "worktree", "add", wt, pr.headBranch],
4480 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4481 );
4482 if (await addWt.exited !== 0) {
4483 return c.redirect(
4484 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
4485 );
4486 }
4487
4488 let ok = false;
4489 try {
4490 const mergeProc = Bun.spawn(
4491 ["git", "merge", "--no-edit", pr.baseBranch],
4492 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
4493 );
4494 if (await mergeProc.exited === 0) {
4495 ok = true;
4496 } else {
4497 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4498 }
4499 } catch {
4500 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4501 }
4502
4503 await Bun.spawn(
4504 ["git", "worktree", "remove", "--force", wt],
4505 { cwd: repoDir }
4506 ).exited.catch(() => {});
4507
4508 if (ok) {
4509 return c.redirect(
4510 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
4511 );
4512 }
4513 return c.redirect(
4514 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
4515 );
4516 }
4517);
4518
b558f23Claude4519// Edit PR title (and optionally body). Owner or author only.
4520pulls.post(
4521 "/:owner/:repo/pulls/:number/edit",
4522 softAuth,
4523 requireAuth,
4524 requireRepoAccess("write"),
4525 async (c) => {
4526 const { owner: ownerName, repo: repoName } = c.req.param();
4527 const prNum = parseInt(c.req.param("number"), 10);
4528 const user = c.get("user")!;
4529 const resolved = await resolveRepo(ownerName, repoName);
4530 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4531
4532 const [pr] = await db
4533 .select()
4534 .from(pullRequests)
4535 .where(and(
4536 eq(pullRequests.repositoryId, resolved.repo.id),
4537 eq(pullRequests.number, prNum),
4538 ))
4539 .limit(1);
4540 if (!pr || pr.state !== "open") {
4541 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4542 }
4543 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
4544 if (!canEdit) {
4545 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4546 }
4547
4548 const body = await c.req.parseBody();
4549 const newTitle = String(body.title || "").trim().slice(0, 256);
4550 if (!newTitle) {
4551 return c.redirect(
4552 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
4553 );
4554 }
4555
4556 await db
4557 .update(pullRequests)
4558 .set({ title: newTitle, updatedAt: new Date() })
4559 .where(eq(pullRequests.id, pr.id));
4560
4561 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
4562 }
4563);
4564
cb5a796Claude4565// Add comment to PR.
4566//
4567// Permission model mirrors `issues.tsx`: any logged-in user with read
4568// access can submit; `decideInitialStatus` routes non-collaborators
4569// through the moderation queue. Slash commands only fire when the
4570// comment is auto-approved — we don't want a banned/pending comment to
4571// silently trigger AI work on the PR.
0074234Claude4572pulls.post(
4573 "/:owner/:repo/pulls/:number/comment",
4574 softAuth,
4575 requireAuth,
cb5a796Claude4576 requireRepoAccess("read"),
0074234Claude4577 async (c) => {
4578 const { owner: ownerName, repo: repoName } = c.req.param();
4579 const prNum = parseInt(c.req.param("number"), 10);
4580 const user = c.get("user")!;
4581 const body = await c.req.parseBody();
4582 const commentBody = String(body.body || "").trim();
47a7a0aClaude4583 const filePathRaw = String(body.file_path || "").trim();
4584 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
4585 const inlineFilePath = filePathRaw || undefined;
4586 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude4587
4588 if (!commentBody) {
4589 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4590 }
4591
4592 const resolved = await resolveRepo(ownerName, repoName);
4593 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4594
4595 const [pr] = await db
4596 .select()
4597 .from(pullRequests)
4598 .where(
4599 and(
4600 eq(pullRequests.repositoryId, resolved.repo.id),
4601 eq(pullRequests.number, prNum)
4602 )
4603 )
4604 .limit(1);
4605
4606 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4607
cb5a796Claude4608 const decision = await decideInitialStatus({
4609 commenterUserId: user.id,
4610 repositoryId: resolved.repo.id,
4611 kind: "pr",
4612 threadId: pr.id,
4613 });
4614
d4ac5c3Claude4615 const [inserted] = await db
4616 .insert(prComments)
4617 .values({
4618 pullRequestId: pr.id,
4619 authorId: user.id,
4620 body: commentBody,
cb5a796Claude4621 moderationStatus: decision.status,
47a7a0aClaude4622 filePath: inlineFilePath,
4623 lineNumber: inlineLineNumber,
d4ac5c3Claude4624 })
4625 .returning();
4626
cb5a796Claude4627 // Live update: only when the comment is actually visible.
4628 if (inserted && decision.status === "approved") {
d4ac5c3Claude4629 try {
4630 const { publish } = await import("../lib/sse");
4631 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
4632 event: "pr-comment",
4633 data: {
4634 pullRequestId: pr.id,
4635 commentId: inserted.id,
4636 authorId: user.id,
4637 authorUsername: user.username,
4638 },
4639 });
4640 } catch {
4641 /* SSE is best-effort */
4642 }
4643 }
0074234Claude4644
cb5a796Claude4645 if (decision.status === "pending") {
4646 void notifyOwnerOfPendingComment({
4647 repositoryId: resolved.repo.id,
4648 commenterUsername: user.username,
4649 kind: "pr",
4650 threadNumber: prNum,
4651 ownerUsername: ownerName,
4652 repoName,
4653 });
4654 return c.redirect(
4655 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4656 );
4657 }
4658 if (decision.status === "rejected") {
4659 // Silent ban path — same UX as 'pending' so we don't leak the gate.
4660 return c.redirect(
4661 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4662 );
4663 }
4664
15db0e0Claude4665 // Slash-command handoff. We always store the original comment above
4666 // first so free-form text that happens to start with `/` is preserved
4667 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude4668 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude4669 const parsed = parseSlashCommand(commentBody);
4670 if (parsed) {
4671 try {
4672 const result = await executeSlashCommand({
4673 command: parsed.command,
4674 args: parsed.args,
4675 prId: pr.id,
4676 userId: user.id,
4677 repositoryId: resolved.repo.id,
4678 });
4679 await db.insert(prComments).values({
4680 pullRequestId: pr.id,
4681 authorId: user.id,
4682 body: result.body,
4683 });
4684 } catch (err) {
4685 // Defence-in-depth — executeSlashCommand promises not to throw,
4686 // but if it ever does we want the PR thread to know.
4687 await db
4688 .insert(prComments)
4689 .values({
4690 pullRequestId: pr.id,
4691 authorId: user.id,
4692 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
4693 })
4694 .catch(() => {});
4695 }
4696 }
4697
47a7a0aClaude4698 // Inline comments go back to the files tab; conversation comments to the conversation tab
4699 const redirectTab = inlineFilePath ? "?tab=files" : "";
4700 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude4701 }
4702);
4703
b5dd694Claude4704// Apply a suggestion from a PR comment — commits the suggested code to the
4705// head branch on behalf of the logged-in user.
4706pulls.post(
4707 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
4708 softAuth,
4709 requireAuth,
4710 requireRepoAccess("read"),
4711 async (c) => {
4712 const { owner: ownerName, repo: repoName } = c.req.param();
4713 const prNum = parseInt(c.req.param("number"), 10);
4714 const commentId = c.req.param("commentId"); // UUID
4715 const user = c.get("user")!;
4716
4717 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
4718
4719 const resolved = await resolveRepo(ownerName, repoName);
4720 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4721
4722 const [pr] = await db
4723 .select()
4724 .from(pullRequests)
4725 .where(
4726 and(
4727 eq(pullRequests.repositoryId, resolved.repo.id),
4728 eq(pullRequests.number, prNum)
4729 )
4730 )
4731 .limit(1);
4732
4733 if (!pr || pr.state !== "open") {
4734 return c.redirect(`${backUrl}&error=pr_not_open`);
4735 }
4736
4737 // Only PR author or repo owner may apply suggestions.
4738 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
4739 return c.redirect(`${backUrl}&error=forbidden`);
4740 }
4741
4742 // Load the comment.
4743 const [comment] = await db
4744 .select()
4745 .from(prComments)
4746 .where(
4747 and(
4748 eq(prComments.id, commentId),
4749 eq(prComments.pullRequestId, pr.id)
4750 )
4751 )
4752 .limit(1);
4753
4754 if (!comment) {
4755 return c.redirect(`${backUrl}&error=comment_not_found`);
4756 }
4757
4758 // Parse suggestion block from comment body.
4759 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
4760 if (!m) {
4761 return c.redirect(`${backUrl}&error=no_suggestion`);
4762 }
4763 const suggestionCode = m[1];
4764
4765 // Get the commenter's details for the commit message co-author line.
4766 const [commenter] = await db
4767 .select()
4768 .from(users)
4769 .where(eq(users.id, comment.authorId))
4770 .limit(1);
4771
4772 // Fetch current file content from head branch.
4773 if (!comment.filePath) {
4774 return c.redirect(`${backUrl}&error=file_not_found`);
4775 }
4776 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
4777 if (!blob) {
4778 return c.redirect(`${backUrl}&error=file_not_found`);
4779 }
4780
4781 // Apply the patch — replace the target line(s) with suggestion lines.
4782 const lines = blob.content.split('\n');
4783 const lineIdx = (comment.lineNumber ?? 1) - 1;
4784 if (lineIdx < 0 || lineIdx >= lines.length) {
4785 return c.redirect(`${backUrl}&error=line_out_of_range`);
4786 }
4787 const suggestionLines = suggestionCode.split('\n');
4788 lines.splice(lineIdx, 1, ...suggestionLines);
4789 const newContent = lines.join('\n');
4790
4791 // Commit the change.
4792 const coAuthorLine = commenter
4793 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
4794 : "";
4795 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
4796
4797 const result = await createOrUpdateFileOnBranch({
4798 owner: ownerName,
4799 name: repoName,
4800 branch: pr.headBranch,
4801 filePath: comment.filePath,
4802 bytes: new TextEncoder().encode(newContent),
4803 message: commitMessage,
4804 authorName: user.username,
4805 authorEmail: `${user.username}@users.noreply.gluecron.com`,
4806 });
4807
4808 if ("error" in result) {
4809 return c.redirect(`${backUrl}&error=apply_failed`);
4810 }
4811
4812 // Post a follow-up comment noting the suggestion was applied.
4813 await db.insert(prComments).values({
4814 pullRequestId: pr.id,
4815 authorId: user.id,
4816 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
4817 });
4818
4819 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
4820 }
4821);
4822
0a67773Claude4823// Formal review — Approve / Request Changes / Comment
4824pulls.post(
4825 "/:owner/:repo/pulls/:number/review",
4826 softAuth,
4827 requireAuth,
4828 requireRepoAccess("read"),
4829 async (c) => {
4830 const { owner: ownerName, repo: repoName } = c.req.param();
4831 const prNum = parseInt(c.req.param("number"), 10);
4832 const user = c.get("user")!;
4833 const body = await c.req.parseBody();
4834 const reviewBody = String(body.body || "").trim();
4835 const reviewState = String(body.review_state || "commented");
4836
4837 const validStates = ["approved", "changes_requested", "commented"];
4838 if (!validStates.includes(reviewState)) {
4839 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4840 }
4841
4842 const resolved = await resolveRepo(ownerName, repoName);
4843 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4844
4845 const [pr] = await db
4846 .select()
4847 .from(pullRequests)
4848 .where(
4849 and(
4850 eq(pullRequests.repositoryId, resolved.repo.id),
4851 eq(pullRequests.number, prNum)
4852 )
4853 )
4854 .limit(1);
4855 if (!pr || pr.state !== "open") {
4856 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4857 }
4858 // Authors can't review their own PR
4859 if (pr.authorId === user.id) {
4860 return c.redirect(
4861 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
4862 );
4863 }
4864
4865 await db.insert(prReviews).values({
4866 pullRequestId: pr.id,
4867 reviewerId: user.id,
4868 state: reviewState,
4869 body: reviewBody || null,
4870 });
4871
4872 const stateLabel =
4873 reviewState === "approved" ? "Approved"
4874 : reviewState === "changes_requested" ? "Changes requested"
4875 : "Commented";
4876 return c.redirect(
4877 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
4878 );
4879 }
4880);
4881
e883329Claude4882// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude4883// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
4884// but we keep it at "write" for v1 so trusted collaborators can ship.
4885// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
4886// surface. Branch-protection rules (evaluated below) are the current mechanism
4887// for locking down merges further on specific branches.
0074234Claude4888pulls.post(
4889 "/:owner/:repo/pulls/:number/merge",
4890 softAuth,
4891 requireAuth,
04f6b7fClaude4892 requireRepoAccess("write"),
0074234Claude4893 async (c) => {
4894 const { owner: ownerName, repo: repoName } = c.req.param();
4895 const prNum = parseInt(c.req.param("number"), 10);
4896 const user = c.get("user")!;
4897
a164a6dClaude4898 // Read merge strategy from form (default: merge commit)
4899 let mergeStrategy = "merge";
4900 try {
4901 const body = await c.req.parseBody();
4902 const s = body.merge_strategy;
4903 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
4904 } catch { /* ignore parse errors — default to merge commit */ }
4905
0074234Claude4906 const resolved = await resolveRepo(ownerName, repoName);
4907 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4908
4909 const [pr] = await db
4910 .select()
4911 .from(pullRequests)
4912 .where(
4913 and(
4914 eq(pullRequests.repositoryId, resolved.repo.id),
4915 eq(pullRequests.number, prNum)
4916 )
4917 )
4918 .limit(1);
4919
4920 if (!pr || pr.state !== "open") {
4921 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4922 }
4923
6fc53bdClaude4924 // Draft PRs cannot be merged — must be marked ready first.
4925 if (pr.isDraft) {
4926 return c.redirect(
4927 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4928 "This PR is a draft. Mark it as ready for review before merging."
4929 )}`
4930 );
4931 }
4932
e883329Claude4933 // Resolve head SHA
4934 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4935 if (!headSha) {
4936 return c.redirect(
4937 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
4938 );
4939 }
4940
4941 // Check if AI review approved this PR
4942 const aiComments = await db
4943 .select()
4944 .from(prComments)
4945 .where(
4946 and(
4947 eq(prComments.pullRequestId, pr.id),
4948 eq(prComments.isAiReview, true)
4949 )
4950 );
4951 const aiApproved = aiComments.length === 0 || aiComments.some(
4952 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude4953 );
e883329Claude4954
4955 // Run all green gate checks (GateTest + mergeability + AI review)
4956 const gateResult = await runAllGateChecks(
4957 ownerName,
4958 repoName,
4959 pr.baseBranch,
4960 pr.headBranch,
4961 headSha,
4962 aiApproved
0074234Claude4963 );
4964
e883329Claude4965 // If GateTest or AI review failed (hard blocks), reject the merge
4966 const hardFailures = gateResult.checks.filter(
4967 (check) => !check.passed && check.name !== "Merge check"
4968 );
4969 if (hardFailures.length > 0) {
4970 const errorMsg = hardFailures
4971 .map((f) => `${f.name}: ${f.details}`)
4972 .join("; ");
0074234Claude4973 return c.redirect(
e883329Claude4974 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude4975 );
4976 }
4977
1e162a8Claude4978 // D5 — Branch-protection enforcement. Looks up the matching rule for the
4979 // base branch and blocks the merge if requireAiApproval / requireGreenGates
4980 // / requireHumanReview / requiredApprovals are not satisfied. Independent
4981 // of repo-global settings, so owners can lock specific branches down
4982 // further than the repo default.
4983 const protectionRule = await matchProtection(
4984 resolved.repo.id,
4985 pr.baseBranch
4986 );
4987 if (protectionRule) {
4988 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude4989 const required = await listRequiredChecks(protectionRule.id);
4990 const passingNames = required.length > 0
4991 ? await passingCheckNames(resolved.repo.id, headSha)
4992 : [];
4993 const decision = evaluateProtection(
4994 protectionRule,
4995 {
4996 aiApproved,
4997 humanApprovalCount: humanApprovals,
4998 gateResultGreen: hardFailures.length === 0,
4999 hasFailedGates: hardFailures.length > 0,
5000 passingCheckNames: passingNames,
5001 },
5002 required.map((r) => r.checkName)
5003 );
1e162a8Claude5004 if (!decision.allowed) {
5005 return c.redirect(
5006 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5007 decision.reasons.join(" ")
5008 )}`
5009 );
5010 }
5011 }
5012
e883329Claude5013 // Attempt the merge — with auto conflict resolution if needed
5014 const repoDir = getRepoPath(ownerName, repoName);
5015 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
5016 const hasConflicts = mergeCheck && !mergeCheck.passed;
5017
5018 if (hasConflicts && isAiReviewEnabled()) {
5019 // Use Claude to auto-resolve conflicts
5020 const mergeResult = await mergeWithAutoResolve(
5021 ownerName,
5022 repoName,
5023 pr.baseBranch,
5024 pr.headBranch,
5025 `Merge pull request #${pr.number}: ${pr.title}`
5026 );
5027
5028 if (!mergeResult.success) {
5029 return c.redirect(
5030 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
5031 );
5032 }
5033
5034 // Post a comment about the auto-resolution
5035 if (mergeResult.resolvedFiles.length > 0) {
5036 await db.insert(prComments).values({
5037 pullRequestId: pr.id,
5038 authorId: user.id,
5039 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
5040 isAiReview: true,
5041 });
5042 }
5043 } else {
a164a6dClaude5044 // Worktree-based merge: supports merge-commit, squash, and fast-forward
5045 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
5046 const gitEnv = {
5047 ...process.env,
5048 GIT_AUTHOR_NAME: user.displayName || user.username,
5049 GIT_AUTHOR_EMAIL: user.email,
5050 GIT_COMMITTER_NAME: user.displayName || user.username,
5051 GIT_COMMITTER_EMAIL: user.email,
5052 };
5053
5054 // Create linked worktree on the base branch
5055 const addWt = Bun.spawn(
5056 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude5057 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5058 );
a164a6dClaude5059 if (await addWt.exited !== 0) {
5060 return c.redirect(
5061 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
5062 );
5063 }
5064
5065 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
5066 let mergeOk = false;
5067
5068 try {
5069 if (mergeStrategy === "squash") {
5070 // Squash: stage all changes without committing
5071 const squashProc = Bun.spawn(
5072 ["git", "merge", "--squash", headSha],
5073 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5074 );
5075 if (await squashProc.exited !== 0) {
5076 const errTxt = await new Response(squashProc.stderr).text();
5077 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
5078 }
5079 // Commit the squashed changes
5080 const commitProc = Bun.spawn(
5081 ["git", "commit", "-m", commitMsg],
5082 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5083 );
5084 if (await commitProc.exited !== 0) {
5085 const errTxt = await new Response(commitProc.stderr).text();
5086 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
5087 }
5088 mergeOk = true;
5089 } else if (mergeStrategy === "ff") {
5090 // Fast-forward only — fail if FF is not possible
5091 const ffProc = Bun.spawn(
5092 ["git", "merge", "--ff-only", headSha],
5093 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5094 );
5095 if (await ffProc.exited !== 0) {
5096 const errTxt = await new Response(ffProc.stderr).text();
5097 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
5098 }
5099 mergeOk = true;
5100 } else {
5101 // Default: merge commit (--no-ff always creates a merge commit)
5102 const mergeProc = Bun.spawn(
5103 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
5104 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5105 );
5106 if (await mergeProc.exited !== 0) {
5107 const errTxt = await new Response(mergeProc.stderr).text();
5108 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
5109 }
5110 mergeOk = true;
5111 }
5112 } catch (err) {
5113 // Always clean up the worktree before redirecting
5114 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
5115 const msg = err instanceof Error ? err.message : "Merge failed";
5116 return c.redirect(
5117 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
5118 );
5119 }
5120
5121 // Clean up worktree (changes are now in the bare repo via linked worktree)
5122 await Bun.spawn(
5123 ["git", "worktree", "remove", "--force", wt],
5124 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5125 ).exited.catch(() => {});
e883329Claude5126
a164a6dClaude5127 if (!mergeOk) {
e883329Claude5128 return c.redirect(
a164a6dClaude5129 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude5130 );
5131 }
5132 }
5133
0074234Claude5134 await db
5135 .update(pullRequests)
5136 .set({
5137 state: "merged",
5138 mergedAt: new Date(),
5139 mergedBy: user.id,
5140 updatedAt: new Date(),
5141 })
5142 .where(eq(pullRequests.id, pr.id));
5143
8809b87Claude5144 // Chat notifier — fan out merge event to Slack/Discord/Teams.
5145 import("../lib/chat-notifier")
5146 .then((m) =>
5147 m.notifyChatChannels({
5148 ownerUserId: resolved.repo.ownerId,
5149 repositoryId: resolved.repo.id,
5150 event: {
5151 event: "pr.merged",
5152 repo: `${ownerName}/${repoName}`,
5153 title: `#${pr.number} ${pr.title}`,
5154 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
5155 actor: user.username,
5156 },
5157 })
5158 )
5159 .catch((err) =>
5160 console.warn(`[chat-notifier] PR merge notify failed:`, err)
5161 );
5162
d62fb36Claude5163 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
5164 // and auto-close each matching open issue with a back-link comment. Bounded
5165 // to the same repo for v1 (cross-repo refs ignored). Failures never block
5166 // the merge redirect.
5167 try {
5168 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
5169 const refs = extractClosingRefsMulti([pr.title, pr.body]);
5170 for (const n of refs) {
5171 const [issue] = await db
5172 .select()
5173 .from(issues)
5174 .where(
5175 and(
5176 eq(issues.repositoryId, resolved.repo.id),
5177 eq(issues.number, n)
5178 )
5179 )
5180 .limit(1);
5181 if (!issue || issue.state !== "open") continue;
5182 await db
5183 .update(issues)
5184 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
5185 .where(eq(issues.id, issue.id));
5186 await db.insert(issueComments).values({
5187 issueId: issue.id,
5188 authorId: user.id,
5189 body: `Closed by pull request #${pr.number}.`,
5190 });
5191 }
5192 } catch {
5193 // Never block the merge on close-keyword failures.
5194 }
5195
0074234Claude5196 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5197 }
5198);
5199
6fc53bdClaude5200// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
5201// hasn't run yet on this PR.
5202pulls.post(
5203 "/:owner/:repo/pulls/:number/ready",
5204 softAuth,
5205 requireAuth,
04f6b7fClaude5206 requireRepoAccess("write"),
6fc53bdClaude5207 async (c) => {
5208 const { owner: ownerName, repo: repoName } = c.req.param();
5209 const prNum = parseInt(c.req.param("number"), 10);
5210 const user = c.get("user")!;
5211
5212 const resolved = await resolveRepo(ownerName, repoName);
5213 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5214
5215 const [pr] = await db
5216 .select()
5217 .from(pullRequests)
5218 .where(
5219 and(
5220 eq(pullRequests.repositoryId, resolved.repo.id),
5221 eq(pullRequests.number, prNum)
5222 )
5223 )
5224 .limit(1);
5225 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5226
5227 // Only the author or repo owner can toggle draft state.
5228 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5229 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5230 }
5231
5232 if (pr.state === "open" && pr.isDraft) {
5233 await db
5234 .update(pullRequests)
5235 .set({ isDraft: false, updatedAt: new Date() })
5236 .where(eq(pullRequests.id, pr.id));
5237
5238 if (isAiReviewEnabled()) {
5239 triggerAiReview(
5240 ownerName,
5241 repoName,
5242 pr.id,
5243 pr.title,
0316dbbClaude5244 pr.body || "",
6fc53bdClaude5245 pr.baseBranch,
5246 pr.headBranch
5247 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
5248 }
5249 }
5250
5251 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5252 }
5253);
5254
5255// Convert a PR back to draft.
5256pulls.post(
5257 "/:owner/:repo/pulls/:number/draft",
5258 softAuth,
5259 requireAuth,
04f6b7fClaude5260 requireRepoAccess("write"),
6fc53bdClaude5261 async (c) => {
5262 const { owner: ownerName, repo: repoName } = c.req.param();
5263 const prNum = parseInt(c.req.param("number"), 10);
5264 const user = c.get("user")!;
5265
5266 const resolved = await resolveRepo(ownerName, repoName);
5267 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5268
5269 const [pr] = await db
5270 .select()
5271 .from(pullRequests)
5272 .where(
5273 and(
5274 eq(pullRequests.repositoryId, resolved.repo.id),
5275 eq(pullRequests.number, prNum)
5276 )
5277 )
5278 .limit(1);
5279 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5280
5281 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5282 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5283 }
5284
5285 if (pr.state === "open" && !pr.isDraft) {
5286 await db
5287 .update(pullRequests)
5288 .set({ isDraft: true, updatedAt: new Date() })
5289 .where(eq(pullRequests.id, pr.id));
5290 }
5291
5292 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5293 }
5294);
5295
0074234Claude5296// Close PR
5297pulls.post(
5298 "/:owner/:repo/pulls/:number/close",
5299 softAuth,
5300 requireAuth,
04f6b7fClaude5301 requireRepoAccess("write"),
0074234Claude5302 async (c) => {
5303 const { owner: ownerName, repo: repoName } = c.req.param();
5304 const prNum = parseInt(c.req.param("number"), 10);
5305
5306 const resolved = await resolveRepo(ownerName, repoName);
5307 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5308
5309 await db
5310 .update(pullRequests)
5311 .set({
5312 state: "closed",
5313 closedAt: new Date(),
5314 updatedAt: new Date(),
5315 })
5316 .where(
5317 and(
5318 eq(pullRequests.repositoryId, resolved.repo.id),
5319 eq(pullRequests.number, prNum)
5320 )
5321 );
5322
5323 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5324 }
5325);
5326
c3e0c07Claude5327// Re-run AI review on demand (e.g. after a force-push). Bypasses the
5328// idempotency marker via { force: true }. Write-access only.
5329pulls.post(
5330 "/:owner/:repo/pulls/:number/ai-rereview",
5331 softAuth,
5332 requireAuth,
5333 requireRepoAccess("write"),
5334 async (c) => {
5335 const { owner: ownerName, repo: repoName } = c.req.param();
5336 const prNum = parseInt(c.req.param("number"), 10);
5337 const resolved = await resolveRepo(ownerName, repoName);
5338 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5339
5340 const [pr] = await db
5341 .select()
5342 .from(pullRequests)
5343 .where(
5344 and(
5345 eq(pullRequests.repositoryId, resolved.repo.id),
5346 eq(pullRequests.number, prNum)
5347 )
5348 )
5349 .limit(1);
5350 if (!pr) {
5351 return c.redirect(`/${ownerName}/${repoName}/pulls`);
5352 }
5353
5354 if (!isAiReviewEnabled()) {
5355 return c.redirect(
5356 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5357 "AI review is not configured (ANTHROPIC_API_KEY)."
5358 )}`
5359 );
5360 }
5361
5362 // Fire-and-forget but with { force: true } to bypass the
5363 // already-reviewed marker. The function still never throws.
5364 triggerAiReview(
5365 ownerName,
5366 repoName,
5367 pr.id,
5368 pr.title || "",
5369 pr.body || "",
5370 pr.baseBranch,
5371 pr.headBranch,
5372 { force: true }
a28cedeClaude5373 ).catch((err) => {
5374 console.warn(
5375 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
5376 err instanceof Error ? err.message : err
5377 );
5378 });
c3e0c07Claude5379
5380 return c.redirect(
5381 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5382 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
5383 )}`
5384 );
5385 }
5386);
5387
1d4ff60Claude5388// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
5389// the PR's head branch carrying just the new test files. Write-access only.
5390// Idempotent — if `ai:added-tests` was previously applied we redirect with
5391// an `info` banner instead of re-firing.
5392pulls.post(
5393 "/:owner/:repo/pulls/:number/generate-tests",
5394 softAuth,
5395 requireAuth,
5396 requireRepoAccess("write"),
5397 async (c) => {
5398 const { owner: ownerName, repo: repoName } = c.req.param();
5399 const prNum = parseInt(c.req.param("number"), 10);
5400 const resolved = await resolveRepo(ownerName, repoName);
5401 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5402
5403 const [pr] = await db
5404 .select()
5405 .from(pullRequests)
5406 .where(
5407 and(
5408 eq(pullRequests.repositoryId, resolved.repo.id),
5409 eq(pullRequests.number, prNum)
5410 )
5411 )
5412 .limit(1);
5413 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5414
5415 if (!isAiReviewEnabled()) {
5416 return c.redirect(
5417 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5418 "AI test generation is not configured (ANTHROPIC_API_KEY)."
5419 )}`
5420 );
5421 }
5422
5423 // Fire-and-forget. The lib never throws.
5424 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
5425 .then((res) => {
5426 if (!res.ok) {
5427 console.warn(
5428 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
5429 );
5430 }
5431 })
5432 .catch((err) => {
5433 console.warn(
5434 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
5435 err instanceof Error ? err.message : err
5436 );
5437 });
5438
5439 return c.redirect(
5440 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5441 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
5442 )}`
5443 );
5444 }
5445);
5446
ace34efClaude5447// ─── Request review ───────────────────────────────────────────────────────────
5448pulls.post(
5449 "/:owner/:repo/pulls/:number/request-review",
5450 softAuth,
5451 requireAuth,
5452 requireRepoAccess("write"),
5453 async (c) => {
5454 const { owner: ownerName, repo: repoName } = c.req.param();
5455 const prNum = parseInt(c.req.param("number"), 10);
5456 const user = c.get("user")!;
5457
5458 const resolved = await resolveRepo(ownerName, repoName);
5459 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5460
5461 const [pr] = await db
5462 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
5463 .from(pullRequests)
5464 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5465 .limit(1);
5466
5467 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5468
5469 const body = await c.req.formData().catch(() => null);
5470 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
5471
5472 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
5473 return c.redirect(
5474 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
5475 );
5476 }
5477
f4abb8eClaude5478 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
5479 // requesting reviews from arbitrary user IDs outside this repository.
5480 const isOwner = reviewerId === resolved.owner.id;
5481 if (!isOwner) {
5482 const [collab] = await db
5483 .select({ id: repoCollaborators.id })
5484 .from(repoCollaborators)
5485 .where(
5486 and(
5487 eq(repoCollaborators.repositoryId, resolved.repo.id),
5488 eq(repoCollaborators.userId, reviewerId),
5489 isNotNull(repoCollaborators.acceptedAt)
5490 )
5491 )
5492 .limit(1);
5493 if (!collab) {
5494 return c.redirect(
5495 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
5496 );
5497 }
5498 }
5499
ace34efClaude5500 const { requestReview } = await import("../lib/reviewer-suggest");
5501 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
5502
5503 const msg = result.ok
5504 ? "Review requested successfully."
5505 : `Failed to request review: ${result.error ?? "unknown error"}`;
5506
5507 return c.redirect(
5508 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
5509 );
5510 }
5511);
5512
0074234Claude5513export default pulls;