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.tsxBlame3481 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";
cb5a796Claude29import { PendingCommentsBanner } from "../views/pending-comments-banner";
47a7a0aClaude30import { DiffView, type InlineDiffComment } from "../views/diff-view";
6fc53bdClaude31import { ReactionsBar } from "../views/reactions";
32import { summariseReactions } from "../lib/reactions";
24cf2caClaude33import { loadPrTemplate } from "../lib/templates";
0074234Claude34import { renderMarkdown } from "../lib/markdown";
15db0e0Claude35import {
36 parseSlashCommand,
37 executeSlashCommand,
38 detectSlashCmdComment,
39 stripSlashCmdMarker,
40} from "../lib/pr-slash-commands";
b584e52Claude41import { liveCommentBannerScript } from "../lib/sse-client";
0074234Claude42import { softAuth, requireAuth } from "../middleware/auth";
43import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude44import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude45import {
46 decideInitialStatus,
47 notifyOwnerOfPendingComment,
48 countPendingForRepo,
49} from "../lib/comment-moderation";
0316dbbClaude50import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
79ed944Claude51import {
52 TRIO_COMMENT_MARKER,
53 TRIO_SUMMARY_MARKER,
54 type TrioPersona,
55} from "../lib/ai-review-trio";
1d4ff60Claude56import {
57 generateTestsForPr,
58 AI_TESTS_MARKER,
59} from "../lib/ai-test-generator";
0316dbbClaude60import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude61import { generatePrSummary } from "../lib/ai-generators";
62import { isAiAvailable } from "../lib/ai-client";
534f04aClaude63import {
64 computePrRiskForPullRequest,
65 getCachedPrRisk,
66 type PrRiskScore,
67} from "../lib/pr-risk";
0316dbbClaude68import { runAllGateChecks } from "../lib/gate";
69import type { GateCheckResult } from "../lib/gate";
70import {
71 matchProtection,
72 countHumanApprovals,
73 listRequiredChecks,
74 passingCheckNames,
75 evaluateProtection,
76} from "../lib/branch-protection";
77import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude78import {
79 listBranches,
80 getRepoPath,
e883329Claude81 resolveRef,
0074234Claude82} from "../git/repository";
83import type { GitDiffFile } from "../git/repository";
84import { html } from "hono/html";
4bbacbeClaude85import {
86 getPreviewForBranch,
87 previewStatusLabel,
88} from "../lib/branch-previews";
1e162a8Claude89import {
bb0f894Claude90 Flex,
91 Container,
92 Badge,
93 Button,
94 LinkButton,
95 Form,
96 FormGroup,
97 Input,
98 TextArea,
99 Select,
100 EmptyState,
101 FilterTabs,
102 TabNav,
103 List,
104 ListItem,
105 Text,
106 Alert,
107 MarkdownContent,
108 CommentBox,
109 formatRelative,
110} from "../views/ui";
0074234Claude111
112const pulls = new Hono<AuthEnv>();
113
b078860Claude114/* ──────────────────────────────────────────────────────────────────────
115 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
116 * into the issue tracker or any other route. Tokens come from layout.tsx
117 * `:root` so light/dark stays consistent if/when light mode lands.
118 * ──────────────────────────────────────────────────────────────────── */
119const PRS_LIST_STYLES = `
120 .prs-hero {
121 position: relative;
122 margin: 0 0 var(--space-5);
123 padding: 22px 26px 24px;
124 background: var(--bg-elevated);
125 border: 1px solid var(--border);
126 border-radius: 16px;
127 overflow: hidden;
128 }
129 .prs-hero::before {
130 content: '';
131 position: absolute; top: 0; left: 0; right: 0;
132 height: 2px;
133 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
134 opacity: 0.7;
135 pointer-events: none;
136 }
137 .prs-hero-inner {
138 position: relative;
139 display: flex;
140 justify-content: space-between;
141 align-items: flex-end;
142 gap: 20px;
143 flex-wrap: wrap;
144 }
145 .prs-hero-text { flex: 1; min-width: 280px; }
146 .prs-hero-eyebrow {
147 font-size: 12px;
148 color: var(--text-muted);
149 text-transform: uppercase;
150 letter-spacing: 0.08em;
151 font-weight: 600;
152 margin-bottom: 8px;
153 }
154 .prs-hero-title {
155 font-family: var(--font-display);
156 font-size: clamp(26px, 3.4vw, 34px);
157 font-weight: 800;
158 letter-spacing: -0.025em;
159 line-height: 1.06;
160 margin: 0 0 8px;
161 color: var(--text-strong);
162 }
163 .prs-hero-title .gradient-text {
164 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
165 -webkit-background-clip: text;
166 background-clip: text;
167 -webkit-text-fill-color: transparent;
168 color: transparent;
169 }
170 .prs-hero-sub {
171 font-size: 14.5px;
172 color: var(--text-muted);
173 margin: 0;
174 line-height: 1.5;
175 max-width: 620px;
176 }
177 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
178 .prs-cta {
179 display: inline-flex; align-items: center; gap: 6px;
180 padding: 10px 16px;
181 border-radius: 10px;
182 font-size: 13.5px;
183 font-weight: 600;
184 color: #fff;
185 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
186 border: 1px solid rgba(140,109,255,0.55);
187 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
188 text-decoration: none;
189 transition: transform 120ms ease, box-shadow 160ms ease;
190 }
191 .prs-cta:hover {
192 transform: translateY(-1px);
193 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
194 color: #fff;
195 }
196
197 .prs-tabs {
198 display: flex; flex-wrap: wrap; gap: 6px;
199 margin: 0 0 18px;
200 padding: 6px;
201 background: var(--bg-secondary);
202 border: 1px solid var(--border);
203 border-radius: 12px;
204 }
205 .prs-tab {
206 display: inline-flex; align-items: center; gap: 8px;
207 padding: 7px 13px;
208 font-size: 13px;
209 font-weight: 500;
210 color: var(--text-muted);
211 border-radius: 8px;
212 text-decoration: none;
213 transition: background 120ms ease, color 120ms ease;
214 }
215 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
216 .prs-tab.is-active {
217 background: var(--bg-elevated);
218 color: var(--text-strong);
219 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
220 }
221 .prs-tab-count {
222 display: inline-flex; align-items: center; justify-content: center;
223 min-width: 22px; padding: 2px 7px;
224 font-size: 11.5px;
225 font-weight: 600;
226 border-radius: 9999px;
227 background: var(--bg-tertiary);
228 color: var(--text-muted);
229 }
230 .prs-tab.is-active .prs-tab-count {
231 background: rgba(140,109,255,0.18);
232 color: var(--text-link);
233 }
234
235 .prs-list { display: flex; flex-direction: column; gap: 10px; }
236 .prs-row {
237 position: relative;
238 display: flex; align-items: flex-start; gap: 14px;
239 padding: 14px 16px;
240 background: var(--bg-elevated);
241 border: 1px solid var(--border);
242 border-radius: 12px;
243 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
244 }
245 .prs-row:hover {
246 transform: translateY(-1px);
247 border-color: var(--border-strong);
248 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
249 }
250 .prs-row-icon {
251 flex: 0 0 auto;
252 width: 26px; height: 26px;
253 display: inline-flex; align-items: center; justify-content: center;
254 border-radius: 9999px;
255 font-size: 13px;
256 margin-top: 2px;
257 }
258 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
259 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
260 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
261 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
262 .prs-row-body { flex: 1; min-width: 0; }
263 .prs-row-title {
264 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
265 font-size: 15px; font-weight: 600;
266 color: var(--text-strong);
267 line-height: 1.35;
268 margin: 0 0 6px;
269 }
270 .prs-row-number {
271 color: var(--text-muted);
272 font-weight: 400;
273 font-size: 14px;
274 }
275 .prs-row-meta {
276 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
277 font-size: 12.5px;
278 color: var(--text-muted);
279 }
280 .prs-branch-chips {
281 display: inline-flex; align-items: center; gap: 6px;
282 font-family: var(--font-mono);
283 font-size: 11.5px;
284 }
285 .prs-branch-chip {
286 padding: 2px 8px;
287 border-radius: 9999px;
288 background: var(--bg-tertiary);
289 border: 1px solid var(--border);
290 color: var(--text);
291 }
292 .prs-branch-arrow {
293 color: var(--text-faint);
294 font-size: 11px;
295 }
296 .prs-row-tags {
297 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
298 margin-left: auto;
299 }
300 .prs-tag {
301 display: inline-flex; align-items: center; gap: 4px;
302 padding: 2px 8px;
303 font-size: 11px;
304 font-weight: 600;
305 border-radius: 9999px;
306 border: 1px solid var(--border);
307 background: var(--bg-secondary);
308 color: var(--text-muted);
309 line-height: 1.6;
310 }
311 .prs-tag.is-draft {
312 color: var(--text-muted);
313 border-color: var(--border-strong);
314 }
315 .prs-tag.is-merged {
316 color: var(--text-link);
317 border-color: rgba(140,109,255,0.45);
318 background: rgba(140,109,255,0.10);
319 }
320
321 .prs-empty {
ea9ed4cClaude322 position: relative;
323 padding: 56px 32px;
b078860Claude324 text-align: center;
325 border: 1px dashed var(--border);
ea9ed4cClaude326 border-radius: 16px;
327 background: var(--bg-elevated);
b078860Claude328 color: var(--text-muted);
ea9ed4cClaude329 overflow: hidden;
b078860Claude330 }
ea9ed4cClaude331 .prs-empty::before {
332 content: '';
333 position: absolute;
334 inset: -40% -20% auto auto;
335 width: 320px; height: 320px;
336 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
337 filter: blur(70px);
338 opacity: 0.55;
339 pointer-events: none;
340 animation: prsEmptyOrb 16s ease-in-out infinite;
341 }
342 @keyframes prsEmptyOrb {
343 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
344 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
345 }
346 @media (prefers-reduced-motion: reduce) {
347 .prs-empty::before { animation: none; }
348 }
349 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude350 .prs-empty strong {
351 display: block;
352 color: var(--text-strong);
ea9ed4cClaude353 font-family: var(--font-display);
354 font-size: 22px;
355 font-weight: 700;
356 letter-spacing: -0.018em;
357 margin-bottom: 2px;
358 }
359 .prs-empty-sub {
360 font-size: 14.5px;
361 color: var(--text-muted);
362 line-height: 1.55;
363 max-width: 460px;
364 margin: 0 0 18px;
b078860Claude365 }
ea9ed4cClaude366 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude367
368 @media (max-width: 720px) {
369 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
370 .prs-hero-actions { width: 100%; }
371 .prs-row-tags { margin-left: 0; }
372 }
f1dc7c7Claude373
374 /* Additional mobile rules. Additive only. */
375 @media (max-width: 720px) {
376 .prs-hero { padding: 18px 18px 20px; }
377 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
378 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
379 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
380 .prs-row { padding: 12px 14px; gap: 10px; }
381 .prs-row-icon { width: 24px; height: 24px; }
382 }
b078860Claude383`;
384
385/* ──────────────────────────────────────────────────────────────────────
386 * Inline CSS for the detail page. Same `.prs-*` namespace.
387 * ──────────────────────────────────────────────────────────────────── */
388const PRS_DETAIL_STYLES = `
389 .prs-detail-hero {
390 position: relative;
391 margin: 0 0 var(--space-4);
392 padding: 24px 26px;
393 background: var(--bg-elevated);
394 border: 1px solid var(--border);
395 border-radius: 16px;
396 overflow: hidden;
397 }
398 .prs-detail-hero::before {
399 content: '';
400 position: absolute; top: 0; left: 0; right: 0;
401 height: 2px;
402 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
403 opacity: 0.7;
404 pointer-events: none;
405 }
406 .prs-detail-title {
407 font-family: var(--font-display);
408 font-size: clamp(22px, 2.6vw, 28px);
409 font-weight: 700;
410 letter-spacing: -0.022em;
411 line-height: 1.2;
412 color: var(--text-strong);
413 margin: 0 0 12px;
414 }
415 .prs-detail-num {
416 color: var(--text-muted);
417 font-weight: 400;
418 }
419 .prs-state-pill {
420 display: inline-flex; align-items: center; gap: 6px;
421 padding: 6px 12px;
422 border-radius: 9999px;
423 font-size: 12.5px;
424 font-weight: 600;
425 line-height: 1;
426 border: 1px solid transparent;
427 }
428 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
429 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
430 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
431 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
432
433 .prs-detail-meta {
434 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
435 font-size: 13px;
436 color: var(--text-muted);
437 }
438 .prs-detail-meta strong { color: var(--text); }
439 .prs-detail-branches {
440 display: inline-flex; align-items: center; gap: 6px;
441 font-family: var(--font-mono);
442 font-size: 12px;
443 }
444 .prs-branch-pill {
445 padding: 3px 9px;
446 border-radius: 9999px;
447 background: var(--bg-tertiary);
448 border: 1px solid var(--border);
449 color: var(--text);
450 }
451 .prs-branch-pill.is-head { color: var(--text-strong); }
452 .prs-branch-arrow-lg {
453 color: var(--accent);
454 font-size: 14px;
455 font-weight: 700;
456 }
457
458 .prs-detail-actions {
459 display: inline-flex; gap: 8px; margin-left: auto;
460 }
461
462 .prs-detail-tabs {
463 display: flex; gap: 4px;
464 margin: 0 0 16px;
465 border-bottom: 1px solid var(--border);
466 }
467 .prs-detail-tab {
468 padding: 10px 14px;
469 font-size: 13.5px;
470 font-weight: 500;
471 color: var(--text-muted);
472 text-decoration: none;
473 border-bottom: 2px solid transparent;
474 transition: color 120ms ease, border-color 120ms ease;
475 margin-bottom: -1px;
476 }
477 .prs-detail-tab:hover { color: var(--text); }
478 .prs-detail-tab.is-active {
479 color: var(--text-strong);
480 border-bottom-color: var(--accent);
481 }
482 .prs-detail-tab-count {
483 display: inline-flex; align-items: center; justify-content: center;
484 min-width: 20px; padding: 0 6px; margin-left: 6px;
485 height: 18px;
486 font-size: 11px;
487 font-weight: 600;
488 border-radius: 9999px;
489 background: var(--bg-tertiary);
490 color: var(--text-muted);
491 }
492
493 /* Gate / check status section */
494 .prs-gate-card {
495 margin-top: 20px;
496 background: var(--bg-elevated);
497 border: 1px solid var(--border);
498 border-radius: 14px;
499 overflow: hidden;
500 }
501 .prs-gate-head {
502 display: flex; align-items: center; gap: 10px;
503 padding: 14px 18px;
504 border-bottom: 1px solid var(--border);
505 }
506 .prs-gate-head h3 {
507 margin: 0;
508 font-size: 14px;
509 font-weight: 600;
510 color: var(--text-strong);
511 }
512 .prs-gate-summary {
513 margin-left: auto;
514 font-size: 12px;
515 color: var(--text-muted);
516 }
517 .prs-gate-row {
518 display: flex; align-items: center; gap: 12px;
519 padding: 12px 18px;
520 border-bottom: 1px solid var(--border-subtle);
521 }
522 .prs-gate-row:last-child { border-bottom: 0; }
523 .prs-gate-icon {
524 flex: 0 0 auto;
525 width: 22px; height: 22px;
526 display: inline-flex; align-items: center; justify-content: center;
527 border-radius: 9999px;
528 font-size: 12px;
529 font-weight: 700;
530 }
531 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
532 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
533 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
534 .prs-gate-name {
535 font-size: 13px;
536 font-weight: 600;
537 color: var(--text);
538 min-width: 140px;
539 }
540 .prs-gate-details {
541 flex: 1; min-width: 0;
542 font-size: 12.5px;
543 color: var(--text-muted);
544 }
545 .prs-gate-pill {
546 flex: 0 0 auto;
547 padding: 3px 10px;
548 border-radius: 9999px;
549 font-size: 11px;
550 font-weight: 600;
551 line-height: 1.5;
552 border: 1px solid transparent;
553 }
554 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
555 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
556 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
557 .prs-gate-footer {
558 padding: 12px 18px;
559 background: var(--bg-secondary);
560 font-size: 12px;
561 color: var(--text-muted);
562 }
563
564 /* Comment cards */
565 .prs-comment {
566 margin-top: 14px;
567 background: var(--bg-elevated);
568 border: 1px solid var(--border);
569 border-radius: 12px;
570 overflow: hidden;
571 }
572 .prs-comment-head {
573 display: flex; align-items: center; gap: 10px;
574 padding: 10px 14px;
575 background: var(--bg-secondary);
576 border-bottom: 1px solid var(--border);
577 font-size: 13px;
578 flex-wrap: wrap;
579 }
580 .prs-comment-head strong { color: var(--text-strong); }
581 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
582 .prs-comment-loc {
583 font-family: var(--font-mono);
584 font-size: 11.5px;
585 color: var(--text-muted);
586 background: var(--bg-tertiary);
587 padding: 2px 8px;
588 border-radius: 6px;
589 }
590 .prs-comment-body { padding: 14px 18px; }
591 .prs-comment.is-ai {
592 border-color: rgba(140,109,255,0.45);
593 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
594 }
595 .prs-comment.is-ai .prs-comment-head {
596 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
597 border-bottom-color: rgba(140,109,255,0.30);
598 }
599 .prs-ai-badge {
600 display: inline-flex; align-items: center; gap: 4px;
601 padding: 2px 9px;
602 font-size: 10.5px;
603 font-weight: 700;
604 letter-spacing: 0.04em;
605 text-transform: uppercase;
606 color: #fff;
607 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
608 border-radius: 9999px;
609 }
610
611 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
612 .prs-files-card {
613 margin-top: 18px;
614 padding: 14px 18px;
615 display: flex; align-items: center; gap: 14px;
616 background: var(--bg-elevated);
617 border: 1px solid var(--border);
618 border-radius: 12px;
619 text-decoration: none;
620 color: inherit;
621 transition: border-color 120ms ease, transform 140ms ease;
622 }
623 .prs-files-card:hover {
624 border-color: rgba(140,109,255,0.45);
625 transform: translateY(-1px);
626 }
627 .prs-files-card-icon {
628 width: 36px; height: 36px;
629 display: inline-flex; align-items: center; justify-content: center;
630 border-radius: 10px;
631 background: rgba(140,109,255,0.12);
632 color: var(--text-link);
633 font-size: 18px;
634 }
635 .prs-files-card-text { flex: 1; min-width: 0; }
636 .prs-files-card-title {
637 font-size: 14px;
638 font-weight: 600;
639 color: var(--text-strong);
640 margin: 0 0 2px;
641 }
642 .prs-files-card-sub {
643 font-size: 12.5px;
644 color: var(--text-muted);
645 margin: 0;
646 }
647 .prs-files-card-cta {
648 font-size: 12.5px;
649 color: var(--text-link);
650 font-weight: 600;
651 }
652
653 /* Merge area */
654 .prs-merge-card {
655 position: relative;
656 margin-top: 22px;
657 padding: 18px;
658 background: var(--bg-elevated);
659 border-radius: 14px;
660 overflow: hidden;
661 }
662 .prs-merge-card::before {
663 content: '';
664 position: absolute; inset: 0;
665 padding: 1px;
666 border-radius: 14px;
667 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
668 -webkit-mask:
669 linear-gradient(#000 0 0) content-box,
670 linear-gradient(#000 0 0);
671 -webkit-mask-composite: xor;
672 mask-composite: exclude;
673 pointer-events: none;
674 }
675 .prs-merge-card.is-closed::before { background: var(--border-strong); }
676 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
677 .prs-merge-head {
678 display: flex; align-items: center; gap: 12px;
679 margin-bottom: 12px;
680 }
681 .prs-merge-head strong {
682 font-family: var(--font-display);
683 font-size: 15px;
684 color: var(--text-strong);
685 font-weight: 700;
686 }
687 .prs-merge-sub {
688 font-size: 13px;
689 color: var(--text-muted);
690 margin: 0 0 12px;
691 }
692 .prs-merge-actions {
693 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
694 }
695 .prs-merge-btn {
696 display: inline-flex; align-items: center; gap: 6px;
697 padding: 9px 16px;
698 border-radius: 10px;
699 font-size: 13.5px;
700 font-weight: 600;
701 color: #fff;
702 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
703 border: 1px solid rgba(52,211,153,0.55);
704 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
705 cursor: pointer;
706 transition: transform 120ms ease, box-shadow 160ms ease;
707 }
708 .prs-merge-btn:hover {
709 transform: translateY(-1px);
710 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
711 }
712 .prs-merge-btn[disabled],
713 .prs-merge-btn.is-disabled {
714 opacity: 0.55;
715 cursor: not-allowed;
716 transform: none;
717 box-shadow: none;
718 }
719 .prs-merge-ready-btn {
720 display: inline-flex; align-items: center; gap: 6px;
721 padding: 9px 16px;
722 border-radius: 10px;
723 font-size: 13.5px;
724 font-weight: 600;
725 color: #fff;
726 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
727 border: 1px solid rgba(140,109,255,0.55);
728 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
729 cursor: pointer;
730 transition: transform 120ms ease, box-shadow 160ms ease;
731 }
732 .prs-merge-ready-btn:hover {
733 transform: translateY(-1px);
734 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
735 }
736 .prs-merge-back-draft {
737 background: none; border: 1px solid var(--border-strong);
738 color: var(--text-muted);
739 padding: 9px 14px; border-radius: 10px;
740 font-size: 13px; cursor: pointer;
741 }
742 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
743
744 /* Inline form helpers */
745 .prs-inline-form { display: inline-flex; }
746
747 /* Comment composer */
748 .prs-composer { margin-top: 22px; }
749 .prs-composer textarea {
750 border-radius: 12px;
751 }
752
753 @media (max-width: 720px) {
754 .prs-detail-actions { margin-left: 0; }
755 .prs-merge-actions { width: 100%; }
756 .prs-merge-actions > * { flex: 1; min-width: 0; }
757 }
f1dc7c7Claude758
759 /* Additional mobile rules. Additive only. */
760 @media (max-width: 720px) {
761 .prs-detail-hero { padding: 18px; }
762 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
763 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
764 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
765 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
766 .prs-gate-name { min-width: 0; }
767 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
768 .prs-gate-summary { margin-left: 0; }
769 .prs-merge-btn,
770 .prs-merge-ready-btn,
771 .prs-merge-back-draft { min-height: 44px; }
772 .prs-comment-body { padding: 12px 14px; }
773 .prs-comment-head { padding: 10px 12px; }
774 .prs-files-card { padding: 12px 14px; }
775 }
3c03977Claude776
777 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
778 .live-pill {
779 display: inline-flex;
780 align-items: center;
781 gap: 8px;
782 padding: 4px 10px 4px 8px;
783 margin-left: 6px;
784 background: var(--bg-elevated);
785 border: 1px solid var(--border);
786 border-radius: 9999px;
787 font-size: 12px;
788 color: var(--text-muted);
789 line-height: 1;
790 vertical-align: middle;
791 }
792 .live-pill.is-busy { color: var(--text); }
793 .live-pill-dot {
794 width: 8px; height: 8px;
795 border-radius: 9999px;
796 background: #34d399;
797 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
798 animation: live-pulse 1.6s ease-in-out infinite;
799 }
800 @keyframes live-pulse {
801 0%, 100% { opacity: 1; }
802 50% { opacity: 0.55; }
803 }
804 .live-avatars {
805 display: inline-flex;
806 margin-left: 2px;
807 }
808 .live-avatar {
809 display: inline-flex;
810 align-items: center;
811 justify-content: center;
812 width: 22px; height: 22px;
813 border-radius: 9999px;
814 font-size: 10px;
815 font-weight: 700;
816 color: #0b1020;
817 margin-left: -6px;
818 border: 2px solid var(--bg-elevated);
819 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
820 }
821 .live-avatar:first-child { margin-left: 0; }
822 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
823 .live-cursor-host {
824 position: relative;
825 }
826 .live-cursor-overlay {
827 position: absolute;
828 inset: 0;
829 pointer-events: none;
830 overflow: hidden;
831 border-radius: inherit;
832 }
833 .live-cursor {
834 position: absolute;
835 width: 2px;
836 height: 18px;
837 border-radius: 2px;
838 transform: translate(-1px, 0);
839 transition: transform 80ms linear, opacity 200ms ease;
840 }
841 .live-cursor::after {
842 content: attr(data-label);
843 position: absolute;
844 top: -16px;
845 left: -2px;
846 font-size: 10px;
847 line-height: 1;
848 color: #0b1020;
849 background: inherit;
850 padding: 2px 5px;
851 border-radius: 4px 4px 4px 0;
852 white-space: nowrap;
853 font-weight: 600;
854 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
855 }
856 .live-cursor.is-idle { opacity: 0.4; }
857 .live-edit-tag {
858 display: inline-block;
859 margin-left: 6px;
860 padding: 1px 6px;
861 font-size: 10px;
862 font-weight: 600;
863 letter-spacing: 0.02em;
864 color: #0b1020;
865 border-radius: 9999px;
866 }
15db0e0Claude867
868 /* ─── Slash-command pill + composer hint ─── */
869 .slash-hint {
870 display: inline-flex;
871 align-items: center;
872 gap: 6px;
873 margin-top: 6px;
874 padding: 3px 9px;
875 font-size: 11.5px;
876 color: var(--text-muted);
877 background: var(--bg-elevated);
878 border: 1px dashed var(--border);
879 border-radius: 9999px;
880 width: fit-content;
881 }
882 .slash-hint code {
883 background: rgba(110, 168, 255, 0.12);
884 color: var(--text-strong);
885 padding: 0 5px;
886 border-radius: 4px;
887 font-size: 11px;
888 }
889 .slash-pill {
890 display: grid;
891 grid-template-columns: auto 1fr auto;
892 align-items: center;
893 column-gap: 10px;
894 row-gap: 6px;
895 margin: 10px 0;
896 padding: 10px 14px;
897 background: linear-gradient(
898 135deg,
899 rgba(110, 168, 255, 0.08),
900 rgba(163, 113, 247, 0.06)
901 );
902 border: 1px solid rgba(110, 168, 255, 0.32);
903 border-left: 3px solid var(--accent, #6ea8ff);
904 border-radius: var(--radius);
905 font-size: 13px;
906 color: var(--text);
907 }
908 .slash-pill-icon {
909 font-size: 14px;
910 line-height: 1;
911 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
912 }
913 .slash-pill-actor { color: var(--text-muted); }
914 .slash-pill-actor strong { color: var(--text-strong); }
915 .slash-pill-cmd {
916 background: rgba(110, 168, 255, 0.16);
917 color: var(--text-strong);
918 padding: 1px 6px;
919 border-radius: 4px;
920 font-size: 12.5px;
921 }
922 .slash-pill-time {
923 color: var(--text-muted);
924 font-size: 12px;
925 justify-self: end;
926 }
927 .slash-pill-body {
928 grid-column: 1 / -1;
929 color: var(--text);
930 font-size: 13px;
931 line-height: 1.55;
932 }
933 .slash-pill-body p:first-child { margin-top: 0; }
934 .slash-pill-body p:last-child { margin-bottom: 0; }
935 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
936 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
937 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
938 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude939
940 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
941 .preview-prpill {
942 display: inline-flex; align-items: center; gap: 6px;
943 padding: 3px 10px;
944 border-radius: 9999px;
945 font-family: var(--font-mono);
946 font-size: 11.5px;
947 font-weight: 600;
948 background: rgba(255,255,255,0.04);
949 color: var(--text-muted);
950 text-decoration: none;
951 border: 1px solid var(--border);
952 }
953 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
954 .preview-prpill .preview-prpill-dot {
955 width: 7px; height: 7px;
956 border-radius: 9999px;
957 background: currentColor;
958 }
959 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
960 .preview-prpill.is-building .preview-prpill-dot {
961 animation: previewPrPulse 1.4s ease-in-out infinite;
962 }
963 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
964 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
965 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
966 @keyframes previewPrPulse {
967 0%, 100% { opacity: 1; }
968 50% { opacity: 0.4; }
969 }
79ed944Claude970
971 /* ─── AI Trio Review — 3-column verdict cards ─── */
972 .trio-wrap {
973 margin-top: 18px;
974 padding: 16px;
975 background: var(--bg-elevated);
976 border: 1px solid var(--border);
977 border-radius: 14px;
978 }
979 .trio-header {
980 display: flex; align-items: center; gap: 10px;
981 margin: 0 0 12px;
982 font-size: 13.5px;
983 color: var(--text);
984 }
985 .trio-header strong { color: var(--text-strong); }
986 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
987 .trio-header-dot {
988 width: 8px; height: 8px; border-radius: 9999px;
989 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
990 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
991 }
992 .trio-grid {
993 display: grid;
994 grid-template-columns: repeat(3, minmax(0, 1fr));
995 gap: 12px;
996 }
997 .trio-card {
998 background: var(--bg-secondary);
999 border: 1px solid var(--border);
1000 border-radius: 12px;
1001 overflow: hidden;
1002 display: flex; flex-direction: column;
1003 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1004 }
1005 .trio-card-head {
1006 display: flex; align-items: center; gap: 8px;
1007 padding: 10px 12px;
1008 border-bottom: 1px solid var(--border);
1009 background: rgba(255,255,255,0.02);
1010 font-size: 13px;
1011 }
1012 .trio-card-icon {
1013 display: inline-flex; align-items: center; justify-content: center;
1014 width: 22px; height: 22px;
1015 border-radius: 9999px;
1016 font-size: 12px;
1017 background: rgba(255,255,255,0.05);
1018 }
1019 .trio-card-title {
1020 color: var(--text-strong);
1021 font-weight: 600;
1022 letter-spacing: 0.01em;
1023 }
1024 .trio-card-verdict {
1025 margin-left: auto;
1026 font-size: 11px;
1027 font-weight: 700;
1028 letter-spacing: 0.06em;
1029 text-transform: uppercase;
1030 padding: 3px 9px;
1031 border-radius: 9999px;
1032 background: var(--bg-tertiary);
1033 color: var(--text-muted);
1034 border: 1px solid var(--border-strong);
1035 }
1036 .trio-card-body {
1037 padding: 12px 14px;
1038 font-size: 13px;
1039 color: var(--text);
1040 flex: 1;
1041 min-height: 64px;
1042 line-height: 1.55;
1043 }
1044 .trio-card-body p { margin: 0 0 8px; }
1045 .trio-card-body p:last-child { margin-bottom: 0; }
1046 .trio-card-body ul { margin: 0; padding-left: 18px; }
1047 .trio-card-body code {
1048 font-family: var(--font-mono);
1049 font-size: 12px;
1050 background: var(--bg-tertiary);
1051 padding: 1px 6px;
1052 border-radius: 5px;
1053 }
1054 .trio-card-empty {
1055 color: var(--text-muted);
1056 font-style: italic;
1057 font-size: 12.5px;
1058 }
1059
1060 /* Pass state — neutral, no accent. */
1061 .trio-card.is-pass .trio-card-verdict {
1062 color: var(--green);
1063 border-color: rgba(52,211,153,0.35);
1064 background: rgba(52,211,153,0.12);
1065 }
1066
1067 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1068 .trio-card.trio-security.is-fail {
1069 border-color: rgba(248,113,113,0.55);
1070 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1071 }
1072 .trio-card.trio-security.is-fail .trio-card-head {
1073 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1074 border-bottom-color: rgba(248,113,113,0.30);
1075 }
1076 .trio-card.trio-security.is-fail .trio-card-verdict {
1077 color: #fecaca;
1078 border-color: rgba(248,113,113,0.55);
1079 background: rgba(248,113,113,0.20);
1080 }
1081
1082 .trio-card.trio-correctness.is-fail {
1083 border-color: rgba(251,191,36,0.55);
1084 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1085 }
1086 .trio-card.trio-correctness.is-fail .trio-card-head {
1087 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1088 border-bottom-color: rgba(251,191,36,0.30);
1089 }
1090 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1091 color: #fde68a;
1092 border-color: rgba(251,191,36,0.55);
1093 background: rgba(251,191,36,0.20);
1094 }
1095
1096 .trio-card.trio-style.is-fail {
1097 border-color: rgba(96,165,250,0.55);
1098 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1099 }
1100 .trio-card.trio-style.is-fail .trio-card-head {
1101 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1102 border-bottom-color: rgba(96,165,250,0.30);
1103 }
1104 .trio-card.trio-style.is-fail .trio-card-verdict {
1105 color: #bfdbfe;
1106 border-color: rgba(96,165,250,0.55);
1107 background: rgba(96,165,250,0.20);
1108 }
1109
1110 /* Disagreement callout strip — yellow, prominent. */
1111 .trio-disagreement-strip {
1112 display: flex;
1113 gap: 12px;
1114 margin-top: 14px;
1115 padding: 12px 14px;
1116 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1117 border: 1px solid rgba(251,191,36,0.45);
1118 border-radius: 10px;
1119 color: var(--text);
1120 font-size: 13px;
1121 }
1122 .trio-disagreement-icon {
1123 flex: 0 0 auto;
1124 width: 26px; height: 26px;
1125 display: inline-flex; align-items: center; justify-content: center;
1126 border-radius: 9999px;
1127 background: rgba(251,191,36,0.25);
1128 color: #fde68a;
1129 font-size: 14px;
1130 }
1131 .trio-disagreement-body strong {
1132 display: block;
1133 color: #fde68a;
1134 margin: 0 0 4px;
1135 font-weight: 700;
1136 }
1137 .trio-disagreement-list {
1138 margin: 0;
1139 padding-left: 18px;
1140 color: var(--text);
1141 font-size: 12.5px;
1142 line-height: 1.55;
1143 }
1144 .trio-disagreement-list code {
1145 font-family: var(--font-mono);
1146 font-size: 11.5px;
1147 background: var(--bg-tertiary);
1148 padding: 1px 5px;
1149 border-radius: 4px;
1150 }
1151
1152 @media (max-width: 720px) {
1153 .trio-grid { grid-template-columns: 1fr; }
1154 .trio-wrap { padding: 12px; }
1155 }
b078860Claude1156`;
1157
81c73c1Claude1158/**
1159 * Tiny inline JS that drives the "Suggest description with AI" button.
1160 * On click, gathers form values, POSTs JSON to the given endpoint, and
1161 * pipes the response into the #pr-body textarea. All DOM lookups are
1162 * defensive — element absence is a silent no-op.
1163 *
1164 * Built as a string template so it lives next to its server-side caller
1165 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1166 * to avoid </script> breakouts.
1167 */
1168function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1169 const url = JSON.stringify(endpointUrl)
1170 .split("<").join("\\u003C")
1171 .split(">").join("\\u003E")
1172 .split("&").join("\\u0026");
1173 return (
1174 "(function(){try{" +
1175 "var btn=document.getElementById('ai-suggest-desc');" +
1176 "var status=document.getElementById('ai-suggest-status');" +
1177 "var body=document.getElementById('pr-body');" +
1178 "var form=btn&&btn.closest&&btn.closest('form');" +
1179 "if(!btn||!body||!form)return;" +
1180 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1181 "var fd=new FormData(form);" +
1182 "var title=String(fd.get('title')||'').trim();" +
1183 "var base=String(fd.get('base')||'').trim();" +
1184 "var head=String(fd.get('head')||'').trim();" +
1185 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1186 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1187 "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'})" +
1188 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1189 ".then(function(j){btn.disabled=false;" +
1190 "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;}}" +
1191 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1192 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1193 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1194 "});" +
1195 "}catch(e){}})();"
1196 );
1197}
1198
3c03977Claude1199/**
1200 * Live co-editing client. Connects to the per-PR SSE feed and:
1201 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1202 * status colour per user).
1203 * - Renders tinted cursor caret overlays inside #pr-body and every
1204 * `[data-live-field]` element.
1205 * - Broadcasts the local user's cursor position (selectionStart /
1206 * selectionEnd) debounced at 100ms.
1207 * - Broadcasts content patches (`replace` of the whole textarea —
1208 * last-write-wins v1) debounced at 250ms.
1209 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1210 * to the matching local field if untouched.
1211 *
1212 * All endpoint URLs are JSON-escaped via safe replacements so they
1213 * can't break out of the <script> tag.
1214 */
1215function LIVE_COEDIT_SCRIPT(prId: string): string {
1216 const idJson = JSON.stringify(prId)
1217 .split("<").join("\\u003C")
1218 .split(">").join("\\u003E")
1219 .split("&").join("\\u0026");
1220 return (
1221 "(function(){try{" +
1222 "if(typeof EventSource==='undefined')return;" +
1223 "var prId=" + idJson + ";" +
1224 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1225 "var pill=document.getElementById('live-pill');" +
1226 "var avEl=document.getElementById('live-avatars');" +
1227 "var countEl=document.getElementById('live-count');" +
1228 "var sessionId=null;var myColor=null;" +
1229 "var presence={};" + // sessionId -> {color,status,userId,initials}
1230 "var lastApplied={};" + // field -> last server value (for echo suppression)
1231 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1232 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1233 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1234 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1235 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1236 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1237 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1238 "avEl.innerHTML=html;}}" +
1239 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1240 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1241 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1242 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1243 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1244 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1245 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1246 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1247 "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);}" +
1248 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1249 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1250 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1251 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1252 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1253 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1254 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1255 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1256 "}catch(e){}" +
1257 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1258 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1259 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1260 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1261 "var es;var delay=1000;" +
1262 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1263 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1264 "(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){}});" +
1265 "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){}});" +
1266 "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){}});" +
1267 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1268 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1269 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1270 "var patch=d.patch;if(!patch||!patch.field)return;" +
1271 "var ta=fieldEl(patch.field);if(!ta)return;" +
1272 "if(document.activeElement===ta)return;" + // don't trample local typing
1273 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1274 "}catch(e){}});" +
1275 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1276 "}connect();" +
1277 "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){}}" +
1278 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1279 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1280 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1281 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1282 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1283 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1284 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1285 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1286 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1287 "}" +
1288 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1289 "var live=document.querySelectorAll('[data-live-field]');" +
1290 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1291 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1292 "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){}});" +
1293 "}catch(e){}})();"
1294 );
1295}
1296
0074234Claude1297async function resolveRepo(ownerName: string, repoName: string) {
1298 const [owner] = await db
1299 .select()
1300 .from(users)
1301 .where(eq(users.username, ownerName))
1302 .limit(1);
1303 if (!owner) return null;
1304 const [repo] = await db
1305 .select()
1306 .from(repositories)
1307 .where(
1308 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1309 )
1310 .limit(1);
1311 if (!repo) return null;
1312 return { owner, repo };
1313}
1314
1315// PR Nav helper
1316const PrNav = ({
1317 owner,
1318 repo,
1319 active,
1320}: {
1321 owner: string;
1322 repo: string;
1323 active: "code" | "issues" | "pulls" | "commits";
1324}) => (
bb0f894Claude1325 <TabNav
1326 tabs={[
1327 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1328 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1329 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1330 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1331 ]}
1332 />
0074234Claude1333);
1334
534f04aClaude1335/**
1336 * Block M3 — pre-merge risk score card. Pure presentational helper.
1337 * Rendered in the conversation tab above the gate checks block. Hidden
1338 * entirely when the PR is closed/merged or there is nothing cached and
1339 * nothing in-flight.
1340 */
1341function PrRiskCard({
1342 risk,
1343 calculating,
1344}: {
1345 risk: PrRiskScore | null;
1346 calculating: boolean;
1347}) {
1348 if (!risk) {
1349 return (
1350 <div
1351 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1352 >
1353 <strong style="font-size: 13px; color: var(--text)">
1354 Risk score: calculating…
1355 </strong>
1356 <div style="font-size: 12px; margin-top: 4px">
1357 Refresh in a moment to see the pre-merge risk score for this PR.
1358 </div>
1359 </div>
1360 );
1361 }
1362
1363 const palette = riskBandPalette(risk.band);
1364 const label = riskBandLabel(risk.band);
1365
1366 return (
1367 <div
1368 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1369 >
1370 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1371 <strong>Risk score:</strong>
1372 <span style={`color:${palette.border};font-weight:600`}>
1373 {palette.icon} {label} ({risk.score}/10)
1374 </span>
1375 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1376 {risk.commitSha.slice(0, 7)}
1377 </span>
1378 </div>
1379 {risk.aiSummary && (
1380 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1381 {risk.aiSummary}
1382 </div>
1383 )}
1384 <details style="margin-top:10px">
1385 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1386 See full signal breakdown
1387 </summary>
1388 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1389 <li>files changed: {risk.signals.filesChanged}</li>
1390 <li>
1391 lines added/removed: {risk.signals.linesAdded} /{" "}
1392 {risk.signals.linesRemoved}
1393 </li>
1394 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1395 <li>
1396 schema migration touched:{" "}
1397 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1398 </li>
1399 <li>
1400 locked / sensitive path touched:{" "}
1401 {risk.signals.lockedPathTouched ? "yes" : "no"}
1402 </li>
1403 <li>
1404 adds new dependency:{" "}
1405 {risk.signals.addsNewDependency ? "yes" : "no"}
1406 </li>
1407 <li>
1408 bumps major dependency:{" "}
1409 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1410 </li>
1411 <li>
1412 tests added for new code:{" "}
1413 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1414 </li>
1415 <li>
1416 diff-minus-test ratio:{" "}
1417 {risk.signals.diffMinusTestRatio.toFixed(2)}
1418 </li>
1419 </ul>
1420 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1421 How is this calculated? The score is a transparent sum of
1422 weighted signals — see <code>src/lib/pr-risk.ts</code>
1423 {" "}<code>computePrRiskScore</code>.
1424 </div>
1425 </details>
1426 {calculating && (
1427 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1428 (recomputing for the latest commit — refresh to update)
1429 </div>
1430 )}
1431 </div>
1432 );
1433}
1434
1435function riskBandPalette(band: PrRiskScore["band"]): {
1436 border: string;
1437 icon: string;
1438} {
1439 switch (band) {
1440 case "low":
1441 return { border: "var(--green)", icon: "" };
1442 case "medium":
1443 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1444 case "high":
1445 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1446 case "critical":
1447 return { border: "var(--red)", icon: "\u{1F6D1}" };
1448 }
1449}
1450
1451function riskBandLabel(band: PrRiskScore["band"]): string {
1452 switch (band) {
1453 case "low":
1454 return "LOW";
1455 case "medium":
1456 return "MEDIUM";
1457 case "high":
1458 return "HIGH";
1459 case "critical":
1460 return "CRITICAL";
1461 }
1462}
1463
422a2d4Claude1464// ---------------------------------------------------------------------------
1465// AI Trio Review — 3-column card grid + disagreement callout.
1466//
1467// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
1468// per run: one per persona (security/correctness/style) plus a top-level
1469// summary. We surface them here as a single grid above the normal
1470// comment stream so reviewers see the verdicts at a glance.
1471// ---------------------------------------------------------------------------
1472
1473const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
1474
1475interface TrioCommentLike {
1476 body: string;
1477}
1478
1479function isTrioComment(body: string | null | undefined): boolean {
1480 if (!body) return false;
1481 return (
1482 body.includes(TRIO_SUMMARY_MARKER) ||
1483 body.includes(TRIO_COMMENT_MARKER.security) ||
1484 body.includes(TRIO_COMMENT_MARKER.correctness) ||
1485 body.includes(TRIO_COMMENT_MARKER.style)
1486 );
1487}
1488
1489function trioPersonaOfComment(body: string): TrioPersona | null {
1490 for (const p of TRIO_PERSONAS) {
1491 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
1492 }
1493 return null;
1494}
1495
1496/**
1497 * Best-effort verdict parse from a persona comment body. The body shape
1498 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
1499 * we only need the "Pass" / "Fail" word from the H2 heading.
1500 */
1501function trioVerdictOfBody(body: string): "pass" | "fail" | null {
1502 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
1503 if (!m) return null;
1504 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
1505}
1506
1507/**
1508 * Parse the disagreement bullet list out of the summary comment so we
1509 * can render it as a polished callout strip. Returns [] when nothing
1510 * matches — the comment author may have edited the marker out.
1511 */
1512function parseDisagreements(summaryBody: string): Array<{
1513 file: string;
1514 failing: string;
1515 passing: string;
1516}> {
1517 const out: Array<{ file: string; failing: string; passing: string }> = [];
1518 // Each disagreement line looks like:
1519 // - `path:42` — security, style say ✗, correctness say ✓
1520 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
1521 let m: RegExpExecArray | null;
1522 while ((m = re.exec(summaryBody)) !== null) {
1523 out.push({
1524 file: m[1].trim(),
1525 failing: m[2].trim().replace(/[,\s]+$/g, ""),
1526 passing: m[3].trim().replace(/[,\s]+$/g, ""),
1527 });
1528 }
1529 return out;
1530}
1531
1532function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
1533 // Find the most recent persona comments + summary. We iterate from
1534 // the end so re-reviews (multiple runs on the same PR) display the
1535 // freshest verdict.
1536 const latest: Partial<Record<TrioPersona, string>> = {};
1537 let summaryBody: string | null = null;
1538 for (let i = comments.length - 1; i >= 0; i--) {
1539 const body = comments[i].body || "";
1540 if (!isTrioComment(body)) continue;
1541 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
1542 summaryBody = body;
1543 continue;
1544 }
1545 const persona = trioPersonaOfComment(body);
1546 if (persona && !latest[persona]) latest[persona] = body;
1547 }
1548 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1549 if (!anyPersona && !summaryBody) return null;
1550
1551 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
1552
1553 return (
1554 <div class="trio-wrap">
1555 <div class="trio-header">
1556 <span class="trio-header-dot" aria-hidden="true"></span>
1557 <strong>AI Trio Review</strong>
1558 <span class="trio-header-sub">
1559 Three independent reviewers ran in parallel.
1560 </span>
1561 </div>
1562 <div class="trio-grid">
1563 {TRIO_PERSONAS.map((persona) => {
1564 const body = latest[persona];
1565 const verdict = body ? trioVerdictOfBody(body) : null;
1566 const stateClass =
1567 verdict === "fail"
1568 ? "is-fail"
1569 : verdict === "pass"
1570 ? "is-pass"
1571 : "is-pending";
1572 return (
1573 <div class={`trio-card trio-${persona} ${stateClass}`}>
1574 <div class="trio-card-head">
1575 <span class="trio-card-icon" aria-hidden="true">
1576 {persona === "security"
1577 ? "🛡"
1578 : persona === "correctness"
1579 ? "✓"
1580 : "✎"}
1581 </span>
1582 <strong class="trio-card-title">
1583 {persona[0].toUpperCase() + persona.slice(1)}
1584 </strong>
1585 <span class="trio-card-verdict">
1586 {verdict === "pass"
1587 ? "Pass"
1588 : verdict === "fail"
1589 ? "Fail"
1590 : "Pending"}
1591 </span>
1592 </div>
1593 <div class="trio-card-body">
1594 {body ? (
1595 <MarkdownContent
1596 html={renderMarkdown(stripTrioHeading(body))}
1597 />
1598 ) : (
1599 <span class="trio-card-empty">
1600 Awaiting reviewer output.
1601 </span>
1602 )}
1603 </div>
1604 </div>
1605 );
1606 })}
1607 </div>
1608 {disagreements.length > 0 && (
1609 <div class="trio-disagreement-strip" role="note">
1610 <span class="trio-disagreement-icon" aria-hidden="true">
1611
1612 </span>
1613 <div class="trio-disagreement-body">
1614 <strong>Reviewers disagree — review carefully.</strong>
1615 <ul class="trio-disagreement-list">
1616 {disagreements.map((d) => (
1617 <li>
1618 <code>{d.file}</code> — {d.failing} says ✗,{" "}
1619 {d.passing} says ✓
1620 </li>
1621 ))}
1622 </ul>
1623 </div>
1624 </div>
1625 )}
1626 </div>
1627 );
1628}
1629
1630/**
1631 * Strip the marker comment + first H2 heading from a persona body so
1632 * the card body shows just the findings list (verdict is already in
1633 * the card head). Best-effort — malformed bodies render whole.
1634 */
1635function stripTrioHeading(body: string): string {
1636 return body
1637 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
1638 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
1639 .trim();
1640}
1641
0074234Claude1642// List PRs
04f6b7fClaude1643pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1644 const { owner: ownerName, repo: repoName } = c.req.param();
1645 const user = c.get("user");
1646 const state = c.req.query("state") || "open";
1647
ea9ed4cClaude1648 // ── Loading skeleton (flag-gated) ──
1649 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
1650 // the user see the page structure before counts + select resolve.
1651 // Behind a flag for now — we don't ship flashes.
1652 if (c.req.query("skeleton") === "1") {
1653 return c.html(
1654 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1655 <RepoHeader owner={ownerName} repo={repoName} />
1656 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1657 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1658 <style
1659 dangerouslySetInnerHTML={{
1660 __html: `
1661 .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; }
1662 @keyframes prsSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1663 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
1664 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
1665 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
1666 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
1667 .prs-skel-row { height: 76px; border-radius: 12px; }
1668 `,
1669 }}
1670 />
1671 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
1672 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
1673 <div class="prs-skel-list" aria-hidden="true">
1674 {Array.from({ length: 6 }).map(() => (
1675 <div class="prs-skel prs-skel-row" />
1676 ))}
1677 </div>
1678 <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">
1679 Loading pull requests for {ownerName}/{repoName}…
1680 </span>
1681 </Layout>
1682 );
1683 }
1684
0074234Claude1685 const resolved = await resolveRepo(ownerName, repoName);
1686 if (!resolved) return c.notFound();
1687
6fc53bdClaude1688 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
1689 const stateFilter =
1690 state === "draft"
1691 ? and(
1692 eq(pullRequests.state, "open"),
1693 eq(pullRequests.isDraft, true)
1694 )
1695 : eq(pullRequests.state, state);
1696
0074234Claude1697 const prList = await db
1698 .select({
1699 pr: pullRequests,
1700 author: { username: users.username },
1701 })
1702 .from(pullRequests)
1703 .innerJoin(users, eq(pullRequests.authorId, users.id))
1704 .where(
6fc53bdClaude1705 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude1706 )
1707 .orderBy(desc(pullRequests.createdAt));
1708
1709 const [counts] = await db
1710 .select({
1711 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude1712 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude1713 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
1714 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
1715 })
1716 .from(pullRequests)
1717 .where(eq(pullRequests.repositoryId, resolved.repo.id));
1718
b078860Claude1719 const openCount = counts?.open ?? 0;
1720 const mergedCount = counts?.merged ?? 0;
1721 const closedCount = counts?.closed ?? 0;
1722 const draftCount = counts?.draft ?? 0;
1723 const allCount = openCount + mergedCount + closedCount;
1724
1725 // "All" is presentational only — the DB query for state='all' matches
1726 // nothing, so we render a friendlier empty state when picked. We do NOT
1727 // change the query logic to keep this commit purely visual.
1728 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
1729 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
1730 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
1731 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
1732 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
1733 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
1734 ];
1735 const isAllState = state === "all";
cb5a796Claude1736 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
1737 const prListPendingCount = viewerIsOwnerOnPrList
1738 ? await countPendingForRepo(resolved.repo.id)
1739 : 0;
b078860Claude1740
0074234Claude1741 return c.html(
1742 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1743 <RepoHeader owner={ownerName} repo={repoName} />
1744 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude1745 <PendingCommentsBanner
1746 owner={ownerName}
1747 repo={repoName}
1748 count={prListPendingCount}
1749 />
b078860Claude1750 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1751
1752 <div class="prs-hero">
1753 <div class="prs-hero-inner">
1754 <div class="prs-hero-text">
1755 <div class="prs-hero-eyebrow">Pull requests</div>
1756 <h1 class="prs-hero-title">
1757 Review, <span class="gradient-text">merge with AI</span>.
1758 </h1>
1759 <p class="prs-hero-sub">
1760 {openCount === 0 && allCount === 0
1761 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
1762 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
1763 </p>
1764 </div>
1765 {user && (
1766 <div class="prs-hero-actions">
1767 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
1768 + New pull request
1769 </a>
1770 </div>
1771 )}
1772 </div>
1773 </div>
1774
1775 <nav class="prs-tabs" aria-label="Pull request filters">
1776 {tabPills.map((t) => {
1777 const isActive =
1778 state === t.key ||
1779 (t.key === "open" &&
1780 state !== "merged" &&
1781 state !== "closed" &&
1782 state !== "all" &&
1783 state !== "draft");
1784 return (
1785 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
1786 <span>{t.label}</span>
1787 <span class="prs-tab-count">{t.count}</span>
1788 </a>
1789 );
1790 })}
1791 </nav>
1792
0074234Claude1793 {prList.length === 0 ? (
b078860Claude1794 <div class="prs-empty">
ea9ed4cClaude1795 <div class="prs-empty-inner">
1796 <strong>
1797 {isAllState
1798 ? "Pick a filter above to browse PRs."
1799 : `No ${state} pull requests.`}
1800 </strong>
1801 <p class="prs-empty-sub">
1802 {state === "open"
1803 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
1804 : isAllState
1805 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1806 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
1807 </p>
1808 <div class="prs-empty-cta">
1809 {user && state === "open" && (
1810 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
1811 + New pull request
1812 </a>
1813 )}
1814 {state !== "open" && (
1815 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
1816 View open PRs
1817 </a>
1818 )}
1819 <a href={`/${ownerName}/${repoName}`} class="btn">
1820 Back to code
1821 </a>
1822 </div>
1823 </div>
b078860Claude1824 </div>
0074234Claude1825 ) : (
b078860Claude1826 <div class="prs-list">
1827 {prList.map(({ pr, author }) => {
1828 const stateClass =
1829 pr.state === "open"
1830 ? pr.isDraft
1831 ? "state-draft"
1832 : "state-open"
1833 : pr.state === "merged"
1834 ? "state-merged"
1835 : "state-closed";
1836 const icon =
1837 pr.state === "open"
1838 ? pr.isDraft
1839 ? "◌"
1840 : "○"
1841 : pr.state === "merged"
1842 ? "⮌"
1843 : "✓";
1844 return (
1845 <a
1846 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1847 class="prs-row"
1848 style="text-decoration:none;color:inherit"
0074234Claude1849 >
b078860Claude1850 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
1851 {icon}
0074234Claude1852 </div>
b078860Claude1853 <div class="prs-row-body">
1854 <h3 class="prs-row-title">
1855 <span>{pr.title}</span>
1856 <span class="prs-row-number">#{pr.number}</span>
1857 </h3>
1858 <div class="prs-row-meta">
1859 <span
1860 class="prs-branch-chips"
1861 title={`${pr.headBranch} into ${pr.baseBranch}`}
1862 >
1863 <span class="prs-branch-chip">{pr.headBranch}</span>
1864 <span class="prs-branch-arrow">{"→"}</span>
1865 <span class="prs-branch-chip">{pr.baseBranch}</span>
1866 </span>
1867 <span>
1868 by{" "}
1869 <strong style="color:var(--text)">
1870 {author.username}
1871 </strong>{" "}
1872 {formatRelative(pr.createdAt)}
1873 </span>
1874 <span class="prs-row-tags">
1875 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
1876 {pr.state === "merged" && (
1877 <span class="prs-tag is-merged">Merged</span>
1878 )}
1879 </span>
1880 </div>
0074234Claude1881 </div>
b078860Claude1882 </a>
1883 );
1884 })}
1885 </div>
0074234Claude1886 )}
1887 </Layout>
1888 );
1889});
1890
1891// New PR form
1892pulls.get(
1893 "/:owner/:repo/pulls/new",
1894 softAuth,
1895 requireAuth,
04f6b7fClaude1896 requireRepoAccess("write"),
0074234Claude1897 async (c) => {
1898 const { owner: ownerName, repo: repoName } = c.req.param();
1899 const user = c.get("user")!;
1900 const branches = await listBranches(ownerName, repoName);
1901 const error = c.req.query("error");
1902 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude1903 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude1904
1905 return c.html(
1906 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
1907 <RepoHeader owner={ownerName} repo={repoName} />
1908 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude1909 <Container maxWidth={800}>
1910 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude1911 {error && (
bb0f894Claude1912 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude1913 )}
0316dbbClaude1914 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
1915 <Flex gap={12} align="center" style="margin-bottom: 16px">
1916 <Select name="base">
0074234Claude1917 {branches.map((b) => (
1918 <option value={b} selected={b === defaultBase}>
1919 {b}
1920 </option>
1921 ))}
bb0f894Claude1922 </Select>
1923 <Text muted>&larr;</Text>
1924 <Select name="head">
0074234Claude1925 {branches
1926 .filter((b) => b !== defaultBase)
1927 .concat(defaultBase === branches[0] ? [] : [branches[0]])
1928 .map((b) => (
1929 <option value={b}>{b}</option>
1930 ))}
bb0f894Claude1931 </Select>
1932 </Flex>
1933 <FormGroup>
1934 <Input
0074234Claude1935 name="title"
1936 required
1937 placeholder="Title"
bb0f894Claude1938 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]1939 aria-label="Pull request title"
0074234Claude1940 />
bb0f894Claude1941 </FormGroup>
1942 <FormGroup>
1943 <TextArea
0074234Claude1944 name="body"
81c73c1Claude1945 id="pr-body"
0074234Claude1946 rows={8}
1947 placeholder="Description (Markdown supported)"
bb0f894Claude1948 mono
0074234Claude1949 />
bb0f894Claude1950 </FormGroup>
81c73c1Claude1951 <Flex gap={8} align="center">
1952 <Button type="submit" variant="primary">
1953 Create pull request
1954 </Button>
1955 <button
1956 type="button"
1957 id="ai-suggest-desc"
1958 class="btn"
1959 style="font-weight:500"
1960 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
1961 >
1962 Suggest description with AI
1963 </button>
1964 <span
1965 id="ai-suggest-status"
1966 style="color:var(--text-muted);font-size:13px"
1967 />
1968 </Flex>
bb0f894Claude1969 </Form>
81c73c1Claude1970 <script
1971 dangerouslySetInnerHTML={{
1972 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
1973 }}
1974 />
bb0f894Claude1975 </Container>
0074234Claude1976 </Layout>
1977 );
1978 }
1979);
1980
81c73c1Claude1981// AI-suggested PR description — JSON endpoint driven by the form button.
1982// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
1983// 200; the inline script reads `ok` to decide what to do.
1984pulls.post(
1985 "/:owner/:repo/ai/pr-description",
1986 softAuth,
1987 requireAuth,
1988 requireRepoAccess("write"),
1989 async (c) => {
1990 const { owner: ownerName, repo: repoName } = c.req.param();
1991 if (!isAiAvailable()) {
1992 return c.json({
1993 ok: false,
1994 error: "AI is not available — set ANTHROPIC_API_KEY.",
1995 });
1996 }
1997 const body = await c.req.parseBody();
1998 const title = String(body.title || "").trim();
1999 const baseBranch = String(body.base || "").trim();
2000 const headBranch = String(body.head || "").trim();
2001 if (!baseBranch || !headBranch) {
2002 return c.json({ ok: false, error: "Pick base + head branches first." });
2003 }
2004 if (baseBranch === headBranch) {
2005 return c.json({ ok: false, error: "Base and head must differ." });
2006 }
2007
2008 let diff = "";
2009 try {
2010 const cwd = getRepoPath(ownerName, repoName);
2011 const proc = Bun.spawn(
2012 [
2013 "git",
2014 "diff",
2015 `${baseBranch}...${headBranch}`,
2016 "--",
2017 ],
2018 { cwd, stdout: "pipe", stderr: "pipe" }
2019 );
6ea2109Claude2020 // 30s ceiling — without this a pathological diff (huge binary or
2021 // a corrupt ref) hangs the request indefinitely.
2022 const killer = setTimeout(() => proc.kill(), 30_000);
2023 try {
2024 diff = await new Response(proc.stdout).text();
2025 await proc.exited;
2026 } finally {
2027 clearTimeout(killer);
2028 }
81c73c1Claude2029 } catch {
2030 diff = "";
2031 }
2032 if (!diff.trim()) {
2033 return c.json({
2034 ok: false,
2035 error: "No diff between branches — nothing to summarise.",
2036 });
2037 }
2038
2039 let summary = "";
2040 try {
2041 summary = await generatePrSummary(title || "(untitled)", diff);
2042 } catch (err) {
2043 const msg = err instanceof Error ? err.message : "AI request failed.";
2044 return c.json({ ok: false, error: msg });
2045 }
2046 if (!summary.trim()) {
2047 return c.json({ ok: false, error: "AI returned an empty draft." });
2048 }
2049 return c.json({ ok: true, body: summary });
2050 }
2051);
2052
0074234Claude2053// Create PR
2054pulls.post(
2055 "/:owner/:repo/pulls/new",
2056 softAuth,
2057 requireAuth,
04f6b7fClaude2058 requireRepoAccess("write"),
0074234Claude2059 async (c) => {
2060 const { owner: ownerName, repo: repoName } = c.req.param();
2061 const user = c.get("user")!;
2062 const body = await c.req.parseBody();
2063 const title = String(body.title || "").trim();
2064 const prBody = String(body.body || "").trim();
2065 const baseBranch = String(body.base || "main");
2066 const headBranch = String(body.head || "");
2067
2068 if (!title || !headBranch) {
2069 return c.redirect(
2070 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
2071 );
2072 }
2073
2074 if (baseBranch === headBranch) {
2075 return c.redirect(
2076 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
2077 );
2078 }
2079
2080 const resolved = await resolveRepo(ownerName, repoName);
2081 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2082
6fc53bdClaude2083 const isDraft = String(body.draft || "") === "1";
2084
0074234Claude2085 const [pr] = await db
2086 .insert(pullRequests)
2087 .values({
2088 repositoryId: resolved.repo.id,
2089 authorId: user.id,
2090 title,
2091 body: prBody || null,
2092 baseBranch,
2093 headBranch,
6fc53bdClaude2094 isDraft,
0074234Claude2095 })
2096 .returning();
2097
6fc53bdClaude2098 // Skip AI review on drafts — it runs again when the PR is marked ready.
2099 if (!isDraft && isAiReviewEnabled()) {
e883329Claude2100 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
2101 (err) => console.error("[ai-review] Failed:", err)
2102 );
2103 }
2104
3cbe3d6Claude2105 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
2106 triggerPrTriage({
2107 ownerName,
2108 repoName,
2109 repositoryId: resolved.repo.id,
2110 prId: pr.id,
2111 prAuthorId: user.id,
2112 title,
2113 body: prBody,
2114 baseBranch,
2115 headBranch,
2116 }).catch((err) => console.error("[pr-triage] Failed:", err));
2117
1d4ff60Claude2118 // Chat notifier — fan out to Slack/Discord/Teams.
2119 import("../lib/chat-notifier")
2120 .then((m) =>
2121 m.notifyChatChannels({
2122 ownerUserId: resolved.repo.ownerId,
2123 repositoryId: resolved.repo.id,
2124 event: {
2125 event: "pr.opened",
2126 repo: `${ownerName}/${repoName}`,
2127 title: `#${pr.number} ${title}`,
2128 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
2129 body: prBody || undefined,
2130 actor: user.username,
2131 },
2132 })
2133 )
2134 .catch((err) =>
2135 console.warn(`[chat-notifier] PR opened notify failed:`, err)
2136 );
2137
9dd96b9Test User2138 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude2139 import("../lib/auto-merge")
2140 .then((m) => m.tryAutoMergeNow(pr.id))
2141 .catch((err) => {
2142 console.warn(
2143 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
2144 err instanceof Error ? err.message : err
2145 );
2146 });
9dd96b9Test User2147
0074234Claude2148 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
2149 }
2150);
2151
2152// View single PR
04f6b7fClaude2153pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2154 const { owner: ownerName, repo: repoName } = c.req.param();
2155 const prNum = parseInt(c.req.param("number"), 10);
2156 const user = c.get("user");
2157 const tab = c.req.query("tab") || "conversation";
2158
2159 const resolved = await resolveRepo(ownerName, repoName);
2160 if (!resolved) return c.notFound();
2161
2162 const [pr] = await db
2163 .select()
2164 .from(pullRequests)
2165 .where(
2166 and(
2167 eq(pullRequests.repositoryId, resolved.repo.id),
2168 eq(pullRequests.number, prNum)
2169 )
2170 )
2171 .limit(1);
2172
2173 if (!pr) return c.notFound();
2174
2175 const [author] = await db
2176 .select()
2177 .from(users)
2178 .where(eq(users.id, pr.authorId))
2179 .limit(1);
2180
cb5a796Claude2181 const allCommentsRaw = await db
0074234Claude2182 .select({
2183 comment: prComments,
cb5a796Claude2184 author: { id: users.id, username: users.username },
0074234Claude2185 })
2186 .from(prComments)
2187 .innerJoin(users, eq(prComments.authorId, users.id))
2188 .where(eq(prComments.pullRequestId, pr.id))
2189 .orderBy(asc(prComments.createdAt));
2190
cb5a796Claude2191 // Filter pending/rejected/spam for non-owner, non-author viewers.
2192 // Owner always sees everything; comment author sees their own pending
2193 // with an "Awaiting approval" badge in the render below.
2194 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
2195 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
2196 if (viewerIsRepoOwner) return true;
2197 if (comment.moderationStatus === "approved") return true;
2198 if (
2199 user &&
2200 cAuthor.id === user.id &&
2201 comment.moderationStatus === "pending"
2202 ) {
2203 return true;
2204 }
2205 return false;
2206 });
2207 const prPendingCount = viewerIsRepoOwner
2208 ? await countPendingForRepo(resolved.repo.id)
2209 : 0;
2210
6fc53bdClaude2211 // Reactions for the PR body + each comment, in parallel.
2212 const [prReactions, ...prCommentReactions] = await Promise.all([
2213 summariseReactions("pr", pr.id, user?.id),
2214 ...comments.map((row) =>
2215 summariseReactions("pr_comment", row.comment.id, user?.id)
2216 ),
2217 ]);
2218
0074234Claude2219 const canManage =
2220 user &&
2221 (user.id === resolved.owner.id || user.id === pr.authorId);
2222
1d4ff60Claude2223 // Has any previous AI-test-generator run already tagged this PR? Used
2224 // both to hide the "Generate tests with AI" button and to short-circuit
2225 // the explicit POST handler.
2226 const hasAiTestsMarker = comments.some(({ comment }) =>
2227 (comment.body || "").includes(AI_TESTS_MARKER)
2228 );
2229
e883329Claude2230 const error = c.req.query("error");
c3e0c07Claude2231 const info = c.req.query("info");
e883329Claude2232
2233 // Get gate check status for open PRs
2234 let gateChecks: GateCheckResult[] = [];
2235 if (pr.state === "open") {
2236 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2237 if (headSha) {
2238 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
2239 const aiApproved = aiComments.length === 0 || aiComments.some(
2240 ({ comment }) => comment.body.includes("**Approved**")
2241 );
2242 const gateResult = await runAllGateChecks(
2243 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
2244 );
2245 gateChecks = gateResult.checks;
2246 }
2247 }
2248
534f04aClaude2249 // Block M3 — pre-merge risk score. Cache-only on the request path so
2250 // the page never waits on Haiku. On a cache miss for an open PR we
2251 // kick off the computation fire-and-forget; the next refresh shows it.
2252 let prRisk: PrRiskScore | null = null;
2253 let prRiskCalculating = false;
2254 if (pr.state === "open") {
2255 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
2256 if (!prRisk) {
2257 prRiskCalculating = true;
a28cedeClaude2258 void computePrRiskForPullRequest(pr.id).catch((err) => {
2259 console.warn(
2260 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
2261 err instanceof Error ? err.message : err
2262 );
2263 });
534f04aClaude2264 }
2265 }
2266
4bbacbeClaude2267 // Migration 0062 — per-branch preview URL. The head branch always
2268 // has a preview row (unless it's the default branch, which never
2269 // happens for an open PR) once it has been pushed at least once.
2270 const preview = await getPreviewForBranch(
2271 (resolved.repo as { id: string }).id,
2272 pr.headBranch
2273 );
2274
47a7a0aClaude2275 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude2276 let diffRaw = "";
2277 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude2278 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude2279 if (tab === "files") {
2280 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude2281 // Run the two git diffs in parallel — they're independent reads of
2282 // the same range. Previously sequential, doubling the wall time on
2283 // big PRs (100+ files = 10-30s for no reason).
0074234Claude2284 const proc = Bun.spawn(
2285 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
2286 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2287 );
2288 const statProc = Bun.spawn(
2289 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
2290 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2291 );
6ea2109Claude2292 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
2293 // would otherwise hang the whole request.
2294 const killer = setTimeout(() => {
2295 proc.kill();
2296 statProc.kill();
2297 }, 30_000);
2298 let stat = "";
2299 try {
2300 [diffRaw, stat] = await Promise.all([
2301 new Response(proc.stdout).text(),
2302 new Response(statProc.stdout).text(),
2303 ]);
2304 await Promise.all([proc.exited, statProc.exited]);
2305 } finally {
2306 clearTimeout(killer);
2307 }
0074234Claude2308
2309 diffFiles = stat
2310 .trim()
2311 .split("\n")
2312 .filter(Boolean)
2313 .map((line) => {
2314 const [add, del, filePath] = line.split("\t");
2315 return {
2316 path: filePath,
2317 status: "modified",
2318 additions: add === "-" ? 0 : parseInt(add, 10),
2319 deletions: del === "-" ? 0 : parseInt(del, 10),
2320 patch: "",
2321 };
2322 });
47a7a0aClaude2323
2324 // Fetch inline comments (file+line anchored) for the files tab
2325 const inlineRows = await db
2326 .select({
2327 id: prComments.id,
2328 filePath: prComments.filePath,
2329 lineNumber: prComments.lineNumber,
2330 body: prComments.body,
2331 isAiReview: prComments.isAiReview,
2332 createdAt: prComments.createdAt,
2333 authorUsername: users.username,
2334 })
2335 .from(prComments)
2336 .innerJoin(users, eq(prComments.authorId, users.id))
2337 .where(
2338 and(
2339 eq(prComments.pullRequestId, pr.id),
2340 eq(prComments.moderationStatus, "approved"),
2341 )
2342 )
2343 .orderBy(asc(prComments.createdAt));
2344
2345 diffInlineComments = inlineRows
2346 .filter(r => r.filePath != null && r.lineNumber != null)
2347 .map(r => ({
2348 id: r.id,
2349 filePath: r.filePath!,
2350 lineNumber: r.lineNumber!,
2351 authorUsername: r.authorUsername,
2352 body: renderMarkdown(r.body),
2353 isAiReview: r.isAiReview,
2354 createdAt: r.createdAt.toISOString(),
2355 }));
0074234Claude2356 }
2357
b078860Claude2358 // ─── Derived visual state ───
2359 const stateKey =
2360 pr.state === "open"
2361 ? pr.isDraft
2362 ? "draft"
2363 : "open"
2364 : pr.state;
2365 const stateLabel =
2366 stateKey === "open"
2367 ? "Open"
2368 : stateKey === "draft"
2369 ? "Draft"
2370 : stateKey === "merged"
2371 ? "Merged"
2372 : "Closed";
2373 const stateIcon =
2374 stateKey === "open"
2375 ? "○"
2376 : stateKey === "draft"
2377 ? "◌"
2378 : stateKey === "merged"
2379 ? "⮌"
2380 : "✓";
2381 const commentCount = comments.length;
2382 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
2383 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
2384 const mergeBlocked =
2385 gateChecks.length > 0 &&
2386 gateChecks.some(
2387 (c) => !c.passed && c.name !== "Merge check"
2388 );
2389
0074234Claude2390 return c.html(
2391 <Layout
2392 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
2393 user={user}
2394 >
2395 <RepoHeader owner={ownerName} repo={repoName} />
2396 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2397 <PendingCommentsBanner
2398 owner={ownerName}
2399 repo={repoName}
2400 count={prPendingCount}
2401 />
b078860Claude2402 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude2403 <div
2404 id="live-comment-banner"
2405 class="alert"
2406 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
2407 >
2408 <strong class="js-live-count">0</strong> new comment(s) —{" "}
2409 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
2410 reload to view
2411 </a>
2412 </div>
2413 <script
2414 dangerouslySetInnerHTML={{
2415 __html: liveCommentBannerScript({
2416 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
2417 bannerElementId: "live-comment-banner",
2418 }),
2419 }}
2420 />
b078860Claude2421
2422 <div class="prs-detail-hero">
2423 <h1 class="prs-detail-title">
0074234Claude2424 {pr.title}{" "}
b078860Claude2425 <span class="prs-detail-num">#{pr.number}</span>
2426 </h1>
2427 <div class="prs-detail-meta">
2428 <span class={`prs-state-pill state-${stateKey}`}>
2429 <span aria-hidden="true">{stateIcon}</span>
2430 <span>{stateLabel}</span>
2431 </span>
2432 <span>
2433 <strong>{author?.username}</strong> wants to merge
2434 </span>
2435 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
2436 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
2437 <span class="prs-branch-arrow-lg">{"→"}</span>
2438 <span class="prs-branch-pill">{pr.baseBranch}</span>
2439 </span>
2440 <span>opened {formatRelative(pr.createdAt)}</span>
3c03977Claude2441 <span
2442 id="live-pill"
2443 class="live-pill"
2444 title="People editing this PR right now"
2445 >
2446 <span class="live-pill-dot" aria-hidden="true"></span>
2447 <span>
2448 Live: <strong id="live-count">0</strong> editing
2449 </span>
2450 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
2451 </span>
4bbacbeClaude2452 {preview && (
2453 <a
2454 class={`preview-prpill is-${preview.status}`}
2455 href={
2456 preview.status === "ready"
2457 ? preview.previewUrl
2458 : `/${ownerName}/${repoName}/previews`
2459 }
2460 target={preview.status === "ready" ? "_blank" : undefined}
2461 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
2462 title={`Preview · ${previewStatusLabel(preview.status)}`}
2463 >
2464 <span class="preview-prpill-dot" aria-hidden="true"></span>
2465 <span>Preview: </span>
2466 <span>{previewStatusLabel(preview.status)}</span>
2467 </a>
2468 )}
b078860Claude2469 {canManage && pr.state === "open" && pr.isDraft && (
2470 <form
2471 method="post"
2472 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2473 class="prs-inline-form prs-detail-actions"
2474 >
2475 <button type="submit" class="prs-merge-ready-btn">
2476 Ready for review
2477 </button>
2478 </form>
2479 )}
2480 </div>
2481 </div>
3c03977Claude2482 <script
2483 dangerouslySetInnerHTML={{
2484 __html: LIVE_COEDIT_SCRIPT(pr.id),
2485 }}
2486 />
0074234Claude2487
b078860Claude2488 <nav class="prs-detail-tabs" aria-label="Pull request sections">
2489 <a
2490 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
2491 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2492 >
2493 Conversation
2494 <span class="prs-detail-tab-count">{commentCount}</span>
2495 </a>
2496 <a
2497 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
2498 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2499 >
2500 Files changed
2501 {diffFiles.length > 0 && (
2502 <span class="prs-detail-tab-count">{diffFiles.length}</span>
2503 )}
2504 </a>
2505 </nav>
2506
2507 {tab === "files" ? (
ea9ed4cClaude2508 <DiffView
2509 raw={diffRaw}
2510 files={diffFiles}
2511 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
47a7a0aClaude2512 inlineComments={diffInlineComments}
2513 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
ea9ed4cClaude2514 />
b078860Claude2515 ) : (
2516 <>
2517 {pr.body && (
2518 <CommentBox
2519 author={author?.username ?? "unknown"}
2520 date={pr.createdAt}
2521 body={renderMarkdown(pr.body)}
2522 />
2523 )}
2524
422a2d4Claude2525 {/* Block H — AI trio review (security/correctness/style). When
2526 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
2527 hoisted into a 3-column card grid above the normal comment
2528 stream so reviewers see verdicts at a glance. Disagreements
2529 are surfaced as a yellow callout. */}
2530 <TrioReviewGrid
2531 comments={comments.map(({ comment }) => comment)}
2532 />
2533
15db0e0Claude2534 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude2535 // Skip trio comments — already rendered in TrioReviewGrid above.
2536 if (isTrioComment(comment.body)) return null;
15db0e0Claude2537 const slashCmd = detectSlashCmdComment(comment.body);
2538 if (slashCmd) {
2539 const visible = stripSlashCmdMarker(comment.body);
2540 return (
2541 <div class={`slash-pill slash-cmd-${slashCmd}`}>
2542 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
2543 <span class="slash-pill-actor">
2544 <strong>{commentAuthor.username}</strong>
2545 {" ran "}
2546 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude2547 </span>
15db0e0Claude2548 <span class="slash-pill-time">
2549 {formatRelative(comment.createdAt)}
2550 </span>
2551 <div class="slash-pill-body">
2552 <MarkdownContent html={renderMarkdown(visible)} />
2553 </div>
2554 </div>
2555 );
2556 }
cb5a796Claude2557 const isPending = comment.moderationStatus === "pending";
15db0e0Claude2558 return (
cb5a796Claude2559 <div
2560 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
2561 >
15db0e0Claude2562 <div class="prs-comment-head">
2563 <strong>{commentAuthor.username}</strong>
2564 {comment.isAiReview && (
2565 <span class="prs-ai-badge">AI Review</span>
2566 )}
cb5a796Claude2567 {isPending && (
2568 <span
2569 class="modq-pending-badge"
2570 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
2571 >
2572 Awaiting approval
2573 </span>
2574 )}
15db0e0Claude2575 <span class="prs-comment-time">
2576 commented {formatRelative(comment.createdAt)}
2577 </span>
2578 {comment.filePath && (
2579 <span class="prs-comment-loc">
2580 {comment.filePath}
2581 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
2582 </span>
2583 )}
2584 </div>
2585 <div class="prs-comment-body">
2586 <MarkdownContent html={renderMarkdown(comment.body)} />
2587 </div>
0074234Claude2588 </div>
15db0e0Claude2589 );
2590 })}
0074234Claude2591
b078860Claude2592 {/* Quick link to the Files changed tab when there's a diff to look at. */}
2593 {pr.state !== "merged" && (
2594 <a
2595 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2596 class="prs-files-card"
2597 >
2598 <span class="prs-files-card-icon" aria-hidden="true">
2599 {"▤"}
2600 </span>
2601 <div class="prs-files-card-text">
2602 <p class="prs-files-card-title">Files changed</p>
2603 <p class="prs-files-card-sub">
2604 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
2605 </p>
e883329Claude2606 </div>
b078860Claude2607 <span class="prs-files-card-cta">View diff {"→"}</span>
2608 </a>
2609 )}
2610
2611 {error && (
2612 <div
2613 class="auth-error"
2614 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)"
2615 >
2616 {decodeURIComponent(error)}
2617 </div>
2618 )}
2619
2620 {info && (
2621 <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)">
2622 {decodeURIComponent(info)}
2623 </div>
2624 )}
e883329Claude2625
b078860Claude2626 {pr.state === "open" && (prRisk || prRiskCalculating) && (
2627 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
2628 )}
2629
2630 {pr.state === "open" && gateChecks.length > 0 && (
2631 <div class="prs-gate-card">
2632 <div class="prs-gate-head">
2633 <h3>Gate checks</h3>
2634 <span class="prs-gate-summary">
2635 {gatesAllPassed
2636 ? `All ${gateChecks.length} checks passed`
2637 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
2638 </span>
c3e0c07Claude2639 </div>
b078860Claude2640 {gateChecks.map((check) => {
2641 const isAi = /ai.*review/i.test(check.name);
2642 const isSkip = check.skipped === true;
2643 const statusClass = isSkip
2644 ? "is-skip"
2645 : check.passed
2646 ? "is-pass"
2647 : "is-fail";
2648 const statusGlyph = isSkip
2649 ? "—"
2650 : check.passed
2651 ? "✓"
2652 : "✗";
2653 const statusLabel = isSkip
2654 ? "Skipped"
2655 : check.passed
2656 ? "Passed"
2657 : "Failing";
2658 return (
2659 <div
2660 class="prs-gate-row"
2661 style={
2662 isAi
2663 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
2664 : ""
2665 }
2666 >
2667 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
2668 {statusGlyph}
2669 </span>
2670 <span class="prs-gate-name">
2671 {check.name}
2672 {isAi && (
2673 <span
2674 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"
2675 >
2676 AI
2677 </span>
2678 )}
2679 </span>
2680 <span class="prs-gate-details">{check.details}</span>
2681 <span class={`prs-gate-pill ${statusClass}`}>
2682 {statusLabel}
e883329Claude2683 </span>
2684 </div>
b078860Claude2685 );
2686 })}
2687 <div class="prs-gate-footer">
2688 {gatesAllPassed
2689 ? "All checks passed — ready to merge."
2690 : gateChecks.some(
2691 (c) => !c.passed && c.name === "Merge check"
2692 )
2693 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
2694 : "Some checks failed — resolve issues before merging."}
2695 {aiReviewCount > 0 && (
2696 <>
2697 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
2698 </>
2699 )}
2700 </div>
2701 </div>
2702 )}
2703
2704 {/* ─── Merge area / state-aware action card ─────────────── */}
2705 {user && pr.state === "open" && (
2706 <div
2707 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
2708 >
2709 <div class="prs-merge-head">
2710 <strong>
2711 {pr.isDraft
2712 ? "Draft — ready for review?"
2713 : mergeBlocked
2714 ? "Merge blocked"
2715 : "Ready to merge"}
2716 </strong>
e883329Claude2717 </div>
b078860Claude2718 <p class="prs-merge-sub">
2719 {pr.isDraft
2720 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
2721 : mergeBlocked
2722 ? "Resolve the failing gate checks above before this PR can land."
2723 : gateChecks.length > 0
2724 ? gatesAllPassed
2725 ? "All gates green. Merge will fast-forward into the base branch."
2726 : "Conflicts will be auto-resolved by GlueCron AI on merge."
2727 : "Run gate checks by refreshing once your branch has a recent commit."}
2728 </p>
2729 <Form
2730 method="post"
2731 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
2732 >
2733 <FormGroup>
3c03977Claude2734 <div class="live-cursor-host" style="position:relative">
2735 <textarea
2736 name="body"
2737 id="pr-comment-body"
2738 data-live-field="comment_new"
2739 rows={5}
2740 required
2741 placeholder="Leave a comment... (Markdown supported)"
2742 style="font-family:var(--font-mono);font-size:13px;width:100%"
2743 ></textarea>
2744 </div>
15db0e0Claude2745 <span class="slash-hint" title="Type a slash-command as the first line">
2746 Type <code>/</code> for commands —{" "}
2747 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
2748 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
2749 </span>
b078860Claude2750 </FormGroup>
2751 <div class="prs-merge-actions">
2752 <Button type="submit" variant="primary">
2753 Comment
2754 </Button>
2755 {canManage && (
2756 <>
2757 {pr.isDraft ? (
2758 <button
2759 type="submit"
2760 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2761 formnovalidate
2762 class="prs-merge-ready-btn"
2763 >
2764 Ready for review
2765 </button>
2766 ) : (
0074234Claude2767 <button
2768 type="submit"
2769 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
b078860Claude2770 formnovalidate
2771 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
2772 title={
2773 mergeBlocked
2774 ? "Failing gate checks must be resolved before this PR can merge."
2775 : "Merge pull request"
2776 }
0074234Claude2777 >
b078860Claude2778 {"✔"} Merge pull request
0074234Claude2779 </button>
b078860Claude2780 )}
2781 {!pr.isDraft && (
2782 <button
0074234Claude2783 type="submit"
b078860Claude2784 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
2785 formnovalidate
2786 class="prs-merge-back-draft"
2787 title="Convert back to draft"
0074234Claude2788 >
b078860Claude2789 Convert to draft
2790 </button>
2791 )}
2792 {isAiReviewEnabled() && (
2793 <button
2794 type="submit"
2795 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
2796 formnovalidate
2797 class="btn"
2798 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
2799 >
2800 Re-run AI review
2801 </button>
2802 )}
1d4ff60Claude2803 {isAiReviewEnabled() && !hasAiTestsMarker && (
2804 <button
2805 type="submit"
2806 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
2807 formnovalidate
2808 class="btn"
2809 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."
2810 >
2811 Generate tests with AI
2812 </button>
2813 )}
b078860Claude2814 <Button
2815 type="submit"
2816 variant="danger"
2817 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
2818 >
2819 Close
2820 </Button>
2821 </>
2822 )}
2823 </div>
2824 </Form>
2825 </div>
2826 )}
2827
2828 {/* Read-only footers for non-open states. */}
2829 {pr.state === "merged" && (
2830 <div class="prs-merge-card is-merged">
2831 <div class="prs-merge-head">
2832 <strong>{"⮌"} Merged</strong>
0074234Claude2833 </div>
b078860Claude2834 <p class="prs-merge-sub">
2835 This pull request was merged into{" "}
2836 <code>{pr.baseBranch}</code>.
2837 </p>
2838 </div>
2839 )}
2840 {pr.state === "closed" && (
2841 <div class="prs-merge-card is-closed">
2842 <div class="prs-merge-head">
2843 <strong>{"✕"} Closed without merging</strong>
2844 </div>
2845 <p class="prs-merge-sub">
2846 This pull request was closed and not merged.
2847 </p>
2848 </div>
2849 )}
2850 </>
2851 )}
0074234Claude2852 </Layout>
2853 );
2854});
2855
cb5a796Claude2856// Add comment to PR.
2857//
2858// Permission model mirrors `issues.tsx`: any logged-in user with read
2859// access can submit; `decideInitialStatus` routes non-collaborators
2860// through the moderation queue. Slash commands only fire when the
2861// comment is auto-approved — we don't want a banned/pending comment to
2862// silently trigger AI work on the PR.
0074234Claude2863pulls.post(
2864 "/:owner/:repo/pulls/:number/comment",
2865 softAuth,
2866 requireAuth,
cb5a796Claude2867 requireRepoAccess("read"),
0074234Claude2868 async (c) => {
2869 const { owner: ownerName, repo: repoName } = c.req.param();
2870 const prNum = parseInt(c.req.param("number"), 10);
2871 const user = c.get("user")!;
2872 const body = await c.req.parseBody();
2873 const commentBody = String(body.body || "").trim();
47a7a0aClaude2874 const filePathRaw = String(body.file_path || "").trim();
2875 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
2876 const inlineFilePath = filePathRaw || undefined;
2877 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude2878
2879 if (!commentBody) {
2880 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2881 }
2882
2883 const resolved = await resolveRepo(ownerName, repoName);
2884 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2885
2886 const [pr] = await db
2887 .select()
2888 .from(pullRequests)
2889 .where(
2890 and(
2891 eq(pullRequests.repositoryId, resolved.repo.id),
2892 eq(pullRequests.number, prNum)
2893 )
2894 )
2895 .limit(1);
2896
2897 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2898
cb5a796Claude2899 const decision = await decideInitialStatus({
2900 commenterUserId: user.id,
2901 repositoryId: resolved.repo.id,
2902 kind: "pr",
2903 threadId: pr.id,
2904 });
2905
d4ac5c3Claude2906 const [inserted] = await db
2907 .insert(prComments)
2908 .values({
2909 pullRequestId: pr.id,
2910 authorId: user.id,
2911 body: commentBody,
cb5a796Claude2912 moderationStatus: decision.status,
47a7a0aClaude2913 filePath: inlineFilePath,
2914 lineNumber: inlineLineNumber,
d4ac5c3Claude2915 })
2916 .returning();
2917
cb5a796Claude2918 // Live update: only when the comment is actually visible.
2919 if (inserted && decision.status === "approved") {
d4ac5c3Claude2920 try {
2921 const { publish } = await import("../lib/sse");
2922 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
2923 event: "pr-comment",
2924 data: {
2925 pullRequestId: pr.id,
2926 commentId: inserted.id,
2927 authorId: user.id,
2928 authorUsername: user.username,
2929 },
2930 });
2931 } catch {
2932 /* SSE is best-effort */
2933 }
2934 }
0074234Claude2935
cb5a796Claude2936 if (decision.status === "pending") {
2937 void notifyOwnerOfPendingComment({
2938 repositoryId: resolved.repo.id,
2939 commenterUsername: user.username,
2940 kind: "pr",
2941 threadNumber: prNum,
2942 ownerUsername: ownerName,
2943 repoName,
2944 });
2945 return c.redirect(
2946 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2947 );
2948 }
2949 if (decision.status === "rejected") {
2950 // Silent ban path — same UX as 'pending' so we don't leak the gate.
2951 return c.redirect(
2952 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2953 );
2954 }
2955
15db0e0Claude2956 // Slash-command handoff. We always store the original comment above
2957 // first so free-form text that happens to start with `/` is preserved
2958 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude2959 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude2960 const parsed = parseSlashCommand(commentBody);
2961 if (parsed) {
2962 try {
2963 const result = await executeSlashCommand({
2964 command: parsed.command,
2965 args: parsed.args,
2966 prId: pr.id,
2967 userId: user.id,
2968 repositoryId: resolved.repo.id,
2969 });
2970 await db.insert(prComments).values({
2971 pullRequestId: pr.id,
2972 authorId: user.id,
2973 body: result.body,
2974 });
2975 } catch (err) {
2976 // Defence-in-depth — executeSlashCommand promises not to throw,
2977 // but if it ever does we want the PR thread to know.
2978 await db
2979 .insert(prComments)
2980 .values({
2981 pullRequestId: pr.id,
2982 authorId: user.id,
2983 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
2984 })
2985 .catch(() => {});
2986 }
2987 }
2988
47a7a0aClaude2989 // Inline comments go back to the files tab; conversation comments to the conversation tab
2990 const redirectTab = inlineFilePath ? "?tab=files" : "";
2991 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude2992 }
2993);
2994
e883329Claude2995// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude2996// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
2997// but we keep it at "write" for v1 so trusted collaborators can ship.
2998// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
2999// surface. Branch-protection rules (evaluated below) are the current mechanism
3000// for locking down merges further on specific branches.
0074234Claude3001pulls.post(
3002 "/:owner/:repo/pulls/:number/merge",
3003 softAuth,
3004 requireAuth,
04f6b7fClaude3005 requireRepoAccess("write"),
0074234Claude3006 async (c) => {
3007 const { owner: ownerName, repo: repoName } = c.req.param();
3008 const prNum = parseInt(c.req.param("number"), 10);
3009 const user = c.get("user")!;
3010
3011 const resolved = await resolveRepo(ownerName, repoName);
3012 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3013
3014 const [pr] = await db
3015 .select()
3016 .from(pullRequests)
3017 .where(
3018 and(
3019 eq(pullRequests.repositoryId, resolved.repo.id),
3020 eq(pullRequests.number, prNum)
3021 )
3022 )
3023 .limit(1);
3024
3025 if (!pr || pr.state !== "open") {
3026 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3027 }
3028
6fc53bdClaude3029 // Draft PRs cannot be merged — must be marked ready first.
3030 if (pr.isDraft) {
3031 return c.redirect(
3032 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3033 "This PR is a draft. Mark it as ready for review before merging."
3034 )}`
3035 );
3036 }
3037
e883329Claude3038 // Resolve head SHA
3039 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3040 if (!headSha) {
3041 return c.redirect(
3042 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
3043 );
3044 }
3045
3046 // Check if AI review approved this PR
3047 const aiComments = await db
3048 .select()
3049 .from(prComments)
3050 .where(
3051 and(
3052 eq(prComments.pullRequestId, pr.id),
3053 eq(prComments.isAiReview, true)
3054 )
3055 );
3056 const aiApproved = aiComments.length === 0 || aiComments.some(
3057 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude3058 );
e883329Claude3059
3060 // Run all green gate checks (GateTest + mergeability + AI review)
3061 const gateResult = await runAllGateChecks(
3062 ownerName,
3063 repoName,
3064 pr.baseBranch,
3065 pr.headBranch,
3066 headSha,
3067 aiApproved
0074234Claude3068 );
3069
e883329Claude3070 // If GateTest or AI review failed (hard blocks), reject the merge
3071 const hardFailures = gateResult.checks.filter(
3072 (check) => !check.passed && check.name !== "Merge check"
3073 );
3074 if (hardFailures.length > 0) {
3075 const errorMsg = hardFailures
3076 .map((f) => `${f.name}: ${f.details}`)
3077 .join("; ");
0074234Claude3078 return c.redirect(
e883329Claude3079 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude3080 );
3081 }
3082
1e162a8Claude3083 // D5 — Branch-protection enforcement. Looks up the matching rule for the
3084 // base branch and blocks the merge if requireAiApproval / requireGreenGates
3085 // / requireHumanReview / requiredApprovals are not satisfied. Independent
3086 // of repo-global settings, so owners can lock specific branches down
3087 // further than the repo default.
3088 const protectionRule = await matchProtection(
3089 resolved.repo.id,
3090 pr.baseBranch
3091 );
3092 if (protectionRule) {
3093 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude3094 const required = await listRequiredChecks(protectionRule.id);
3095 const passingNames = required.length > 0
3096 ? await passingCheckNames(resolved.repo.id, headSha)
3097 : [];
3098 const decision = evaluateProtection(
3099 protectionRule,
3100 {
3101 aiApproved,
3102 humanApprovalCount: humanApprovals,
3103 gateResultGreen: hardFailures.length === 0,
3104 hasFailedGates: hardFailures.length > 0,
3105 passingCheckNames: passingNames,
3106 },
3107 required.map((r) => r.checkName)
3108 );
1e162a8Claude3109 if (!decision.allowed) {
3110 return c.redirect(
3111 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3112 decision.reasons.join(" ")
3113 )}`
3114 );
3115 }
3116 }
3117
e883329Claude3118 // Attempt the merge — with auto conflict resolution if needed
3119 const repoDir = getRepoPath(ownerName, repoName);
3120 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
3121 const hasConflicts = mergeCheck && !mergeCheck.passed;
3122
3123 if (hasConflicts && isAiReviewEnabled()) {
3124 // Use Claude to auto-resolve conflicts
3125 const mergeResult = await mergeWithAutoResolve(
3126 ownerName,
3127 repoName,
3128 pr.baseBranch,
3129 pr.headBranch,
3130 `Merge pull request #${pr.number}: ${pr.title}`
3131 );
3132
3133 if (!mergeResult.success) {
3134 return c.redirect(
3135 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
3136 );
3137 }
3138
3139 // Post a comment about the auto-resolution
3140 if (mergeResult.resolvedFiles.length > 0) {
3141 await db.insert(prComments).values({
3142 pullRequestId: pr.id,
3143 authorId: user.id,
3144 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
3145 isAiReview: true,
3146 });
3147 }
3148 } else {
3149 // Standard merge — fast-forward or clean merge
3150 const ffProc = Bun.spawn(
3151 [
3152 "git",
3153 "update-ref",
3154 `refs/heads/${pr.baseBranch}`,
3155 `refs/heads/${pr.headBranch}`,
3156 ],
3157 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3158 );
3159 const ffExit = await ffProc.exited;
3160
3161 if (ffExit !== 0) {
3162 return c.redirect(
3163 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
3164 );
3165 }
3166 }
3167
0074234Claude3168 await db
3169 .update(pullRequests)
3170 .set({
3171 state: "merged",
3172 mergedAt: new Date(),
3173 mergedBy: user.id,
3174 updatedAt: new Date(),
3175 })
3176 .where(eq(pullRequests.id, pr.id));
3177
8809b87Claude3178 // Chat notifier — fan out merge event to Slack/Discord/Teams.
3179 import("../lib/chat-notifier")
3180 .then((m) =>
3181 m.notifyChatChannels({
3182 ownerUserId: resolved.repo.ownerId,
3183 repositoryId: resolved.repo.id,
3184 event: {
3185 event: "pr.merged",
3186 repo: `${ownerName}/${repoName}`,
3187 title: `#${pr.number} ${pr.title}`,
3188 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3189 actor: user.username,
3190 },
3191 })
3192 )
3193 .catch((err) =>
3194 console.warn(`[chat-notifier] PR merge notify failed:`, err)
3195 );
3196
d62fb36Claude3197 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
3198 // and auto-close each matching open issue with a back-link comment. Bounded
3199 // to the same repo for v1 (cross-repo refs ignored). Failures never block
3200 // the merge redirect.
3201 try {
3202 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3203 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3204 for (const n of refs) {
3205 const [issue] = await db
3206 .select()
3207 .from(issues)
3208 .where(
3209 and(
3210 eq(issues.repositoryId, resolved.repo.id),
3211 eq(issues.number, n)
3212 )
3213 )
3214 .limit(1);
3215 if (!issue || issue.state !== "open") continue;
3216 await db
3217 .update(issues)
3218 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
3219 .where(eq(issues.id, issue.id));
3220 await db.insert(issueComments).values({
3221 issueId: issue.id,
3222 authorId: user.id,
3223 body: `Closed by pull request #${pr.number}.`,
3224 });
3225 }
3226 } catch {
3227 // Never block the merge on close-keyword failures.
3228 }
3229
0074234Claude3230 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3231 }
3232);
3233
6fc53bdClaude3234// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
3235// hasn't run yet on this PR.
3236pulls.post(
3237 "/:owner/:repo/pulls/:number/ready",
3238 softAuth,
3239 requireAuth,
04f6b7fClaude3240 requireRepoAccess("write"),
6fc53bdClaude3241 async (c) => {
3242 const { owner: ownerName, repo: repoName } = c.req.param();
3243 const prNum = parseInt(c.req.param("number"), 10);
3244 const user = c.get("user")!;
3245
3246 const resolved = await resolveRepo(ownerName, repoName);
3247 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3248
3249 const [pr] = await db
3250 .select()
3251 .from(pullRequests)
3252 .where(
3253 and(
3254 eq(pullRequests.repositoryId, resolved.repo.id),
3255 eq(pullRequests.number, prNum)
3256 )
3257 )
3258 .limit(1);
3259 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3260
3261 // Only the author or repo owner can toggle draft state.
3262 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
3263 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3264 }
3265
3266 if (pr.state === "open" && pr.isDraft) {
3267 await db
3268 .update(pullRequests)
3269 .set({ isDraft: false, updatedAt: new Date() })
3270 .where(eq(pullRequests.id, pr.id));
3271
3272 if (isAiReviewEnabled()) {
3273 triggerAiReview(
3274 ownerName,
3275 repoName,
3276 pr.id,
3277 pr.title,
0316dbbClaude3278 pr.body || "",
6fc53bdClaude3279 pr.baseBranch,
3280 pr.headBranch
3281 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
3282 }
3283 }
3284
3285 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3286 }
3287);
3288
3289// Convert a PR back to draft.
3290pulls.post(
3291 "/:owner/:repo/pulls/:number/draft",
3292 softAuth,
3293 requireAuth,
04f6b7fClaude3294 requireRepoAccess("write"),
6fc53bdClaude3295 async (c) => {
3296 const { owner: ownerName, repo: repoName } = c.req.param();
3297 const prNum = parseInt(c.req.param("number"), 10);
3298 const user = c.get("user")!;
3299
3300 const resolved = await resolveRepo(ownerName, repoName);
3301 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3302
3303 const [pr] = await db
3304 .select()
3305 .from(pullRequests)
3306 .where(
3307 and(
3308 eq(pullRequests.repositoryId, resolved.repo.id),
3309 eq(pullRequests.number, prNum)
3310 )
3311 )
3312 .limit(1);
3313 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3314
3315 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
3316 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3317 }
3318
3319 if (pr.state === "open" && !pr.isDraft) {
3320 await db
3321 .update(pullRequests)
3322 .set({ isDraft: true, updatedAt: new Date() })
3323 .where(eq(pullRequests.id, pr.id));
3324 }
3325
3326 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3327 }
3328);
3329
0074234Claude3330// Close PR
3331pulls.post(
3332 "/:owner/:repo/pulls/:number/close",
3333 softAuth,
3334 requireAuth,
04f6b7fClaude3335 requireRepoAccess("write"),
0074234Claude3336 async (c) => {
3337 const { owner: ownerName, repo: repoName } = c.req.param();
3338 const prNum = parseInt(c.req.param("number"), 10);
3339
3340 const resolved = await resolveRepo(ownerName, repoName);
3341 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3342
3343 await db
3344 .update(pullRequests)
3345 .set({
3346 state: "closed",
3347 closedAt: new Date(),
3348 updatedAt: new Date(),
3349 })
3350 .where(
3351 and(
3352 eq(pullRequests.repositoryId, resolved.repo.id),
3353 eq(pullRequests.number, prNum)
3354 )
3355 );
3356
3357 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3358 }
3359);
3360
c3e0c07Claude3361// Re-run AI review on demand (e.g. after a force-push). Bypasses the
3362// idempotency marker via { force: true }. Write-access only.
3363pulls.post(
3364 "/:owner/:repo/pulls/:number/ai-rereview",
3365 softAuth,
3366 requireAuth,
3367 requireRepoAccess("write"),
3368 async (c) => {
3369 const { owner: ownerName, repo: repoName } = c.req.param();
3370 const prNum = parseInt(c.req.param("number"), 10);
3371 const resolved = await resolveRepo(ownerName, repoName);
3372 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3373
3374 const [pr] = await db
3375 .select()
3376 .from(pullRequests)
3377 .where(
3378 and(
3379 eq(pullRequests.repositoryId, resolved.repo.id),
3380 eq(pullRequests.number, prNum)
3381 )
3382 )
3383 .limit(1);
3384 if (!pr) {
3385 return c.redirect(`/${ownerName}/${repoName}/pulls`);
3386 }
3387
3388 if (!isAiReviewEnabled()) {
3389 return c.redirect(
3390 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3391 "AI review is not configured (ANTHROPIC_API_KEY)."
3392 )}`
3393 );
3394 }
3395
3396 // Fire-and-forget but with { force: true } to bypass the
3397 // already-reviewed marker. The function still never throws.
3398 triggerAiReview(
3399 ownerName,
3400 repoName,
3401 pr.id,
3402 pr.title || "",
3403 pr.body || "",
3404 pr.baseBranch,
3405 pr.headBranch,
3406 { force: true }
a28cedeClaude3407 ).catch((err) => {
3408 console.warn(
3409 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
3410 err instanceof Error ? err.message : err
3411 );
3412 });
c3e0c07Claude3413
3414 return c.redirect(
3415 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
3416 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
3417 )}`
3418 );
3419 }
3420);
3421
1d4ff60Claude3422// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
3423// the PR's head branch carrying just the new test files. Write-access only.
3424// Idempotent — if `ai:added-tests` was previously applied we redirect with
3425// an `info` banner instead of re-firing.
3426pulls.post(
3427 "/:owner/:repo/pulls/:number/generate-tests",
3428 softAuth,
3429 requireAuth,
3430 requireRepoAccess("write"),
3431 async (c) => {
3432 const { owner: ownerName, repo: repoName } = c.req.param();
3433 const prNum = parseInt(c.req.param("number"), 10);
3434 const resolved = await resolveRepo(ownerName, repoName);
3435 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3436
3437 const [pr] = await db
3438 .select()
3439 .from(pullRequests)
3440 .where(
3441 and(
3442 eq(pullRequests.repositoryId, resolved.repo.id),
3443 eq(pullRequests.number, prNum)
3444 )
3445 )
3446 .limit(1);
3447 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3448
3449 if (!isAiReviewEnabled()) {
3450 return c.redirect(
3451 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3452 "AI test generation is not configured (ANTHROPIC_API_KEY)."
3453 )}`
3454 );
3455 }
3456
3457 // Fire-and-forget. The lib never throws.
3458 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
3459 .then((res) => {
3460 if (!res.ok) {
3461 console.warn(
3462 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
3463 );
3464 }
3465 })
3466 .catch((err) => {
3467 console.warn(
3468 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
3469 err instanceof Error ? err.message : err
3470 );
3471 });
3472
3473 return c.redirect(
3474 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
3475 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
3476 )}`
3477 );
3478 }
3479);
3480
0074234Claude3481export default pulls;