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