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