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.tsxBlame3652 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";
17import { eq, and, desc, asc, sql } from "drizzle-orm";
18import { 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";
0074234Claude43import { softAuth, requireAuth } from "../middleware/auth";
44import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude45import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude46import {
47 decideInitialStatus,
48 notifyOwnerOfPendingComment,
49 countPendingForRepo,
50} from "../lib/comment-moderation";
0316dbbClaude51import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
79ed944Claude52import {
53 TRIO_COMMENT_MARKER,
54 TRIO_SUMMARY_MARKER,
55 type TrioPersona,
56} from "../lib/ai-review-trio";
1d4ff60Claude57import {
58 generateTestsForPr,
59 AI_TESTS_MARKER,
60} from "../lib/ai-test-generator";
0316dbbClaude61import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude62import { generatePrSummary } from "../lib/ai-generators";
63import { isAiAvailable } from "../lib/ai-client";
534f04aClaude64import {
65 computePrRiskForPullRequest,
66 getCachedPrRisk,
67 type PrRiskScore,
68} from "../lib/pr-risk";
0316dbbClaude69import { runAllGateChecks } from "../lib/gate";
70import type { GateCheckResult } from "../lib/gate";
71import {
72 matchProtection,
73 countHumanApprovals,
74 listRequiredChecks,
75 passingCheckNames,
76 evaluateProtection,
77} from "../lib/branch-protection";
78import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude79import {
80 listBranches,
81 getRepoPath,
e883329Claude82 resolveRef,
0074234Claude83} from "../git/repository";
84import type { GitDiffFile } from "../git/repository";
85import { html } from "hono/html";
4bbacbeClaude86import {
87 getPreviewForBranch,
88 previewStatusLabel,
89} from "../lib/branch-previews";
1e162a8Claude90import {
bb0f894Claude91 Flex,
92 Container,
93 Badge,
94 Button,
95 LinkButton,
96 Form,
97 FormGroup,
98 Input,
99 TextArea,
100 Select,
101 EmptyState,
102 FilterTabs,
103 TabNav,
104 List,
105 ListItem,
106 Text,
107 Alert,
108 MarkdownContent,
109 CommentBox,
110 formatRelative,
111} from "../views/ui";
0074234Claude112
113const pulls = new Hono<AuthEnv>();
114
b078860Claude115/* ──────────────────────────────────────────────────────────────────────
116 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
117 * into the issue tracker or any other route. Tokens come from layout.tsx
118 * `:root` so light/dark stays consistent if/when light mode lands.
119 * ──────────────────────────────────────────────────────────────────── */
120const PRS_LIST_STYLES = `
121 .prs-hero {
122 position: relative;
123 margin: 0 0 var(--space-5);
124 padding: 22px 26px 24px;
125 background: var(--bg-elevated);
126 border: 1px solid var(--border);
127 border-radius: 16px;
128 overflow: hidden;
129 }
130 .prs-hero::before {
131 content: '';
132 position: absolute; top: 0; left: 0; right: 0;
133 height: 2px;
134 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
135 opacity: 0.7;
136 pointer-events: none;
137 }
138 .prs-hero-inner {
139 position: relative;
140 display: flex;
141 justify-content: space-between;
142 align-items: flex-end;
143 gap: 20px;
144 flex-wrap: wrap;
145 }
146 .prs-hero-text { flex: 1; min-width: 280px; }
147 .prs-hero-eyebrow {
148 font-size: 12px;
149 color: var(--text-muted);
150 text-transform: uppercase;
151 letter-spacing: 0.08em;
152 font-weight: 600;
153 margin-bottom: 8px;
154 }
155 .prs-hero-title {
156 font-family: var(--font-display);
157 font-size: clamp(26px, 3.4vw, 34px);
158 font-weight: 800;
159 letter-spacing: -0.025em;
160 line-height: 1.06;
161 margin: 0 0 8px;
162 color: var(--text-strong);
163 }
164 .prs-hero-title .gradient-text {
165 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
166 -webkit-background-clip: text;
167 background-clip: text;
168 -webkit-text-fill-color: transparent;
169 color: transparent;
170 }
171 .prs-hero-sub {
172 font-size: 14.5px;
173 color: var(--text-muted);
174 margin: 0;
175 line-height: 1.5;
176 max-width: 620px;
177 }
178 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
179 .prs-cta {
180 display: inline-flex; align-items: center; gap: 6px;
181 padding: 10px 16px;
182 border-radius: 10px;
183 font-size: 13.5px;
184 font-weight: 600;
185 color: #fff;
186 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
187 border: 1px solid rgba(140,109,255,0.55);
188 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
189 text-decoration: none;
190 transition: transform 120ms ease, box-shadow 160ms ease;
191 }
192 .prs-cta:hover {
193 transform: translateY(-1px);
194 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
195 color: #fff;
196 }
197
198 .prs-tabs {
199 display: flex; flex-wrap: wrap; gap: 6px;
200 margin: 0 0 18px;
201 padding: 6px;
202 background: var(--bg-secondary);
203 border: 1px solid var(--border);
204 border-radius: 12px;
205 }
206 .prs-tab {
207 display: inline-flex; align-items: center; gap: 8px;
208 padding: 7px 13px;
209 font-size: 13px;
210 font-weight: 500;
211 color: var(--text-muted);
212 border-radius: 8px;
213 text-decoration: none;
214 transition: background 120ms ease, color 120ms ease;
215 }
216 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
217 .prs-tab.is-active {
218 background: var(--bg-elevated);
219 color: var(--text-strong);
220 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
221 }
222 .prs-tab-count {
223 display: inline-flex; align-items: center; justify-content: center;
224 min-width: 22px; padding: 2px 7px;
225 font-size: 11.5px;
226 font-weight: 600;
227 border-radius: 9999px;
228 background: var(--bg-tertiary);
229 color: var(--text-muted);
230 }
231 .prs-tab.is-active .prs-tab-count {
232 background: rgba(140,109,255,0.18);
233 color: var(--text-link);
234 }
235
236 .prs-list { display: flex; flex-direction: column; gap: 10px; }
237 .prs-row {
238 position: relative;
239 display: flex; align-items: flex-start; gap: 14px;
240 padding: 14px 16px;
241 background: var(--bg-elevated);
242 border: 1px solid var(--border);
243 border-radius: 12px;
244 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
245 }
246 .prs-row:hover {
247 transform: translateY(-1px);
248 border-color: var(--border-strong);
249 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
250 }
251 .prs-row-icon {
252 flex: 0 0 auto;
253 width: 26px; height: 26px;
254 display: inline-flex; align-items: center; justify-content: center;
255 border-radius: 9999px;
256 font-size: 13px;
257 margin-top: 2px;
258 }
259 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
260 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
261 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
262 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
263 .prs-row-body { flex: 1; min-width: 0; }
264 .prs-row-title {
265 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
266 font-size: 15px; font-weight: 600;
267 color: var(--text-strong);
268 line-height: 1.35;
269 margin: 0 0 6px;
270 }
271 .prs-row-number {
272 color: var(--text-muted);
273 font-weight: 400;
274 font-size: 14px;
275 }
276 .prs-row-meta {
277 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
278 font-size: 12.5px;
279 color: var(--text-muted);
280 }
281 .prs-branch-chips {
282 display: inline-flex; align-items: center; gap: 6px;
283 font-family: var(--font-mono);
284 font-size: 11.5px;
285 }
286 .prs-branch-chip {
287 padding: 2px 8px;
288 border-radius: 9999px;
289 background: var(--bg-tertiary);
290 border: 1px solid var(--border);
291 color: var(--text);
292 }
293 .prs-branch-arrow {
294 color: var(--text-faint);
295 font-size: 11px;
296 }
297 .prs-row-tags {
298 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
299 margin-left: auto;
300 }
301 .prs-tag {
302 display: inline-flex; align-items: center; gap: 4px;
303 padding: 2px 8px;
304 font-size: 11px;
305 font-weight: 600;
306 border-radius: 9999px;
307 border: 1px solid var(--border);
308 background: var(--bg-secondary);
309 color: var(--text-muted);
310 line-height: 1.6;
311 }
312 .prs-tag.is-draft {
313 color: var(--text-muted);
314 border-color: var(--border-strong);
315 }
316 .prs-tag.is-merged {
317 color: var(--text-link);
318 border-color: rgba(140,109,255,0.45);
319 background: rgba(140,109,255,0.10);
320 }
321
322 .prs-empty {
ea9ed4cClaude323 position: relative;
324 padding: 56px 32px;
b078860Claude325 text-align: center;
326 border: 1px dashed var(--border);
ea9ed4cClaude327 border-radius: 16px;
328 background: var(--bg-elevated);
b078860Claude329 color: var(--text-muted);
ea9ed4cClaude330 overflow: hidden;
b078860Claude331 }
ea9ed4cClaude332 .prs-empty::before {
333 content: '';
334 position: absolute;
335 inset: -40% -20% auto auto;
336 width: 320px; height: 320px;
337 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
338 filter: blur(70px);
339 opacity: 0.55;
340 pointer-events: none;
341 animation: prsEmptyOrb 16s ease-in-out infinite;
342 }
343 @keyframes prsEmptyOrb {
344 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
345 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
346 }
347 @media (prefers-reduced-motion: reduce) {
348 .prs-empty::before { animation: none; }
349 }
350 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude351 .prs-empty strong {
352 display: block;
353 color: var(--text-strong);
ea9ed4cClaude354 font-family: var(--font-display);
355 font-size: 22px;
356 font-weight: 700;
357 letter-spacing: -0.018em;
358 margin-bottom: 2px;
359 }
360 .prs-empty-sub {
361 font-size: 14.5px;
362 color: var(--text-muted);
363 line-height: 1.55;
364 max-width: 460px;
365 margin: 0 0 18px;
b078860Claude366 }
ea9ed4cClaude367 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude368
369 @media (max-width: 720px) {
370 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
371 .prs-hero-actions { width: 100%; }
372 .prs-row-tags { margin-left: 0; }
373 }
f1dc7c7Claude374
375 /* Additional mobile rules. Additive only. */
376 @media (max-width: 720px) {
377 .prs-hero { padding: 18px 18px 20px; }
378 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
379 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
380 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
381 .prs-row { padding: 12px 14px; gap: 10px; }
382 .prs-row-icon { width: 24px; height: 24px; }
383 }
b078860Claude384`;
385
386/* ──────────────────────────────────────────────────────────────────────
387 * Inline CSS for the detail page. Same `.prs-*` namespace.
388 * ──────────────────────────────────────────────────────────────────── */
389const PRS_DETAIL_STYLES = `
390 .prs-detail-hero {
391 position: relative;
392 margin: 0 0 var(--space-4);
393 padding: 24px 26px;
394 background: var(--bg-elevated);
395 border: 1px solid var(--border);
396 border-radius: 16px;
397 overflow: hidden;
398 }
399 .prs-detail-hero::before {
400 content: '';
401 position: absolute; top: 0; left: 0; right: 0;
402 height: 2px;
403 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
404 opacity: 0.7;
405 pointer-events: none;
406 }
407 .prs-detail-title {
408 font-family: var(--font-display);
409 font-size: clamp(22px, 2.6vw, 28px);
410 font-weight: 700;
411 letter-spacing: -0.022em;
412 line-height: 1.2;
413 color: var(--text-strong);
414 margin: 0 0 12px;
415 }
416 .prs-detail-num {
417 color: var(--text-muted);
418 font-weight: 400;
419 }
420 .prs-state-pill {
421 display: inline-flex; align-items: center; gap: 6px;
422 padding: 6px 12px;
423 border-radius: 9999px;
424 font-size: 12.5px;
425 font-weight: 600;
426 line-height: 1;
427 border: 1px solid transparent;
428 }
429 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
430 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
431 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
432 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
433
434 .prs-detail-meta {
435 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
436 font-size: 13px;
437 color: var(--text-muted);
438 }
439 .prs-detail-meta strong { color: var(--text); }
440 .prs-detail-branches {
441 display: inline-flex; align-items: center; gap: 6px;
442 font-family: var(--font-mono);
443 font-size: 12px;
444 }
445 .prs-branch-pill {
446 padding: 3px 9px;
447 border-radius: 9999px;
448 background: var(--bg-tertiary);
449 border: 1px solid var(--border);
450 color: var(--text);
451 }
452 .prs-branch-pill.is-head { color: var(--text-strong); }
453 .prs-branch-arrow-lg {
454 color: var(--accent);
455 font-size: 14px;
456 font-weight: 700;
457 }
458
459 .prs-detail-actions {
460 display: inline-flex; gap: 8px; margin-left: auto;
461 }
462
463 .prs-detail-tabs {
464 display: flex; gap: 4px;
465 margin: 0 0 16px;
466 border-bottom: 1px solid var(--border);
467 }
468 .prs-detail-tab {
469 padding: 10px 14px;
470 font-size: 13.5px;
471 font-weight: 500;
472 color: var(--text-muted);
473 text-decoration: none;
474 border-bottom: 2px solid transparent;
475 transition: color 120ms ease, border-color 120ms ease;
476 margin-bottom: -1px;
477 }
478 .prs-detail-tab:hover { color: var(--text); }
479 .prs-detail-tab.is-active {
480 color: var(--text-strong);
481 border-bottom-color: var(--accent);
482 }
483 .prs-detail-tab-count {
484 display: inline-flex; align-items: center; justify-content: center;
485 min-width: 20px; padding: 0 6px; margin-left: 6px;
486 height: 18px;
487 font-size: 11px;
488 font-weight: 600;
489 border-radius: 9999px;
490 background: var(--bg-tertiary);
491 color: var(--text-muted);
492 }
493
494 /* Gate / check status section */
495 .prs-gate-card {
496 margin-top: 20px;
497 background: var(--bg-elevated);
498 border: 1px solid var(--border);
499 border-radius: 14px;
500 overflow: hidden;
501 }
502 .prs-gate-head {
503 display: flex; align-items: center; gap: 10px;
504 padding: 14px 18px;
505 border-bottom: 1px solid var(--border);
506 }
507 .prs-gate-head h3 {
508 margin: 0;
509 font-size: 14px;
510 font-weight: 600;
511 color: var(--text-strong);
512 }
513 .prs-gate-summary {
514 margin-left: auto;
515 font-size: 12px;
516 color: var(--text-muted);
517 }
518 .prs-gate-row {
519 display: flex; align-items: center; gap: 12px;
520 padding: 12px 18px;
521 border-bottom: 1px solid var(--border-subtle);
522 }
523 .prs-gate-row:last-child { border-bottom: 0; }
524 .prs-gate-icon {
525 flex: 0 0 auto;
526 width: 22px; height: 22px;
527 display: inline-flex; align-items: center; justify-content: center;
528 border-radius: 9999px;
529 font-size: 12px;
530 font-weight: 700;
531 }
532 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
533 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
534 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
535 .prs-gate-name {
536 font-size: 13px;
537 font-weight: 600;
538 color: var(--text);
539 min-width: 140px;
540 }
541 .prs-gate-details {
542 flex: 1; min-width: 0;
543 font-size: 12.5px;
544 color: var(--text-muted);
545 }
546 .prs-gate-pill {
547 flex: 0 0 auto;
548 padding: 3px 10px;
549 border-radius: 9999px;
550 font-size: 11px;
551 font-weight: 600;
552 line-height: 1.5;
553 border: 1px solid transparent;
554 }
555 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
556 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
557 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
558 .prs-gate-footer {
559 padding: 12px 18px;
560 background: var(--bg-secondary);
561 font-size: 12px;
562 color: var(--text-muted);
563 }
564
565 /* Comment cards */
566 .prs-comment {
567 margin-top: 14px;
568 background: var(--bg-elevated);
569 border: 1px solid var(--border);
570 border-radius: 12px;
571 overflow: hidden;
572 }
573 .prs-comment-head {
574 display: flex; align-items: center; gap: 10px;
575 padding: 10px 14px;
576 background: var(--bg-secondary);
577 border-bottom: 1px solid var(--border);
578 font-size: 13px;
579 flex-wrap: wrap;
580 }
581 .prs-comment-head strong { color: var(--text-strong); }
582 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
583 .prs-comment-loc {
584 font-family: var(--font-mono);
585 font-size: 11.5px;
586 color: var(--text-muted);
587 background: var(--bg-tertiary);
588 padding: 2px 8px;
589 border-radius: 6px;
590 }
591 .prs-comment-body { padding: 14px 18px; }
592 .prs-comment.is-ai {
593 border-color: rgba(140,109,255,0.45);
594 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
595 }
596 .prs-comment.is-ai .prs-comment-head {
597 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
598 border-bottom-color: rgba(140,109,255,0.30);
599 }
600 .prs-ai-badge {
601 display: inline-flex; align-items: center; gap: 4px;
602 padding: 2px 9px;
603 font-size: 10.5px;
604 font-weight: 700;
605 letter-spacing: 0.04em;
606 text-transform: uppercase;
607 color: #fff;
608 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
609 border-radius: 9999px;
610 }
611
612 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
613 .prs-files-card {
614 margin-top: 18px;
615 padding: 14px 18px;
616 display: flex; align-items: center; gap: 14px;
617 background: var(--bg-elevated);
618 border: 1px solid var(--border);
619 border-radius: 12px;
620 text-decoration: none;
621 color: inherit;
622 transition: border-color 120ms ease, transform 140ms ease;
623 }
624 .prs-files-card:hover {
625 border-color: rgba(140,109,255,0.45);
626 transform: translateY(-1px);
627 }
628 .prs-files-card-icon {
629 width: 36px; height: 36px;
630 display: inline-flex; align-items: center; justify-content: center;
631 border-radius: 10px;
632 background: rgba(140,109,255,0.12);
633 color: var(--text-link);
634 font-size: 18px;
635 }
636 .prs-files-card-text { flex: 1; min-width: 0; }
637 .prs-files-card-title {
638 font-size: 14px;
639 font-weight: 600;
640 color: var(--text-strong);
641 margin: 0 0 2px;
642 }
643 .prs-files-card-sub {
644 font-size: 12.5px;
645 color: var(--text-muted);
646 margin: 0;
647 }
648 .prs-files-card-cta {
649 font-size: 12.5px;
650 color: var(--text-link);
651 font-weight: 600;
652 }
653
654 /* Merge area */
655 .prs-merge-card {
656 position: relative;
657 margin-top: 22px;
658 padding: 18px;
659 background: var(--bg-elevated);
660 border-radius: 14px;
661 overflow: hidden;
662 }
663 .prs-merge-card::before {
664 content: '';
665 position: absolute; inset: 0;
666 padding: 1px;
667 border-radius: 14px;
668 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
669 -webkit-mask:
670 linear-gradient(#000 0 0) content-box,
671 linear-gradient(#000 0 0);
672 -webkit-mask-composite: xor;
673 mask-composite: exclude;
674 pointer-events: none;
675 }
676 .prs-merge-card.is-closed::before { background: var(--border-strong); }
677 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
678 .prs-merge-head {
679 display: flex; align-items: center; gap: 12px;
680 margin-bottom: 12px;
681 }
682 .prs-merge-head strong {
683 font-family: var(--font-display);
684 font-size: 15px;
685 color: var(--text-strong);
686 font-weight: 700;
687 }
688 .prs-merge-sub {
689 font-size: 13px;
690 color: var(--text-muted);
691 margin: 0 0 12px;
692 }
693 .prs-merge-actions {
694 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
695 }
696 .prs-merge-btn {
697 display: inline-flex; align-items: center; gap: 6px;
698 padding: 9px 16px;
699 border-radius: 10px;
700 font-size: 13.5px;
701 font-weight: 600;
702 color: #fff;
703 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
704 border: 1px solid rgba(52,211,153,0.55);
705 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
706 cursor: pointer;
707 transition: transform 120ms ease, box-shadow 160ms ease;
708 }
709 .prs-merge-btn:hover {
710 transform: translateY(-1px);
711 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
712 }
713 .prs-merge-btn[disabled],
714 .prs-merge-btn.is-disabled {
715 opacity: 0.55;
716 cursor: not-allowed;
717 transform: none;
718 box-shadow: none;
719 }
720 .prs-merge-ready-btn {
721 display: inline-flex; align-items: center; gap: 6px;
722 padding: 9px 16px;
723 border-radius: 10px;
724 font-size: 13.5px;
725 font-weight: 600;
726 color: #fff;
727 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
728 border: 1px solid rgba(140,109,255,0.55);
729 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
730 cursor: pointer;
731 transition: transform 120ms ease, box-shadow 160ms ease;
732 }
733 .prs-merge-ready-btn:hover {
734 transform: translateY(-1px);
735 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
736 }
737 .prs-merge-back-draft {
738 background: none; border: 1px solid var(--border-strong);
739 color: var(--text-muted);
740 padding: 9px 14px; border-radius: 10px;
741 font-size: 13px; cursor: pointer;
742 }
743 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
744
0a67773Claude745 /* Review summary banner */
746 .prs-review-summary {
747 display: flex; flex-direction: column; gap: 6px;
748 padding: 12px 16px;
749 background: var(--bg-elevated);
750 border: 1px solid var(--border);
751 border-radius: var(--r-md, 8px);
752 margin-bottom: 12px;
753 }
754 .prs-review-row {
755 display: flex; align-items: center; gap: 10px;
756 font-size: 13px;
757 }
758 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
759 .prs-review-approved .prs-review-icon { color: #34d399; }
760 .prs-review-changes .prs-review-icon { color: #f87171; }
761
762 /* Review action buttons */
763 .prs-review-approve-btn {
764 display: inline-flex; align-items: center; gap: 5px;
765 padding: 8px 14px; border-radius: 8px; font-size: 13px;
766 font-weight: 600; cursor: pointer;
767 background: rgba(52,211,153,0.12);
768 color: #34d399;
769 border: 1px solid rgba(52,211,153,0.35);
770 transition: background 120ms;
771 }
772 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
773 .prs-review-changes-btn {
774 display: inline-flex; align-items: center; gap: 5px;
775 padding: 8px 14px; border-radius: 8px; font-size: 13px;
776 font-weight: 600; cursor: pointer;
777 background: rgba(248,113,113,0.10);
778 color: #f87171;
779 border: 1px solid rgba(248,113,113,0.30);
780 transition: background 120ms;
781 }
782 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
783
b078860Claude784 /* Inline form helpers */
785 .prs-inline-form { display: inline-flex; }
786
787 /* Comment composer */
788 .prs-composer { margin-top: 22px; }
789 .prs-composer textarea {
790 border-radius: 12px;
791 }
792
793 @media (max-width: 720px) {
794 .prs-detail-actions { margin-left: 0; }
795 .prs-merge-actions { width: 100%; }
796 .prs-merge-actions > * { flex: 1; min-width: 0; }
797 }
f1dc7c7Claude798
799 /* Additional mobile rules. Additive only. */
800 @media (max-width: 720px) {
801 .prs-detail-hero { padding: 18px; }
802 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
803 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
804 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
805 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
806 .prs-gate-name { min-width: 0; }
807 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
808 .prs-gate-summary { margin-left: 0; }
809 .prs-merge-btn,
810 .prs-merge-ready-btn,
811 .prs-merge-back-draft { min-height: 44px; }
812 .prs-comment-body { padding: 12px 14px; }
813 .prs-comment-head { padding: 10px 12px; }
814 .prs-files-card { padding: 12px 14px; }
815 }
3c03977Claude816
817 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
818 .live-pill {
819 display: inline-flex;
820 align-items: center;
821 gap: 8px;
822 padding: 4px 10px 4px 8px;
823 margin-left: 6px;
824 background: var(--bg-elevated);
825 border: 1px solid var(--border);
826 border-radius: 9999px;
827 font-size: 12px;
828 color: var(--text-muted);
829 line-height: 1;
830 vertical-align: middle;
831 }
832 .live-pill.is-busy { color: var(--text); }
833 .live-pill-dot {
834 width: 8px; height: 8px;
835 border-radius: 9999px;
836 background: #34d399;
837 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
838 animation: live-pulse 1.6s ease-in-out infinite;
839 }
840 @keyframes live-pulse {
841 0%, 100% { opacity: 1; }
842 50% { opacity: 0.55; }
843 }
844 .live-avatars {
845 display: inline-flex;
846 margin-left: 2px;
847 }
848 .live-avatar {
849 display: inline-flex;
850 align-items: center;
851 justify-content: center;
852 width: 22px; height: 22px;
853 border-radius: 9999px;
854 font-size: 10px;
855 font-weight: 700;
856 color: #0b1020;
857 margin-left: -6px;
858 border: 2px solid var(--bg-elevated);
859 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
860 }
861 .live-avatar:first-child { margin-left: 0; }
862 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
863 .live-cursor-host {
864 position: relative;
865 }
866 .live-cursor-overlay {
867 position: absolute;
868 inset: 0;
869 pointer-events: none;
870 overflow: hidden;
871 border-radius: inherit;
872 }
873 .live-cursor {
874 position: absolute;
875 width: 2px;
876 height: 18px;
877 border-radius: 2px;
878 transform: translate(-1px, 0);
879 transition: transform 80ms linear, opacity 200ms ease;
880 }
881 .live-cursor::after {
882 content: attr(data-label);
883 position: absolute;
884 top: -16px;
885 left: -2px;
886 font-size: 10px;
887 line-height: 1;
888 color: #0b1020;
889 background: inherit;
890 padding: 2px 5px;
891 border-radius: 4px 4px 4px 0;
892 white-space: nowrap;
893 font-weight: 600;
894 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
895 }
896 .live-cursor.is-idle { opacity: 0.4; }
897 .live-edit-tag {
898 display: inline-block;
899 margin-left: 6px;
900 padding: 1px 6px;
901 font-size: 10px;
902 font-weight: 600;
903 letter-spacing: 0.02em;
904 color: #0b1020;
905 border-radius: 9999px;
906 }
15db0e0Claude907
908 /* ─── Slash-command pill + composer hint ─── */
909 .slash-hint {
910 display: inline-flex;
911 align-items: center;
912 gap: 6px;
913 margin-top: 6px;
914 padding: 3px 9px;
915 font-size: 11.5px;
916 color: var(--text-muted);
917 background: var(--bg-elevated);
918 border: 1px dashed var(--border);
919 border-radius: 9999px;
920 width: fit-content;
921 }
922 .slash-hint code {
923 background: rgba(110, 168, 255, 0.12);
924 color: var(--text-strong);
925 padding: 0 5px;
926 border-radius: 4px;
927 font-size: 11px;
928 }
929 .slash-pill {
930 display: grid;
931 grid-template-columns: auto 1fr auto;
932 align-items: center;
933 column-gap: 10px;
934 row-gap: 6px;
935 margin: 10px 0;
936 padding: 10px 14px;
937 background: linear-gradient(
938 135deg,
939 rgba(110, 168, 255, 0.08),
940 rgba(163, 113, 247, 0.06)
941 );
942 border: 1px solid rgba(110, 168, 255, 0.32);
943 border-left: 3px solid var(--accent, #6ea8ff);
944 border-radius: var(--radius);
945 font-size: 13px;
946 color: var(--text);
947 }
948 .slash-pill-icon {
949 font-size: 14px;
950 line-height: 1;
951 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
952 }
953 .slash-pill-actor { color: var(--text-muted); }
954 .slash-pill-actor strong { color: var(--text-strong); }
955 .slash-pill-cmd {
956 background: rgba(110, 168, 255, 0.16);
957 color: var(--text-strong);
958 padding: 1px 6px;
959 border-radius: 4px;
960 font-size: 12.5px;
961 }
962 .slash-pill-time {
963 color: var(--text-muted);
964 font-size: 12px;
965 justify-self: end;
966 }
967 .slash-pill-body {
968 grid-column: 1 / -1;
969 color: var(--text);
970 font-size: 13px;
971 line-height: 1.55;
972 }
973 .slash-pill-body p:first-child { margin-top: 0; }
974 .slash-pill-body p:last-child { margin-bottom: 0; }
975 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
976 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
977 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
978 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude979
980 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
981 .preview-prpill {
982 display: inline-flex; align-items: center; gap: 6px;
983 padding: 3px 10px;
984 border-radius: 9999px;
985 font-family: var(--font-mono);
986 font-size: 11.5px;
987 font-weight: 600;
988 background: rgba(255,255,255,0.04);
989 color: var(--text-muted);
990 text-decoration: none;
991 border: 1px solid var(--border);
992 }
993 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
994 .preview-prpill .preview-prpill-dot {
995 width: 7px; height: 7px;
996 border-radius: 9999px;
997 background: currentColor;
998 }
999 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1000 .preview-prpill.is-building .preview-prpill-dot {
1001 animation: previewPrPulse 1.4s ease-in-out infinite;
1002 }
1003 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1004 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1005 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1006 @keyframes previewPrPulse {
1007 0%, 100% { opacity: 1; }
1008 50% { opacity: 0.4; }
1009 }
79ed944Claude1010
1011 /* ─── AI Trio Review — 3-column verdict cards ─── */
1012 .trio-wrap {
1013 margin-top: 18px;
1014 padding: 16px;
1015 background: var(--bg-elevated);
1016 border: 1px solid var(--border);
1017 border-radius: 14px;
1018 }
1019 .trio-header {
1020 display: flex; align-items: center; gap: 10px;
1021 margin: 0 0 12px;
1022 font-size: 13.5px;
1023 color: var(--text);
1024 }
1025 .trio-header strong { color: var(--text-strong); }
1026 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1027 .trio-header-dot {
1028 width: 8px; height: 8px; border-radius: 9999px;
1029 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1030 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1031 }
1032 .trio-grid {
1033 display: grid;
1034 grid-template-columns: repeat(3, minmax(0, 1fr));
1035 gap: 12px;
1036 }
1037 .trio-card {
1038 background: var(--bg-secondary);
1039 border: 1px solid var(--border);
1040 border-radius: 12px;
1041 overflow: hidden;
1042 display: flex; flex-direction: column;
1043 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1044 }
1045 .trio-card-head {
1046 display: flex; align-items: center; gap: 8px;
1047 padding: 10px 12px;
1048 border-bottom: 1px solid var(--border);
1049 background: rgba(255,255,255,0.02);
1050 font-size: 13px;
1051 }
1052 .trio-card-icon {
1053 display: inline-flex; align-items: center; justify-content: center;
1054 width: 22px; height: 22px;
1055 border-radius: 9999px;
1056 font-size: 12px;
1057 background: rgba(255,255,255,0.05);
1058 }
1059 .trio-card-title {
1060 color: var(--text-strong);
1061 font-weight: 600;
1062 letter-spacing: 0.01em;
1063 }
1064 .trio-card-verdict {
1065 margin-left: auto;
1066 font-size: 11px;
1067 font-weight: 700;
1068 letter-spacing: 0.06em;
1069 text-transform: uppercase;
1070 padding: 3px 9px;
1071 border-radius: 9999px;
1072 background: var(--bg-tertiary);
1073 color: var(--text-muted);
1074 border: 1px solid var(--border-strong);
1075 }
1076 .trio-card-body {
1077 padding: 12px 14px;
1078 font-size: 13px;
1079 color: var(--text);
1080 flex: 1;
1081 min-height: 64px;
1082 line-height: 1.55;
1083 }
1084 .trio-card-body p { margin: 0 0 8px; }
1085 .trio-card-body p:last-child { margin-bottom: 0; }
1086 .trio-card-body ul { margin: 0; padding-left: 18px; }
1087 .trio-card-body code {
1088 font-family: var(--font-mono);
1089 font-size: 12px;
1090 background: var(--bg-tertiary);
1091 padding: 1px 6px;
1092 border-radius: 5px;
1093 }
1094 .trio-card-empty {
1095 color: var(--text-muted);
1096 font-style: italic;
1097 font-size: 12.5px;
1098 }
1099
1100 /* Pass state — neutral, no accent. */
1101 .trio-card.is-pass .trio-card-verdict {
1102 color: var(--green);
1103 border-color: rgba(52,211,153,0.35);
1104 background: rgba(52,211,153,0.12);
1105 }
1106
1107 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1108 .trio-card.trio-security.is-fail {
1109 border-color: rgba(248,113,113,0.55);
1110 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1111 }
1112 .trio-card.trio-security.is-fail .trio-card-head {
1113 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1114 border-bottom-color: rgba(248,113,113,0.30);
1115 }
1116 .trio-card.trio-security.is-fail .trio-card-verdict {
1117 color: #fecaca;
1118 border-color: rgba(248,113,113,0.55);
1119 background: rgba(248,113,113,0.20);
1120 }
1121
1122 .trio-card.trio-correctness.is-fail {
1123 border-color: rgba(251,191,36,0.55);
1124 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1125 }
1126 .trio-card.trio-correctness.is-fail .trio-card-head {
1127 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1128 border-bottom-color: rgba(251,191,36,0.30);
1129 }
1130 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1131 color: #fde68a;
1132 border-color: rgba(251,191,36,0.55);
1133 background: rgba(251,191,36,0.20);
1134 }
1135
1136 .trio-card.trio-style.is-fail {
1137 border-color: rgba(96,165,250,0.55);
1138 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1139 }
1140 .trio-card.trio-style.is-fail .trio-card-head {
1141 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1142 border-bottom-color: rgba(96,165,250,0.30);
1143 }
1144 .trio-card.trio-style.is-fail .trio-card-verdict {
1145 color: #bfdbfe;
1146 border-color: rgba(96,165,250,0.55);
1147 background: rgba(96,165,250,0.20);
1148 }
1149
1150 /* Disagreement callout strip — yellow, prominent. */
1151 .trio-disagreement-strip {
1152 display: flex;
1153 gap: 12px;
1154 margin-top: 14px;
1155 padding: 12px 14px;
1156 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1157 border: 1px solid rgba(251,191,36,0.45);
1158 border-radius: 10px;
1159 color: var(--text);
1160 font-size: 13px;
1161 }
1162 .trio-disagreement-icon {
1163 flex: 0 0 auto;
1164 width: 26px; height: 26px;
1165 display: inline-flex; align-items: center; justify-content: center;
1166 border-radius: 9999px;
1167 background: rgba(251,191,36,0.25);
1168 color: #fde68a;
1169 font-size: 14px;
1170 }
1171 .trio-disagreement-body strong {
1172 display: block;
1173 color: #fde68a;
1174 margin: 0 0 4px;
1175 font-weight: 700;
1176 }
1177 .trio-disagreement-list {
1178 margin: 0;
1179 padding-left: 18px;
1180 color: var(--text);
1181 font-size: 12.5px;
1182 line-height: 1.55;
1183 }
1184 .trio-disagreement-list code {
1185 font-family: var(--font-mono);
1186 font-size: 11.5px;
1187 background: var(--bg-tertiary);
1188 padding: 1px 5px;
1189 border-radius: 4px;
1190 }
1191
1192 @media (max-width: 720px) {
1193 .trio-grid { grid-template-columns: 1fr; }
1194 .trio-wrap { padding: 12px; }
1195 }
b078860Claude1196`;
1197
81c73c1Claude1198/**
1199 * Tiny inline JS that drives the "Suggest description with AI" button.
1200 * On click, gathers form values, POSTs JSON to the given endpoint, and
1201 * pipes the response into the #pr-body textarea. All DOM lookups are
1202 * defensive — element absence is a silent no-op.
1203 *
1204 * Built as a string template so it lives next to its server-side caller
1205 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1206 * to avoid </script> breakouts.
1207 */
1208function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1209 const url = JSON.stringify(endpointUrl)
1210 .split("<").join("\\u003C")
1211 .split(">").join("\\u003E")
1212 .split("&").join("\\u0026");
1213 return (
1214 "(function(){try{" +
1215 "var btn=document.getElementById('ai-suggest-desc');" +
1216 "var status=document.getElementById('ai-suggest-status');" +
1217 "var body=document.getElementById('pr-body');" +
1218 "var form=btn&&btn.closest&&btn.closest('form');" +
1219 "if(!btn||!body||!form)return;" +
1220 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1221 "var fd=new FormData(form);" +
1222 "var title=String(fd.get('title')||'').trim();" +
1223 "var base=String(fd.get('base')||'').trim();" +
1224 "var head=String(fd.get('head')||'').trim();" +
1225 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1226 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1227 "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'})" +
1228 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1229 ".then(function(j){btn.disabled=false;" +
1230 "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;}}" +
1231 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1232 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1233 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1234 "});" +
1235 "}catch(e){}})();"
1236 );
1237}
1238
3c03977Claude1239/**
1240 * Live co-editing client. Connects to the per-PR SSE feed and:
1241 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1242 * status colour per user).
1243 * - Renders tinted cursor caret overlays inside #pr-body and every
1244 * `[data-live-field]` element.
1245 * - Broadcasts the local user's cursor position (selectionStart /
1246 * selectionEnd) debounced at 100ms.
1247 * - Broadcasts content patches (`replace` of the whole textarea —
1248 * last-write-wins v1) debounced at 250ms.
1249 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1250 * to the matching local field if untouched.
1251 *
1252 * All endpoint URLs are JSON-escaped via safe replacements so they
1253 * can't break out of the <script> tag.
1254 */
1255function LIVE_COEDIT_SCRIPT(prId: string): string {
1256 const idJson = JSON.stringify(prId)
1257 .split("<").join("\\u003C")
1258 .split(">").join("\\u003E")
1259 .split("&").join("\\u0026");
1260 return (
1261 "(function(){try{" +
1262 "if(typeof EventSource==='undefined')return;" +
1263 "var prId=" + idJson + ";" +
1264 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1265 "var pill=document.getElementById('live-pill');" +
1266 "var avEl=document.getElementById('live-avatars');" +
1267 "var countEl=document.getElementById('live-count');" +
1268 "var sessionId=null;var myColor=null;" +
1269 "var presence={};" + // sessionId -> {color,status,userId,initials}
1270 "var lastApplied={};" + // field -> last server value (for echo suppression)
1271 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1272 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1273 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1274 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1275 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1276 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1277 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1278 "avEl.innerHTML=html;}}" +
1279 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1280 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1281 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1282 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1283 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1284 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1285 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1286 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1287 "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);}" +
1288 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1289 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1290 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1291 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1292 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1293 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1294 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1295 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1296 "}catch(e){}" +
1297 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1298 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1299 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1300 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1301 "var es;var delay=1000;" +
1302 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1303 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1304 "(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){}});" +
1305 "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){}});" +
1306 "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){}});" +
1307 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1308 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1309 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1310 "var patch=d.patch;if(!patch||!patch.field)return;" +
1311 "var ta=fieldEl(patch.field);if(!ta)return;" +
1312 "if(document.activeElement===ta)return;" + // don't trample local typing
1313 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1314 "}catch(e){}});" +
1315 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1316 "}connect();" +
1317 "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){}}" +
1318 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1319 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1320 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1321 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1322 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1323 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1324 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1325 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1326 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1327 "}" +
1328 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1329 "var live=document.querySelectorAll('[data-live-field]');" +
1330 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1331 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1332 "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){}});" +
1333 "}catch(e){}})();"
1334 );
1335}
1336
0074234Claude1337async function resolveRepo(ownerName: string, repoName: string) {
1338 const [owner] = await db
1339 .select()
1340 .from(users)
1341 .where(eq(users.username, ownerName))
1342 .limit(1);
1343 if (!owner) return null;
1344 const [repo] = await db
1345 .select()
1346 .from(repositories)
1347 .where(
1348 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1349 )
1350 .limit(1);
1351 if (!repo) return null;
1352 return { owner, repo };
1353}
1354
1355// PR Nav helper
1356const PrNav = ({
1357 owner,
1358 repo,
1359 active,
1360}: {
1361 owner: string;
1362 repo: string;
1363 active: "code" | "issues" | "pulls" | "commits";
1364}) => (
bb0f894Claude1365 <TabNav
1366 tabs={[
1367 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1368 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1369 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1370 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1371 ]}
1372 />
0074234Claude1373);
1374
534f04aClaude1375/**
1376 * Block M3 — pre-merge risk score card. Pure presentational helper.
1377 * Rendered in the conversation tab above the gate checks block. Hidden
1378 * entirely when the PR is closed/merged or there is nothing cached and
1379 * nothing in-flight.
1380 */
1381function PrRiskCard({
1382 risk,
1383 calculating,
1384}: {
1385 risk: PrRiskScore | null;
1386 calculating: boolean;
1387}) {
1388 if (!risk) {
1389 return (
1390 <div
1391 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1392 >
1393 <strong style="font-size: 13px; color: var(--text)">
1394 Risk score: calculating…
1395 </strong>
1396 <div style="font-size: 12px; margin-top: 4px">
1397 Refresh in a moment to see the pre-merge risk score for this PR.
1398 </div>
1399 </div>
1400 );
1401 }
1402
1403 const palette = riskBandPalette(risk.band);
1404 const label = riskBandLabel(risk.band);
1405
1406 return (
1407 <div
1408 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1409 >
1410 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1411 <strong>Risk score:</strong>
1412 <span style={`color:${palette.border};font-weight:600`}>
1413 {palette.icon} {label} ({risk.score}/10)
1414 </span>
1415 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1416 {risk.commitSha.slice(0, 7)}
1417 </span>
1418 </div>
1419 {risk.aiSummary && (
1420 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1421 {risk.aiSummary}
1422 </div>
1423 )}
1424 <details style="margin-top:10px">
1425 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1426 See full signal breakdown
1427 </summary>
1428 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1429 <li>files changed: {risk.signals.filesChanged}</li>
1430 <li>
1431 lines added/removed: {risk.signals.linesAdded} /{" "}
1432 {risk.signals.linesRemoved}
1433 </li>
1434 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1435 <li>
1436 schema migration touched:{" "}
1437 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1438 </li>
1439 <li>
1440 locked / sensitive path touched:{" "}
1441 {risk.signals.lockedPathTouched ? "yes" : "no"}
1442 </li>
1443 <li>
1444 adds new dependency:{" "}
1445 {risk.signals.addsNewDependency ? "yes" : "no"}
1446 </li>
1447 <li>
1448 bumps major dependency:{" "}
1449 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1450 </li>
1451 <li>
1452 tests added for new code:{" "}
1453 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1454 </li>
1455 <li>
1456 diff-minus-test ratio:{" "}
1457 {risk.signals.diffMinusTestRatio.toFixed(2)}
1458 </li>
1459 </ul>
1460 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1461 How is this calculated? The score is a transparent sum of
1462 weighted signals — see <code>src/lib/pr-risk.ts</code>
1463 {" "}<code>computePrRiskScore</code>.
1464 </div>
1465 </details>
1466 {calculating && (
1467 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1468 (recomputing for the latest commit — refresh to update)
1469 </div>
1470 )}
1471 </div>
1472 );
1473}
1474
1475function riskBandPalette(band: PrRiskScore["band"]): {
1476 border: string;
1477 icon: string;
1478} {
1479 switch (band) {
1480 case "low":
1481 return { border: "var(--green)", icon: "" };
1482 case "medium":
1483 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1484 case "high":
1485 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1486 case "critical":
1487 return { border: "var(--red)", icon: "\u{1F6D1}" };
1488 }
1489}
1490
1491function riskBandLabel(band: PrRiskScore["band"]): string {
1492 switch (band) {
1493 case "low":
1494 return "LOW";
1495 case "medium":
1496 return "MEDIUM";
1497 case "high":
1498 return "HIGH";
1499 case "critical":
1500 return "CRITICAL";
1501 }
1502}
1503
422a2d4Claude1504// ---------------------------------------------------------------------------
1505// AI Trio Review — 3-column card grid + disagreement callout.
1506//
1507// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
1508// per run: one per persona (security/correctness/style) plus a top-level
1509// summary. We surface them here as a single grid above the normal
1510// comment stream so reviewers see the verdicts at a glance.
1511// ---------------------------------------------------------------------------
1512
1513const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
1514
1515interface TrioCommentLike {
1516 body: string;
1517}
1518
1519function isTrioComment(body: string | null | undefined): boolean {
1520 if (!body) return false;
1521 return (
1522 body.includes(TRIO_SUMMARY_MARKER) ||
1523 body.includes(TRIO_COMMENT_MARKER.security) ||
1524 body.includes(TRIO_COMMENT_MARKER.correctness) ||
1525 body.includes(TRIO_COMMENT_MARKER.style)
1526 );
1527}
1528
1529function trioPersonaOfComment(body: string): TrioPersona | null {
1530 for (const p of TRIO_PERSONAS) {
1531 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
1532 }
1533 return null;
1534}
1535
1536/**
1537 * Best-effort verdict parse from a persona comment body. The body shape
1538 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
1539 * we only need the "Pass" / "Fail" word from the H2 heading.
1540 */
1541function trioVerdictOfBody(body: string): "pass" | "fail" | null {
1542 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
1543 if (!m) return null;
1544 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
1545}
1546
1547/**
1548 * Parse the disagreement bullet list out of the summary comment so we
1549 * can render it as a polished callout strip. Returns [] when nothing
1550 * matches — the comment author may have edited the marker out.
1551 */
1552function parseDisagreements(summaryBody: string): Array<{
1553 file: string;
1554 failing: string;
1555 passing: string;
1556}> {
1557 const out: Array<{ file: string; failing: string; passing: string }> = [];
1558 // Each disagreement line looks like:
1559 // - `path:42` — security, style say ✗, correctness say ✓
1560 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
1561 let m: RegExpExecArray | null;
1562 while ((m = re.exec(summaryBody)) !== null) {
1563 out.push({
1564 file: m[1].trim(),
1565 failing: m[2].trim().replace(/[,\s]+$/g, ""),
1566 passing: m[3].trim().replace(/[,\s]+$/g, ""),
1567 });
1568 }
1569 return out;
1570}
1571
1572function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
1573 // Find the most recent persona comments + summary. We iterate from
1574 // the end so re-reviews (multiple runs on the same PR) display the
1575 // freshest verdict.
1576 const latest: Partial<Record<TrioPersona, string>> = {};
1577 let summaryBody: string | null = null;
1578 for (let i = comments.length - 1; i >= 0; i--) {
1579 const body = comments[i].body || "";
1580 if (!isTrioComment(body)) continue;
1581 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
1582 summaryBody = body;
1583 continue;
1584 }
1585 const persona = trioPersonaOfComment(body);
1586 if (persona && !latest[persona]) latest[persona] = body;
1587 }
1588 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1589 if (!anyPersona && !summaryBody) return null;
1590
1591 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
1592
1593 return (
1594 <div class="trio-wrap">
1595 <div class="trio-header">
1596 <span class="trio-header-dot" aria-hidden="true"></span>
1597 <strong>AI Trio Review</strong>
1598 <span class="trio-header-sub">
1599 Three independent reviewers ran in parallel.
1600 </span>
1601 </div>
1602 <div class="trio-grid">
1603 {TRIO_PERSONAS.map((persona) => {
1604 const body = latest[persona];
1605 const verdict = body ? trioVerdictOfBody(body) : null;
1606 const stateClass =
1607 verdict === "fail"
1608 ? "is-fail"
1609 : verdict === "pass"
1610 ? "is-pass"
1611 : "is-pending";
1612 return (
1613 <div class={`trio-card trio-${persona} ${stateClass}`}>
1614 <div class="trio-card-head">
1615 <span class="trio-card-icon" aria-hidden="true">
1616 {persona === "security"
1617 ? "🛡"
1618 : persona === "correctness"
1619 ? "✓"
1620 : "✎"}
1621 </span>
1622 <strong class="trio-card-title">
1623 {persona[0].toUpperCase() + persona.slice(1)}
1624 </strong>
1625 <span class="trio-card-verdict">
1626 {verdict === "pass"
1627 ? "Pass"
1628 : verdict === "fail"
1629 ? "Fail"
1630 : "Pending"}
1631 </span>
1632 </div>
1633 <div class="trio-card-body">
1634 {body ? (
1635 <MarkdownContent
1636 html={renderMarkdown(stripTrioHeading(body))}
1637 />
1638 ) : (
1639 <span class="trio-card-empty">
1640 Awaiting reviewer output.
1641 </span>
1642 )}
1643 </div>
1644 </div>
1645 );
1646 })}
1647 </div>
1648 {disagreements.length > 0 && (
1649 <div class="trio-disagreement-strip" role="note">
1650 <span class="trio-disagreement-icon" aria-hidden="true">
1651
1652 </span>
1653 <div class="trio-disagreement-body">
1654 <strong>Reviewers disagree — review carefully.</strong>
1655 <ul class="trio-disagreement-list">
1656 {disagreements.map((d) => (
1657 <li>
1658 <code>{d.file}</code> — {d.failing} says ✗,{" "}
1659 {d.passing} says ✓
1660 </li>
1661 ))}
1662 </ul>
1663 </div>
1664 </div>
1665 )}
1666 </div>
1667 );
1668}
1669
1670/**
1671 * Strip the marker comment + first H2 heading from a persona body so
1672 * the card body shows just the findings list (verdict is already in
1673 * the card head). Best-effort — malformed bodies render whole.
1674 */
1675function stripTrioHeading(body: string): string {
1676 return body
1677 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
1678 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
1679 .trim();
1680}
1681
0074234Claude1682// List PRs
04f6b7fClaude1683pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1684 const { owner: ownerName, repo: repoName } = c.req.param();
1685 const user = c.get("user");
1686 const state = c.req.query("state") || "open";
1687
ea9ed4cClaude1688 // ── Loading skeleton (flag-gated) ──
1689 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
1690 // the user see the page structure before counts + select resolve.
1691 // Behind a flag for now — we don't ship flashes.
1692 if (c.req.query("skeleton") === "1") {
1693 return c.html(
1694 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1695 <RepoHeader owner={ownerName} repo={repoName} />
1696 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1697 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1698 <style
1699 dangerouslySetInnerHTML={{
1700 __html: `
1701 .prs-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: prsSkelShimmer 1.4s infinite; border-radius: 6px; display: block; }
1702 @keyframes prsSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1703 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
1704 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
1705 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
1706 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
1707 .prs-skel-row { height: 76px; border-radius: 12px; }
1708 `,
1709 }}
1710 />
1711 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
1712 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
1713 <div class="prs-skel-list" aria-hidden="true">
1714 {Array.from({ length: 6 }).map(() => (
1715 <div class="prs-skel prs-skel-row" />
1716 ))}
1717 </div>
1718 <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">
1719 Loading pull requests for {ownerName}/{repoName}…
1720 </span>
1721 </Layout>
1722 );
1723 }
1724
0074234Claude1725 const resolved = await resolveRepo(ownerName, repoName);
1726 if (!resolved) return c.notFound();
1727
6fc53bdClaude1728 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
1729 const stateFilter =
1730 state === "draft"
1731 ? and(
1732 eq(pullRequests.state, "open"),
1733 eq(pullRequests.isDraft, true)
1734 )
1735 : eq(pullRequests.state, state);
1736
0074234Claude1737 const prList = await db
1738 .select({
1739 pr: pullRequests,
1740 author: { username: users.username },
1741 })
1742 .from(pullRequests)
1743 .innerJoin(users, eq(pullRequests.authorId, users.id))
1744 .where(
6fc53bdClaude1745 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude1746 )
1747 .orderBy(desc(pullRequests.createdAt));
1748
1749 const [counts] = await db
1750 .select({
1751 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude1752 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude1753 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
1754 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
1755 })
1756 .from(pullRequests)
1757 .where(eq(pullRequests.repositoryId, resolved.repo.id));
1758
b078860Claude1759 const openCount = counts?.open ?? 0;
1760 const mergedCount = counts?.merged ?? 0;
1761 const closedCount = counts?.closed ?? 0;
1762 const draftCount = counts?.draft ?? 0;
1763 const allCount = openCount + mergedCount + closedCount;
1764
1765 // "All" is presentational only — the DB query for state='all' matches
1766 // nothing, so we render a friendlier empty state when picked. We do NOT
1767 // change the query logic to keep this commit purely visual.
1768 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
1769 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
1770 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
1771 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
1772 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
1773 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
1774 ];
1775 const isAllState = state === "all";
cb5a796Claude1776 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
1777 const prListPendingCount = viewerIsOwnerOnPrList
1778 ? await countPendingForRepo(resolved.repo.id)
1779 : 0;
b078860Claude1780
0074234Claude1781 return c.html(
1782 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1783 <RepoHeader owner={ownerName} repo={repoName} />
1784 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude1785 <PendingCommentsBanner
1786 owner={ownerName}
1787 repo={repoName}
1788 count={prListPendingCount}
1789 />
b078860Claude1790 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1791
1792 <div class="prs-hero">
1793 <div class="prs-hero-inner">
1794 <div class="prs-hero-text">
1795 <div class="prs-hero-eyebrow">Pull requests</div>
1796 <h1 class="prs-hero-title">
1797 Review, <span class="gradient-text">merge with AI</span>.
1798 </h1>
1799 <p class="prs-hero-sub">
1800 {openCount === 0 && allCount === 0
1801 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
1802 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
1803 </p>
1804 </div>
1805 {user && (
1806 <div class="prs-hero-actions">
1807 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
1808 + New pull request
1809 </a>
1810 </div>
1811 )}
1812 </div>
1813 </div>
1814
1815 <nav class="prs-tabs" aria-label="Pull request filters">
1816 {tabPills.map((t) => {
1817 const isActive =
1818 state === t.key ||
1819 (t.key === "open" &&
1820 state !== "merged" &&
1821 state !== "closed" &&
1822 state !== "all" &&
1823 state !== "draft");
1824 return (
1825 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
1826 <span>{t.label}</span>
1827 <span class="prs-tab-count">{t.count}</span>
1828 </a>
1829 );
1830 })}
1831 </nav>
1832
0074234Claude1833 {prList.length === 0 ? (
b078860Claude1834 <div class="prs-empty">
ea9ed4cClaude1835 <div class="prs-empty-inner">
1836 <strong>
1837 {isAllState
1838 ? "Pick a filter above to browse PRs."
1839 : `No ${state} pull requests.`}
1840 </strong>
1841 <p class="prs-empty-sub">
1842 {state === "open"
1843 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
1844 : isAllState
1845 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1846 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
1847 </p>
1848 <div class="prs-empty-cta">
1849 {user && state === "open" && (
1850 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
1851 + New pull request
1852 </a>
1853 )}
1854 {state !== "open" && (
1855 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
1856 View open PRs
1857 </a>
1858 )}
1859 <a href={`/${ownerName}/${repoName}`} class="btn">
1860 Back to code
1861 </a>
1862 </div>
1863 </div>
b078860Claude1864 </div>
0074234Claude1865 ) : (
b078860Claude1866 <div class="prs-list">
1867 {prList.map(({ pr, author }) => {
1868 const stateClass =
1869 pr.state === "open"
1870 ? pr.isDraft
1871 ? "state-draft"
1872 : "state-open"
1873 : pr.state === "merged"
1874 ? "state-merged"
1875 : "state-closed";
1876 const icon =
1877 pr.state === "open"
1878 ? pr.isDraft
1879 ? "◌"
1880 : "○"
1881 : pr.state === "merged"
1882 ? "⮌"
1883 : "✓";
1884 return (
1885 <a
1886 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1887 class="prs-row"
1888 style="text-decoration:none;color:inherit"
0074234Claude1889 >
b078860Claude1890 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
1891 {icon}
0074234Claude1892 </div>
b078860Claude1893 <div class="prs-row-body">
1894 <h3 class="prs-row-title">
1895 <span>{pr.title}</span>
1896 <span class="prs-row-number">#{pr.number}</span>
1897 </h3>
1898 <div class="prs-row-meta">
1899 <span
1900 class="prs-branch-chips"
1901 title={`${pr.headBranch} into ${pr.baseBranch}`}
1902 >
1903 <span class="prs-branch-chip">{pr.headBranch}</span>
1904 <span class="prs-branch-arrow">{"→"}</span>
1905 <span class="prs-branch-chip">{pr.baseBranch}</span>
1906 </span>
1907 <span>
1908 by{" "}
1909 <strong style="color:var(--text)">
1910 {author.username}
1911 </strong>{" "}
1912 {formatRelative(pr.createdAt)}
1913 </span>
1914 <span class="prs-row-tags">
1915 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
1916 {pr.state === "merged" && (
1917 <span class="prs-tag is-merged">Merged</span>
1918 )}
1919 </span>
1920 </div>
0074234Claude1921 </div>
b078860Claude1922 </a>
1923 );
1924 })}
1925 </div>
0074234Claude1926 )}
1927 </Layout>
1928 );
1929});
1930
1931// New PR form
1932pulls.get(
1933 "/:owner/:repo/pulls/new",
1934 softAuth,
1935 requireAuth,
04f6b7fClaude1936 requireRepoAccess("write"),
0074234Claude1937 async (c) => {
1938 const { owner: ownerName, repo: repoName } = c.req.param();
1939 const user = c.get("user")!;
1940 const branches = await listBranches(ownerName, repoName);
1941 const error = c.req.query("error");
1942 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude1943 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude1944
1945 return c.html(
1946 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
1947 <RepoHeader owner={ownerName} repo={repoName} />
1948 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude1949 <Container maxWidth={800}>
1950 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude1951 {error && (
bb0f894Claude1952 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude1953 )}
0316dbbClaude1954 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
1955 <Flex gap={12} align="center" style="margin-bottom: 16px">
1956 <Select name="base">
0074234Claude1957 {branches.map((b) => (
1958 <option value={b} selected={b === defaultBase}>
1959 {b}
1960 </option>
1961 ))}
bb0f894Claude1962 </Select>
1963 <Text muted>&larr;</Text>
1964 <Select name="head">
0074234Claude1965 {branches
1966 .filter((b) => b !== defaultBase)
1967 .concat(defaultBase === branches[0] ? [] : [branches[0]])
1968 .map((b) => (
1969 <option value={b}>{b}</option>
1970 ))}
bb0f894Claude1971 </Select>
1972 </Flex>
1973 <FormGroup>
1974 <Input
0074234Claude1975 name="title"
1976 required
1977 placeholder="Title"
bb0f894Claude1978 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]1979 aria-label="Pull request title"
0074234Claude1980 />
bb0f894Claude1981 </FormGroup>
1982 <FormGroup>
1983 <TextArea
0074234Claude1984 name="body"
81c73c1Claude1985 id="pr-body"
0074234Claude1986 rows={8}
1987 placeholder="Description (Markdown supported)"
bb0f894Claude1988 mono
0074234Claude1989 />
bb0f894Claude1990 </FormGroup>
81c73c1Claude1991 <Flex gap={8} align="center">
1992 <Button type="submit" variant="primary">
1993 Create pull request
1994 </Button>
1995 <button
1996 type="button"
1997 id="ai-suggest-desc"
1998 class="btn"
1999 style="font-weight:500"
2000 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
2001 >
2002 Suggest description with AI
2003 </button>
2004 <span
2005 id="ai-suggest-status"
2006 style="color:var(--text-muted);font-size:13px"
2007 />
2008 </Flex>
bb0f894Claude2009 </Form>
81c73c1Claude2010 <script
2011 dangerouslySetInnerHTML={{
2012 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
2013 }}
2014 />
bb0f894Claude2015 </Container>
0074234Claude2016 </Layout>
2017 );
2018 }
2019);
2020
81c73c1Claude2021// AI-suggested PR description — JSON endpoint driven by the form button.
2022// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
2023// 200; the inline script reads `ok` to decide what to do.
2024pulls.post(
2025 "/:owner/:repo/ai/pr-description",
2026 softAuth,
2027 requireAuth,
2028 requireRepoAccess("write"),
2029 async (c) => {
2030 const { owner: ownerName, repo: repoName } = c.req.param();
2031 if (!isAiAvailable()) {
2032 return c.json({
2033 ok: false,
2034 error: "AI is not available — set ANTHROPIC_API_KEY.",
2035 });
2036 }
2037 const body = await c.req.parseBody();
2038 const title = String(body.title || "").trim();
2039 const baseBranch = String(body.base || "").trim();
2040 const headBranch = String(body.head || "").trim();
2041 if (!baseBranch || !headBranch) {
2042 return c.json({ ok: false, error: "Pick base + head branches first." });
2043 }
2044 if (baseBranch === headBranch) {
2045 return c.json({ ok: false, error: "Base and head must differ." });
2046 }
2047
2048 let diff = "";
2049 try {
2050 const cwd = getRepoPath(ownerName, repoName);
2051 const proc = Bun.spawn(
2052 [
2053 "git",
2054 "diff",
2055 `${baseBranch}...${headBranch}`,
2056 "--",
2057 ],
2058 { cwd, stdout: "pipe", stderr: "pipe" }
2059 );
6ea2109Claude2060 // 30s ceiling — without this a pathological diff (huge binary or
2061 // a corrupt ref) hangs the request indefinitely.
2062 const killer = setTimeout(() => proc.kill(), 30_000);
2063 try {
2064 diff = await new Response(proc.stdout).text();
2065 await proc.exited;
2066 } finally {
2067 clearTimeout(killer);
2068 }
81c73c1Claude2069 } catch {
2070 diff = "";
2071 }
2072 if (!diff.trim()) {
2073 return c.json({
2074 ok: false,
2075 error: "No diff between branches — nothing to summarise.",
2076 });
2077 }
2078
2079 let summary = "";
2080 try {
2081 summary = await generatePrSummary(title || "(untitled)", diff);
2082 } catch (err) {
2083 const msg = err instanceof Error ? err.message : "AI request failed.";
2084 return c.json({ ok: false, error: msg });
2085 }
2086 if (!summary.trim()) {
2087 return c.json({ ok: false, error: "AI returned an empty draft." });
2088 }
2089 return c.json({ ok: true, body: summary });
2090 }
2091);
2092
0074234Claude2093// Create PR
2094pulls.post(
2095 "/:owner/:repo/pulls/new",
2096 softAuth,
2097 requireAuth,
04f6b7fClaude2098 requireRepoAccess("write"),
0074234Claude2099 async (c) => {
2100 const { owner: ownerName, repo: repoName } = c.req.param();
2101 const user = c.get("user")!;
2102 const body = await c.req.parseBody();
2103 const title = String(body.title || "").trim();
2104 const prBody = String(body.body || "").trim();
2105 const baseBranch = String(body.base || "main");
2106 const headBranch = String(body.head || "");
2107
2108 if (!title || !headBranch) {
2109 return c.redirect(
2110 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
2111 );
2112 }
2113
2114 if (baseBranch === headBranch) {
2115 return c.redirect(
2116 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
2117 );
2118 }
2119
2120 const resolved = await resolveRepo(ownerName, repoName);
2121 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2122
6fc53bdClaude2123 const isDraft = String(body.draft || "") === "1";
2124
0074234Claude2125 const [pr] = await db
2126 .insert(pullRequests)
2127 .values({
2128 repositoryId: resolved.repo.id,
2129 authorId: user.id,
2130 title,
2131 body: prBody || null,
2132 baseBranch,
2133 headBranch,
6fc53bdClaude2134 isDraft,
0074234Claude2135 })
2136 .returning();
2137
6fc53bdClaude2138 // Skip AI review on drafts — it runs again when the PR is marked ready.
2139 if (!isDraft && isAiReviewEnabled()) {
e883329Claude2140 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
2141 (err) => console.error("[ai-review] Failed:", err)
2142 );
2143 }
2144
3cbe3d6Claude2145 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
2146 triggerPrTriage({
2147 ownerName,
2148 repoName,
2149 repositoryId: resolved.repo.id,
2150 prId: pr.id,
2151 prAuthorId: user.id,
2152 title,
2153 body: prBody,
2154 baseBranch,
2155 headBranch,
2156 }).catch((err) => console.error("[pr-triage] Failed:", err));
2157
1d4ff60Claude2158 // Chat notifier — fan out to Slack/Discord/Teams.
2159 import("../lib/chat-notifier")
2160 .then((m) =>
2161 m.notifyChatChannels({
2162 ownerUserId: resolved.repo.ownerId,
2163 repositoryId: resolved.repo.id,
2164 event: {
2165 event: "pr.opened",
2166 repo: `${ownerName}/${repoName}`,
2167 title: `#${pr.number} ${title}`,
2168 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
2169 body: prBody || undefined,
2170 actor: user.username,
2171 },
2172 })
2173 )
2174 .catch((err) =>
2175 console.warn(`[chat-notifier] PR opened notify failed:`, err)
2176 );
2177
9dd96b9Test User2178 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude2179 import("../lib/auto-merge")
2180 .then((m) => m.tryAutoMergeNow(pr.id))
2181 .catch((err) => {
2182 console.warn(
2183 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
2184 err instanceof Error ? err.message : err
2185 );
2186 });
9dd96b9Test User2187
0074234Claude2188 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
2189 }
2190);
2191
2192// View single PR
04f6b7fClaude2193pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2194 const { owner: ownerName, repo: repoName } = c.req.param();
2195 const prNum = parseInt(c.req.param("number"), 10);
2196 const user = c.get("user");
2197 const tab = c.req.query("tab") || "conversation";
2198
2199 const resolved = await resolveRepo(ownerName, repoName);
2200 if (!resolved) return c.notFound();
2201
2202 const [pr] = await db
2203 .select()
2204 .from(pullRequests)
2205 .where(
2206 and(
2207 eq(pullRequests.repositoryId, resolved.repo.id),
2208 eq(pullRequests.number, prNum)
2209 )
2210 )
2211 .limit(1);
2212
2213 if (!pr) return c.notFound();
2214
2215 const [author] = await db
2216 .select()
2217 .from(users)
2218 .where(eq(users.id, pr.authorId))
2219 .limit(1);
2220
cb5a796Claude2221 const allCommentsRaw = await db
0074234Claude2222 .select({
2223 comment: prComments,
cb5a796Claude2224 author: { id: users.id, username: users.username },
0074234Claude2225 })
2226 .from(prComments)
2227 .innerJoin(users, eq(prComments.authorId, users.id))
2228 .where(eq(prComments.pullRequestId, pr.id))
2229 .orderBy(asc(prComments.createdAt));
2230
cb5a796Claude2231 // Filter pending/rejected/spam for non-owner, non-author viewers.
2232 // Owner always sees everything; comment author sees their own pending
2233 // with an "Awaiting approval" badge in the render below.
2234 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
2235 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
2236 if (viewerIsRepoOwner) return true;
2237 if (comment.moderationStatus === "approved") return true;
2238 if (
2239 user &&
2240 cAuthor.id === user.id &&
2241 comment.moderationStatus === "pending"
2242 ) {
2243 return true;
2244 }
2245 return false;
2246 });
2247 const prPendingCount = viewerIsRepoOwner
2248 ? await countPendingForRepo(resolved.repo.id)
2249 : 0;
2250
6fc53bdClaude2251 // Reactions for the PR body + each comment, in parallel.
2252 const [prReactions, ...prCommentReactions] = await Promise.all([
2253 summariseReactions("pr", pr.id, user?.id),
2254 ...comments.map((row) =>
2255 summariseReactions("pr_comment", row.comment.id, user?.id)
2256 ),
2257 ]);
2258
0a67773Claude2259 // Formal reviews (Approve / Request Changes)
2260 const reviewRows = await db
2261 .select({
2262 id: prReviews.id,
2263 state: prReviews.state,
2264 body: prReviews.body,
2265 isAi: prReviews.isAi,
2266 createdAt: prReviews.createdAt,
2267 reviewerUsername: users.username,
2268 reviewerId: prReviews.reviewerId,
2269 })
2270 .from(prReviews)
2271 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2272 .where(eq(prReviews.pullRequestId, pr.id))
2273 .orderBy(asc(prReviews.createdAt));
2274 // Most recent review per reviewer determines the current state
2275 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
2276 for (const r of reviewRows) {
2277 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
2278 }
2279 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
2280 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
2281 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
2282
0074234Claude2283 const canManage =
2284 user &&
2285 (user.id === resolved.owner.id || user.id === pr.authorId);
2286
1d4ff60Claude2287 // Has any previous AI-test-generator run already tagged this PR? Used
2288 // both to hide the "Generate tests with AI" button and to short-circuit
2289 // the explicit POST handler.
2290 const hasAiTestsMarker = comments.some(({ comment }) =>
2291 (comment.body || "").includes(AI_TESTS_MARKER)
2292 );
2293
e883329Claude2294 const error = c.req.query("error");
c3e0c07Claude2295 const info = c.req.query("info");
e883329Claude2296
2297 // Get gate check status for open PRs
2298 let gateChecks: GateCheckResult[] = [];
2299 if (pr.state === "open") {
2300 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2301 if (headSha) {
2302 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
2303 const aiApproved = aiComments.length === 0 || aiComments.some(
2304 ({ comment }) => comment.body.includes("**Approved**")
2305 );
2306 const gateResult = await runAllGateChecks(
2307 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
2308 );
2309 gateChecks = gateResult.checks;
2310 }
2311 }
2312
534f04aClaude2313 // Block M3 — pre-merge risk score. Cache-only on the request path so
2314 // the page never waits on Haiku. On a cache miss for an open PR we
2315 // kick off the computation fire-and-forget; the next refresh shows it.
2316 let prRisk: PrRiskScore | null = null;
2317 let prRiskCalculating = false;
2318 if (pr.state === "open") {
2319 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
2320 if (!prRisk) {
2321 prRiskCalculating = true;
a28cedeClaude2322 void computePrRiskForPullRequest(pr.id).catch((err) => {
2323 console.warn(
2324 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
2325 err instanceof Error ? err.message : err
2326 );
2327 });
534f04aClaude2328 }
2329 }
2330
4bbacbeClaude2331 // Migration 0062 — per-branch preview URL. The head branch always
2332 // has a preview row (unless it's the default branch, which never
2333 // happens for an open PR) once it has been pushed at least once.
2334 const preview = await getPreviewForBranch(
2335 (resolved.repo as { id: string }).id,
2336 pr.headBranch
2337 );
2338
47a7a0aClaude2339 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude2340 let diffRaw = "";
2341 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude2342 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude2343 if (tab === "files") {
2344 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude2345 // Run the two git diffs in parallel — they're independent reads of
2346 // the same range. Previously sequential, doubling the wall time on
2347 // big PRs (100+ files = 10-30s for no reason).
0074234Claude2348 const proc = Bun.spawn(
2349 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
2350 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2351 );
2352 const statProc = Bun.spawn(
2353 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
2354 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2355 );
6ea2109Claude2356 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
2357 // would otherwise hang the whole request.
2358 const killer = setTimeout(() => {
2359 proc.kill();
2360 statProc.kill();
2361 }, 30_000);
2362 let stat = "";
2363 try {
2364 [diffRaw, stat] = await Promise.all([
2365 new Response(proc.stdout).text(),
2366 new Response(statProc.stdout).text(),
2367 ]);
2368 await Promise.all([proc.exited, statProc.exited]);
2369 } finally {
2370 clearTimeout(killer);
2371 }
0074234Claude2372
2373 diffFiles = stat
2374 .trim()
2375 .split("\n")
2376 .filter(Boolean)
2377 .map((line) => {
2378 const [add, del, filePath] = line.split("\t");
2379 return {
2380 path: filePath,
2381 status: "modified",
2382 additions: add === "-" ? 0 : parseInt(add, 10),
2383 deletions: del === "-" ? 0 : parseInt(del, 10),
2384 patch: "",
2385 };
2386 });
47a7a0aClaude2387
2388 // Fetch inline comments (file+line anchored) for the files tab
2389 const inlineRows = await db
2390 .select({
2391 id: prComments.id,
2392 filePath: prComments.filePath,
2393 lineNumber: prComments.lineNumber,
2394 body: prComments.body,
2395 isAiReview: prComments.isAiReview,
2396 createdAt: prComments.createdAt,
2397 authorUsername: users.username,
2398 })
2399 .from(prComments)
2400 .innerJoin(users, eq(prComments.authorId, users.id))
2401 .where(
2402 and(
2403 eq(prComments.pullRequestId, pr.id),
2404 eq(prComments.moderationStatus, "approved"),
2405 )
2406 )
2407 .orderBy(asc(prComments.createdAt));
2408
2409 diffInlineComments = inlineRows
2410 .filter(r => r.filePath != null && r.lineNumber != null)
2411 .map(r => ({
2412 id: r.id,
2413 filePath: r.filePath!,
2414 lineNumber: r.lineNumber!,
2415 authorUsername: r.authorUsername,
2416 body: renderMarkdown(r.body),
2417 isAiReview: r.isAiReview,
2418 createdAt: r.createdAt.toISOString(),
2419 }));
0074234Claude2420 }
2421
b078860Claude2422 // ─── Derived visual state ───
2423 const stateKey =
2424 pr.state === "open"
2425 ? pr.isDraft
2426 ? "draft"
2427 : "open"
2428 : pr.state;
2429 const stateLabel =
2430 stateKey === "open"
2431 ? "Open"
2432 : stateKey === "draft"
2433 ? "Draft"
2434 : stateKey === "merged"
2435 ? "Merged"
2436 : "Closed";
2437 const stateIcon =
2438 stateKey === "open"
2439 ? "○"
2440 : stateKey === "draft"
2441 ? "◌"
2442 : stateKey === "merged"
2443 ? "⮌"
2444 : "✓";
2445 const commentCount = comments.length;
2446 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
2447 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
2448 const mergeBlocked =
2449 gateChecks.length > 0 &&
2450 gateChecks.some(
2451 (c) => !c.passed && c.name !== "Merge check"
2452 );
2453
0074234Claude2454 return c.html(
2455 <Layout
2456 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
2457 user={user}
2458 >
2459 <RepoHeader owner={ownerName} repo={repoName} />
2460 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2461 <PendingCommentsBanner
2462 owner={ownerName}
2463 repo={repoName}
2464 count={prPendingCount}
2465 />
b078860Claude2466 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude2467 <div
2468 id="live-comment-banner"
2469 class="alert"
2470 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
2471 >
2472 <strong class="js-live-count">0</strong> new comment(s) —{" "}
2473 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
2474 reload to view
2475 </a>
2476 </div>
2477 <script
2478 dangerouslySetInnerHTML={{
2479 __html: liveCommentBannerScript({
2480 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
2481 bannerElementId: "live-comment-banner",
2482 }),
2483 }}
2484 />
b078860Claude2485
2486 <div class="prs-detail-hero">
2487 <h1 class="prs-detail-title">
0074234Claude2488 {pr.title}{" "}
b078860Claude2489 <span class="prs-detail-num">#{pr.number}</span>
2490 </h1>
2491 <div class="prs-detail-meta">
2492 <span class={`prs-state-pill state-${stateKey}`}>
2493 <span aria-hidden="true">{stateIcon}</span>
2494 <span>{stateLabel}</span>
2495 </span>
2496 <span>
2497 <strong>{author?.username}</strong> wants to merge
2498 </span>
2499 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
2500 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
2501 <span class="prs-branch-arrow-lg">{"→"}</span>
2502 <span class="prs-branch-pill">{pr.baseBranch}</span>
2503 </span>
2504 <span>opened {formatRelative(pr.createdAt)}</span>
3c03977Claude2505 <span
2506 id="live-pill"
2507 class="live-pill"
2508 title="People editing this PR right now"
2509 >
2510 <span class="live-pill-dot" aria-hidden="true"></span>
2511 <span>
2512 Live: <strong id="live-count">0</strong> editing
2513 </span>
2514 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
2515 </span>
4bbacbeClaude2516 {preview && (
2517 <a
2518 class={`preview-prpill is-${preview.status}`}
2519 href={
2520 preview.status === "ready"
2521 ? preview.previewUrl
2522 : `/${ownerName}/${repoName}/previews`
2523 }
2524 target={preview.status === "ready" ? "_blank" : undefined}
2525 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
2526 title={`Preview · ${previewStatusLabel(preview.status)}`}
2527 >
2528 <span class="preview-prpill-dot" aria-hidden="true"></span>
2529 <span>Preview: </span>
2530 <span>{previewStatusLabel(preview.status)}</span>
2531 </a>
2532 )}
b078860Claude2533 {canManage && pr.state === "open" && pr.isDraft && (
2534 <form
2535 method="post"
2536 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2537 class="prs-inline-form prs-detail-actions"
2538 >
2539 <button type="submit" class="prs-merge-ready-btn">
2540 Ready for review
2541 </button>
2542 </form>
2543 )}
2544 </div>
2545 </div>
3c03977Claude2546 <script
2547 dangerouslySetInnerHTML={{
2548 __html: LIVE_COEDIT_SCRIPT(pr.id),
2549 }}
2550 />
0074234Claude2551
b078860Claude2552 <nav class="prs-detail-tabs" aria-label="Pull request sections">
2553 <a
2554 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
2555 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2556 >
2557 Conversation
2558 <span class="prs-detail-tab-count">{commentCount}</span>
2559 </a>
2560 <a
2561 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
2562 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2563 >
2564 Files changed
2565 {diffFiles.length > 0 && (
2566 <span class="prs-detail-tab-count">{diffFiles.length}</span>
2567 )}
2568 </a>
2569 </nav>
2570
2571 {tab === "files" ? (
ea9ed4cClaude2572 <DiffView
2573 raw={diffRaw}
2574 files={diffFiles}
2575 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
47a7a0aClaude2576 inlineComments={diffInlineComments}
2577 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
ea9ed4cClaude2578 />
b078860Claude2579 ) : (
2580 <>
2581 {pr.body && (
2582 <CommentBox
2583 author={author?.username ?? "unknown"}
2584 date={pr.createdAt}
2585 body={renderMarkdown(pr.body)}
2586 />
2587 )}
2588
422a2d4Claude2589 {/* Block H — AI trio review (security/correctness/style). When
2590 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
2591 hoisted into a 3-column card grid above the normal comment
2592 stream so reviewers see verdicts at a glance. Disagreements
2593 are surfaced as a yellow callout. */}
2594 <TrioReviewGrid
2595 comments={comments.map(({ comment }) => comment)}
2596 />
2597
15db0e0Claude2598 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude2599 // Skip trio comments — already rendered in TrioReviewGrid above.
2600 if (isTrioComment(comment.body)) return null;
15db0e0Claude2601 const slashCmd = detectSlashCmdComment(comment.body);
2602 if (slashCmd) {
2603 const visible = stripSlashCmdMarker(comment.body);
2604 return (
2605 <div class={`slash-pill slash-cmd-${slashCmd}`}>
2606 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
2607 <span class="slash-pill-actor">
2608 <strong>{commentAuthor.username}</strong>
2609 {" ran "}
2610 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude2611 </span>
15db0e0Claude2612 <span class="slash-pill-time">
2613 {formatRelative(comment.createdAt)}
2614 </span>
2615 <div class="slash-pill-body">
2616 <MarkdownContent html={renderMarkdown(visible)} />
2617 </div>
2618 </div>
2619 );
2620 }
cb5a796Claude2621 const isPending = comment.moderationStatus === "pending";
15db0e0Claude2622 return (
cb5a796Claude2623 <div
2624 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
2625 >
15db0e0Claude2626 <div class="prs-comment-head">
2627 <strong>{commentAuthor.username}</strong>
2628 {comment.isAiReview && (
2629 <span class="prs-ai-badge">AI Review</span>
2630 )}
cb5a796Claude2631 {isPending && (
2632 <span
2633 class="modq-pending-badge"
2634 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
2635 >
2636 Awaiting approval
2637 </span>
2638 )}
15db0e0Claude2639 <span class="prs-comment-time">
2640 commented {formatRelative(comment.createdAt)}
2641 </span>
2642 {comment.filePath && (
2643 <span class="prs-comment-loc">
2644 {comment.filePath}
2645 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
2646 </span>
2647 )}
2648 </div>
2649 <div class="prs-comment-body">
2650 <MarkdownContent html={renderMarkdown(comment.body)} />
2651 </div>
0074234Claude2652 </div>
15db0e0Claude2653 );
2654 })}
0074234Claude2655
b078860Claude2656 {/* Quick link to the Files changed tab when there's a diff to look at. */}
2657 {pr.state !== "merged" && (
2658 <a
2659 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2660 class="prs-files-card"
2661 >
2662 <span class="prs-files-card-icon" aria-hidden="true">
2663 {"▤"}
2664 </span>
2665 <div class="prs-files-card-text">
2666 <p class="prs-files-card-title">Files changed</p>
2667 <p class="prs-files-card-sub">
2668 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
2669 </p>
e883329Claude2670 </div>
b078860Claude2671 <span class="prs-files-card-cta">View diff {"→"}</span>
2672 </a>
2673 )}
2674
2675 {error && (
2676 <div
2677 class="auth-error"
2678 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)"
2679 >
2680 {decodeURIComponent(error)}
2681 </div>
2682 )}
2683
2684 {info && (
2685 <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)">
2686 {decodeURIComponent(info)}
2687 </div>
2688 )}
e883329Claude2689
b078860Claude2690 {pr.state === "open" && (prRisk || prRiskCalculating) && (
2691 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
2692 )}
2693
0a67773Claude2694 {/* ─── Review summary ─────────────────────────────────── */}
2695 {(approvals.length > 0 || changesRequested.length > 0) && (
2696 <div class="prs-review-summary">
2697 {approvals.length > 0 && (
2698 <div class="prs-review-row prs-review-approved">
2699 <span class="prs-review-icon">✓</span>
2700 <span>
2701 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
2702 approved this pull request
2703 </span>
2704 </div>
2705 )}
2706 {changesRequested.length > 0 && (
2707 <div class="prs-review-row prs-review-changes">
2708 <span class="prs-review-icon">✗</span>
2709 <span>
2710 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
2711 requested changes
2712 </span>
2713 </div>
2714 )}
2715 </div>
2716 )}
2717
b078860Claude2718 {pr.state === "open" && gateChecks.length > 0 && (
2719 <div class="prs-gate-card">
2720 <div class="prs-gate-head">
2721 <h3>Gate checks</h3>
2722 <span class="prs-gate-summary">
2723 {gatesAllPassed
2724 ? `All ${gateChecks.length} checks passed`
2725 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
2726 </span>
c3e0c07Claude2727 </div>
b078860Claude2728 {gateChecks.map((check) => {
2729 const isAi = /ai.*review/i.test(check.name);
2730 const isSkip = check.skipped === true;
2731 const statusClass = isSkip
2732 ? "is-skip"
2733 : check.passed
2734 ? "is-pass"
2735 : "is-fail";
2736 const statusGlyph = isSkip
2737 ? "—"
2738 : check.passed
2739 ? "✓"
2740 : "✗";
2741 const statusLabel = isSkip
2742 ? "Skipped"
2743 : check.passed
2744 ? "Passed"
2745 : "Failing";
2746 return (
2747 <div
2748 class="prs-gate-row"
2749 style={
2750 isAi
2751 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
2752 : ""
2753 }
2754 >
2755 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
2756 {statusGlyph}
2757 </span>
2758 <span class="prs-gate-name">
2759 {check.name}
2760 {isAi && (
2761 <span
2762 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"
2763 >
2764 AI
2765 </span>
2766 )}
2767 </span>
2768 <span class="prs-gate-details">{check.details}</span>
2769 <span class={`prs-gate-pill ${statusClass}`}>
2770 {statusLabel}
e883329Claude2771 </span>
2772 </div>
b078860Claude2773 );
2774 })}
2775 <div class="prs-gate-footer">
2776 {gatesAllPassed
2777 ? "All checks passed — ready to merge."
2778 : gateChecks.some(
2779 (c) => !c.passed && c.name === "Merge check"
2780 )
2781 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
2782 : "Some checks failed — resolve issues before merging."}
2783 {aiReviewCount > 0 && (
2784 <>
2785 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
2786 </>
2787 )}
2788 </div>
2789 </div>
2790 )}
2791
2792 {/* ─── Merge area / state-aware action card ─────────────── */}
2793 {user && pr.state === "open" && (
2794 <div
2795 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
2796 >
2797 <div class="prs-merge-head">
2798 <strong>
2799 {pr.isDraft
2800 ? "Draft — ready for review?"
2801 : mergeBlocked
2802 ? "Merge blocked"
2803 : "Ready to merge"}
2804 </strong>
e883329Claude2805 </div>
b078860Claude2806 <p class="prs-merge-sub">
2807 {pr.isDraft
2808 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
2809 : mergeBlocked
2810 ? "Resolve the failing gate checks above before this PR can land."
2811 : gateChecks.length > 0
2812 ? gatesAllPassed
2813 ? "All gates green. Merge will fast-forward into the base branch."
2814 : "Conflicts will be auto-resolved by GlueCron AI on merge."
2815 : "Run gate checks by refreshing once your branch has a recent commit."}
2816 </p>
2817 <Form
2818 method="post"
2819 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
2820 >
2821 <FormGroup>
3c03977Claude2822 <div class="live-cursor-host" style="position:relative">
2823 <textarea
2824 name="body"
2825 id="pr-comment-body"
2826 data-live-field="comment_new"
2827 rows={5}
2828 required
2829 placeholder="Leave a comment... (Markdown supported)"
2830 style="font-family:var(--font-mono);font-size:13px;width:100%"
2831 ></textarea>
2832 </div>
15db0e0Claude2833 <span class="slash-hint" title="Type a slash-command as the first line">
2834 Type <code>/</code> for commands —{" "}
2835 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
2836 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
2837 </span>
b078860Claude2838 </FormGroup>
2839 <div class="prs-merge-actions">
2840 <Button type="submit" variant="primary">
2841 Comment
2842 </Button>
0a67773Claude2843 {user && user.id !== pr.authorId && pr.state === "open" && (
2844 <>
2845 <button
2846 type="submit"
2847 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
2848 name="review_state"
2849 value="approved"
2850 class="prs-review-approve-btn"
2851 title="Approve this pull request"
2852 >
2853 ✓ Approve
2854 </button>
2855 <button
2856 type="submit"
2857 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
2858 name="review_state"
2859 value="changes_requested"
2860 class="prs-review-changes-btn"
2861 title="Request changes before merging"
2862 >
2863 ✗ Request changes
2864 </button>
2865 </>
2866 )}
b078860Claude2867 {canManage && (
2868 <>
2869 {pr.isDraft ? (
2870 <button
2871 type="submit"
2872 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2873 formnovalidate
2874 class="prs-merge-ready-btn"
2875 >
2876 Ready for review
2877 </button>
2878 ) : (
0074234Claude2879 <button
2880 type="submit"
2881 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
b078860Claude2882 formnovalidate
2883 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
2884 title={
2885 mergeBlocked
2886 ? "Failing gate checks must be resolved before this PR can merge."
2887 : "Merge pull request"
2888 }
0074234Claude2889 >
b078860Claude2890 {"✔"} Merge pull request
0074234Claude2891 </button>
b078860Claude2892 )}
2893 {!pr.isDraft && (
2894 <button
0074234Claude2895 type="submit"
b078860Claude2896 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
2897 formnovalidate
2898 class="prs-merge-back-draft"
2899 title="Convert back to draft"
0074234Claude2900 >
b078860Claude2901 Convert to draft
2902 </button>
2903 )}
2904 {isAiReviewEnabled() && (
2905 <button
2906 type="submit"
2907 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
2908 formnovalidate
2909 class="btn"
2910 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
2911 >
2912 Re-run AI review
2913 </button>
2914 )}
1d4ff60Claude2915 {isAiReviewEnabled() && !hasAiTestsMarker && (
2916 <button
2917 type="submit"
2918 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
2919 formnovalidate
2920 class="btn"
2921 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."
2922 >
2923 Generate tests with AI
2924 </button>
2925 )}
b078860Claude2926 <Button
2927 type="submit"
2928 variant="danger"
2929 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
2930 >
2931 Close
2932 </Button>
2933 </>
2934 )}
2935 </div>
2936 </Form>
2937 </div>
2938 )}
2939
2940 {/* Read-only footers for non-open states. */}
2941 {pr.state === "merged" && (
2942 <div class="prs-merge-card is-merged">
2943 <div class="prs-merge-head">
2944 <strong>{"⮌"} Merged</strong>
0074234Claude2945 </div>
b078860Claude2946 <p class="prs-merge-sub">
2947 This pull request was merged into{" "}
2948 <code>{pr.baseBranch}</code>.
2949 </p>
2950 </div>
2951 )}
2952 {pr.state === "closed" && (
2953 <div class="prs-merge-card is-closed">
2954 <div class="prs-merge-head">
2955 <strong>{"✕"} Closed without merging</strong>
2956 </div>
2957 <p class="prs-merge-sub">
2958 This pull request was closed and not merged.
2959 </p>
2960 </div>
2961 )}
2962 </>
2963 )}
0074234Claude2964 </Layout>
2965 );
2966});
2967
cb5a796Claude2968// Add comment to PR.
2969//
2970// Permission model mirrors `issues.tsx`: any logged-in user with read
2971// access can submit; `decideInitialStatus` routes non-collaborators
2972// through the moderation queue. Slash commands only fire when the
2973// comment is auto-approved — we don't want a banned/pending comment to
2974// silently trigger AI work on the PR.
0074234Claude2975pulls.post(
2976 "/:owner/:repo/pulls/:number/comment",
2977 softAuth,
2978 requireAuth,
cb5a796Claude2979 requireRepoAccess("read"),
0074234Claude2980 async (c) => {
2981 const { owner: ownerName, repo: repoName } = c.req.param();
2982 const prNum = parseInt(c.req.param("number"), 10);
2983 const user = c.get("user")!;
2984 const body = await c.req.parseBody();
2985 const commentBody = String(body.body || "").trim();
47a7a0aClaude2986 const filePathRaw = String(body.file_path || "").trim();
2987 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
2988 const inlineFilePath = filePathRaw || undefined;
2989 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude2990
2991 if (!commentBody) {
2992 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2993 }
2994
2995 const resolved = await resolveRepo(ownerName, repoName);
2996 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2997
2998 const [pr] = await db
2999 .select()
3000 .from(pullRequests)
3001 .where(
3002 and(
3003 eq(pullRequests.repositoryId, resolved.repo.id),
3004 eq(pullRequests.number, prNum)
3005 )
3006 )
3007 .limit(1);
3008
3009 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3010
cb5a796Claude3011 const decision = await decideInitialStatus({
3012 commenterUserId: user.id,
3013 repositoryId: resolved.repo.id,
3014 kind: "pr",
3015 threadId: pr.id,
3016 });
3017
d4ac5c3Claude3018 const [inserted] = await db
3019 .insert(prComments)
3020 .values({
3021 pullRequestId: pr.id,
3022 authorId: user.id,
3023 body: commentBody,
cb5a796Claude3024 moderationStatus: decision.status,
47a7a0aClaude3025 filePath: inlineFilePath,
3026 lineNumber: inlineLineNumber,
d4ac5c3Claude3027 })
3028 .returning();
3029
cb5a796Claude3030 // Live update: only when the comment is actually visible.
3031 if (inserted && decision.status === "approved") {
d4ac5c3Claude3032 try {
3033 const { publish } = await import("../lib/sse");
3034 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
3035 event: "pr-comment",
3036 data: {
3037 pullRequestId: pr.id,
3038 commentId: inserted.id,
3039 authorId: user.id,
3040 authorUsername: user.username,
3041 },
3042 });
3043 } catch {
3044 /* SSE is best-effort */
3045 }
3046 }
0074234Claude3047
cb5a796Claude3048 if (decision.status === "pending") {
3049 void notifyOwnerOfPendingComment({
3050 repositoryId: resolved.repo.id,
3051 commenterUsername: user.username,
3052 kind: "pr",
3053 threadNumber: prNum,
3054 ownerUsername: ownerName,
3055 repoName,
3056 });
3057 return c.redirect(
3058 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
3059 );
3060 }
3061 if (decision.status === "rejected") {
3062 // Silent ban path — same UX as 'pending' so we don't leak the gate.
3063 return c.redirect(
3064 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
3065 );
3066 }
3067
15db0e0Claude3068 // Slash-command handoff. We always store the original comment above
3069 // first so free-form text that happens to start with `/` is preserved
3070 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude3071 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude3072 const parsed = parseSlashCommand(commentBody);
3073 if (parsed) {
3074 try {
3075 const result = await executeSlashCommand({
3076 command: parsed.command,
3077 args: parsed.args,
3078 prId: pr.id,
3079 userId: user.id,
3080 repositoryId: resolved.repo.id,
3081 });
3082 await db.insert(prComments).values({
3083 pullRequestId: pr.id,
3084 authorId: user.id,
3085 body: result.body,
3086 });
3087 } catch (err) {
3088 // Defence-in-depth — executeSlashCommand promises not to throw,
3089 // but if it ever does we want the PR thread to know.
3090 await db
3091 .insert(prComments)
3092 .values({
3093 pullRequestId: pr.id,
3094 authorId: user.id,
3095 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
3096 })
3097 .catch(() => {});
3098 }
3099 }
3100
47a7a0aClaude3101 // Inline comments go back to the files tab; conversation comments to the conversation tab
3102 const redirectTab = inlineFilePath ? "?tab=files" : "";
3103 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude3104 }
3105);
3106
0a67773Claude3107// Formal review — Approve / Request Changes / Comment
3108pulls.post(
3109 "/:owner/:repo/pulls/:number/review",
3110 softAuth,
3111 requireAuth,
3112 requireRepoAccess("read"),
3113 async (c) => {
3114 const { owner: ownerName, repo: repoName } = c.req.param();
3115 const prNum = parseInt(c.req.param("number"), 10);
3116 const user = c.get("user")!;
3117 const body = await c.req.parseBody();
3118 const reviewBody = String(body.body || "").trim();
3119 const reviewState = String(body.review_state || "commented");
3120
3121 const validStates = ["approved", "changes_requested", "commented"];
3122 if (!validStates.includes(reviewState)) {
3123 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3124 }
3125
3126 const resolved = await resolveRepo(ownerName, repoName);
3127 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3128
3129 const [pr] = await db
3130 .select()
3131 .from(pullRequests)
3132 .where(
3133 and(
3134 eq(pullRequests.repositoryId, resolved.repo.id),
3135 eq(pullRequests.number, prNum)
3136 )
3137 )
3138 .limit(1);
3139 if (!pr || pr.state !== "open") {
3140 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3141 }
3142 // Authors can't review their own PR
3143 if (pr.authorId === user.id) {
3144 return c.redirect(
3145 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
3146 );
3147 }
3148
3149 await db.insert(prReviews).values({
3150 pullRequestId: pr.id,
3151 reviewerId: user.id,
3152 state: reviewState,
3153 body: reviewBody || null,
3154 });
3155
3156 const stateLabel =
3157 reviewState === "approved" ? "Approved"
3158 : reviewState === "changes_requested" ? "Changes requested"
3159 : "Commented";
3160 return c.redirect(
3161 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
3162 );
3163 }
3164);
3165
e883329Claude3166// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude3167// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
3168// but we keep it at "write" for v1 so trusted collaborators can ship.
3169// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
3170// surface. Branch-protection rules (evaluated below) are the current mechanism
3171// for locking down merges further on specific branches.
0074234Claude3172pulls.post(
3173 "/:owner/:repo/pulls/:number/merge",
3174 softAuth,
3175 requireAuth,
04f6b7fClaude3176 requireRepoAccess("write"),
0074234Claude3177 async (c) => {
3178 const { owner: ownerName, repo: repoName } = c.req.param();
3179 const prNum = parseInt(c.req.param("number"), 10);
3180 const user = c.get("user")!;
3181
3182 const resolved = await resolveRepo(ownerName, repoName);
3183 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3184
3185 const [pr] = await db
3186 .select()
3187 .from(pullRequests)
3188 .where(
3189 and(
3190 eq(pullRequests.repositoryId, resolved.repo.id),
3191 eq(pullRequests.number, prNum)
3192 )
3193 )
3194 .limit(1);
3195
3196 if (!pr || pr.state !== "open") {
3197 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3198 }
3199
6fc53bdClaude3200 // Draft PRs cannot be merged — must be marked ready first.
3201 if (pr.isDraft) {
3202 return c.redirect(
3203 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3204 "This PR is a draft. Mark it as ready for review before merging."
3205 )}`
3206 );
3207 }
3208
e883329Claude3209 // Resolve head SHA
3210 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3211 if (!headSha) {
3212 return c.redirect(
3213 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
3214 );
3215 }
3216
3217 // Check if AI review approved this PR
3218 const aiComments = await db
3219 .select()
3220 .from(prComments)
3221 .where(
3222 and(
3223 eq(prComments.pullRequestId, pr.id),
3224 eq(prComments.isAiReview, true)
3225 )
3226 );
3227 const aiApproved = aiComments.length === 0 || aiComments.some(
3228 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude3229 );
e883329Claude3230
3231 // Run all green gate checks (GateTest + mergeability + AI review)
3232 const gateResult = await runAllGateChecks(
3233 ownerName,
3234 repoName,
3235 pr.baseBranch,
3236 pr.headBranch,
3237 headSha,
3238 aiApproved
0074234Claude3239 );
3240
e883329Claude3241 // If GateTest or AI review failed (hard blocks), reject the merge
3242 const hardFailures = gateResult.checks.filter(
3243 (check) => !check.passed && check.name !== "Merge check"
3244 );
3245 if (hardFailures.length > 0) {
3246 const errorMsg = hardFailures
3247 .map((f) => `${f.name}: ${f.details}`)
3248 .join("; ");
0074234Claude3249 return c.redirect(
e883329Claude3250 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude3251 );
3252 }
3253
1e162a8Claude3254 // D5 — Branch-protection enforcement. Looks up the matching rule for the
3255 // base branch and blocks the merge if requireAiApproval / requireGreenGates
3256 // / requireHumanReview / requiredApprovals are not satisfied. Independent
3257 // of repo-global settings, so owners can lock specific branches down
3258 // further than the repo default.
3259 const protectionRule = await matchProtection(
3260 resolved.repo.id,
3261 pr.baseBranch
3262 );
3263 if (protectionRule) {
3264 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude3265 const required = await listRequiredChecks(protectionRule.id);
3266 const passingNames = required.length > 0
3267 ? await passingCheckNames(resolved.repo.id, headSha)
3268 : [];
3269 const decision = evaluateProtection(
3270 protectionRule,
3271 {
3272 aiApproved,
3273 humanApprovalCount: humanApprovals,
3274 gateResultGreen: hardFailures.length === 0,
3275 hasFailedGates: hardFailures.length > 0,
3276 passingCheckNames: passingNames,
3277 },
3278 required.map((r) => r.checkName)
3279 );
1e162a8Claude3280 if (!decision.allowed) {
3281 return c.redirect(
3282 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3283 decision.reasons.join(" ")
3284 )}`
3285 );
3286 }
3287 }
3288
e883329Claude3289 // Attempt the merge — with auto conflict resolution if needed
3290 const repoDir = getRepoPath(ownerName, repoName);
3291 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
3292 const hasConflicts = mergeCheck && !mergeCheck.passed;
3293
3294 if (hasConflicts && isAiReviewEnabled()) {
3295 // Use Claude to auto-resolve conflicts
3296 const mergeResult = await mergeWithAutoResolve(
3297 ownerName,
3298 repoName,
3299 pr.baseBranch,
3300 pr.headBranch,
3301 `Merge pull request #${pr.number}: ${pr.title}`
3302 );
3303
3304 if (!mergeResult.success) {
3305 return c.redirect(
3306 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
3307 );
3308 }
3309
3310 // Post a comment about the auto-resolution
3311 if (mergeResult.resolvedFiles.length > 0) {
3312 await db.insert(prComments).values({
3313 pullRequestId: pr.id,
3314 authorId: user.id,
3315 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
3316 isAiReview: true,
3317 });
3318 }
3319 } else {
3320 // Standard merge — fast-forward or clean merge
3321 const ffProc = Bun.spawn(
3322 [
3323 "git",
3324 "update-ref",
3325 `refs/heads/${pr.baseBranch}`,
3326 `refs/heads/${pr.headBranch}`,
3327 ],
3328 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3329 );
3330 const ffExit = await ffProc.exited;
3331
3332 if (ffExit !== 0) {
3333 return c.redirect(
3334 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
3335 );
3336 }
3337 }
3338
0074234Claude3339 await db
3340 .update(pullRequests)
3341 .set({
3342 state: "merged",
3343 mergedAt: new Date(),
3344 mergedBy: user.id,
3345 updatedAt: new Date(),
3346 })
3347 .where(eq(pullRequests.id, pr.id));
3348
8809b87Claude3349 // Chat notifier — fan out merge event to Slack/Discord/Teams.
3350 import("../lib/chat-notifier")
3351 .then((m) =>
3352 m.notifyChatChannels({
3353 ownerUserId: resolved.repo.ownerId,
3354 repositoryId: resolved.repo.id,
3355 event: {
3356 event: "pr.merged",
3357 repo: `${ownerName}/${repoName}`,
3358 title: `#${pr.number} ${pr.title}`,
3359 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3360 actor: user.username,
3361 },
3362 })
3363 )
3364 .catch((err) =>
3365 console.warn(`[chat-notifier] PR merge notify failed:`, err)
3366 );
3367
d62fb36Claude3368 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
3369 // and auto-close each matching open issue with a back-link comment. Bounded
3370 // to the same repo for v1 (cross-repo refs ignored). Failures never block
3371 // the merge redirect.
3372 try {
3373 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3374 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3375 for (const n of refs) {
3376 const [issue] = await db
3377 .select()
3378 .from(issues)
3379 .where(
3380 and(
3381 eq(issues.repositoryId, resolved.repo.id),
3382 eq(issues.number, n)
3383 )
3384 )
3385 .limit(1);
3386 if (!issue || issue.state !== "open") continue;
3387 await db
3388 .update(issues)
3389 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
3390 .where(eq(issues.id, issue.id));
3391 await db.insert(issueComments).values({
3392 issueId: issue.id,
3393 authorId: user.id,
3394 body: `Closed by pull request #${pr.number}.`,
3395 });
3396 }
3397 } catch {
3398 // Never block the merge on close-keyword failures.
3399 }
3400
0074234Claude3401 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3402 }
3403);
3404
6fc53bdClaude3405// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
3406// hasn't run yet on this PR.
3407pulls.post(
3408 "/:owner/:repo/pulls/:number/ready",
3409 softAuth,
3410 requireAuth,
04f6b7fClaude3411 requireRepoAccess("write"),
6fc53bdClaude3412 async (c) => {
3413 const { owner: ownerName, repo: repoName } = c.req.param();
3414 const prNum = parseInt(c.req.param("number"), 10);
3415 const user = c.get("user")!;
3416
3417 const resolved = await resolveRepo(ownerName, repoName);
3418 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3419
3420 const [pr] = await db
3421 .select()
3422 .from(pullRequests)
3423 .where(
3424 and(
3425 eq(pullRequests.repositoryId, resolved.repo.id),
3426 eq(pullRequests.number, prNum)
3427 )
3428 )
3429 .limit(1);
3430 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3431
3432 // Only the author or repo owner can toggle draft state.
3433 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
3434 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3435 }
3436
3437 if (pr.state === "open" && pr.isDraft) {
3438 await db
3439 .update(pullRequests)
3440 .set({ isDraft: false, updatedAt: new Date() })
3441 .where(eq(pullRequests.id, pr.id));
3442
3443 if (isAiReviewEnabled()) {
3444 triggerAiReview(
3445 ownerName,
3446 repoName,
3447 pr.id,
3448 pr.title,
0316dbbClaude3449 pr.body || "",
6fc53bdClaude3450 pr.baseBranch,
3451 pr.headBranch
3452 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
3453 }
3454 }
3455
3456 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3457 }
3458);
3459
3460// Convert a PR back to draft.
3461pulls.post(
3462 "/:owner/:repo/pulls/:number/draft",
3463 softAuth,
3464 requireAuth,
04f6b7fClaude3465 requireRepoAccess("write"),
6fc53bdClaude3466 async (c) => {
3467 const { owner: ownerName, repo: repoName } = c.req.param();
3468 const prNum = parseInt(c.req.param("number"), 10);
3469 const user = c.get("user")!;
3470
3471 const resolved = await resolveRepo(ownerName, repoName);
3472 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3473
3474 const [pr] = await db
3475 .select()
3476 .from(pullRequests)
3477 .where(
3478 and(
3479 eq(pullRequests.repositoryId, resolved.repo.id),
3480 eq(pullRequests.number, prNum)
3481 )
3482 )
3483 .limit(1);
3484 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3485
3486 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
3487 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3488 }
3489
3490 if (pr.state === "open" && !pr.isDraft) {
3491 await db
3492 .update(pullRequests)
3493 .set({ isDraft: true, updatedAt: new Date() })
3494 .where(eq(pullRequests.id, pr.id));
3495 }
3496
3497 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3498 }
3499);
3500
0074234Claude3501// Close PR
3502pulls.post(
3503 "/:owner/:repo/pulls/:number/close",
3504 softAuth,
3505 requireAuth,
04f6b7fClaude3506 requireRepoAccess("write"),
0074234Claude3507 async (c) => {
3508 const { owner: ownerName, repo: repoName } = c.req.param();
3509 const prNum = parseInt(c.req.param("number"), 10);
3510
3511 const resolved = await resolveRepo(ownerName, repoName);
3512 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3513
3514 await db
3515 .update(pullRequests)
3516 .set({
3517 state: "closed",
3518 closedAt: new Date(),
3519 updatedAt: new Date(),
3520 })
3521 .where(
3522 and(
3523 eq(pullRequests.repositoryId, resolved.repo.id),
3524 eq(pullRequests.number, prNum)
3525 )
3526 );
3527
3528 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3529 }
3530);
3531
c3e0c07Claude3532// Re-run AI review on demand (e.g. after a force-push). Bypasses the
3533// idempotency marker via { force: true }. Write-access only.
3534pulls.post(
3535 "/:owner/:repo/pulls/:number/ai-rereview",
3536 softAuth,
3537 requireAuth,
3538 requireRepoAccess("write"),
3539 async (c) => {
3540 const { owner: ownerName, repo: repoName } = c.req.param();
3541 const prNum = parseInt(c.req.param("number"), 10);
3542 const resolved = await resolveRepo(ownerName, repoName);
3543 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3544
3545 const [pr] = await db
3546 .select()
3547 .from(pullRequests)
3548 .where(
3549 and(
3550 eq(pullRequests.repositoryId, resolved.repo.id),
3551 eq(pullRequests.number, prNum)
3552 )
3553 )
3554 .limit(1);
3555 if (!pr) {
3556 return c.redirect(`/${ownerName}/${repoName}/pulls`);
3557 }
3558
3559 if (!isAiReviewEnabled()) {
3560 return c.redirect(
3561 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3562 "AI review is not configured (ANTHROPIC_API_KEY)."
3563 )}`
3564 );
3565 }
3566
3567 // Fire-and-forget but with { force: true } to bypass the
3568 // already-reviewed marker. The function still never throws.
3569 triggerAiReview(
3570 ownerName,
3571 repoName,
3572 pr.id,
3573 pr.title || "",
3574 pr.body || "",
3575 pr.baseBranch,
3576 pr.headBranch,
3577 { force: true }
a28cedeClaude3578 ).catch((err) => {
3579 console.warn(
3580 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
3581 err instanceof Error ? err.message : err
3582 );
3583 });
c3e0c07Claude3584
3585 return c.redirect(
3586 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
3587 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
3588 )}`
3589 );
3590 }
3591);
3592
1d4ff60Claude3593// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
3594// the PR's head branch carrying just the new test files. Write-access only.
3595// Idempotent — if `ai:added-tests` was previously applied we redirect with
3596// an `info` banner instead of re-firing.
3597pulls.post(
3598 "/:owner/:repo/pulls/:number/generate-tests",
3599 softAuth,
3600 requireAuth,
3601 requireRepoAccess("write"),
3602 async (c) => {
3603 const { owner: ownerName, repo: repoName } = c.req.param();
3604 const prNum = parseInt(c.req.param("number"), 10);
3605 const resolved = await resolveRepo(ownerName, repoName);
3606 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3607
3608 const [pr] = await db
3609 .select()
3610 .from(pullRequests)
3611 .where(
3612 and(
3613 eq(pullRequests.repositoryId, resolved.repo.id),
3614 eq(pullRequests.number, prNum)
3615 )
3616 )
3617 .limit(1);
3618 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3619
3620 if (!isAiReviewEnabled()) {
3621 return c.redirect(
3622 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3623 "AI test generation is not configured (ANTHROPIC_API_KEY)."
3624 )}`
3625 );
3626 }
3627
3628 // Fire-and-forget. The lib never throws.
3629 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
3630 .then((res) => {
3631 if (!res.ok) {
3632 console.warn(
3633 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
3634 );
3635 }
3636 })
3637 .catch((err) => {
3638 console.warn(
3639 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
3640 err instanceof Error ? err.message : err
3641 );
3642 });
3643
3644 return c.redirect(
3645 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
3646 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
3647 )}`
3648 );
3649 }
3650);
3651
0074234Claude3652export default pulls;