Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame3437 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";
ea9ed4cClaude30import { DiffView } 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
0074234Claude2275 // Get diff for "Files changed" tab
2276 let diffRaw = "";
2277 let diffFiles: GitDiffFile[] = [];
2278 if (tab === "files") {
2279 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude2280 // Run the two git diffs in parallel — they're independent reads of
2281 // the same range. Previously sequential, doubling the wall time on
2282 // big PRs (100+ files = 10-30s for no reason).
0074234Claude2283 const proc = Bun.spawn(
2284 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
2285 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2286 );
2287 const statProc = Bun.spawn(
2288 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
2289 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2290 );
6ea2109Claude2291 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
2292 // would otherwise hang the whole request.
2293 const killer = setTimeout(() => {
2294 proc.kill();
2295 statProc.kill();
2296 }, 30_000);
2297 let stat = "";
2298 try {
2299 [diffRaw, stat] = await Promise.all([
2300 new Response(proc.stdout).text(),
2301 new Response(statProc.stdout).text(),
2302 ]);
2303 await Promise.all([proc.exited, statProc.exited]);
2304 } finally {
2305 clearTimeout(killer);
2306 }
0074234Claude2307
2308 diffFiles = stat
2309 .trim()
2310 .split("\n")
2311 .filter(Boolean)
2312 .map((line) => {
2313 const [add, del, filePath] = line.split("\t");
2314 return {
2315 path: filePath,
2316 status: "modified",
2317 additions: add === "-" ? 0 : parseInt(add, 10),
2318 deletions: del === "-" ? 0 : parseInt(del, 10),
2319 patch: "",
2320 };
2321 });
2322 }
2323
b078860Claude2324 // ─── Derived visual state ───
2325 const stateKey =
2326 pr.state === "open"
2327 ? pr.isDraft
2328 ? "draft"
2329 : "open"
2330 : pr.state;
2331 const stateLabel =
2332 stateKey === "open"
2333 ? "Open"
2334 : stateKey === "draft"
2335 ? "Draft"
2336 : stateKey === "merged"
2337 ? "Merged"
2338 : "Closed";
2339 const stateIcon =
2340 stateKey === "open"
2341 ? "○"
2342 : stateKey === "draft"
2343 ? "◌"
2344 : stateKey === "merged"
2345 ? "⮌"
2346 : "✓";
2347 const commentCount = comments.length;
2348 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
2349 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
2350 const mergeBlocked =
2351 gateChecks.length > 0 &&
2352 gateChecks.some(
2353 (c) => !c.passed && c.name !== "Merge check"
2354 );
2355
0074234Claude2356 return c.html(
2357 <Layout
2358 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
2359 user={user}
2360 >
2361 <RepoHeader owner={ownerName} repo={repoName} />
2362 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2363 <PendingCommentsBanner
2364 owner={ownerName}
2365 repo={repoName}
2366 count={prPendingCount}
2367 />
b078860Claude2368 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude2369 <div
2370 id="live-comment-banner"
2371 class="alert"
2372 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
2373 >
2374 <strong class="js-live-count">0</strong> new comment(s) —{" "}
2375 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
2376 reload to view
2377 </a>
2378 </div>
2379 <script
2380 dangerouslySetInnerHTML={{
2381 __html: liveCommentBannerScript({
2382 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
2383 bannerElementId: "live-comment-banner",
2384 }),
2385 }}
2386 />
b078860Claude2387
2388 <div class="prs-detail-hero">
2389 <h1 class="prs-detail-title">
0074234Claude2390 {pr.title}{" "}
b078860Claude2391 <span class="prs-detail-num">#{pr.number}</span>
2392 </h1>
2393 <div class="prs-detail-meta">
2394 <span class={`prs-state-pill state-${stateKey}`}>
2395 <span aria-hidden="true">{stateIcon}</span>
2396 <span>{stateLabel}</span>
2397 </span>
2398 <span>
2399 <strong>{author?.username}</strong> wants to merge
2400 </span>
2401 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
2402 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
2403 <span class="prs-branch-arrow-lg">{"→"}</span>
2404 <span class="prs-branch-pill">{pr.baseBranch}</span>
2405 </span>
2406 <span>opened {formatRelative(pr.createdAt)}</span>
3c03977Claude2407 <span
2408 id="live-pill"
2409 class="live-pill"
2410 title="People editing this PR right now"
2411 >
2412 <span class="live-pill-dot" aria-hidden="true"></span>
2413 <span>
2414 Live: <strong id="live-count">0</strong> editing
2415 </span>
2416 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
2417 </span>
4bbacbeClaude2418 {preview && (
2419 <a
2420 class={`preview-prpill is-${preview.status}`}
2421 href={
2422 preview.status === "ready"
2423 ? preview.previewUrl
2424 : `/${ownerName}/${repoName}/previews`
2425 }
2426 target={preview.status === "ready" ? "_blank" : undefined}
2427 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
2428 title={`Preview · ${previewStatusLabel(preview.status)}`}
2429 >
2430 <span class="preview-prpill-dot" aria-hidden="true"></span>
2431 <span>Preview: </span>
2432 <span>{previewStatusLabel(preview.status)}</span>
2433 </a>
2434 )}
b078860Claude2435 {canManage && pr.state === "open" && pr.isDraft && (
2436 <form
2437 method="post"
2438 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2439 class="prs-inline-form prs-detail-actions"
2440 >
2441 <button type="submit" class="prs-merge-ready-btn">
2442 Ready for review
2443 </button>
2444 </form>
2445 )}
2446 </div>
2447 </div>
3c03977Claude2448 <script
2449 dangerouslySetInnerHTML={{
2450 __html: LIVE_COEDIT_SCRIPT(pr.id),
2451 }}
2452 />
0074234Claude2453
b078860Claude2454 <nav class="prs-detail-tabs" aria-label="Pull request sections">
2455 <a
2456 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
2457 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2458 >
2459 Conversation
2460 <span class="prs-detail-tab-count">{commentCount}</span>
2461 </a>
2462 <a
2463 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
2464 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2465 >
2466 Files changed
2467 {diffFiles.length > 0 && (
2468 <span class="prs-detail-tab-count">{diffFiles.length}</span>
2469 )}
2470 </a>
2471 </nav>
2472
2473 {tab === "files" ? (
ea9ed4cClaude2474 <DiffView
2475 raw={diffRaw}
2476 files={diffFiles}
2477 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
2478 />
b078860Claude2479 ) : (
2480 <>
2481 {pr.body && (
2482 <CommentBox
2483 author={author?.username ?? "unknown"}
2484 date={pr.createdAt}
2485 body={renderMarkdown(pr.body)}
2486 />
2487 )}
2488
422a2d4Claude2489 {/* Block H — AI trio review (security/correctness/style). When
2490 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
2491 hoisted into a 3-column card grid above the normal comment
2492 stream so reviewers see verdicts at a glance. Disagreements
2493 are surfaced as a yellow callout. */}
2494 <TrioReviewGrid
2495 comments={comments.map(({ comment }) => comment)}
2496 />
2497
15db0e0Claude2498 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude2499 // Skip trio comments — already rendered in TrioReviewGrid above.
2500 if (isTrioComment(comment.body)) return null;
15db0e0Claude2501 const slashCmd = detectSlashCmdComment(comment.body);
2502 if (slashCmd) {
2503 const visible = stripSlashCmdMarker(comment.body);
2504 return (
2505 <div class={`slash-pill slash-cmd-${slashCmd}`}>
2506 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
2507 <span class="slash-pill-actor">
2508 <strong>{commentAuthor.username}</strong>
2509 {" ran "}
2510 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude2511 </span>
15db0e0Claude2512 <span class="slash-pill-time">
2513 {formatRelative(comment.createdAt)}
2514 </span>
2515 <div class="slash-pill-body">
2516 <MarkdownContent html={renderMarkdown(visible)} />
2517 </div>
2518 </div>
2519 );
2520 }
cb5a796Claude2521 const isPending = comment.moderationStatus === "pending";
15db0e0Claude2522 return (
cb5a796Claude2523 <div
2524 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
2525 >
15db0e0Claude2526 <div class="prs-comment-head">
2527 <strong>{commentAuthor.username}</strong>
2528 {comment.isAiReview && (
2529 <span class="prs-ai-badge">AI Review</span>
2530 )}
cb5a796Claude2531 {isPending && (
2532 <span
2533 class="modq-pending-badge"
2534 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
2535 >
2536 Awaiting approval
2537 </span>
2538 )}
15db0e0Claude2539 <span class="prs-comment-time">
2540 commented {formatRelative(comment.createdAt)}
2541 </span>
2542 {comment.filePath && (
2543 <span class="prs-comment-loc">
2544 {comment.filePath}
2545 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
2546 </span>
2547 )}
2548 </div>
2549 <div class="prs-comment-body">
2550 <MarkdownContent html={renderMarkdown(comment.body)} />
2551 </div>
0074234Claude2552 </div>
15db0e0Claude2553 );
2554 })}
0074234Claude2555
b078860Claude2556 {/* Quick link to the Files changed tab when there's a diff to look at. */}
2557 {pr.state !== "merged" && (
2558 <a
2559 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2560 class="prs-files-card"
2561 >
2562 <span class="prs-files-card-icon" aria-hidden="true">
2563 {"▤"}
2564 </span>
2565 <div class="prs-files-card-text">
2566 <p class="prs-files-card-title">Files changed</p>
2567 <p class="prs-files-card-sub">
2568 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
2569 </p>
e883329Claude2570 </div>
b078860Claude2571 <span class="prs-files-card-cta">View diff {"→"}</span>
2572 </a>
2573 )}
2574
2575 {error && (
2576 <div
2577 class="auth-error"
2578 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)"
2579 >
2580 {decodeURIComponent(error)}
2581 </div>
2582 )}
2583
2584 {info && (
2585 <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)">
2586 {decodeURIComponent(info)}
2587 </div>
2588 )}
e883329Claude2589
b078860Claude2590 {pr.state === "open" && (prRisk || prRiskCalculating) && (
2591 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
2592 )}
2593
2594 {pr.state === "open" && gateChecks.length > 0 && (
2595 <div class="prs-gate-card">
2596 <div class="prs-gate-head">
2597 <h3>Gate checks</h3>
2598 <span class="prs-gate-summary">
2599 {gatesAllPassed
2600 ? `All ${gateChecks.length} checks passed`
2601 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
2602 </span>
c3e0c07Claude2603 </div>
b078860Claude2604 {gateChecks.map((check) => {
2605 const isAi = /ai.*review/i.test(check.name);
2606 const isSkip = check.skipped === true;
2607 const statusClass = isSkip
2608 ? "is-skip"
2609 : check.passed
2610 ? "is-pass"
2611 : "is-fail";
2612 const statusGlyph = isSkip
2613 ? "—"
2614 : check.passed
2615 ? "✓"
2616 : "✗";
2617 const statusLabel = isSkip
2618 ? "Skipped"
2619 : check.passed
2620 ? "Passed"
2621 : "Failing";
2622 return (
2623 <div
2624 class="prs-gate-row"
2625 style={
2626 isAi
2627 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
2628 : ""
2629 }
2630 >
2631 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
2632 {statusGlyph}
2633 </span>
2634 <span class="prs-gate-name">
2635 {check.name}
2636 {isAi && (
2637 <span
2638 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"
2639 >
2640 AI
2641 </span>
2642 )}
2643 </span>
2644 <span class="prs-gate-details">{check.details}</span>
2645 <span class={`prs-gate-pill ${statusClass}`}>
2646 {statusLabel}
e883329Claude2647 </span>
2648 </div>
b078860Claude2649 );
2650 })}
2651 <div class="prs-gate-footer">
2652 {gatesAllPassed
2653 ? "All checks passed — ready to merge."
2654 : gateChecks.some(
2655 (c) => !c.passed && c.name === "Merge check"
2656 )
2657 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
2658 : "Some checks failed — resolve issues before merging."}
2659 {aiReviewCount > 0 && (
2660 <>
2661 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
2662 </>
2663 )}
2664 </div>
2665 </div>
2666 )}
2667
2668 {/* ─── Merge area / state-aware action card ─────────────── */}
2669 {user && pr.state === "open" && (
2670 <div
2671 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
2672 >
2673 <div class="prs-merge-head">
2674 <strong>
2675 {pr.isDraft
2676 ? "Draft — ready for review?"
2677 : mergeBlocked
2678 ? "Merge blocked"
2679 : "Ready to merge"}
2680 </strong>
e883329Claude2681 </div>
b078860Claude2682 <p class="prs-merge-sub">
2683 {pr.isDraft
2684 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
2685 : mergeBlocked
2686 ? "Resolve the failing gate checks above before this PR can land."
2687 : gateChecks.length > 0
2688 ? gatesAllPassed
2689 ? "All gates green. Merge will fast-forward into the base branch."
2690 : "Conflicts will be auto-resolved by GlueCron AI on merge."
2691 : "Run gate checks by refreshing once your branch has a recent commit."}
2692 </p>
2693 <Form
2694 method="post"
2695 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
2696 >
2697 <FormGroup>
3c03977Claude2698 <div class="live-cursor-host" style="position:relative">
2699 <textarea
2700 name="body"
2701 id="pr-comment-body"
2702 data-live-field="comment_new"
2703 rows={5}
2704 required
2705 placeholder="Leave a comment... (Markdown supported)"
2706 style="font-family:var(--font-mono);font-size:13px;width:100%"
2707 ></textarea>
2708 </div>
15db0e0Claude2709 <span class="slash-hint" title="Type a slash-command as the first line">
2710 Type <code>/</code> for commands —{" "}
2711 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
2712 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
2713 </span>
b078860Claude2714 </FormGroup>
2715 <div class="prs-merge-actions">
2716 <Button type="submit" variant="primary">
2717 Comment
2718 </Button>
2719 {canManage && (
2720 <>
2721 {pr.isDraft ? (
2722 <button
2723 type="submit"
2724 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2725 formnovalidate
2726 class="prs-merge-ready-btn"
2727 >
2728 Ready for review
2729 </button>
2730 ) : (
0074234Claude2731 <button
2732 type="submit"
2733 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
b078860Claude2734 formnovalidate
2735 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
2736 title={
2737 mergeBlocked
2738 ? "Failing gate checks must be resolved before this PR can merge."
2739 : "Merge pull request"
2740 }
0074234Claude2741 >
b078860Claude2742 {"✔"} Merge pull request
0074234Claude2743 </button>
b078860Claude2744 )}
2745 {!pr.isDraft && (
2746 <button
0074234Claude2747 type="submit"
b078860Claude2748 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
2749 formnovalidate
2750 class="prs-merge-back-draft"
2751 title="Convert back to draft"
0074234Claude2752 >
b078860Claude2753 Convert to draft
2754 </button>
2755 )}
2756 {isAiReviewEnabled() && (
2757 <button
2758 type="submit"
2759 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
2760 formnovalidate
2761 class="btn"
2762 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
2763 >
2764 Re-run AI review
2765 </button>
2766 )}
1d4ff60Claude2767 {isAiReviewEnabled() && !hasAiTestsMarker && (
2768 <button
2769 type="submit"
2770 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
2771 formnovalidate
2772 class="btn"
2773 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."
2774 >
2775 Generate tests with AI
2776 </button>
2777 )}
b078860Claude2778 <Button
2779 type="submit"
2780 variant="danger"
2781 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
2782 >
2783 Close
2784 </Button>
2785 </>
2786 )}
2787 </div>
2788 </Form>
2789 </div>
2790 )}
2791
2792 {/* Read-only footers for non-open states. */}
2793 {pr.state === "merged" && (
2794 <div class="prs-merge-card is-merged">
2795 <div class="prs-merge-head">
2796 <strong>{"⮌"} Merged</strong>
0074234Claude2797 </div>
b078860Claude2798 <p class="prs-merge-sub">
2799 This pull request was merged into{" "}
2800 <code>{pr.baseBranch}</code>.
2801 </p>
2802 </div>
2803 )}
2804 {pr.state === "closed" && (
2805 <div class="prs-merge-card is-closed">
2806 <div class="prs-merge-head">
2807 <strong>{"✕"} Closed without merging</strong>
2808 </div>
2809 <p class="prs-merge-sub">
2810 This pull request was closed and not merged.
2811 </p>
2812 </div>
2813 )}
2814 </>
2815 )}
0074234Claude2816 </Layout>
2817 );
2818});
2819
cb5a796Claude2820// Add comment to PR.
2821//
2822// Permission model mirrors `issues.tsx`: any logged-in user with read
2823// access can submit; `decideInitialStatus` routes non-collaborators
2824// through the moderation queue. Slash commands only fire when the
2825// comment is auto-approved — we don't want a banned/pending comment to
2826// silently trigger AI work on the PR.
0074234Claude2827pulls.post(
2828 "/:owner/:repo/pulls/:number/comment",
2829 softAuth,
2830 requireAuth,
cb5a796Claude2831 requireRepoAccess("read"),
0074234Claude2832 async (c) => {
2833 const { owner: ownerName, repo: repoName } = c.req.param();
2834 const prNum = parseInt(c.req.param("number"), 10);
2835 const user = c.get("user")!;
2836 const body = await c.req.parseBody();
2837 const commentBody = String(body.body || "").trim();
2838
2839 if (!commentBody) {
2840 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2841 }
2842
2843 const resolved = await resolveRepo(ownerName, repoName);
2844 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2845
2846 const [pr] = await db
2847 .select()
2848 .from(pullRequests)
2849 .where(
2850 and(
2851 eq(pullRequests.repositoryId, resolved.repo.id),
2852 eq(pullRequests.number, prNum)
2853 )
2854 )
2855 .limit(1);
2856
2857 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2858
cb5a796Claude2859 const decision = await decideInitialStatus({
2860 commenterUserId: user.id,
2861 repositoryId: resolved.repo.id,
2862 kind: "pr",
2863 threadId: pr.id,
2864 });
2865
d4ac5c3Claude2866 const [inserted] = await db
2867 .insert(prComments)
2868 .values({
2869 pullRequestId: pr.id,
2870 authorId: user.id,
2871 body: commentBody,
cb5a796Claude2872 moderationStatus: decision.status,
d4ac5c3Claude2873 })
2874 .returning();
2875
cb5a796Claude2876 // Live update: only when the comment is actually visible.
2877 if (inserted && decision.status === "approved") {
d4ac5c3Claude2878 try {
2879 const { publish } = await import("../lib/sse");
2880 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
2881 event: "pr-comment",
2882 data: {
2883 pullRequestId: pr.id,
2884 commentId: inserted.id,
2885 authorId: user.id,
2886 authorUsername: user.username,
2887 },
2888 });
2889 } catch {
2890 /* SSE is best-effort */
2891 }
2892 }
0074234Claude2893
cb5a796Claude2894 if (decision.status === "pending") {
2895 void notifyOwnerOfPendingComment({
2896 repositoryId: resolved.repo.id,
2897 commenterUsername: user.username,
2898 kind: "pr",
2899 threadNumber: prNum,
2900 ownerUsername: ownerName,
2901 repoName,
2902 });
2903 return c.redirect(
2904 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2905 );
2906 }
2907 if (decision.status === "rejected") {
2908 // Silent ban path — same UX as 'pending' so we don't leak the gate.
2909 return c.redirect(
2910 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
2911 );
2912 }
2913
15db0e0Claude2914 // Slash-command handoff. We always store the original comment above
2915 // first so free-form text that happens to start with `/` is preserved
2916 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude2917 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude2918 const parsed = parseSlashCommand(commentBody);
2919 if (parsed) {
2920 try {
2921 const result = await executeSlashCommand({
2922 command: parsed.command,
2923 args: parsed.args,
2924 prId: pr.id,
2925 userId: user.id,
2926 repositoryId: resolved.repo.id,
2927 });
2928 await db.insert(prComments).values({
2929 pullRequestId: pr.id,
2930 authorId: user.id,
2931 body: result.body,
2932 });
2933 } catch (err) {
2934 // Defence-in-depth — executeSlashCommand promises not to throw,
2935 // but if it ever does we want the PR thread to know.
2936 await db
2937 .insert(prComments)
2938 .values({
2939 pullRequestId: pr.id,
2940 authorId: user.id,
2941 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
2942 })
2943 .catch(() => {});
2944 }
2945 }
2946
0074234Claude2947 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2948 }
2949);
2950
e883329Claude2951// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude2952// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
2953// but we keep it at "write" for v1 so trusted collaborators can ship.
2954// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
2955// surface. Branch-protection rules (evaluated below) are the current mechanism
2956// for locking down merges further on specific branches.
0074234Claude2957pulls.post(
2958 "/:owner/:repo/pulls/:number/merge",
2959 softAuth,
2960 requireAuth,
04f6b7fClaude2961 requireRepoAccess("write"),
0074234Claude2962 async (c) => {
2963 const { owner: ownerName, repo: repoName } = c.req.param();
2964 const prNum = parseInt(c.req.param("number"), 10);
2965 const user = c.get("user")!;
2966
2967 const resolved = await resolveRepo(ownerName, repoName);
2968 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2969
2970 const [pr] = await db
2971 .select()
2972 .from(pullRequests)
2973 .where(
2974 and(
2975 eq(pullRequests.repositoryId, resolved.repo.id),
2976 eq(pullRequests.number, prNum)
2977 )
2978 )
2979 .limit(1);
2980
2981 if (!pr || pr.state !== "open") {
2982 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2983 }
2984
6fc53bdClaude2985 // Draft PRs cannot be merged — must be marked ready first.
2986 if (pr.isDraft) {
2987 return c.redirect(
2988 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2989 "This PR is a draft. Mark it as ready for review before merging."
2990 )}`
2991 );
2992 }
2993
e883329Claude2994 // Resolve head SHA
2995 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2996 if (!headSha) {
2997 return c.redirect(
2998 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
2999 );
3000 }
3001
3002 // Check if AI review approved this PR
3003 const aiComments = await db
3004 .select()
3005 .from(prComments)
3006 .where(
3007 and(
3008 eq(prComments.pullRequestId, pr.id),
3009 eq(prComments.isAiReview, true)
3010 )
3011 );
3012 const aiApproved = aiComments.length === 0 || aiComments.some(
3013 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude3014 );
e883329Claude3015
3016 // Run all green gate checks (GateTest + mergeability + AI review)
3017 const gateResult = await runAllGateChecks(
3018 ownerName,
3019 repoName,
3020 pr.baseBranch,
3021 pr.headBranch,
3022 headSha,
3023 aiApproved
0074234Claude3024 );
3025
e883329Claude3026 // If GateTest or AI review failed (hard blocks), reject the merge
3027 const hardFailures = gateResult.checks.filter(
3028 (check) => !check.passed && check.name !== "Merge check"
3029 );
3030 if (hardFailures.length > 0) {
3031 const errorMsg = hardFailures
3032 .map((f) => `${f.name}: ${f.details}`)
3033 .join("; ");
0074234Claude3034 return c.redirect(
e883329Claude3035 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude3036 );
3037 }
3038
1e162a8Claude3039 // D5 — Branch-protection enforcement. Looks up the matching rule for the
3040 // base branch and blocks the merge if requireAiApproval / requireGreenGates
3041 // / requireHumanReview / requiredApprovals are not satisfied. Independent
3042 // of repo-global settings, so owners can lock specific branches down
3043 // further than the repo default.
3044 const protectionRule = await matchProtection(
3045 resolved.repo.id,
3046 pr.baseBranch
3047 );
3048 if (protectionRule) {
3049 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude3050 const required = await listRequiredChecks(protectionRule.id);
3051 const passingNames = required.length > 0
3052 ? await passingCheckNames(resolved.repo.id, headSha)
3053 : [];
3054 const decision = evaluateProtection(
3055 protectionRule,
3056 {
3057 aiApproved,
3058 humanApprovalCount: humanApprovals,
3059 gateResultGreen: hardFailures.length === 0,
3060 hasFailedGates: hardFailures.length > 0,
3061 passingCheckNames: passingNames,
3062 },
3063 required.map((r) => r.checkName)
3064 );
1e162a8Claude3065 if (!decision.allowed) {
3066 return c.redirect(
3067 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3068 decision.reasons.join(" ")
3069 )}`
3070 );
3071 }
3072 }
3073
e883329Claude3074 // Attempt the merge — with auto conflict resolution if needed
3075 const repoDir = getRepoPath(ownerName, repoName);
3076 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
3077 const hasConflicts = mergeCheck && !mergeCheck.passed;
3078
3079 if (hasConflicts && isAiReviewEnabled()) {
3080 // Use Claude to auto-resolve conflicts
3081 const mergeResult = await mergeWithAutoResolve(
3082 ownerName,
3083 repoName,
3084 pr.baseBranch,
3085 pr.headBranch,
3086 `Merge pull request #${pr.number}: ${pr.title}`
3087 );
3088
3089 if (!mergeResult.success) {
3090 return c.redirect(
3091 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
3092 );
3093 }
3094
3095 // Post a comment about the auto-resolution
3096 if (mergeResult.resolvedFiles.length > 0) {
3097 await db.insert(prComments).values({
3098 pullRequestId: pr.id,
3099 authorId: user.id,
3100 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
3101 isAiReview: true,
3102 });
3103 }
3104 } else {
3105 // Standard merge — fast-forward or clean merge
3106 const ffProc = Bun.spawn(
3107 [
3108 "git",
3109 "update-ref",
3110 `refs/heads/${pr.baseBranch}`,
3111 `refs/heads/${pr.headBranch}`,
3112 ],
3113 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3114 );
3115 const ffExit = await ffProc.exited;
3116
3117 if (ffExit !== 0) {
3118 return c.redirect(
3119 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
3120 );
3121 }
3122 }
3123
0074234Claude3124 await db
3125 .update(pullRequests)
3126 .set({
3127 state: "merged",
3128 mergedAt: new Date(),
3129 mergedBy: user.id,
3130 updatedAt: new Date(),
3131 })
3132 .where(eq(pullRequests.id, pr.id));
3133
8809b87Claude3134 // Chat notifier — fan out merge event to Slack/Discord/Teams.
3135 import("../lib/chat-notifier")
3136 .then((m) =>
3137 m.notifyChatChannels({
3138 ownerUserId: resolved.repo.ownerId,
3139 repositoryId: resolved.repo.id,
3140 event: {
3141 event: "pr.merged",
3142 repo: `${ownerName}/${repoName}`,
3143 title: `#${pr.number} ${pr.title}`,
3144 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3145 actor: user.username,
3146 },
3147 })
3148 )
3149 .catch((err) =>
3150 console.warn(`[chat-notifier] PR merge notify failed:`, err)
3151 );
3152
d62fb36Claude3153 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
3154 // and auto-close each matching open issue with a back-link comment. Bounded
3155 // to the same repo for v1 (cross-repo refs ignored). Failures never block
3156 // the merge redirect.
3157 try {
3158 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3159 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3160 for (const n of refs) {
3161 const [issue] = await db
3162 .select()
3163 .from(issues)
3164 .where(
3165 and(
3166 eq(issues.repositoryId, resolved.repo.id),
3167 eq(issues.number, n)
3168 )
3169 )
3170 .limit(1);
3171 if (!issue || issue.state !== "open") continue;
3172 await db
3173 .update(issues)
3174 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
3175 .where(eq(issues.id, issue.id));
3176 await db.insert(issueComments).values({
3177 issueId: issue.id,
3178 authorId: user.id,
3179 body: `Closed by pull request #${pr.number}.`,
3180 });
3181 }
3182 } catch {
3183 // Never block the merge on close-keyword failures.
3184 }
3185
0074234Claude3186 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3187 }
3188);
3189
6fc53bdClaude3190// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
3191// hasn't run yet on this PR.
3192pulls.post(
3193 "/:owner/:repo/pulls/:number/ready",
3194 softAuth,
3195 requireAuth,
04f6b7fClaude3196 requireRepoAccess("write"),
6fc53bdClaude3197 async (c) => {
3198 const { owner: ownerName, repo: repoName } = c.req.param();
3199 const prNum = parseInt(c.req.param("number"), 10);
3200 const user = c.get("user")!;
3201
3202 const resolved = await resolveRepo(ownerName, repoName);
3203 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3204
3205 const [pr] = await db
3206 .select()
3207 .from(pullRequests)
3208 .where(
3209 and(
3210 eq(pullRequests.repositoryId, resolved.repo.id),
3211 eq(pullRequests.number, prNum)
3212 )
3213 )
3214 .limit(1);
3215 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3216
3217 // Only the author or repo owner can toggle draft state.
3218 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
3219 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3220 }
3221
3222 if (pr.state === "open" && pr.isDraft) {
3223 await db
3224 .update(pullRequests)
3225 .set({ isDraft: false, updatedAt: new Date() })
3226 .where(eq(pullRequests.id, pr.id));
3227
3228 if (isAiReviewEnabled()) {
3229 triggerAiReview(
3230 ownerName,
3231 repoName,
3232 pr.id,
3233 pr.title,
0316dbbClaude3234 pr.body || "",
6fc53bdClaude3235 pr.baseBranch,
3236 pr.headBranch
3237 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
3238 }
3239 }
3240
3241 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3242 }
3243);
3244
3245// Convert a PR back to draft.
3246pulls.post(
3247 "/:owner/:repo/pulls/:number/draft",
3248 softAuth,
3249 requireAuth,
04f6b7fClaude3250 requireRepoAccess("write"),
6fc53bdClaude3251 async (c) => {
3252 const { owner: ownerName, repo: repoName } = c.req.param();
3253 const prNum = parseInt(c.req.param("number"), 10);
3254 const user = c.get("user")!;
3255
3256 const resolved = await resolveRepo(ownerName, repoName);
3257 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3258
3259 const [pr] = await db
3260 .select()
3261 .from(pullRequests)
3262 .where(
3263 and(
3264 eq(pullRequests.repositoryId, resolved.repo.id),
3265 eq(pullRequests.number, prNum)
3266 )
3267 )
3268 .limit(1);
3269 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3270
3271 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
3272 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3273 }
3274
3275 if (pr.state === "open" && !pr.isDraft) {
3276 await db
3277 .update(pullRequests)
3278 .set({ isDraft: true, updatedAt: new Date() })
3279 .where(eq(pullRequests.id, pr.id));
3280 }
3281
3282 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3283 }
3284);
3285
0074234Claude3286// Close PR
3287pulls.post(
3288 "/:owner/:repo/pulls/:number/close",
3289 softAuth,
3290 requireAuth,
04f6b7fClaude3291 requireRepoAccess("write"),
0074234Claude3292 async (c) => {
3293 const { owner: ownerName, repo: repoName } = c.req.param();
3294 const prNum = parseInt(c.req.param("number"), 10);
3295
3296 const resolved = await resolveRepo(ownerName, repoName);
3297 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3298
3299 await db
3300 .update(pullRequests)
3301 .set({
3302 state: "closed",
3303 closedAt: new Date(),
3304 updatedAt: new Date(),
3305 })
3306 .where(
3307 and(
3308 eq(pullRequests.repositoryId, resolved.repo.id),
3309 eq(pullRequests.number, prNum)
3310 )
3311 );
3312
3313 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3314 }
3315);
3316
c3e0c07Claude3317// Re-run AI review on demand (e.g. after a force-push). Bypasses the
3318// idempotency marker via { force: true }. Write-access only.
3319pulls.post(
3320 "/:owner/:repo/pulls/:number/ai-rereview",
3321 softAuth,
3322 requireAuth,
3323 requireRepoAccess("write"),
3324 async (c) => {
3325 const { owner: ownerName, repo: repoName } = c.req.param();
3326 const prNum = parseInt(c.req.param("number"), 10);
3327 const resolved = await resolveRepo(ownerName, repoName);
3328 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3329
3330 const [pr] = await db
3331 .select()
3332 .from(pullRequests)
3333 .where(
3334 and(
3335 eq(pullRequests.repositoryId, resolved.repo.id),
3336 eq(pullRequests.number, prNum)
3337 )
3338 )
3339 .limit(1);
3340 if (!pr) {
3341 return c.redirect(`/${ownerName}/${repoName}/pulls`);
3342 }
3343
3344 if (!isAiReviewEnabled()) {
3345 return c.redirect(
3346 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3347 "AI review is not configured (ANTHROPIC_API_KEY)."
3348 )}`
3349 );
3350 }
3351
3352 // Fire-and-forget but with { force: true } to bypass the
3353 // already-reviewed marker. The function still never throws.
3354 triggerAiReview(
3355 ownerName,
3356 repoName,
3357 pr.id,
3358 pr.title || "",
3359 pr.body || "",
3360 pr.baseBranch,
3361 pr.headBranch,
3362 { force: true }
a28cedeClaude3363 ).catch((err) => {
3364 console.warn(
3365 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
3366 err instanceof Error ? err.message : err
3367 );
3368 });
c3e0c07Claude3369
3370 return c.redirect(
3371 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
3372 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
3373 )}`
3374 );
3375 }
3376);
3377
1d4ff60Claude3378// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
3379// the PR's head branch carrying just the new test files. Write-access only.
3380// Idempotent — if `ai:added-tests` was previously applied we redirect with
3381// an `info` banner instead of re-firing.
3382pulls.post(
3383 "/:owner/:repo/pulls/:number/generate-tests",
3384 softAuth,
3385 requireAuth,
3386 requireRepoAccess("write"),
3387 async (c) => {
3388 const { owner: ownerName, repo: repoName } = c.req.param();
3389 const prNum = parseInt(c.req.param("number"), 10);
3390 const resolved = await resolveRepo(ownerName, repoName);
3391 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3392
3393 const [pr] = await db
3394 .select()
3395 .from(pullRequests)
3396 .where(
3397 and(
3398 eq(pullRequests.repositoryId, resolved.repo.id),
3399 eq(pullRequests.number, prNum)
3400 )
3401 )
3402 .limit(1);
3403 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3404
3405 if (!isAiReviewEnabled()) {
3406 return c.redirect(
3407 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3408 "AI test generation is not configured (ANTHROPIC_API_KEY)."
3409 )}`
3410 );
3411 }
3412
3413 // Fire-and-forget. The lib never throws.
3414 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
3415 .then((res) => {
3416 if (!res.ok) {
3417 console.warn(
3418 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
3419 );
3420 }
3421 })
3422 .catch((err) => {
3423 console.warn(
3424 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
3425 err instanceof Error ? err.message : err
3426 );
3427 });
3428
3429 return c.redirect(
3430 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
3431 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
3432 )}`
3433 );
3434 }
3435);
3436
0074234Claude3437export default pulls;