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.tsxBlame3351 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,
22 repositories,
23 users,
d62fb36Claude24 issues,
25 issueComments,
0074234Claude26} from "../db/schema";
27import { Layout } from "../views/layout";
ea9ed4cClaude28import { RepoHeader } from "../views/components";
29import { DiffView } from "../views/diff-view";
6fc53bdClaude30import { ReactionsBar } from "../views/reactions";
31import { summariseReactions } from "../lib/reactions";
24cf2caClaude32import { loadPrTemplate } from "../lib/templates";
0074234Claude33import { renderMarkdown } from "../lib/markdown";
15db0e0Claude34import {
35 parseSlashCommand,
36 executeSlashCommand,
37 detectSlashCmdComment,
38 stripSlashCmdMarker,
39} from "../lib/pr-slash-commands";
b584e52Claude40import { liveCommentBannerScript } from "../lib/sse-client";
0074234Claude41import { softAuth, requireAuth } from "../middleware/auth";
42import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude43import { requireRepoAccess } from "../middleware/repo-access";
0316dbbClaude44import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
79ed944Claude45import {
46 TRIO_COMMENT_MARKER,
47 TRIO_SUMMARY_MARKER,
48 type TrioPersona,
49} from "../lib/ai-review-trio";
1d4ff60Claude50import {
51 generateTestsForPr,
52 AI_TESTS_MARKER,
53} from "../lib/ai-test-generator";
0316dbbClaude54import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude55import { generatePrSummary } from "../lib/ai-generators";
56import { isAiAvailable } from "../lib/ai-client";
534f04aClaude57import {
58 computePrRiskForPullRequest,
59 getCachedPrRisk,
60 type PrRiskScore,
61} from "../lib/pr-risk";
0316dbbClaude62import { runAllGateChecks } from "../lib/gate";
63import type { GateCheckResult } from "../lib/gate";
64import {
65 matchProtection,
66 countHumanApprovals,
67 listRequiredChecks,
68 passingCheckNames,
69 evaluateProtection,
70} from "../lib/branch-protection";
71import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude72import {
73 listBranches,
74 getRepoPath,
e883329Claude75 resolveRef,
0074234Claude76} from "../git/repository";
77import type { GitDiffFile } from "../git/repository";
78import { html } from "hono/html";
4bbacbeClaude79import {
80 getPreviewForBranch,
81 previewStatusLabel,
82} from "../lib/branch-previews";
1e162a8Claude83import {
bb0f894Claude84 Flex,
85 Container,
86 Badge,
87 Button,
88 LinkButton,
89 Form,
90 FormGroup,
91 Input,
92 TextArea,
93 Select,
94 EmptyState,
95 FilterTabs,
96 TabNav,
97 List,
98 ListItem,
99 Text,
100 Alert,
101 MarkdownContent,
102 CommentBox,
103 formatRelative,
104} from "../views/ui";
0074234Claude105
106const pulls = new Hono<AuthEnv>();
107
b078860Claude108/* ──────────────────────────────────────────────────────────────────────
109 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
110 * into the issue tracker or any other route. Tokens come from layout.tsx
111 * `:root` so light/dark stays consistent if/when light mode lands.
112 * ──────────────────────────────────────────────────────────────────── */
113const PRS_LIST_STYLES = `
114 .prs-hero {
115 position: relative;
116 margin: 0 0 var(--space-5);
117 padding: 22px 26px 24px;
118 background: var(--bg-elevated);
119 border: 1px solid var(--border);
120 border-radius: 16px;
121 overflow: hidden;
122 }
123 .prs-hero::before {
124 content: '';
125 position: absolute; top: 0; left: 0; right: 0;
126 height: 2px;
127 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
128 opacity: 0.7;
129 pointer-events: none;
130 }
131 .prs-hero-inner {
132 position: relative;
133 display: flex;
134 justify-content: space-between;
135 align-items: flex-end;
136 gap: 20px;
137 flex-wrap: wrap;
138 }
139 .prs-hero-text { flex: 1; min-width: 280px; }
140 .prs-hero-eyebrow {
141 font-size: 12px;
142 color: var(--text-muted);
143 text-transform: uppercase;
144 letter-spacing: 0.08em;
145 font-weight: 600;
146 margin-bottom: 8px;
147 }
148 .prs-hero-title {
149 font-family: var(--font-display);
150 font-size: clamp(26px, 3.4vw, 34px);
151 font-weight: 800;
152 letter-spacing: -0.025em;
153 line-height: 1.06;
154 margin: 0 0 8px;
155 color: var(--text-strong);
156 }
157 .prs-hero-title .gradient-text {
158 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
159 -webkit-background-clip: text;
160 background-clip: text;
161 -webkit-text-fill-color: transparent;
162 color: transparent;
163 }
164 .prs-hero-sub {
165 font-size: 14.5px;
166 color: var(--text-muted);
167 margin: 0;
168 line-height: 1.5;
169 max-width: 620px;
170 }
171 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
172 .prs-cta {
173 display: inline-flex; align-items: center; gap: 6px;
174 padding: 10px 16px;
175 border-radius: 10px;
176 font-size: 13.5px;
177 font-weight: 600;
178 color: #fff;
179 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
180 border: 1px solid rgba(140,109,255,0.55);
181 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
182 text-decoration: none;
183 transition: transform 120ms ease, box-shadow 160ms ease;
184 }
185 .prs-cta:hover {
186 transform: translateY(-1px);
187 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
188 color: #fff;
189 }
190
191 .prs-tabs {
192 display: flex; flex-wrap: wrap; gap: 6px;
193 margin: 0 0 18px;
194 padding: 6px;
195 background: var(--bg-secondary);
196 border: 1px solid var(--border);
197 border-radius: 12px;
198 }
199 .prs-tab {
200 display: inline-flex; align-items: center; gap: 8px;
201 padding: 7px 13px;
202 font-size: 13px;
203 font-weight: 500;
204 color: var(--text-muted);
205 border-radius: 8px;
206 text-decoration: none;
207 transition: background 120ms ease, color 120ms ease;
208 }
209 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
210 .prs-tab.is-active {
211 background: var(--bg-elevated);
212 color: var(--text-strong);
213 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
214 }
215 .prs-tab-count {
216 display: inline-flex; align-items: center; justify-content: center;
217 min-width: 22px; padding: 2px 7px;
218 font-size: 11.5px;
219 font-weight: 600;
220 border-radius: 9999px;
221 background: var(--bg-tertiary);
222 color: var(--text-muted);
223 }
224 .prs-tab.is-active .prs-tab-count {
225 background: rgba(140,109,255,0.18);
226 color: var(--text-link);
227 }
228
229 .prs-list { display: flex; flex-direction: column; gap: 10px; }
230 .prs-row {
231 position: relative;
232 display: flex; align-items: flex-start; gap: 14px;
233 padding: 14px 16px;
234 background: var(--bg-elevated);
235 border: 1px solid var(--border);
236 border-radius: 12px;
237 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
238 }
239 .prs-row:hover {
240 transform: translateY(-1px);
241 border-color: var(--border-strong);
242 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
243 }
244 .prs-row-icon {
245 flex: 0 0 auto;
246 width: 26px; height: 26px;
247 display: inline-flex; align-items: center; justify-content: center;
248 border-radius: 9999px;
249 font-size: 13px;
250 margin-top: 2px;
251 }
252 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
253 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
254 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
255 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
256 .prs-row-body { flex: 1; min-width: 0; }
257 .prs-row-title {
258 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
259 font-size: 15px; font-weight: 600;
260 color: var(--text-strong);
261 line-height: 1.35;
262 margin: 0 0 6px;
263 }
264 .prs-row-number {
265 color: var(--text-muted);
266 font-weight: 400;
267 font-size: 14px;
268 }
269 .prs-row-meta {
270 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
271 font-size: 12.5px;
272 color: var(--text-muted);
273 }
274 .prs-branch-chips {
275 display: inline-flex; align-items: center; gap: 6px;
276 font-family: var(--font-mono);
277 font-size: 11.5px;
278 }
279 .prs-branch-chip {
280 padding: 2px 8px;
281 border-radius: 9999px;
282 background: var(--bg-tertiary);
283 border: 1px solid var(--border);
284 color: var(--text);
285 }
286 .prs-branch-arrow {
287 color: var(--text-faint);
288 font-size: 11px;
289 }
290 .prs-row-tags {
291 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
292 margin-left: auto;
293 }
294 .prs-tag {
295 display: inline-flex; align-items: center; gap: 4px;
296 padding: 2px 8px;
297 font-size: 11px;
298 font-weight: 600;
299 border-radius: 9999px;
300 border: 1px solid var(--border);
301 background: var(--bg-secondary);
302 color: var(--text-muted);
303 line-height: 1.6;
304 }
305 .prs-tag.is-draft {
306 color: var(--text-muted);
307 border-color: var(--border-strong);
308 }
309 .prs-tag.is-merged {
310 color: var(--text-link);
311 border-color: rgba(140,109,255,0.45);
312 background: rgba(140,109,255,0.10);
313 }
314
315 .prs-empty {
ea9ed4cClaude316 position: relative;
317 padding: 56px 32px;
b078860Claude318 text-align: center;
319 border: 1px dashed var(--border);
ea9ed4cClaude320 border-radius: 16px;
321 background: var(--bg-elevated);
b078860Claude322 color: var(--text-muted);
ea9ed4cClaude323 overflow: hidden;
b078860Claude324 }
ea9ed4cClaude325 .prs-empty::before {
326 content: '';
327 position: absolute;
328 inset: -40% -20% auto auto;
329 width: 320px; height: 320px;
330 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
331 filter: blur(70px);
332 opacity: 0.55;
333 pointer-events: none;
334 animation: prsEmptyOrb 16s ease-in-out infinite;
335 }
336 @keyframes prsEmptyOrb {
337 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
338 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
339 }
340 @media (prefers-reduced-motion: reduce) {
341 .prs-empty::before { animation: none; }
342 }
343 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude344 .prs-empty strong {
345 display: block;
346 color: var(--text-strong);
ea9ed4cClaude347 font-family: var(--font-display);
348 font-size: 22px;
349 font-weight: 700;
350 letter-spacing: -0.018em;
351 margin-bottom: 2px;
352 }
353 .prs-empty-sub {
354 font-size: 14.5px;
355 color: var(--text-muted);
356 line-height: 1.55;
357 max-width: 460px;
358 margin: 0 0 18px;
b078860Claude359 }
ea9ed4cClaude360 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude361
362 @media (max-width: 720px) {
363 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
364 .prs-hero-actions { width: 100%; }
365 .prs-row-tags { margin-left: 0; }
366 }
f1dc7c7Claude367
368 /* Additional mobile rules. Additive only. */
369 @media (max-width: 720px) {
370 .prs-hero { padding: 18px 18px 20px; }
371 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
372 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
373 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
374 .prs-row { padding: 12px 14px; gap: 10px; }
375 .prs-row-icon { width: 24px; height: 24px; }
376 }
b078860Claude377`;
378
379/* ──────────────────────────────────────────────────────────────────────
380 * Inline CSS for the detail page. Same `.prs-*` namespace.
381 * ──────────────────────────────────────────────────────────────────── */
382const PRS_DETAIL_STYLES = `
383 .prs-detail-hero {
384 position: relative;
385 margin: 0 0 var(--space-4);
386 padding: 24px 26px;
387 background: var(--bg-elevated);
388 border: 1px solid var(--border);
389 border-radius: 16px;
390 overflow: hidden;
391 }
392 .prs-detail-hero::before {
393 content: '';
394 position: absolute; top: 0; left: 0; right: 0;
395 height: 2px;
396 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
397 opacity: 0.7;
398 pointer-events: none;
399 }
400 .prs-detail-title {
401 font-family: var(--font-display);
402 font-size: clamp(22px, 2.6vw, 28px);
403 font-weight: 700;
404 letter-spacing: -0.022em;
405 line-height: 1.2;
406 color: var(--text-strong);
407 margin: 0 0 12px;
408 }
409 .prs-detail-num {
410 color: var(--text-muted);
411 font-weight: 400;
412 }
413 .prs-state-pill {
414 display: inline-flex; align-items: center; gap: 6px;
415 padding: 6px 12px;
416 border-radius: 9999px;
417 font-size: 12.5px;
418 font-weight: 600;
419 line-height: 1;
420 border: 1px solid transparent;
421 }
422 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
423 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
424 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
425 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
426
427 .prs-detail-meta {
428 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
429 font-size: 13px;
430 color: var(--text-muted);
431 }
432 .prs-detail-meta strong { color: var(--text); }
433 .prs-detail-branches {
434 display: inline-flex; align-items: center; gap: 6px;
435 font-family: var(--font-mono);
436 font-size: 12px;
437 }
438 .prs-branch-pill {
439 padding: 3px 9px;
440 border-radius: 9999px;
441 background: var(--bg-tertiary);
442 border: 1px solid var(--border);
443 color: var(--text);
444 }
445 .prs-branch-pill.is-head { color: var(--text-strong); }
446 .prs-branch-arrow-lg {
447 color: var(--accent);
448 font-size: 14px;
449 font-weight: 700;
450 }
451
452 .prs-detail-actions {
453 display: inline-flex; gap: 8px; margin-left: auto;
454 }
455
456 .prs-detail-tabs {
457 display: flex; gap: 4px;
458 margin: 0 0 16px;
459 border-bottom: 1px solid var(--border);
460 }
461 .prs-detail-tab {
462 padding: 10px 14px;
463 font-size: 13.5px;
464 font-weight: 500;
465 color: var(--text-muted);
466 text-decoration: none;
467 border-bottom: 2px solid transparent;
468 transition: color 120ms ease, border-color 120ms ease;
469 margin-bottom: -1px;
470 }
471 .prs-detail-tab:hover { color: var(--text); }
472 .prs-detail-tab.is-active {
473 color: var(--text-strong);
474 border-bottom-color: var(--accent);
475 }
476 .prs-detail-tab-count {
477 display: inline-flex; align-items: center; justify-content: center;
478 min-width: 20px; padding: 0 6px; margin-left: 6px;
479 height: 18px;
480 font-size: 11px;
481 font-weight: 600;
482 border-radius: 9999px;
483 background: var(--bg-tertiary);
484 color: var(--text-muted);
485 }
486
487 /* Gate / check status section */
488 .prs-gate-card {
489 margin-top: 20px;
490 background: var(--bg-elevated);
491 border: 1px solid var(--border);
492 border-radius: 14px;
493 overflow: hidden;
494 }
495 .prs-gate-head {
496 display: flex; align-items: center; gap: 10px;
497 padding: 14px 18px;
498 border-bottom: 1px solid var(--border);
499 }
500 .prs-gate-head h3 {
501 margin: 0;
502 font-size: 14px;
503 font-weight: 600;
504 color: var(--text-strong);
505 }
506 .prs-gate-summary {
507 margin-left: auto;
508 font-size: 12px;
509 color: var(--text-muted);
510 }
511 .prs-gate-row {
512 display: flex; align-items: center; gap: 12px;
513 padding: 12px 18px;
514 border-bottom: 1px solid var(--border-subtle);
515 }
516 .prs-gate-row:last-child { border-bottom: 0; }
517 .prs-gate-icon {
518 flex: 0 0 auto;
519 width: 22px; height: 22px;
520 display: inline-flex; align-items: center; justify-content: center;
521 border-radius: 9999px;
522 font-size: 12px;
523 font-weight: 700;
524 }
525 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
526 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
527 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
528 .prs-gate-name {
529 font-size: 13px;
530 font-weight: 600;
531 color: var(--text);
532 min-width: 140px;
533 }
534 .prs-gate-details {
535 flex: 1; min-width: 0;
536 font-size: 12.5px;
537 color: var(--text-muted);
538 }
539 .prs-gate-pill {
540 flex: 0 0 auto;
541 padding: 3px 10px;
542 border-radius: 9999px;
543 font-size: 11px;
544 font-weight: 600;
545 line-height: 1.5;
546 border: 1px solid transparent;
547 }
548 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
549 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
550 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
551 .prs-gate-footer {
552 padding: 12px 18px;
553 background: var(--bg-secondary);
554 font-size: 12px;
555 color: var(--text-muted);
556 }
557
558 /* Comment cards */
559 .prs-comment {
560 margin-top: 14px;
561 background: var(--bg-elevated);
562 border: 1px solid var(--border);
563 border-radius: 12px;
564 overflow: hidden;
565 }
566 .prs-comment-head {
567 display: flex; align-items: center; gap: 10px;
568 padding: 10px 14px;
569 background: var(--bg-secondary);
570 border-bottom: 1px solid var(--border);
571 font-size: 13px;
572 flex-wrap: wrap;
573 }
574 .prs-comment-head strong { color: var(--text-strong); }
575 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
576 .prs-comment-loc {
577 font-family: var(--font-mono);
578 font-size: 11.5px;
579 color: var(--text-muted);
580 background: var(--bg-tertiary);
581 padding: 2px 8px;
582 border-radius: 6px;
583 }
584 .prs-comment-body { padding: 14px 18px; }
585 .prs-comment.is-ai {
586 border-color: rgba(140,109,255,0.45);
587 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
588 }
589 .prs-comment.is-ai .prs-comment-head {
590 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
591 border-bottom-color: rgba(140,109,255,0.30);
592 }
593 .prs-ai-badge {
594 display: inline-flex; align-items: center; gap: 4px;
595 padding: 2px 9px;
596 font-size: 10.5px;
597 font-weight: 700;
598 letter-spacing: 0.04em;
599 text-transform: uppercase;
600 color: #fff;
601 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
602 border-radius: 9999px;
603 }
604
605 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
606 .prs-files-card {
607 margin-top: 18px;
608 padding: 14px 18px;
609 display: flex; align-items: center; gap: 14px;
610 background: var(--bg-elevated);
611 border: 1px solid var(--border);
612 border-radius: 12px;
613 text-decoration: none;
614 color: inherit;
615 transition: border-color 120ms ease, transform 140ms ease;
616 }
617 .prs-files-card:hover {
618 border-color: rgba(140,109,255,0.45);
619 transform: translateY(-1px);
620 }
621 .prs-files-card-icon {
622 width: 36px; height: 36px;
623 display: inline-flex; align-items: center; justify-content: center;
624 border-radius: 10px;
625 background: rgba(140,109,255,0.12);
626 color: var(--text-link);
627 font-size: 18px;
628 }
629 .prs-files-card-text { flex: 1; min-width: 0; }
630 .prs-files-card-title {
631 font-size: 14px;
632 font-weight: 600;
633 color: var(--text-strong);
634 margin: 0 0 2px;
635 }
636 .prs-files-card-sub {
637 font-size: 12.5px;
638 color: var(--text-muted);
639 margin: 0;
640 }
641 .prs-files-card-cta {
642 font-size: 12.5px;
643 color: var(--text-link);
644 font-weight: 600;
645 }
646
647 /* Merge area */
648 .prs-merge-card {
649 position: relative;
650 margin-top: 22px;
651 padding: 18px;
652 background: var(--bg-elevated);
653 border-radius: 14px;
654 overflow: hidden;
655 }
656 .prs-merge-card::before {
657 content: '';
658 position: absolute; inset: 0;
659 padding: 1px;
660 border-radius: 14px;
661 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
662 -webkit-mask:
663 linear-gradient(#000 0 0) content-box,
664 linear-gradient(#000 0 0);
665 -webkit-mask-composite: xor;
666 mask-composite: exclude;
667 pointer-events: none;
668 }
669 .prs-merge-card.is-closed::before { background: var(--border-strong); }
670 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
671 .prs-merge-head {
672 display: flex; align-items: center; gap: 12px;
673 margin-bottom: 12px;
674 }
675 .prs-merge-head strong {
676 font-family: var(--font-display);
677 font-size: 15px;
678 color: var(--text-strong);
679 font-weight: 700;
680 }
681 .prs-merge-sub {
682 font-size: 13px;
683 color: var(--text-muted);
684 margin: 0 0 12px;
685 }
686 .prs-merge-actions {
687 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
688 }
689 .prs-merge-btn {
690 display: inline-flex; align-items: center; gap: 6px;
691 padding: 9px 16px;
692 border-radius: 10px;
693 font-size: 13.5px;
694 font-weight: 600;
695 color: #fff;
696 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
697 border: 1px solid rgba(52,211,153,0.55);
698 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
699 cursor: pointer;
700 transition: transform 120ms ease, box-shadow 160ms ease;
701 }
702 .prs-merge-btn:hover {
703 transform: translateY(-1px);
704 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
705 }
706 .prs-merge-btn[disabled],
707 .prs-merge-btn.is-disabled {
708 opacity: 0.55;
709 cursor: not-allowed;
710 transform: none;
711 box-shadow: none;
712 }
713 .prs-merge-ready-btn {
714 display: inline-flex; align-items: center; gap: 6px;
715 padding: 9px 16px;
716 border-radius: 10px;
717 font-size: 13.5px;
718 font-weight: 600;
719 color: #fff;
720 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
721 border: 1px solid rgba(140,109,255,0.55);
722 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
723 cursor: pointer;
724 transition: transform 120ms ease, box-shadow 160ms ease;
725 }
726 .prs-merge-ready-btn:hover {
727 transform: translateY(-1px);
728 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
729 }
730 .prs-merge-back-draft {
731 background: none; border: 1px solid var(--border-strong);
732 color: var(--text-muted);
733 padding: 9px 14px; border-radius: 10px;
734 font-size: 13px; cursor: pointer;
735 }
736 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
737
738 /* Inline form helpers */
739 .prs-inline-form { display: inline-flex; }
740
741 /* Comment composer */
742 .prs-composer { margin-top: 22px; }
743 .prs-composer textarea {
744 border-radius: 12px;
745 }
746
747 @media (max-width: 720px) {
748 .prs-detail-actions { margin-left: 0; }
749 .prs-merge-actions { width: 100%; }
750 .prs-merge-actions > * { flex: 1; min-width: 0; }
751 }
f1dc7c7Claude752
753 /* Additional mobile rules. Additive only. */
754 @media (max-width: 720px) {
755 .prs-detail-hero { padding: 18px; }
756 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
757 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
758 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
759 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
760 .prs-gate-name { min-width: 0; }
761 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
762 .prs-gate-summary { margin-left: 0; }
763 .prs-merge-btn,
764 .prs-merge-ready-btn,
765 .prs-merge-back-draft { min-height: 44px; }
766 .prs-comment-body { padding: 12px 14px; }
767 .prs-comment-head { padding: 10px 12px; }
768 .prs-files-card { padding: 12px 14px; }
769 }
3c03977Claude770
771 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
772 .live-pill {
773 display: inline-flex;
774 align-items: center;
775 gap: 8px;
776 padding: 4px 10px 4px 8px;
777 margin-left: 6px;
778 background: var(--bg-elevated);
779 border: 1px solid var(--border);
780 border-radius: 9999px;
781 font-size: 12px;
782 color: var(--text-muted);
783 line-height: 1;
784 vertical-align: middle;
785 }
786 .live-pill.is-busy { color: var(--text); }
787 .live-pill-dot {
788 width: 8px; height: 8px;
789 border-radius: 9999px;
790 background: #34d399;
791 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
792 animation: live-pulse 1.6s ease-in-out infinite;
793 }
794 @keyframes live-pulse {
795 0%, 100% { opacity: 1; }
796 50% { opacity: 0.55; }
797 }
798 .live-avatars {
799 display: inline-flex;
800 margin-left: 2px;
801 }
802 .live-avatar {
803 display: inline-flex;
804 align-items: center;
805 justify-content: center;
806 width: 22px; height: 22px;
807 border-radius: 9999px;
808 font-size: 10px;
809 font-weight: 700;
810 color: #0b1020;
811 margin-left: -6px;
812 border: 2px solid var(--bg-elevated);
813 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
814 }
815 .live-avatar:first-child { margin-left: 0; }
816 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
817 .live-cursor-host {
818 position: relative;
819 }
820 .live-cursor-overlay {
821 position: absolute;
822 inset: 0;
823 pointer-events: none;
824 overflow: hidden;
825 border-radius: inherit;
826 }
827 .live-cursor {
828 position: absolute;
829 width: 2px;
830 height: 18px;
831 border-radius: 2px;
832 transform: translate(-1px, 0);
833 transition: transform 80ms linear, opacity 200ms ease;
834 }
835 .live-cursor::after {
836 content: attr(data-label);
837 position: absolute;
838 top: -16px;
839 left: -2px;
840 font-size: 10px;
841 line-height: 1;
842 color: #0b1020;
843 background: inherit;
844 padding: 2px 5px;
845 border-radius: 4px 4px 4px 0;
846 white-space: nowrap;
847 font-weight: 600;
848 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
849 }
850 .live-cursor.is-idle { opacity: 0.4; }
851 .live-edit-tag {
852 display: inline-block;
853 margin-left: 6px;
854 padding: 1px 6px;
855 font-size: 10px;
856 font-weight: 600;
857 letter-spacing: 0.02em;
858 color: #0b1020;
859 border-radius: 9999px;
860 }
15db0e0Claude861
862 /* ─── Slash-command pill + composer hint ─── */
863 .slash-hint {
864 display: inline-flex;
865 align-items: center;
866 gap: 6px;
867 margin-top: 6px;
868 padding: 3px 9px;
869 font-size: 11.5px;
870 color: var(--text-muted);
871 background: var(--bg-elevated);
872 border: 1px dashed var(--border);
873 border-radius: 9999px;
874 width: fit-content;
875 }
876 .slash-hint code {
877 background: rgba(110, 168, 255, 0.12);
878 color: var(--text-strong);
879 padding: 0 5px;
880 border-radius: 4px;
881 font-size: 11px;
882 }
883 .slash-pill {
884 display: grid;
885 grid-template-columns: auto 1fr auto;
886 align-items: center;
887 column-gap: 10px;
888 row-gap: 6px;
889 margin: 10px 0;
890 padding: 10px 14px;
891 background: linear-gradient(
892 135deg,
893 rgba(110, 168, 255, 0.08),
894 rgba(163, 113, 247, 0.06)
895 );
896 border: 1px solid rgba(110, 168, 255, 0.32);
897 border-left: 3px solid var(--accent, #6ea8ff);
898 border-radius: var(--radius);
899 font-size: 13px;
900 color: var(--text);
901 }
902 .slash-pill-icon {
903 font-size: 14px;
904 line-height: 1;
905 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
906 }
907 .slash-pill-actor { color: var(--text-muted); }
908 .slash-pill-actor strong { color: var(--text-strong); }
909 .slash-pill-cmd {
910 background: rgba(110, 168, 255, 0.16);
911 color: var(--text-strong);
912 padding: 1px 6px;
913 border-radius: 4px;
914 font-size: 12.5px;
915 }
916 .slash-pill-time {
917 color: var(--text-muted);
918 font-size: 12px;
919 justify-self: end;
920 }
921 .slash-pill-body {
922 grid-column: 1 / -1;
923 color: var(--text);
924 font-size: 13px;
925 line-height: 1.55;
926 }
927 .slash-pill-body p:first-child { margin-top: 0; }
928 .slash-pill-body p:last-child { margin-bottom: 0; }
929 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
930 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
931 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
932 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude933
934 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
935 .preview-prpill {
936 display: inline-flex; align-items: center; gap: 6px;
937 padding: 3px 10px;
938 border-radius: 9999px;
939 font-family: var(--font-mono);
940 font-size: 11.5px;
941 font-weight: 600;
942 background: rgba(255,255,255,0.04);
943 color: var(--text-muted);
944 text-decoration: none;
945 border: 1px solid var(--border);
946 }
947 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
948 .preview-prpill .preview-prpill-dot {
949 width: 7px; height: 7px;
950 border-radius: 9999px;
951 background: currentColor;
952 }
953 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
954 .preview-prpill.is-building .preview-prpill-dot {
955 animation: previewPrPulse 1.4s ease-in-out infinite;
956 }
957 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
958 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
959 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
960 @keyframes previewPrPulse {
961 0%, 100% { opacity: 1; }
962 50% { opacity: 0.4; }
963 }
79ed944Claude964
965 /* ─── AI Trio Review — 3-column verdict cards ─── */
966 .trio-wrap {
967 margin-top: 18px;
968 padding: 16px;
969 background: var(--bg-elevated);
970 border: 1px solid var(--border);
971 border-radius: 14px;
972 }
973 .trio-header {
974 display: flex; align-items: center; gap: 10px;
975 margin: 0 0 12px;
976 font-size: 13.5px;
977 color: var(--text);
978 }
979 .trio-header strong { color: var(--text-strong); }
980 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
981 .trio-header-dot {
982 width: 8px; height: 8px; border-radius: 9999px;
983 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
984 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
985 }
986 .trio-grid {
987 display: grid;
988 grid-template-columns: repeat(3, minmax(0, 1fr));
989 gap: 12px;
990 }
991 .trio-card {
992 background: var(--bg-secondary);
993 border: 1px solid var(--border);
994 border-radius: 12px;
995 overflow: hidden;
996 display: flex; flex-direction: column;
997 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
998 }
999 .trio-card-head {
1000 display: flex; align-items: center; gap: 8px;
1001 padding: 10px 12px;
1002 border-bottom: 1px solid var(--border);
1003 background: rgba(255,255,255,0.02);
1004 font-size: 13px;
1005 }
1006 .trio-card-icon {
1007 display: inline-flex; align-items: center; justify-content: center;
1008 width: 22px; height: 22px;
1009 border-radius: 9999px;
1010 font-size: 12px;
1011 background: rgba(255,255,255,0.05);
1012 }
1013 .trio-card-title {
1014 color: var(--text-strong);
1015 font-weight: 600;
1016 letter-spacing: 0.01em;
1017 }
1018 .trio-card-verdict {
1019 margin-left: auto;
1020 font-size: 11px;
1021 font-weight: 700;
1022 letter-spacing: 0.06em;
1023 text-transform: uppercase;
1024 padding: 3px 9px;
1025 border-radius: 9999px;
1026 background: var(--bg-tertiary);
1027 color: var(--text-muted);
1028 border: 1px solid var(--border-strong);
1029 }
1030 .trio-card-body {
1031 padding: 12px 14px;
1032 font-size: 13px;
1033 color: var(--text);
1034 flex: 1;
1035 min-height: 64px;
1036 line-height: 1.55;
1037 }
1038 .trio-card-body p { margin: 0 0 8px; }
1039 .trio-card-body p:last-child { margin-bottom: 0; }
1040 .trio-card-body ul { margin: 0; padding-left: 18px; }
1041 .trio-card-body code {
1042 font-family: var(--font-mono);
1043 font-size: 12px;
1044 background: var(--bg-tertiary);
1045 padding: 1px 6px;
1046 border-radius: 5px;
1047 }
1048 .trio-card-empty {
1049 color: var(--text-muted);
1050 font-style: italic;
1051 font-size: 12.5px;
1052 }
1053
1054 /* Pass state — neutral, no accent. */
1055 .trio-card.is-pass .trio-card-verdict {
1056 color: var(--green);
1057 border-color: rgba(52,211,153,0.35);
1058 background: rgba(52,211,153,0.12);
1059 }
1060
1061 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1062 .trio-card.trio-security.is-fail {
1063 border-color: rgba(248,113,113,0.55);
1064 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1065 }
1066 .trio-card.trio-security.is-fail .trio-card-head {
1067 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1068 border-bottom-color: rgba(248,113,113,0.30);
1069 }
1070 .trio-card.trio-security.is-fail .trio-card-verdict {
1071 color: #fecaca;
1072 border-color: rgba(248,113,113,0.55);
1073 background: rgba(248,113,113,0.20);
1074 }
1075
1076 .trio-card.trio-correctness.is-fail {
1077 border-color: rgba(251,191,36,0.55);
1078 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1079 }
1080 .trio-card.trio-correctness.is-fail .trio-card-head {
1081 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1082 border-bottom-color: rgba(251,191,36,0.30);
1083 }
1084 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1085 color: #fde68a;
1086 border-color: rgba(251,191,36,0.55);
1087 background: rgba(251,191,36,0.20);
1088 }
1089
1090 .trio-card.trio-style.is-fail {
1091 border-color: rgba(96,165,250,0.55);
1092 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1093 }
1094 .trio-card.trio-style.is-fail .trio-card-head {
1095 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1096 border-bottom-color: rgba(96,165,250,0.30);
1097 }
1098 .trio-card.trio-style.is-fail .trio-card-verdict {
1099 color: #bfdbfe;
1100 border-color: rgba(96,165,250,0.55);
1101 background: rgba(96,165,250,0.20);
1102 }
1103
1104 /* Disagreement callout strip — yellow, prominent. */
1105 .trio-disagreement-strip {
1106 display: flex;
1107 gap: 12px;
1108 margin-top: 14px;
1109 padding: 12px 14px;
1110 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1111 border: 1px solid rgba(251,191,36,0.45);
1112 border-radius: 10px;
1113 color: var(--text);
1114 font-size: 13px;
1115 }
1116 .trio-disagreement-icon {
1117 flex: 0 0 auto;
1118 width: 26px; height: 26px;
1119 display: inline-flex; align-items: center; justify-content: center;
1120 border-radius: 9999px;
1121 background: rgba(251,191,36,0.25);
1122 color: #fde68a;
1123 font-size: 14px;
1124 }
1125 .trio-disagreement-body strong {
1126 display: block;
1127 color: #fde68a;
1128 margin: 0 0 4px;
1129 font-weight: 700;
1130 }
1131 .trio-disagreement-list {
1132 margin: 0;
1133 padding-left: 18px;
1134 color: var(--text);
1135 font-size: 12.5px;
1136 line-height: 1.55;
1137 }
1138 .trio-disagreement-list code {
1139 font-family: var(--font-mono);
1140 font-size: 11.5px;
1141 background: var(--bg-tertiary);
1142 padding: 1px 5px;
1143 border-radius: 4px;
1144 }
1145
1146 @media (max-width: 720px) {
1147 .trio-grid { grid-template-columns: 1fr; }
1148 .trio-wrap { padding: 12px; }
1149 }
b078860Claude1150`;
1151
81c73c1Claude1152/**
1153 * Tiny inline JS that drives the "Suggest description with AI" button.
1154 * On click, gathers form values, POSTs JSON to the given endpoint, and
1155 * pipes the response into the #pr-body textarea. All DOM lookups are
1156 * defensive — element absence is a silent no-op.
1157 *
1158 * Built as a string template so it lives next to its server-side caller
1159 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1160 * to avoid </script> breakouts.
1161 */
1162function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1163 const url = JSON.stringify(endpointUrl)
1164 .split("<").join("\\u003C")
1165 .split(">").join("\\u003E")
1166 .split("&").join("\\u0026");
1167 return (
1168 "(function(){try{" +
1169 "var btn=document.getElementById('ai-suggest-desc');" +
1170 "var status=document.getElementById('ai-suggest-status');" +
1171 "var body=document.getElementById('pr-body');" +
1172 "var form=btn&&btn.closest&&btn.closest('form');" +
1173 "if(!btn||!body||!form)return;" +
1174 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1175 "var fd=new FormData(form);" +
1176 "var title=String(fd.get('title')||'').trim();" +
1177 "var base=String(fd.get('base')||'').trim();" +
1178 "var head=String(fd.get('head')||'').trim();" +
1179 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1180 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1181 "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'})" +
1182 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1183 ".then(function(j){btn.disabled=false;" +
1184 "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;}}" +
1185 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1186 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1187 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1188 "});" +
1189 "}catch(e){}})();"
1190 );
1191}
1192
3c03977Claude1193/**
1194 * Live co-editing client. Connects to the per-PR SSE feed and:
1195 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1196 * status colour per user).
1197 * - Renders tinted cursor caret overlays inside #pr-body and every
1198 * `[data-live-field]` element.
1199 * - Broadcasts the local user's cursor position (selectionStart /
1200 * selectionEnd) debounced at 100ms.
1201 * - Broadcasts content patches (`replace` of the whole textarea —
1202 * last-write-wins v1) debounced at 250ms.
1203 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1204 * to the matching local field if untouched.
1205 *
1206 * All endpoint URLs are JSON-escaped via safe replacements so they
1207 * can't break out of the <script> tag.
1208 */
1209function LIVE_COEDIT_SCRIPT(prId: string): string {
1210 const idJson = JSON.stringify(prId)
1211 .split("<").join("\\u003C")
1212 .split(">").join("\\u003E")
1213 .split("&").join("\\u0026");
1214 return (
1215 "(function(){try{" +
1216 "if(typeof EventSource==='undefined')return;" +
1217 "var prId=" + idJson + ";" +
1218 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1219 "var pill=document.getElementById('live-pill');" +
1220 "var avEl=document.getElementById('live-avatars');" +
1221 "var countEl=document.getElementById('live-count');" +
1222 "var sessionId=null;var myColor=null;" +
1223 "var presence={};" + // sessionId -> {color,status,userId,initials}
1224 "var lastApplied={};" + // field -> last server value (for echo suppression)
1225 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1226 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1227 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1228 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1229 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1230 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1231 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1232 "avEl.innerHTML=html;}}" +
1233 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1234 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1235 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1236 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1237 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1238 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1239 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1240 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1241 "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);}" +
1242 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1243 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1244 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1245 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1246 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1247 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1248 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1249 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1250 "}catch(e){}" +
1251 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1252 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1253 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1254 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1255 "var es;var delay=1000;" +
1256 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1257 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1258 "(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){}});" +
1259 "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){}});" +
1260 "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){}});" +
1261 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1262 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1263 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1264 "var patch=d.patch;if(!patch||!patch.field)return;" +
1265 "var ta=fieldEl(patch.field);if(!ta)return;" +
1266 "if(document.activeElement===ta)return;" + // don't trample local typing
1267 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1268 "}catch(e){}});" +
1269 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1270 "}connect();" +
1271 "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){}}" +
1272 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1273 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1274 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1275 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1276 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1277 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1278 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1279 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1280 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1281 "}" +
1282 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1283 "var live=document.querySelectorAll('[data-live-field]');" +
1284 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1285 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1286 "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){}});" +
1287 "}catch(e){}})();"
1288 );
1289}
1290
0074234Claude1291async function resolveRepo(ownerName: string, repoName: string) {
1292 const [owner] = await db
1293 .select()
1294 .from(users)
1295 .where(eq(users.username, ownerName))
1296 .limit(1);
1297 if (!owner) return null;
1298 const [repo] = await db
1299 .select()
1300 .from(repositories)
1301 .where(
1302 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1303 )
1304 .limit(1);
1305 if (!repo) return null;
1306 return { owner, repo };
1307}
1308
1309// PR Nav helper
1310const PrNav = ({
1311 owner,
1312 repo,
1313 active,
1314}: {
1315 owner: string;
1316 repo: string;
1317 active: "code" | "issues" | "pulls" | "commits";
1318}) => (
bb0f894Claude1319 <TabNav
1320 tabs={[
1321 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1322 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1323 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1324 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1325 ]}
1326 />
0074234Claude1327);
1328
534f04aClaude1329/**
1330 * Block M3 — pre-merge risk score card. Pure presentational helper.
1331 * Rendered in the conversation tab above the gate checks block. Hidden
1332 * entirely when the PR is closed/merged or there is nothing cached and
1333 * nothing in-flight.
1334 */
1335function PrRiskCard({
1336 risk,
1337 calculating,
1338}: {
1339 risk: PrRiskScore | null;
1340 calculating: boolean;
1341}) {
1342 if (!risk) {
1343 return (
1344 <div
1345 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1346 >
1347 <strong style="font-size: 13px; color: var(--text)">
1348 Risk score: calculating…
1349 </strong>
1350 <div style="font-size: 12px; margin-top: 4px">
1351 Refresh in a moment to see the pre-merge risk score for this PR.
1352 </div>
1353 </div>
1354 );
1355 }
1356
1357 const palette = riskBandPalette(risk.band);
1358 const label = riskBandLabel(risk.band);
1359
1360 return (
1361 <div
1362 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1363 >
1364 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1365 <strong>Risk score:</strong>
1366 <span style={`color:${palette.border};font-weight:600`}>
1367 {palette.icon} {label} ({risk.score}/10)
1368 </span>
1369 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1370 {risk.commitSha.slice(0, 7)}
1371 </span>
1372 </div>
1373 {risk.aiSummary && (
1374 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1375 {risk.aiSummary}
1376 </div>
1377 )}
1378 <details style="margin-top:10px">
1379 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1380 See full signal breakdown
1381 </summary>
1382 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1383 <li>files changed: {risk.signals.filesChanged}</li>
1384 <li>
1385 lines added/removed: {risk.signals.linesAdded} /{" "}
1386 {risk.signals.linesRemoved}
1387 </li>
1388 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1389 <li>
1390 schema migration touched:{" "}
1391 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1392 </li>
1393 <li>
1394 locked / sensitive path touched:{" "}
1395 {risk.signals.lockedPathTouched ? "yes" : "no"}
1396 </li>
1397 <li>
1398 adds new dependency:{" "}
1399 {risk.signals.addsNewDependency ? "yes" : "no"}
1400 </li>
1401 <li>
1402 bumps major dependency:{" "}
1403 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1404 </li>
1405 <li>
1406 tests added for new code:{" "}
1407 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1408 </li>
1409 <li>
1410 diff-minus-test ratio:{" "}
1411 {risk.signals.diffMinusTestRatio.toFixed(2)}
1412 </li>
1413 </ul>
1414 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1415 How is this calculated? The score is a transparent sum of
1416 weighted signals — see <code>src/lib/pr-risk.ts</code>
1417 {" "}<code>computePrRiskScore</code>.
1418 </div>
1419 </details>
1420 {calculating && (
1421 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1422 (recomputing for the latest commit — refresh to update)
1423 </div>
1424 )}
1425 </div>
1426 );
1427}
1428
1429function riskBandPalette(band: PrRiskScore["band"]): {
1430 border: string;
1431 icon: string;
1432} {
1433 switch (band) {
1434 case "low":
1435 return { border: "var(--green)", icon: "" };
1436 case "medium":
1437 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1438 case "high":
1439 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1440 case "critical":
1441 return { border: "var(--red)", icon: "\u{1F6D1}" };
1442 }
1443}
1444
1445function riskBandLabel(band: PrRiskScore["band"]): string {
1446 switch (band) {
1447 case "low":
1448 return "LOW";
1449 case "medium":
1450 return "MEDIUM";
1451 case "high":
1452 return "HIGH";
1453 case "critical":
1454 return "CRITICAL";
1455 }
1456}
1457
422a2d4Claude1458// ---------------------------------------------------------------------------
1459// AI Trio Review — 3-column card grid + disagreement callout.
1460//
1461// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
1462// per run: one per persona (security/correctness/style) plus a top-level
1463// summary. We surface them here as a single grid above the normal
1464// comment stream so reviewers see the verdicts at a glance.
1465// ---------------------------------------------------------------------------
1466
1467const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
1468
1469interface TrioCommentLike {
1470 body: string;
1471}
1472
1473function isTrioComment(body: string | null | undefined): boolean {
1474 if (!body) return false;
1475 return (
1476 body.includes(TRIO_SUMMARY_MARKER) ||
1477 body.includes(TRIO_COMMENT_MARKER.security) ||
1478 body.includes(TRIO_COMMENT_MARKER.correctness) ||
1479 body.includes(TRIO_COMMENT_MARKER.style)
1480 );
1481}
1482
1483function trioPersonaOfComment(body: string): TrioPersona | null {
1484 for (const p of TRIO_PERSONAS) {
1485 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
1486 }
1487 return null;
1488}
1489
1490/**
1491 * Best-effort verdict parse from a persona comment body. The body shape
1492 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
1493 * we only need the "Pass" / "Fail" word from the H2 heading.
1494 */
1495function trioVerdictOfBody(body: string): "pass" | "fail" | null {
1496 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
1497 if (!m) return null;
1498 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
1499}
1500
1501/**
1502 * Parse the disagreement bullet list out of the summary comment so we
1503 * can render it as a polished callout strip. Returns [] when nothing
1504 * matches — the comment author may have edited the marker out.
1505 */
1506function parseDisagreements(summaryBody: string): Array<{
1507 file: string;
1508 failing: string;
1509 passing: string;
1510}> {
1511 const out: Array<{ file: string; failing: string; passing: string }> = [];
1512 // Each disagreement line looks like:
1513 // - `path:42` — security, style say ✗, correctness say ✓
1514 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
1515 let m: RegExpExecArray | null;
1516 while ((m = re.exec(summaryBody)) !== null) {
1517 out.push({
1518 file: m[1].trim(),
1519 failing: m[2].trim().replace(/[,\s]+$/g, ""),
1520 passing: m[3].trim().replace(/[,\s]+$/g, ""),
1521 });
1522 }
1523 return out;
1524}
1525
1526function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
1527 // Find the most recent persona comments + summary. We iterate from
1528 // the end so re-reviews (multiple runs on the same PR) display the
1529 // freshest verdict.
1530 const latest: Partial<Record<TrioPersona, string>> = {};
1531 let summaryBody: string | null = null;
1532 for (let i = comments.length - 1; i >= 0; i--) {
1533 const body = comments[i].body || "";
1534 if (!isTrioComment(body)) continue;
1535 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
1536 summaryBody = body;
1537 continue;
1538 }
1539 const persona = trioPersonaOfComment(body);
1540 if (persona && !latest[persona]) latest[persona] = body;
1541 }
1542 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1543 if (!anyPersona && !summaryBody) return null;
1544
1545 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
1546
1547 return (
1548 <div class="trio-wrap">
1549 <div class="trio-header">
1550 <span class="trio-header-dot" aria-hidden="true"></span>
1551 <strong>AI Trio Review</strong>
1552 <span class="trio-header-sub">
1553 Three independent reviewers ran in parallel.
1554 </span>
1555 </div>
1556 <div class="trio-grid">
1557 {TRIO_PERSONAS.map((persona) => {
1558 const body = latest[persona];
1559 const verdict = body ? trioVerdictOfBody(body) : null;
1560 const stateClass =
1561 verdict === "fail"
1562 ? "is-fail"
1563 : verdict === "pass"
1564 ? "is-pass"
1565 : "is-pending";
1566 return (
1567 <div class={`trio-card trio-${persona} ${stateClass}`}>
1568 <div class="trio-card-head">
1569 <span class="trio-card-icon" aria-hidden="true">
1570 {persona === "security"
1571 ? "🛡"
1572 : persona === "correctness"
1573 ? "✓"
1574 : "✎"}
1575 </span>
1576 <strong class="trio-card-title">
1577 {persona[0].toUpperCase() + persona.slice(1)}
1578 </strong>
1579 <span class="trio-card-verdict">
1580 {verdict === "pass"
1581 ? "Pass"
1582 : verdict === "fail"
1583 ? "Fail"
1584 : "Pending"}
1585 </span>
1586 </div>
1587 <div class="trio-card-body">
1588 {body ? (
1589 <MarkdownContent
1590 html={renderMarkdown(stripTrioHeading(body))}
1591 />
1592 ) : (
1593 <span class="trio-card-empty">
1594 Awaiting reviewer output.
1595 </span>
1596 )}
1597 </div>
1598 </div>
1599 );
1600 })}
1601 </div>
1602 {disagreements.length > 0 && (
1603 <div class="trio-disagreement-strip" role="note">
1604 <span class="trio-disagreement-icon" aria-hidden="true">
1605
1606 </span>
1607 <div class="trio-disagreement-body">
1608 <strong>Reviewers disagree — review carefully.</strong>
1609 <ul class="trio-disagreement-list">
1610 {disagreements.map((d) => (
1611 <li>
1612 <code>{d.file}</code> — {d.failing} says ✗,{" "}
1613 {d.passing} says ✓
1614 </li>
1615 ))}
1616 </ul>
1617 </div>
1618 </div>
1619 )}
1620 </div>
1621 );
1622}
1623
1624/**
1625 * Strip the marker comment + first H2 heading from a persona body so
1626 * the card body shows just the findings list (verdict is already in
1627 * the card head). Best-effort — malformed bodies render whole.
1628 */
1629function stripTrioHeading(body: string): string {
1630 return body
1631 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
1632 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
1633 .trim();
1634}
1635
0074234Claude1636// List PRs
04f6b7fClaude1637pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1638 const { owner: ownerName, repo: repoName } = c.req.param();
1639 const user = c.get("user");
1640 const state = c.req.query("state") || "open";
1641
ea9ed4cClaude1642 // ── Loading skeleton (flag-gated) ──
1643 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
1644 // the user see the page structure before counts + select resolve.
1645 // Behind a flag for now — we don't ship flashes.
1646 if (c.req.query("skeleton") === "1") {
1647 return c.html(
1648 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1649 <RepoHeader owner={ownerName} repo={repoName} />
1650 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1651 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1652 <style
1653 dangerouslySetInnerHTML={{
1654 __html: `
1655 .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; }
1656 @keyframes prsSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1657 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
1658 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
1659 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
1660 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
1661 .prs-skel-row { height: 76px; border-radius: 12px; }
1662 `,
1663 }}
1664 />
1665 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
1666 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
1667 <div class="prs-skel-list" aria-hidden="true">
1668 {Array.from({ length: 6 }).map(() => (
1669 <div class="prs-skel prs-skel-row" />
1670 ))}
1671 </div>
1672 <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">
1673 Loading pull requests for {ownerName}/{repoName}…
1674 </span>
1675 </Layout>
1676 );
1677 }
1678
0074234Claude1679 const resolved = await resolveRepo(ownerName, repoName);
1680 if (!resolved) return c.notFound();
1681
6fc53bdClaude1682 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
1683 const stateFilter =
1684 state === "draft"
1685 ? and(
1686 eq(pullRequests.state, "open"),
1687 eq(pullRequests.isDraft, true)
1688 )
1689 : eq(pullRequests.state, state);
1690
0074234Claude1691 const prList = await db
1692 .select({
1693 pr: pullRequests,
1694 author: { username: users.username },
1695 })
1696 .from(pullRequests)
1697 .innerJoin(users, eq(pullRequests.authorId, users.id))
1698 .where(
6fc53bdClaude1699 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude1700 )
1701 .orderBy(desc(pullRequests.createdAt));
1702
1703 const [counts] = await db
1704 .select({
1705 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude1706 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude1707 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
1708 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
1709 })
1710 .from(pullRequests)
1711 .where(eq(pullRequests.repositoryId, resolved.repo.id));
1712
b078860Claude1713 const openCount = counts?.open ?? 0;
1714 const mergedCount = counts?.merged ?? 0;
1715 const closedCount = counts?.closed ?? 0;
1716 const draftCount = counts?.draft ?? 0;
1717 const allCount = openCount + mergedCount + closedCount;
1718
1719 // "All" is presentational only — the DB query for state='all' matches
1720 // nothing, so we render a friendlier empty state when picked. We do NOT
1721 // change the query logic to keep this commit purely visual.
1722 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
1723 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
1724 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
1725 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
1726 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
1727 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
1728 ];
1729 const isAllState = state === "all";
1730
0074234Claude1731 return c.html(
1732 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1733 <RepoHeader owner={ownerName} repo={repoName} />
1734 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude1735 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1736
1737 <div class="prs-hero">
1738 <div class="prs-hero-inner">
1739 <div class="prs-hero-text">
1740 <div class="prs-hero-eyebrow">Pull requests</div>
1741 <h1 class="prs-hero-title">
1742 Review, <span class="gradient-text">merge with AI</span>.
1743 </h1>
1744 <p class="prs-hero-sub">
1745 {openCount === 0 && allCount === 0
1746 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
1747 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
1748 </p>
1749 </div>
1750 {user && (
1751 <div class="prs-hero-actions">
1752 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
1753 + New pull request
1754 </a>
1755 </div>
1756 )}
1757 </div>
1758 </div>
1759
1760 <nav class="prs-tabs" aria-label="Pull request filters">
1761 {tabPills.map((t) => {
1762 const isActive =
1763 state === t.key ||
1764 (t.key === "open" &&
1765 state !== "merged" &&
1766 state !== "closed" &&
1767 state !== "all" &&
1768 state !== "draft");
1769 return (
1770 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
1771 <span>{t.label}</span>
1772 <span class="prs-tab-count">{t.count}</span>
1773 </a>
1774 );
1775 })}
1776 </nav>
1777
0074234Claude1778 {prList.length === 0 ? (
b078860Claude1779 <div class="prs-empty">
ea9ed4cClaude1780 <div class="prs-empty-inner">
1781 <strong>
1782 {isAllState
1783 ? "Pick a filter above to browse PRs."
1784 : `No ${state} pull requests.`}
1785 </strong>
1786 <p class="prs-empty-sub">
1787 {state === "open"
1788 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
1789 : isAllState
1790 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1791 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
1792 </p>
1793 <div class="prs-empty-cta">
1794 {user && state === "open" && (
1795 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
1796 + New pull request
1797 </a>
1798 )}
1799 {state !== "open" && (
1800 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
1801 View open PRs
1802 </a>
1803 )}
1804 <a href={`/${ownerName}/${repoName}`} class="btn">
1805 Back to code
1806 </a>
1807 </div>
1808 </div>
b078860Claude1809 </div>
0074234Claude1810 ) : (
b078860Claude1811 <div class="prs-list">
1812 {prList.map(({ pr, author }) => {
1813 const stateClass =
1814 pr.state === "open"
1815 ? pr.isDraft
1816 ? "state-draft"
1817 : "state-open"
1818 : pr.state === "merged"
1819 ? "state-merged"
1820 : "state-closed";
1821 const icon =
1822 pr.state === "open"
1823 ? pr.isDraft
1824 ? "◌"
1825 : "○"
1826 : pr.state === "merged"
1827 ? "⮌"
1828 : "✓";
1829 return (
1830 <a
1831 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1832 class="prs-row"
1833 style="text-decoration:none;color:inherit"
0074234Claude1834 >
b078860Claude1835 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
1836 {icon}
0074234Claude1837 </div>
b078860Claude1838 <div class="prs-row-body">
1839 <h3 class="prs-row-title">
1840 <span>{pr.title}</span>
1841 <span class="prs-row-number">#{pr.number}</span>
1842 </h3>
1843 <div class="prs-row-meta">
1844 <span
1845 class="prs-branch-chips"
1846 title={`${pr.headBranch} into ${pr.baseBranch}`}
1847 >
1848 <span class="prs-branch-chip">{pr.headBranch}</span>
1849 <span class="prs-branch-arrow">{"→"}</span>
1850 <span class="prs-branch-chip">{pr.baseBranch}</span>
1851 </span>
1852 <span>
1853 by{" "}
1854 <strong style="color:var(--text)">
1855 {author.username}
1856 </strong>{" "}
1857 {formatRelative(pr.createdAt)}
1858 </span>
1859 <span class="prs-row-tags">
1860 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
1861 {pr.state === "merged" && (
1862 <span class="prs-tag is-merged">Merged</span>
1863 )}
1864 </span>
1865 </div>
0074234Claude1866 </div>
b078860Claude1867 </a>
1868 );
1869 })}
1870 </div>
0074234Claude1871 )}
1872 </Layout>
1873 );
1874});
1875
1876// New PR form
1877pulls.get(
1878 "/:owner/:repo/pulls/new",
1879 softAuth,
1880 requireAuth,
04f6b7fClaude1881 requireRepoAccess("write"),
0074234Claude1882 async (c) => {
1883 const { owner: ownerName, repo: repoName } = c.req.param();
1884 const user = c.get("user")!;
1885 const branches = await listBranches(ownerName, repoName);
1886 const error = c.req.query("error");
1887 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude1888 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude1889
1890 return c.html(
1891 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
1892 <RepoHeader owner={ownerName} repo={repoName} />
1893 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude1894 <Container maxWidth={800}>
1895 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude1896 {error && (
bb0f894Claude1897 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude1898 )}
0316dbbClaude1899 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
1900 <Flex gap={12} align="center" style="margin-bottom: 16px">
1901 <Select name="base">
0074234Claude1902 {branches.map((b) => (
1903 <option value={b} selected={b === defaultBase}>
1904 {b}
1905 </option>
1906 ))}
bb0f894Claude1907 </Select>
1908 <Text muted>&larr;</Text>
1909 <Select name="head">
0074234Claude1910 {branches
1911 .filter((b) => b !== defaultBase)
1912 .concat(defaultBase === branches[0] ? [] : [branches[0]])
1913 .map((b) => (
1914 <option value={b}>{b}</option>
1915 ))}
bb0f894Claude1916 </Select>
1917 </Flex>
1918 <FormGroup>
1919 <Input
0074234Claude1920 name="title"
1921 required
1922 placeholder="Title"
bb0f894Claude1923 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]1924 aria-label="Pull request title"
0074234Claude1925 />
bb0f894Claude1926 </FormGroup>
1927 <FormGroup>
1928 <TextArea
0074234Claude1929 name="body"
81c73c1Claude1930 id="pr-body"
0074234Claude1931 rows={8}
1932 placeholder="Description (Markdown supported)"
bb0f894Claude1933 mono
0074234Claude1934 />
bb0f894Claude1935 </FormGroup>
81c73c1Claude1936 <Flex gap={8} align="center">
1937 <Button type="submit" variant="primary">
1938 Create pull request
1939 </Button>
1940 <button
1941 type="button"
1942 id="ai-suggest-desc"
1943 class="btn"
1944 style="font-weight:500"
1945 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
1946 >
1947 Suggest description with AI
1948 </button>
1949 <span
1950 id="ai-suggest-status"
1951 style="color:var(--text-muted);font-size:13px"
1952 />
1953 </Flex>
bb0f894Claude1954 </Form>
81c73c1Claude1955 <script
1956 dangerouslySetInnerHTML={{
1957 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
1958 }}
1959 />
bb0f894Claude1960 </Container>
0074234Claude1961 </Layout>
1962 );
1963 }
1964);
1965
81c73c1Claude1966// AI-suggested PR description — JSON endpoint driven by the form button.
1967// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
1968// 200; the inline script reads `ok` to decide what to do.
1969pulls.post(
1970 "/:owner/:repo/ai/pr-description",
1971 softAuth,
1972 requireAuth,
1973 requireRepoAccess("write"),
1974 async (c) => {
1975 const { owner: ownerName, repo: repoName } = c.req.param();
1976 if (!isAiAvailable()) {
1977 return c.json({
1978 ok: false,
1979 error: "AI is not available — set ANTHROPIC_API_KEY.",
1980 });
1981 }
1982 const body = await c.req.parseBody();
1983 const title = String(body.title || "").trim();
1984 const baseBranch = String(body.base || "").trim();
1985 const headBranch = String(body.head || "").trim();
1986 if (!baseBranch || !headBranch) {
1987 return c.json({ ok: false, error: "Pick base + head branches first." });
1988 }
1989 if (baseBranch === headBranch) {
1990 return c.json({ ok: false, error: "Base and head must differ." });
1991 }
1992
1993 let diff = "";
1994 try {
1995 const cwd = getRepoPath(ownerName, repoName);
1996 const proc = Bun.spawn(
1997 [
1998 "git",
1999 "diff",
2000 `${baseBranch}...${headBranch}`,
2001 "--",
2002 ],
2003 { cwd, stdout: "pipe", stderr: "pipe" }
2004 );
6ea2109Claude2005 // 30s ceiling — without this a pathological diff (huge binary or
2006 // a corrupt ref) hangs the request indefinitely.
2007 const killer = setTimeout(() => proc.kill(), 30_000);
2008 try {
2009 diff = await new Response(proc.stdout).text();
2010 await proc.exited;
2011 } finally {
2012 clearTimeout(killer);
2013 }
81c73c1Claude2014 } catch {
2015 diff = "";
2016 }
2017 if (!diff.trim()) {
2018 return c.json({
2019 ok: false,
2020 error: "No diff between branches — nothing to summarise.",
2021 });
2022 }
2023
2024 let summary = "";
2025 try {
2026 summary = await generatePrSummary(title || "(untitled)", diff);
2027 } catch (err) {
2028 const msg = err instanceof Error ? err.message : "AI request failed.";
2029 return c.json({ ok: false, error: msg });
2030 }
2031 if (!summary.trim()) {
2032 return c.json({ ok: false, error: "AI returned an empty draft." });
2033 }
2034 return c.json({ ok: true, body: summary });
2035 }
2036);
2037
0074234Claude2038// Create PR
2039pulls.post(
2040 "/:owner/:repo/pulls/new",
2041 softAuth,
2042 requireAuth,
04f6b7fClaude2043 requireRepoAccess("write"),
0074234Claude2044 async (c) => {
2045 const { owner: ownerName, repo: repoName } = c.req.param();
2046 const user = c.get("user")!;
2047 const body = await c.req.parseBody();
2048 const title = String(body.title || "").trim();
2049 const prBody = String(body.body || "").trim();
2050 const baseBranch = String(body.base || "main");
2051 const headBranch = String(body.head || "");
2052
2053 if (!title || !headBranch) {
2054 return c.redirect(
2055 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
2056 );
2057 }
2058
2059 if (baseBranch === headBranch) {
2060 return c.redirect(
2061 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
2062 );
2063 }
2064
2065 const resolved = await resolveRepo(ownerName, repoName);
2066 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2067
6fc53bdClaude2068 const isDraft = String(body.draft || "") === "1";
2069
0074234Claude2070 const [pr] = await db
2071 .insert(pullRequests)
2072 .values({
2073 repositoryId: resolved.repo.id,
2074 authorId: user.id,
2075 title,
2076 body: prBody || null,
2077 baseBranch,
2078 headBranch,
6fc53bdClaude2079 isDraft,
0074234Claude2080 })
2081 .returning();
2082
6fc53bdClaude2083 // Skip AI review on drafts — it runs again when the PR is marked ready.
2084 if (!isDraft && isAiReviewEnabled()) {
e883329Claude2085 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
2086 (err) => console.error("[ai-review] Failed:", err)
2087 );
2088 }
2089
3cbe3d6Claude2090 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
2091 triggerPrTriage({
2092 ownerName,
2093 repoName,
2094 repositoryId: resolved.repo.id,
2095 prId: pr.id,
2096 prAuthorId: user.id,
2097 title,
2098 body: prBody,
2099 baseBranch,
2100 headBranch,
2101 }).catch((err) => console.error("[pr-triage] Failed:", err));
2102
1d4ff60Claude2103 // Chat notifier — fan out to Slack/Discord/Teams.
2104 import("../lib/chat-notifier")
2105 .then((m) =>
2106 m.notifyChatChannels({
2107 ownerUserId: resolved.repo.ownerId,
2108 repositoryId: resolved.repo.id,
2109 event: {
2110 event: "pr.opened",
2111 repo: `${ownerName}/${repoName}`,
2112 title: `#${pr.number} ${title}`,
2113 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
2114 body: prBody || undefined,
2115 actor: user.username,
2116 },
2117 })
2118 )
2119 .catch((err) =>
2120 console.warn(`[chat-notifier] PR opened notify failed:`, err)
2121 );
2122
9dd96b9Test User2123 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude2124 import("../lib/auto-merge")
2125 .then((m) => m.tryAutoMergeNow(pr.id))
2126 .catch((err) => {
2127 console.warn(
2128 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
2129 err instanceof Error ? err.message : err
2130 );
2131 });
9dd96b9Test User2132
0074234Claude2133 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
2134 }
2135);
2136
2137// View single PR
04f6b7fClaude2138pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2139 const { owner: ownerName, repo: repoName } = c.req.param();
2140 const prNum = parseInt(c.req.param("number"), 10);
2141 const user = c.get("user");
2142 const tab = c.req.query("tab") || "conversation";
2143
2144 const resolved = await resolveRepo(ownerName, repoName);
2145 if (!resolved) return c.notFound();
2146
2147 const [pr] = await db
2148 .select()
2149 .from(pullRequests)
2150 .where(
2151 and(
2152 eq(pullRequests.repositoryId, resolved.repo.id),
2153 eq(pullRequests.number, prNum)
2154 )
2155 )
2156 .limit(1);
2157
2158 if (!pr) return c.notFound();
2159
2160 const [author] = await db
2161 .select()
2162 .from(users)
2163 .where(eq(users.id, pr.authorId))
2164 .limit(1);
2165
2166 const comments = await db
2167 .select({
2168 comment: prComments,
2169 author: { username: users.username },
2170 })
2171 .from(prComments)
2172 .innerJoin(users, eq(prComments.authorId, users.id))
2173 .where(eq(prComments.pullRequestId, pr.id))
2174 .orderBy(asc(prComments.createdAt));
2175
6fc53bdClaude2176 // Reactions for the PR body + each comment, in parallel.
2177 const [prReactions, ...prCommentReactions] = await Promise.all([
2178 summariseReactions("pr", pr.id, user?.id),
2179 ...comments.map((row) =>
2180 summariseReactions("pr_comment", row.comment.id, user?.id)
2181 ),
2182 ]);
2183
0074234Claude2184 const canManage =
2185 user &&
2186 (user.id === resolved.owner.id || user.id === pr.authorId);
2187
1d4ff60Claude2188 // Has any previous AI-test-generator run already tagged this PR? Used
2189 // both to hide the "Generate tests with AI" button and to short-circuit
2190 // the explicit POST handler.
2191 const hasAiTestsMarker = comments.some(({ comment }) =>
2192 (comment.body || "").includes(AI_TESTS_MARKER)
2193 );
2194
e883329Claude2195 const error = c.req.query("error");
c3e0c07Claude2196 const info = c.req.query("info");
e883329Claude2197
2198 // Get gate check status for open PRs
2199 let gateChecks: GateCheckResult[] = [];
2200 if (pr.state === "open") {
2201 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2202 if (headSha) {
2203 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
2204 const aiApproved = aiComments.length === 0 || aiComments.some(
2205 ({ comment }) => comment.body.includes("**Approved**")
2206 );
2207 const gateResult = await runAllGateChecks(
2208 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
2209 );
2210 gateChecks = gateResult.checks;
2211 }
2212 }
2213
534f04aClaude2214 // Block M3 — pre-merge risk score. Cache-only on the request path so
2215 // the page never waits on Haiku. On a cache miss for an open PR we
2216 // kick off the computation fire-and-forget; the next refresh shows it.
2217 let prRisk: PrRiskScore | null = null;
2218 let prRiskCalculating = false;
2219 if (pr.state === "open") {
2220 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
2221 if (!prRisk) {
2222 prRiskCalculating = true;
a28cedeClaude2223 void computePrRiskForPullRequest(pr.id).catch((err) => {
2224 console.warn(
2225 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
2226 err instanceof Error ? err.message : err
2227 );
2228 });
534f04aClaude2229 }
2230 }
2231
4bbacbeClaude2232 // Migration 0062 — per-branch preview URL. The head branch always
2233 // has a preview row (unless it's the default branch, which never
2234 // happens for an open PR) once it has been pushed at least once.
2235 const preview = await getPreviewForBranch(
2236 (resolved.repo as { id: string }).id,
2237 pr.headBranch
2238 );
2239
0074234Claude2240 // Get diff for "Files changed" tab
2241 let diffRaw = "";
2242 let diffFiles: GitDiffFile[] = [];
2243 if (tab === "files") {
2244 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude2245 // Run the two git diffs in parallel — they're independent reads of
2246 // the same range. Previously sequential, doubling the wall time on
2247 // big PRs (100+ files = 10-30s for no reason).
0074234Claude2248 const proc = Bun.spawn(
2249 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
2250 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2251 );
2252 const statProc = Bun.spawn(
2253 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
2254 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2255 );
6ea2109Claude2256 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
2257 // would otherwise hang the whole request.
2258 const killer = setTimeout(() => {
2259 proc.kill();
2260 statProc.kill();
2261 }, 30_000);
2262 let stat = "";
2263 try {
2264 [diffRaw, stat] = await Promise.all([
2265 new Response(proc.stdout).text(),
2266 new Response(statProc.stdout).text(),
2267 ]);
2268 await Promise.all([proc.exited, statProc.exited]);
2269 } finally {
2270 clearTimeout(killer);
2271 }
0074234Claude2272
2273 diffFiles = stat
2274 .trim()
2275 .split("\n")
2276 .filter(Boolean)
2277 .map((line) => {
2278 const [add, del, filePath] = line.split("\t");
2279 return {
2280 path: filePath,
2281 status: "modified",
2282 additions: add === "-" ? 0 : parseInt(add, 10),
2283 deletions: del === "-" ? 0 : parseInt(del, 10),
2284 patch: "",
2285 };
2286 });
2287 }
2288
b078860Claude2289 // ─── Derived visual state ───
2290 const stateKey =
2291 pr.state === "open"
2292 ? pr.isDraft
2293 ? "draft"
2294 : "open"
2295 : pr.state;
2296 const stateLabel =
2297 stateKey === "open"
2298 ? "Open"
2299 : stateKey === "draft"
2300 ? "Draft"
2301 : stateKey === "merged"
2302 ? "Merged"
2303 : "Closed";
2304 const stateIcon =
2305 stateKey === "open"
2306 ? "○"
2307 : stateKey === "draft"
2308 ? "◌"
2309 : stateKey === "merged"
2310 ? "⮌"
2311 : "✓";
2312 const commentCount = comments.length;
2313 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
2314 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
2315 const mergeBlocked =
2316 gateChecks.length > 0 &&
2317 gateChecks.some(
2318 (c) => !c.passed && c.name !== "Merge check"
2319 );
2320
0074234Claude2321 return c.html(
2322 <Layout
2323 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
2324 user={user}
2325 >
2326 <RepoHeader owner={ownerName} repo={repoName} />
2327 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude2328 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude2329 <div
2330 id="live-comment-banner"
2331 class="alert"
2332 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
2333 >
2334 <strong class="js-live-count">0</strong> new comment(s) —{" "}
2335 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
2336 reload to view
2337 </a>
2338 </div>
2339 <script
2340 dangerouslySetInnerHTML={{
2341 __html: liveCommentBannerScript({
2342 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
2343 bannerElementId: "live-comment-banner",
2344 }),
2345 }}
2346 />
b078860Claude2347
2348 <div class="prs-detail-hero">
2349 <h1 class="prs-detail-title">
0074234Claude2350 {pr.title}{" "}
b078860Claude2351 <span class="prs-detail-num">#{pr.number}</span>
2352 </h1>
2353 <div class="prs-detail-meta">
2354 <span class={`prs-state-pill state-${stateKey}`}>
2355 <span aria-hidden="true">{stateIcon}</span>
2356 <span>{stateLabel}</span>
2357 </span>
2358 <span>
2359 <strong>{author?.username}</strong> wants to merge
2360 </span>
2361 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
2362 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
2363 <span class="prs-branch-arrow-lg">{"→"}</span>
2364 <span class="prs-branch-pill">{pr.baseBranch}</span>
2365 </span>
2366 <span>opened {formatRelative(pr.createdAt)}</span>
3c03977Claude2367 <span
2368 id="live-pill"
2369 class="live-pill"
2370 title="People editing this PR right now"
2371 >
2372 <span class="live-pill-dot" aria-hidden="true"></span>
2373 <span>
2374 Live: <strong id="live-count">0</strong> editing
2375 </span>
2376 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
2377 </span>
4bbacbeClaude2378 {preview && (
2379 <a
2380 class={`preview-prpill is-${preview.status}`}
2381 href={
2382 preview.status === "ready"
2383 ? preview.previewUrl
2384 : `/${ownerName}/${repoName}/previews`
2385 }
2386 target={preview.status === "ready" ? "_blank" : undefined}
2387 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
2388 title={`Preview · ${previewStatusLabel(preview.status)}`}
2389 >
2390 <span class="preview-prpill-dot" aria-hidden="true"></span>
2391 <span>Preview: </span>
2392 <span>{previewStatusLabel(preview.status)}</span>
2393 </a>
2394 )}
b078860Claude2395 {canManage && pr.state === "open" && pr.isDraft && (
2396 <form
2397 method="post"
2398 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2399 class="prs-inline-form prs-detail-actions"
2400 >
2401 <button type="submit" class="prs-merge-ready-btn">
2402 Ready for review
2403 </button>
2404 </form>
2405 )}
2406 </div>
2407 </div>
3c03977Claude2408 <script
2409 dangerouslySetInnerHTML={{
2410 __html: LIVE_COEDIT_SCRIPT(pr.id),
2411 }}
2412 />
0074234Claude2413
b078860Claude2414 <nav class="prs-detail-tabs" aria-label="Pull request sections">
2415 <a
2416 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
2417 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2418 >
2419 Conversation
2420 <span class="prs-detail-tab-count">{commentCount}</span>
2421 </a>
2422 <a
2423 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
2424 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2425 >
2426 Files changed
2427 {diffFiles.length > 0 && (
2428 <span class="prs-detail-tab-count">{diffFiles.length}</span>
2429 )}
2430 </a>
2431 </nav>
2432
2433 {tab === "files" ? (
ea9ed4cClaude2434 <DiffView
2435 raw={diffRaw}
2436 files={diffFiles}
2437 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
2438 />
b078860Claude2439 ) : (
2440 <>
2441 {pr.body && (
2442 <CommentBox
2443 author={author?.username ?? "unknown"}
2444 date={pr.createdAt}
2445 body={renderMarkdown(pr.body)}
2446 />
2447 )}
2448
422a2d4Claude2449 {/* Block H — AI trio review (security/correctness/style). When
2450 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
2451 hoisted into a 3-column card grid above the normal comment
2452 stream so reviewers see verdicts at a glance. Disagreements
2453 are surfaced as a yellow callout. */}
2454 <TrioReviewGrid
2455 comments={comments.map(({ comment }) => comment)}
2456 />
2457
15db0e0Claude2458 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude2459 // Skip trio comments — already rendered in TrioReviewGrid above.
2460 if (isTrioComment(comment.body)) return null;
15db0e0Claude2461 const slashCmd = detectSlashCmdComment(comment.body);
2462 if (slashCmd) {
2463 const visible = stripSlashCmdMarker(comment.body);
2464 return (
2465 <div class={`slash-pill slash-cmd-${slashCmd}`}>
2466 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
2467 <span class="slash-pill-actor">
2468 <strong>{commentAuthor.username}</strong>
2469 {" ran "}
2470 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude2471 </span>
15db0e0Claude2472 <span class="slash-pill-time">
2473 {formatRelative(comment.createdAt)}
2474 </span>
2475 <div class="slash-pill-body">
2476 <MarkdownContent html={renderMarkdown(visible)} />
2477 </div>
2478 </div>
2479 );
2480 }
2481 return (
2482 <div class={`prs-comment${comment.isAiReview ? " is-ai" : ""}`}>
2483 <div class="prs-comment-head">
2484 <strong>{commentAuthor.username}</strong>
2485 {comment.isAiReview && (
2486 <span class="prs-ai-badge">AI Review</span>
2487 )}
2488 <span class="prs-comment-time">
2489 commented {formatRelative(comment.createdAt)}
2490 </span>
2491 {comment.filePath && (
2492 <span class="prs-comment-loc">
2493 {comment.filePath}
2494 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
2495 </span>
2496 )}
2497 </div>
2498 <div class="prs-comment-body">
2499 <MarkdownContent html={renderMarkdown(comment.body)} />
2500 </div>
0074234Claude2501 </div>
15db0e0Claude2502 );
2503 })}
0074234Claude2504
b078860Claude2505 {/* Quick link to the Files changed tab when there's a diff to look at. */}
2506 {pr.state !== "merged" && (
2507 <a
2508 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2509 class="prs-files-card"
2510 >
2511 <span class="prs-files-card-icon" aria-hidden="true">
2512 {"▤"}
2513 </span>
2514 <div class="prs-files-card-text">
2515 <p class="prs-files-card-title">Files changed</p>
2516 <p class="prs-files-card-sub">
2517 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
2518 </p>
e883329Claude2519 </div>
b078860Claude2520 <span class="prs-files-card-cta">View diff {"→"}</span>
2521 </a>
2522 )}
2523
2524 {error && (
2525 <div
2526 class="auth-error"
2527 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)"
2528 >
2529 {decodeURIComponent(error)}
2530 </div>
2531 )}
2532
2533 {info && (
2534 <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)">
2535 {decodeURIComponent(info)}
2536 </div>
2537 )}
e883329Claude2538
b078860Claude2539 {pr.state === "open" && (prRisk || prRiskCalculating) && (
2540 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
2541 )}
2542
2543 {pr.state === "open" && gateChecks.length > 0 && (
2544 <div class="prs-gate-card">
2545 <div class="prs-gate-head">
2546 <h3>Gate checks</h3>
2547 <span class="prs-gate-summary">
2548 {gatesAllPassed
2549 ? `All ${gateChecks.length} checks passed`
2550 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
2551 </span>
c3e0c07Claude2552 </div>
b078860Claude2553 {gateChecks.map((check) => {
2554 const isAi = /ai.*review/i.test(check.name);
2555 const isSkip = check.skipped === true;
2556 const statusClass = isSkip
2557 ? "is-skip"
2558 : check.passed
2559 ? "is-pass"
2560 : "is-fail";
2561 const statusGlyph = isSkip
2562 ? "—"
2563 : check.passed
2564 ? "✓"
2565 : "✗";
2566 const statusLabel = isSkip
2567 ? "Skipped"
2568 : check.passed
2569 ? "Passed"
2570 : "Failing";
2571 return (
2572 <div
2573 class="prs-gate-row"
2574 style={
2575 isAi
2576 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
2577 : ""
2578 }
2579 >
2580 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
2581 {statusGlyph}
2582 </span>
2583 <span class="prs-gate-name">
2584 {check.name}
2585 {isAi && (
2586 <span
2587 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"
2588 >
2589 AI
2590 </span>
2591 )}
2592 </span>
2593 <span class="prs-gate-details">{check.details}</span>
2594 <span class={`prs-gate-pill ${statusClass}`}>
2595 {statusLabel}
e883329Claude2596 </span>
2597 </div>
b078860Claude2598 );
2599 })}
2600 <div class="prs-gate-footer">
2601 {gatesAllPassed
2602 ? "All checks passed — ready to merge."
2603 : gateChecks.some(
2604 (c) => !c.passed && c.name === "Merge check"
2605 )
2606 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
2607 : "Some checks failed — resolve issues before merging."}
2608 {aiReviewCount > 0 && (
2609 <>
2610 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
2611 </>
2612 )}
2613 </div>
2614 </div>
2615 )}
2616
2617 {/* ─── Merge area / state-aware action card ─────────────── */}
2618 {user && pr.state === "open" && (
2619 <div
2620 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
2621 >
2622 <div class="prs-merge-head">
2623 <strong>
2624 {pr.isDraft
2625 ? "Draft — ready for review?"
2626 : mergeBlocked
2627 ? "Merge blocked"
2628 : "Ready to merge"}
2629 </strong>
e883329Claude2630 </div>
b078860Claude2631 <p class="prs-merge-sub">
2632 {pr.isDraft
2633 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
2634 : mergeBlocked
2635 ? "Resolve the failing gate checks above before this PR can land."
2636 : gateChecks.length > 0
2637 ? gatesAllPassed
2638 ? "All gates green. Merge will fast-forward into the base branch."
2639 : "Conflicts will be auto-resolved by GlueCron AI on merge."
2640 : "Run gate checks by refreshing once your branch has a recent commit."}
2641 </p>
2642 <Form
2643 method="post"
2644 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
2645 >
2646 <FormGroup>
3c03977Claude2647 <div class="live-cursor-host" style="position:relative">
2648 <textarea
2649 name="body"
2650 id="pr-comment-body"
2651 data-live-field="comment_new"
2652 rows={5}
2653 required
2654 placeholder="Leave a comment... (Markdown supported)"
2655 style="font-family:var(--font-mono);font-size:13px;width:100%"
2656 ></textarea>
2657 </div>
15db0e0Claude2658 <span class="slash-hint" title="Type a slash-command as the first line">
2659 Type <code>/</code> for commands —{" "}
2660 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
2661 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
2662 </span>
b078860Claude2663 </FormGroup>
2664 <div class="prs-merge-actions">
2665 <Button type="submit" variant="primary">
2666 Comment
2667 </Button>
2668 {canManage && (
2669 <>
2670 {pr.isDraft ? (
2671 <button
2672 type="submit"
2673 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2674 formnovalidate
2675 class="prs-merge-ready-btn"
2676 >
2677 Ready for review
2678 </button>
2679 ) : (
0074234Claude2680 <button
2681 type="submit"
2682 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
b078860Claude2683 formnovalidate
2684 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
2685 title={
2686 mergeBlocked
2687 ? "Failing gate checks must be resolved before this PR can merge."
2688 : "Merge pull request"
2689 }
0074234Claude2690 >
b078860Claude2691 {"✔"} Merge pull request
0074234Claude2692 </button>
b078860Claude2693 )}
2694 {!pr.isDraft && (
2695 <button
0074234Claude2696 type="submit"
b078860Claude2697 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
2698 formnovalidate
2699 class="prs-merge-back-draft"
2700 title="Convert back to draft"
0074234Claude2701 >
b078860Claude2702 Convert to draft
2703 </button>
2704 )}
2705 {isAiReviewEnabled() && (
2706 <button
2707 type="submit"
2708 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
2709 formnovalidate
2710 class="btn"
2711 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
2712 >
2713 Re-run AI review
2714 </button>
2715 )}
1d4ff60Claude2716 {isAiReviewEnabled() && !hasAiTestsMarker && (
2717 <button
2718 type="submit"
2719 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
2720 formnovalidate
2721 class="btn"
2722 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."
2723 >
2724 Generate tests with AI
2725 </button>
2726 )}
b078860Claude2727 <Button
2728 type="submit"
2729 variant="danger"
2730 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
2731 >
2732 Close
2733 </Button>
2734 </>
2735 )}
2736 </div>
2737 </Form>
2738 </div>
2739 )}
2740
2741 {/* Read-only footers for non-open states. */}
2742 {pr.state === "merged" && (
2743 <div class="prs-merge-card is-merged">
2744 <div class="prs-merge-head">
2745 <strong>{"⮌"} Merged</strong>
0074234Claude2746 </div>
b078860Claude2747 <p class="prs-merge-sub">
2748 This pull request was merged into{" "}
2749 <code>{pr.baseBranch}</code>.
2750 </p>
2751 </div>
2752 )}
2753 {pr.state === "closed" && (
2754 <div class="prs-merge-card is-closed">
2755 <div class="prs-merge-head">
2756 <strong>{"✕"} Closed without merging</strong>
2757 </div>
2758 <p class="prs-merge-sub">
2759 This pull request was closed and not merged.
2760 </p>
2761 </div>
2762 )}
2763 </>
2764 )}
0074234Claude2765 </Layout>
2766 );
2767});
2768
2769// Add comment to PR
2770pulls.post(
2771 "/:owner/:repo/pulls/:number/comment",
2772 softAuth,
2773 requireAuth,
04f6b7fClaude2774 requireRepoAccess("write"),
0074234Claude2775 async (c) => {
2776 const { owner: ownerName, repo: repoName } = c.req.param();
2777 const prNum = parseInt(c.req.param("number"), 10);
2778 const user = c.get("user")!;
2779 const body = await c.req.parseBody();
2780 const commentBody = String(body.body || "").trim();
2781
2782 if (!commentBody) {
2783 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2784 }
2785
2786 const resolved = await resolveRepo(ownerName, repoName);
2787 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2788
2789 const [pr] = await db
2790 .select()
2791 .from(pullRequests)
2792 .where(
2793 and(
2794 eq(pullRequests.repositoryId, resolved.repo.id),
2795 eq(pullRequests.number, prNum)
2796 )
2797 )
2798 .limit(1);
2799
2800 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2801
d4ac5c3Claude2802 const [inserted] = await db
2803 .insert(prComments)
2804 .values({
2805 pullRequestId: pr.id,
2806 authorId: user.id,
2807 body: commentBody,
2808 })
2809 .returning();
2810
2811 // Live update: nudge any browser tabs subscribed to this PR.
2812 if (inserted) {
2813 try {
2814 const { publish } = await import("../lib/sse");
2815 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
2816 event: "pr-comment",
2817 data: {
2818 pullRequestId: pr.id,
2819 commentId: inserted.id,
2820 authorId: user.id,
2821 authorUsername: user.username,
2822 },
2823 });
2824 } catch {
2825 /* SSE is best-effort */
2826 }
2827 }
0074234Claude2828
15db0e0Claude2829 // Slash-command handoff. We always store the original comment above
2830 // first so free-form text that happens to start with `/` is preserved
2831 // verbatim; only recognised commands trigger a follow-up bot comment.
2832 const parsed = parseSlashCommand(commentBody);
2833 if (parsed) {
2834 try {
2835 const result = await executeSlashCommand({
2836 command: parsed.command,
2837 args: parsed.args,
2838 prId: pr.id,
2839 userId: user.id,
2840 repositoryId: resolved.repo.id,
2841 });
2842 await db.insert(prComments).values({
2843 pullRequestId: pr.id,
2844 authorId: user.id,
2845 body: result.body,
2846 });
2847 } catch (err) {
2848 // Defence-in-depth — executeSlashCommand promises not to throw,
2849 // but if it ever does we want the PR thread to know.
2850 await db
2851 .insert(prComments)
2852 .values({
2853 pullRequestId: pr.id,
2854 authorId: user.id,
2855 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
2856 })
2857 .catch(() => {});
2858 }
2859 }
2860
0074234Claude2861 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2862 }
2863);
2864
e883329Claude2865// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude2866// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
2867// but we keep it at "write" for v1 so trusted collaborators can ship.
2868// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
2869// surface. Branch-protection rules (evaluated below) are the current mechanism
2870// for locking down merges further on specific branches.
0074234Claude2871pulls.post(
2872 "/:owner/:repo/pulls/:number/merge",
2873 softAuth,
2874 requireAuth,
04f6b7fClaude2875 requireRepoAccess("write"),
0074234Claude2876 async (c) => {
2877 const { owner: ownerName, repo: repoName } = c.req.param();
2878 const prNum = parseInt(c.req.param("number"), 10);
2879 const user = c.get("user")!;
2880
2881 const resolved = await resolveRepo(ownerName, repoName);
2882 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2883
2884 const [pr] = await db
2885 .select()
2886 .from(pullRequests)
2887 .where(
2888 and(
2889 eq(pullRequests.repositoryId, resolved.repo.id),
2890 eq(pullRequests.number, prNum)
2891 )
2892 )
2893 .limit(1);
2894
2895 if (!pr || pr.state !== "open") {
2896 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2897 }
2898
6fc53bdClaude2899 // Draft PRs cannot be merged — must be marked ready first.
2900 if (pr.isDraft) {
2901 return c.redirect(
2902 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2903 "This PR is a draft. Mark it as ready for review before merging."
2904 )}`
2905 );
2906 }
2907
e883329Claude2908 // Resolve head SHA
2909 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2910 if (!headSha) {
2911 return c.redirect(
2912 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
2913 );
2914 }
2915
2916 // Check if AI review approved this PR
2917 const aiComments = await db
2918 .select()
2919 .from(prComments)
2920 .where(
2921 and(
2922 eq(prComments.pullRequestId, pr.id),
2923 eq(prComments.isAiReview, true)
2924 )
2925 );
2926 const aiApproved = aiComments.length === 0 || aiComments.some(
2927 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude2928 );
e883329Claude2929
2930 // Run all green gate checks (GateTest + mergeability + AI review)
2931 const gateResult = await runAllGateChecks(
2932 ownerName,
2933 repoName,
2934 pr.baseBranch,
2935 pr.headBranch,
2936 headSha,
2937 aiApproved
0074234Claude2938 );
2939
e883329Claude2940 // If GateTest or AI review failed (hard blocks), reject the merge
2941 const hardFailures = gateResult.checks.filter(
2942 (check) => !check.passed && check.name !== "Merge check"
2943 );
2944 if (hardFailures.length > 0) {
2945 const errorMsg = hardFailures
2946 .map((f) => `${f.name}: ${f.details}`)
2947 .join("; ");
0074234Claude2948 return c.redirect(
e883329Claude2949 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude2950 );
2951 }
2952
1e162a8Claude2953 // D5 — Branch-protection enforcement. Looks up the matching rule for the
2954 // base branch and blocks the merge if requireAiApproval / requireGreenGates
2955 // / requireHumanReview / requiredApprovals are not satisfied. Independent
2956 // of repo-global settings, so owners can lock specific branches down
2957 // further than the repo default.
2958 const protectionRule = await matchProtection(
2959 resolved.repo.id,
2960 pr.baseBranch
2961 );
2962 if (protectionRule) {
2963 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude2964 const required = await listRequiredChecks(protectionRule.id);
2965 const passingNames = required.length > 0
2966 ? await passingCheckNames(resolved.repo.id, headSha)
2967 : [];
2968 const decision = evaluateProtection(
2969 protectionRule,
2970 {
2971 aiApproved,
2972 humanApprovalCount: humanApprovals,
2973 gateResultGreen: hardFailures.length === 0,
2974 hasFailedGates: hardFailures.length > 0,
2975 passingCheckNames: passingNames,
2976 },
2977 required.map((r) => r.checkName)
2978 );
1e162a8Claude2979 if (!decision.allowed) {
2980 return c.redirect(
2981 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2982 decision.reasons.join(" ")
2983 )}`
2984 );
2985 }
2986 }
2987
e883329Claude2988 // Attempt the merge — with auto conflict resolution if needed
2989 const repoDir = getRepoPath(ownerName, repoName);
2990 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
2991 const hasConflicts = mergeCheck && !mergeCheck.passed;
2992
2993 if (hasConflicts && isAiReviewEnabled()) {
2994 // Use Claude to auto-resolve conflicts
2995 const mergeResult = await mergeWithAutoResolve(
2996 ownerName,
2997 repoName,
2998 pr.baseBranch,
2999 pr.headBranch,
3000 `Merge pull request #${pr.number}: ${pr.title}`
3001 );
3002
3003 if (!mergeResult.success) {
3004 return c.redirect(
3005 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
3006 );
3007 }
3008
3009 // Post a comment about the auto-resolution
3010 if (mergeResult.resolvedFiles.length > 0) {
3011 await db.insert(prComments).values({
3012 pullRequestId: pr.id,
3013 authorId: user.id,
3014 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
3015 isAiReview: true,
3016 });
3017 }
3018 } else {
3019 // Standard merge — fast-forward or clean merge
3020 const ffProc = Bun.spawn(
3021 [
3022 "git",
3023 "update-ref",
3024 `refs/heads/${pr.baseBranch}`,
3025 `refs/heads/${pr.headBranch}`,
3026 ],
3027 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3028 );
3029 const ffExit = await ffProc.exited;
3030
3031 if (ffExit !== 0) {
3032 return c.redirect(
3033 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
3034 );
3035 }
3036 }
3037
0074234Claude3038 await db
3039 .update(pullRequests)
3040 .set({
3041 state: "merged",
3042 mergedAt: new Date(),
3043 mergedBy: user.id,
3044 updatedAt: new Date(),
3045 })
3046 .where(eq(pullRequests.id, pr.id));
3047
8809b87Claude3048 // Chat notifier — fan out merge event to Slack/Discord/Teams.
3049 import("../lib/chat-notifier")
3050 .then((m) =>
3051 m.notifyChatChannels({
3052 ownerUserId: resolved.repo.ownerId,
3053 repositoryId: resolved.repo.id,
3054 event: {
3055 event: "pr.merged",
3056 repo: `${ownerName}/${repoName}`,
3057 title: `#${pr.number} ${pr.title}`,
3058 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3059 actor: user.username,
3060 },
3061 })
3062 )
3063 .catch((err) =>
3064 console.warn(`[chat-notifier] PR merge notify failed:`, err)
3065 );
3066
d62fb36Claude3067 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
3068 // and auto-close each matching open issue with a back-link comment. Bounded
3069 // to the same repo for v1 (cross-repo refs ignored). Failures never block
3070 // the merge redirect.
3071 try {
3072 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3073 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3074 for (const n of refs) {
3075 const [issue] = await db
3076 .select()
3077 .from(issues)
3078 .where(
3079 and(
3080 eq(issues.repositoryId, resolved.repo.id),
3081 eq(issues.number, n)
3082 )
3083 )
3084 .limit(1);
3085 if (!issue || issue.state !== "open") continue;
3086 await db
3087 .update(issues)
3088 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
3089 .where(eq(issues.id, issue.id));
3090 await db.insert(issueComments).values({
3091 issueId: issue.id,
3092 authorId: user.id,
3093 body: `Closed by pull request #${pr.number}.`,
3094 });
3095 }
3096 } catch {
3097 // Never block the merge on close-keyword failures.
3098 }
3099
0074234Claude3100 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3101 }
3102);
3103
6fc53bdClaude3104// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
3105// hasn't run yet on this PR.
3106pulls.post(
3107 "/:owner/:repo/pulls/:number/ready",
3108 softAuth,
3109 requireAuth,
04f6b7fClaude3110 requireRepoAccess("write"),
6fc53bdClaude3111 async (c) => {
3112 const { owner: ownerName, repo: repoName } = c.req.param();
3113 const prNum = parseInt(c.req.param("number"), 10);
3114 const user = c.get("user")!;
3115
3116 const resolved = await resolveRepo(ownerName, repoName);
3117 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3118
3119 const [pr] = await db
3120 .select()
3121 .from(pullRequests)
3122 .where(
3123 and(
3124 eq(pullRequests.repositoryId, resolved.repo.id),
3125 eq(pullRequests.number, prNum)
3126 )
3127 )
3128 .limit(1);
3129 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3130
3131 // Only the author or repo owner can toggle draft state.
3132 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
3133 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3134 }
3135
3136 if (pr.state === "open" && pr.isDraft) {
3137 await db
3138 .update(pullRequests)
3139 .set({ isDraft: false, updatedAt: new Date() })
3140 .where(eq(pullRequests.id, pr.id));
3141
3142 if (isAiReviewEnabled()) {
3143 triggerAiReview(
3144 ownerName,
3145 repoName,
3146 pr.id,
3147 pr.title,
0316dbbClaude3148 pr.body || "",
6fc53bdClaude3149 pr.baseBranch,
3150 pr.headBranch
3151 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
3152 }
3153 }
3154
3155 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3156 }
3157);
3158
3159// Convert a PR back to draft.
3160pulls.post(
3161 "/:owner/:repo/pulls/:number/draft",
3162 softAuth,
3163 requireAuth,
04f6b7fClaude3164 requireRepoAccess("write"),
6fc53bdClaude3165 async (c) => {
3166 const { owner: ownerName, repo: repoName } = c.req.param();
3167 const prNum = parseInt(c.req.param("number"), 10);
3168 const user = c.get("user")!;
3169
3170 const resolved = await resolveRepo(ownerName, repoName);
3171 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3172
3173 const [pr] = await db
3174 .select()
3175 .from(pullRequests)
3176 .where(
3177 and(
3178 eq(pullRequests.repositoryId, resolved.repo.id),
3179 eq(pullRequests.number, prNum)
3180 )
3181 )
3182 .limit(1);
3183 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3184
3185 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
3186 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3187 }
3188
3189 if (pr.state === "open" && !pr.isDraft) {
3190 await db
3191 .update(pullRequests)
3192 .set({ isDraft: true, updatedAt: new Date() })
3193 .where(eq(pullRequests.id, pr.id));
3194 }
3195
3196 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3197 }
3198);
3199
0074234Claude3200// Close PR
3201pulls.post(
3202 "/:owner/:repo/pulls/:number/close",
3203 softAuth,
3204 requireAuth,
04f6b7fClaude3205 requireRepoAccess("write"),
0074234Claude3206 async (c) => {
3207 const { owner: ownerName, repo: repoName } = c.req.param();
3208 const prNum = parseInt(c.req.param("number"), 10);
3209
3210 const resolved = await resolveRepo(ownerName, repoName);
3211 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3212
3213 await db
3214 .update(pullRequests)
3215 .set({
3216 state: "closed",
3217 closedAt: new Date(),
3218 updatedAt: new Date(),
3219 })
3220 .where(
3221 and(
3222 eq(pullRequests.repositoryId, resolved.repo.id),
3223 eq(pullRequests.number, prNum)
3224 )
3225 );
3226
3227 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3228 }
3229);
3230
c3e0c07Claude3231// Re-run AI review on demand (e.g. after a force-push). Bypasses the
3232// idempotency marker via { force: true }. Write-access only.
3233pulls.post(
3234 "/:owner/:repo/pulls/:number/ai-rereview",
3235 softAuth,
3236 requireAuth,
3237 requireRepoAccess("write"),
3238 async (c) => {
3239 const { owner: ownerName, repo: repoName } = c.req.param();
3240 const prNum = parseInt(c.req.param("number"), 10);
3241 const resolved = await resolveRepo(ownerName, repoName);
3242 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3243
3244 const [pr] = await db
3245 .select()
3246 .from(pullRequests)
3247 .where(
3248 and(
3249 eq(pullRequests.repositoryId, resolved.repo.id),
3250 eq(pullRequests.number, prNum)
3251 )
3252 )
3253 .limit(1);
3254 if (!pr) {
3255 return c.redirect(`/${ownerName}/${repoName}/pulls`);
3256 }
3257
3258 if (!isAiReviewEnabled()) {
3259 return c.redirect(
3260 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3261 "AI review is not configured (ANTHROPIC_API_KEY)."
3262 )}`
3263 );
3264 }
3265
3266 // Fire-and-forget but with { force: true } to bypass the
3267 // already-reviewed marker. The function still never throws.
3268 triggerAiReview(
3269 ownerName,
3270 repoName,
3271 pr.id,
3272 pr.title || "",
3273 pr.body || "",
3274 pr.baseBranch,
3275 pr.headBranch,
3276 { force: true }
a28cedeClaude3277 ).catch((err) => {
3278 console.warn(
3279 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
3280 err instanceof Error ? err.message : err
3281 );
3282 });
c3e0c07Claude3283
3284 return c.redirect(
3285 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
3286 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
3287 )}`
3288 );
3289 }
3290);
3291
1d4ff60Claude3292// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
3293// the PR's head branch carrying just the new test files. Write-access only.
3294// Idempotent — if `ai:added-tests` was previously applied we redirect with
3295// an `info` banner instead of re-firing.
3296pulls.post(
3297 "/:owner/:repo/pulls/:number/generate-tests",
3298 softAuth,
3299 requireAuth,
3300 requireRepoAccess("write"),
3301 async (c) => {
3302 const { owner: ownerName, repo: repoName } = c.req.param();
3303 const prNum = parseInt(c.req.param("number"), 10);
3304 const resolved = await resolveRepo(ownerName, repoName);
3305 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3306
3307 const [pr] = await db
3308 .select()
3309 .from(pullRequests)
3310 .where(
3311 and(
3312 eq(pullRequests.repositoryId, resolved.repo.id),
3313 eq(pullRequests.number, prNum)
3314 )
3315 )
3316 .limit(1);
3317 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3318
3319 if (!isAiReviewEnabled()) {
3320 return c.redirect(
3321 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3322 "AI test generation is not configured (ANTHROPIC_API_KEY)."
3323 )}`
3324 );
3325 }
3326
3327 // Fire-and-forget. The lib never throws.
3328 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
3329 .then((res) => {
3330 if (!res.ok) {
3331 console.warn(
3332 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
3333 );
3334 }
3335 })
3336 .catch((err) => {
3337 console.warn(
3338 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
3339 err instanceof Error ? err.message : err
3340 );
3341 });
3342
3343 return c.redirect(
3344 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
3345 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
3346 )}`
3347 );
3348 }
3349);
3350
0074234Claude3351export default pulls;