Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

pulls.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

pulls.tsxBlame2971 lines · 3 contributors
0074234Claude1/**
2 * Pull request routes — create, list, view, merge, close, comment.
b078860Claude3 *
4 * The list view (`GET /:owner/:repo/pulls`) and detail view
5 * (`GET /:owner/:repo/pulls/:number`) carry the 2026 polish: hero with
6 * gradient title + hairline strip, pill-style state tabs, soft-lift
7 * row cards, conversation thread with AI-review accent border, distinct
8 * gate-check rows, and a gradient-bordered "Merge pull request" button.
9 *
10 * All visual styling is scoped via `.prs-*` class prefixes inside inline
11 * <style> blocks so other surfaces are untouched. No business logic was
12 * changed in this polish pass — AI review triggers, auto-merge wiring,
13 * gate evaluation, and the merge handler are preserved exactly.
0074234Claude14 */
15
16import { Hono } from "hono";
17import { eq, and, desc, asc, sql } from "drizzle-orm";
18import { db } from "../db";
19import {
20 pullRequests,
21 prComments,
22 repositories,
23 users,
d62fb36Claude24 issues,
25 issueComments,
0074234Claude26} from "../db/schema";
27import { Layout } from "../views/layout";
ea9ed4cClaude28import { RepoHeader } from "../views/components";
29import { DiffView } from "../views/diff-view";
6fc53bdClaude30import { ReactionsBar } from "../views/reactions";
31import { summariseReactions } from "../lib/reactions";
24cf2caClaude32import { loadPrTemplate } from "../lib/templates";
0074234Claude33import { renderMarkdown } from "../lib/markdown";
15db0e0Claude34import {
35 parseSlashCommand,
36 executeSlashCommand,
37 detectSlashCmdComment,
38 stripSlashCmdMarker,
39} from "../lib/pr-slash-commands";
b584e52Claude40import { liveCommentBannerScript } from "../lib/sse-client";
0074234Claude41import { softAuth, requireAuth } from "../middleware/auth";
42import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude43import { requireRepoAccess } from "../middleware/repo-access";
0316dbbClaude44import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
1d4ff60Claude45import {
46 generateTestsForPr,
47 AI_TESTS_MARKER,
48} from "../lib/ai-test-generator";
0316dbbClaude49import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude50import { generatePrSummary } from "../lib/ai-generators";
51import { isAiAvailable } from "../lib/ai-client";
534f04aClaude52import {
53 computePrRiskForPullRequest,
54 getCachedPrRisk,
55 type PrRiskScore,
56} from "../lib/pr-risk";
0316dbbClaude57import { runAllGateChecks } from "../lib/gate";
58import type { GateCheckResult } from "../lib/gate";
59import {
60 matchProtection,
61 countHumanApprovals,
62 listRequiredChecks,
63 passingCheckNames,
64 evaluateProtection,
65} from "../lib/branch-protection";
66import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude67import {
68 listBranches,
69 getRepoPath,
e883329Claude70 resolveRef,
0074234Claude71} from "../git/repository";
72import type { GitDiffFile } from "../git/repository";
73import { html } from "hono/html";
4bbacbeClaude74import {
75 getPreviewForBranch,
76 previewStatusLabel,
77} from "../lib/branch-previews";
1e162a8Claude78import {
bb0f894Claude79 Flex,
80 Container,
81 Badge,
82 Button,
83 LinkButton,
84 Form,
85 FormGroup,
86 Input,
87 TextArea,
88 Select,
89 EmptyState,
90 FilterTabs,
91 TabNav,
92 List,
93 ListItem,
94 Text,
95 Alert,
96 MarkdownContent,
97 CommentBox,
98 formatRelative,
99} from "../views/ui";
0074234Claude100
101const pulls = new Hono<AuthEnv>();
102
b078860Claude103/* ──────────────────────────────────────────────────────────────────────
104 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
105 * into the issue tracker or any other route. Tokens come from layout.tsx
106 * `:root` so light/dark stays consistent if/when light mode lands.
107 * ──────────────────────────────────────────────────────────────────── */
108const PRS_LIST_STYLES = `
109 .prs-hero {
110 position: relative;
111 margin: 0 0 var(--space-5);
112 padding: 22px 26px 24px;
113 background: var(--bg-elevated);
114 border: 1px solid var(--border);
115 border-radius: 16px;
116 overflow: hidden;
117 }
118 .prs-hero::before {
119 content: '';
120 position: absolute; top: 0; left: 0; right: 0;
121 height: 2px;
122 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
123 opacity: 0.7;
124 pointer-events: none;
125 }
126 .prs-hero-inner {
127 position: relative;
128 display: flex;
129 justify-content: space-between;
130 align-items: flex-end;
131 gap: 20px;
132 flex-wrap: wrap;
133 }
134 .prs-hero-text { flex: 1; min-width: 280px; }
135 .prs-hero-eyebrow {
136 font-size: 12px;
137 color: var(--text-muted);
138 text-transform: uppercase;
139 letter-spacing: 0.08em;
140 font-weight: 600;
141 margin-bottom: 8px;
142 }
143 .prs-hero-title {
144 font-family: var(--font-display);
145 font-size: clamp(26px, 3.4vw, 34px);
146 font-weight: 800;
147 letter-spacing: -0.025em;
148 line-height: 1.06;
149 margin: 0 0 8px;
150 color: var(--text-strong);
151 }
152 .prs-hero-title .gradient-text {
153 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
154 -webkit-background-clip: text;
155 background-clip: text;
156 -webkit-text-fill-color: transparent;
157 color: transparent;
158 }
159 .prs-hero-sub {
160 font-size: 14.5px;
161 color: var(--text-muted);
162 margin: 0;
163 line-height: 1.5;
164 max-width: 620px;
165 }
166 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
167 .prs-cta {
168 display: inline-flex; align-items: center; gap: 6px;
169 padding: 10px 16px;
170 border-radius: 10px;
171 font-size: 13.5px;
172 font-weight: 600;
173 color: #fff;
174 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
175 border: 1px solid rgba(140,109,255,0.55);
176 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
177 text-decoration: none;
178 transition: transform 120ms ease, box-shadow 160ms ease;
179 }
180 .prs-cta:hover {
181 transform: translateY(-1px);
182 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
183 color: #fff;
184 }
185
186 .prs-tabs {
187 display: flex; flex-wrap: wrap; gap: 6px;
188 margin: 0 0 18px;
189 padding: 6px;
190 background: var(--bg-secondary);
191 border: 1px solid var(--border);
192 border-radius: 12px;
193 }
194 .prs-tab {
195 display: inline-flex; align-items: center; gap: 8px;
196 padding: 7px 13px;
197 font-size: 13px;
198 font-weight: 500;
199 color: var(--text-muted);
200 border-radius: 8px;
201 text-decoration: none;
202 transition: background 120ms ease, color 120ms ease;
203 }
204 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
205 .prs-tab.is-active {
206 background: var(--bg-elevated);
207 color: var(--text-strong);
208 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
209 }
210 .prs-tab-count {
211 display: inline-flex; align-items: center; justify-content: center;
212 min-width: 22px; padding: 2px 7px;
213 font-size: 11.5px;
214 font-weight: 600;
215 border-radius: 9999px;
216 background: var(--bg-tertiary);
217 color: var(--text-muted);
218 }
219 .prs-tab.is-active .prs-tab-count {
220 background: rgba(140,109,255,0.18);
221 color: var(--text-link);
222 }
223
224 .prs-list { display: flex; flex-direction: column; gap: 10px; }
225 .prs-row {
226 position: relative;
227 display: flex; align-items: flex-start; gap: 14px;
228 padding: 14px 16px;
229 background: var(--bg-elevated);
230 border: 1px solid var(--border);
231 border-radius: 12px;
232 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
233 }
234 .prs-row:hover {
235 transform: translateY(-1px);
236 border-color: var(--border-strong);
237 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
238 }
239 .prs-row-icon {
240 flex: 0 0 auto;
241 width: 26px; height: 26px;
242 display: inline-flex; align-items: center; justify-content: center;
243 border-radius: 9999px;
244 font-size: 13px;
245 margin-top: 2px;
246 }
247 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
248 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
249 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
250 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
251 .prs-row-body { flex: 1; min-width: 0; }
252 .prs-row-title {
253 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
254 font-size: 15px; font-weight: 600;
255 color: var(--text-strong);
256 line-height: 1.35;
257 margin: 0 0 6px;
258 }
259 .prs-row-number {
260 color: var(--text-muted);
261 font-weight: 400;
262 font-size: 14px;
263 }
264 .prs-row-meta {
265 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
266 font-size: 12.5px;
267 color: var(--text-muted);
268 }
269 .prs-branch-chips {
270 display: inline-flex; align-items: center; gap: 6px;
271 font-family: var(--font-mono);
272 font-size: 11.5px;
273 }
274 .prs-branch-chip {
275 padding: 2px 8px;
276 border-radius: 9999px;
277 background: var(--bg-tertiary);
278 border: 1px solid var(--border);
279 color: var(--text);
280 }
281 .prs-branch-arrow {
282 color: var(--text-faint);
283 font-size: 11px;
284 }
285 .prs-row-tags {
286 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
287 margin-left: auto;
288 }
289 .prs-tag {
290 display: inline-flex; align-items: center; gap: 4px;
291 padding: 2px 8px;
292 font-size: 11px;
293 font-weight: 600;
294 border-radius: 9999px;
295 border: 1px solid var(--border);
296 background: var(--bg-secondary);
297 color: var(--text-muted);
298 line-height: 1.6;
299 }
300 .prs-tag.is-draft {
301 color: var(--text-muted);
302 border-color: var(--border-strong);
303 }
304 .prs-tag.is-merged {
305 color: var(--text-link);
306 border-color: rgba(140,109,255,0.45);
307 background: rgba(140,109,255,0.10);
308 }
309
310 .prs-empty {
ea9ed4cClaude311 position: relative;
312 padding: 56px 32px;
b078860Claude313 text-align: center;
314 border: 1px dashed var(--border);
ea9ed4cClaude315 border-radius: 16px;
316 background: var(--bg-elevated);
b078860Claude317 color: var(--text-muted);
ea9ed4cClaude318 overflow: hidden;
b078860Claude319 }
ea9ed4cClaude320 .prs-empty::before {
321 content: '';
322 position: absolute;
323 inset: -40% -20% auto auto;
324 width: 320px; height: 320px;
325 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
326 filter: blur(70px);
327 opacity: 0.55;
328 pointer-events: none;
329 animation: prsEmptyOrb 16s ease-in-out infinite;
330 }
331 @keyframes prsEmptyOrb {
332 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
333 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
334 }
335 @media (prefers-reduced-motion: reduce) {
336 .prs-empty::before { animation: none; }
337 }
338 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude339 .prs-empty strong {
340 display: block;
341 color: var(--text-strong);
ea9ed4cClaude342 font-family: var(--font-display);
343 font-size: 22px;
344 font-weight: 700;
345 letter-spacing: -0.018em;
346 margin-bottom: 2px;
347 }
348 .prs-empty-sub {
349 font-size: 14.5px;
350 color: var(--text-muted);
351 line-height: 1.55;
352 max-width: 460px;
353 margin: 0 0 18px;
b078860Claude354 }
ea9ed4cClaude355 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude356
357 @media (max-width: 720px) {
358 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
359 .prs-hero-actions { width: 100%; }
360 .prs-row-tags { margin-left: 0; }
361 }
f1dc7c7Claude362
363 /* Additional mobile rules. Additive only. */
364 @media (max-width: 720px) {
365 .prs-hero { padding: 18px 18px 20px; }
366 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
367 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
368 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
369 .prs-row { padding: 12px 14px; gap: 10px; }
370 .prs-row-icon { width: 24px; height: 24px; }
371 }
b078860Claude372`;
373
374/* ──────────────────────────────────────────────────────────────────────
375 * Inline CSS for the detail page. Same `.prs-*` namespace.
376 * ──────────────────────────────────────────────────────────────────── */
377const PRS_DETAIL_STYLES = `
378 .prs-detail-hero {
379 position: relative;
380 margin: 0 0 var(--space-4);
381 padding: 24px 26px;
382 background: var(--bg-elevated);
383 border: 1px solid var(--border);
384 border-radius: 16px;
385 overflow: hidden;
386 }
387 .prs-detail-hero::before {
388 content: '';
389 position: absolute; top: 0; left: 0; right: 0;
390 height: 2px;
391 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
392 opacity: 0.7;
393 pointer-events: none;
394 }
395 .prs-detail-title {
396 font-family: var(--font-display);
397 font-size: clamp(22px, 2.6vw, 28px);
398 font-weight: 700;
399 letter-spacing: -0.022em;
400 line-height: 1.2;
401 color: var(--text-strong);
402 margin: 0 0 12px;
403 }
404 .prs-detail-num {
405 color: var(--text-muted);
406 font-weight: 400;
407 }
408 .prs-state-pill {
409 display: inline-flex; align-items: center; gap: 6px;
410 padding: 6px 12px;
411 border-radius: 9999px;
412 font-size: 12.5px;
413 font-weight: 600;
414 line-height: 1;
415 border: 1px solid transparent;
416 }
417 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
418 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
419 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
420 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
421
422 .prs-detail-meta {
423 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
424 font-size: 13px;
425 color: var(--text-muted);
426 }
427 .prs-detail-meta strong { color: var(--text); }
428 .prs-detail-branches {
429 display: inline-flex; align-items: center; gap: 6px;
430 font-family: var(--font-mono);
431 font-size: 12px;
432 }
433 .prs-branch-pill {
434 padding: 3px 9px;
435 border-radius: 9999px;
436 background: var(--bg-tertiary);
437 border: 1px solid var(--border);
438 color: var(--text);
439 }
440 .prs-branch-pill.is-head { color: var(--text-strong); }
441 .prs-branch-arrow-lg {
442 color: var(--accent);
443 font-size: 14px;
444 font-weight: 700;
445 }
446
447 .prs-detail-actions {
448 display: inline-flex; gap: 8px; margin-left: auto;
449 }
450
451 .prs-detail-tabs {
452 display: flex; gap: 4px;
453 margin: 0 0 16px;
454 border-bottom: 1px solid var(--border);
455 }
456 .prs-detail-tab {
457 padding: 10px 14px;
458 font-size: 13.5px;
459 font-weight: 500;
460 color: var(--text-muted);
461 text-decoration: none;
462 border-bottom: 2px solid transparent;
463 transition: color 120ms ease, border-color 120ms ease;
464 margin-bottom: -1px;
465 }
466 .prs-detail-tab:hover { color: var(--text); }
467 .prs-detail-tab.is-active {
468 color: var(--text-strong);
469 border-bottom-color: var(--accent);
470 }
471 .prs-detail-tab-count {
472 display: inline-flex; align-items: center; justify-content: center;
473 min-width: 20px; padding: 0 6px; margin-left: 6px;
474 height: 18px;
475 font-size: 11px;
476 font-weight: 600;
477 border-radius: 9999px;
478 background: var(--bg-tertiary);
479 color: var(--text-muted);
480 }
481
482 /* Gate / check status section */
483 .prs-gate-card {
484 margin-top: 20px;
485 background: var(--bg-elevated);
486 border: 1px solid var(--border);
487 border-radius: 14px;
488 overflow: hidden;
489 }
490 .prs-gate-head {
491 display: flex; align-items: center; gap: 10px;
492 padding: 14px 18px;
493 border-bottom: 1px solid var(--border);
494 }
495 .prs-gate-head h3 {
496 margin: 0;
497 font-size: 14px;
498 font-weight: 600;
499 color: var(--text-strong);
500 }
501 .prs-gate-summary {
502 margin-left: auto;
503 font-size: 12px;
504 color: var(--text-muted);
505 }
506 .prs-gate-row {
507 display: flex; align-items: center; gap: 12px;
508 padding: 12px 18px;
509 border-bottom: 1px solid var(--border-subtle);
510 }
511 .prs-gate-row:last-child { border-bottom: 0; }
512 .prs-gate-icon {
513 flex: 0 0 auto;
514 width: 22px; height: 22px;
515 display: inline-flex; align-items: center; justify-content: center;
516 border-radius: 9999px;
517 font-size: 12px;
518 font-weight: 700;
519 }
520 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
521 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
522 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
523 .prs-gate-name {
524 font-size: 13px;
525 font-weight: 600;
526 color: var(--text);
527 min-width: 140px;
528 }
529 .prs-gate-details {
530 flex: 1; min-width: 0;
531 font-size: 12.5px;
532 color: var(--text-muted);
533 }
534 .prs-gate-pill {
535 flex: 0 0 auto;
536 padding: 3px 10px;
537 border-radius: 9999px;
538 font-size: 11px;
539 font-weight: 600;
540 line-height: 1.5;
541 border: 1px solid transparent;
542 }
543 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
544 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
545 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
546 .prs-gate-footer {
547 padding: 12px 18px;
548 background: var(--bg-secondary);
549 font-size: 12px;
550 color: var(--text-muted);
551 }
552
553 /* Comment cards */
554 .prs-comment {
555 margin-top: 14px;
556 background: var(--bg-elevated);
557 border: 1px solid var(--border);
558 border-radius: 12px;
559 overflow: hidden;
560 }
561 .prs-comment-head {
562 display: flex; align-items: center; gap: 10px;
563 padding: 10px 14px;
564 background: var(--bg-secondary);
565 border-bottom: 1px solid var(--border);
566 font-size: 13px;
567 flex-wrap: wrap;
568 }
569 .prs-comment-head strong { color: var(--text-strong); }
570 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
571 .prs-comment-loc {
572 font-family: var(--font-mono);
573 font-size: 11.5px;
574 color: var(--text-muted);
575 background: var(--bg-tertiary);
576 padding: 2px 8px;
577 border-radius: 6px;
578 }
579 .prs-comment-body { padding: 14px 18px; }
580 .prs-comment.is-ai {
581 border-color: rgba(140,109,255,0.45);
582 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
583 }
584 .prs-comment.is-ai .prs-comment-head {
585 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
586 border-bottom-color: rgba(140,109,255,0.30);
587 }
588 .prs-ai-badge {
589 display: inline-flex; align-items: center; gap: 4px;
590 padding: 2px 9px;
591 font-size: 10.5px;
592 font-weight: 700;
593 letter-spacing: 0.04em;
594 text-transform: uppercase;
595 color: #fff;
596 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
597 border-radius: 9999px;
598 }
599
600 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
601 .prs-files-card {
602 margin-top: 18px;
603 padding: 14px 18px;
604 display: flex; align-items: center; gap: 14px;
605 background: var(--bg-elevated);
606 border: 1px solid var(--border);
607 border-radius: 12px;
608 text-decoration: none;
609 color: inherit;
610 transition: border-color 120ms ease, transform 140ms ease;
611 }
612 .prs-files-card:hover {
613 border-color: rgba(140,109,255,0.45);
614 transform: translateY(-1px);
615 }
616 .prs-files-card-icon {
617 width: 36px; height: 36px;
618 display: inline-flex; align-items: center; justify-content: center;
619 border-radius: 10px;
620 background: rgba(140,109,255,0.12);
621 color: var(--text-link);
622 font-size: 18px;
623 }
624 .prs-files-card-text { flex: 1; min-width: 0; }
625 .prs-files-card-title {
626 font-size: 14px;
627 font-weight: 600;
628 color: var(--text-strong);
629 margin: 0 0 2px;
630 }
631 .prs-files-card-sub {
632 font-size: 12.5px;
633 color: var(--text-muted);
634 margin: 0;
635 }
636 .prs-files-card-cta {
637 font-size: 12.5px;
638 color: var(--text-link);
639 font-weight: 600;
640 }
641
642 /* Merge area */
643 .prs-merge-card {
644 position: relative;
645 margin-top: 22px;
646 padding: 18px;
647 background: var(--bg-elevated);
648 border-radius: 14px;
649 overflow: hidden;
650 }
651 .prs-merge-card::before {
652 content: '';
653 position: absolute; inset: 0;
654 padding: 1px;
655 border-radius: 14px;
656 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
657 -webkit-mask:
658 linear-gradient(#000 0 0) content-box,
659 linear-gradient(#000 0 0);
660 -webkit-mask-composite: xor;
661 mask-composite: exclude;
662 pointer-events: none;
663 }
664 .prs-merge-card.is-closed::before { background: var(--border-strong); }
665 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
666 .prs-merge-head {
667 display: flex; align-items: center; gap: 12px;
668 margin-bottom: 12px;
669 }
670 .prs-merge-head strong {
671 font-family: var(--font-display);
672 font-size: 15px;
673 color: var(--text-strong);
674 font-weight: 700;
675 }
676 .prs-merge-sub {
677 font-size: 13px;
678 color: var(--text-muted);
679 margin: 0 0 12px;
680 }
681 .prs-merge-actions {
682 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
683 }
684 .prs-merge-btn {
685 display: inline-flex; align-items: center; gap: 6px;
686 padding: 9px 16px;
687 border-radius: 10px;
688 font-size: 13.5px;
689 font-weight: 600;
690 color: #fff;
691 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
692 border: 1px solid rgba(52,211,153,0.55);
693 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
694 cursor: pointer;
695 transition: transform 120ms ease, box-shadow 160ms ease;
696 }
697 .prs-merge-btn:hover {
698 transform: translateY(-1px);
699 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
700 }
701 .prs-merge-btn[disabled],
702 .prs-merge-btn.is-disabled {
703 opacity: 0.55;
704 cursor: not-allowed;
705 transform: none;
706 box-shadow: none;
707 }
708 .prs-merge-ready-btn {
709 display: inline-flex; align-items: center; gap: 6px;
710 padding: 9px 16px;
711 border-radius: 10px;
712 font-size: 13.5px;
713 font-weight: 600;
714 color: #fff;
715 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
716 border: 1px solid rgba(140,109,255,0.55);
717 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
718 cursor: pointer;
719 transition: transform 120ms ease, box-shadow 160ms ease;
720 }
721 .prs-merge-ready-btn:hover {
722 transform: translateY(-1px);
723 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
724 }
725 .prs-merge-back-draft {
726 background: none; border: 1px solid var(--border-strong);
727 color: var(--text-muted);
728 padding: 9px 14px; border-radius: 10px;
729 font-size: 13px; cursor: pointer;
730 }
731 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
732
733 /* Inline form helpers */
734 .prs-inline-form { display: inline-flex; }
735
736 /* Comment composer */
737 .prs-composer { margin-top: 22px; }
738 .prs-composer textarea {
739 border-radius: 12px;
740 }
741
742 @media (max-width: 720px) {
743 .prs-detail-actions { margin-left: 0; }
744 .prs-merge-actions { width: 100%; }
745 .prs-merge-actions > * { flex: 1; min-width: 0; }
746 }
f1dc7c7Claude747
748 /* Additional mobile rules. Additive only. */
749 @media (max-width: 720px) {
750 .prs-detail-hero { padding: 18px; }
751 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
752 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
753 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
754 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
755 .prs-gate-name { min-width: 0; }
756 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
757 .prs-gate-summary { margin-left: 0; }
758 .prs-merge-btn,
759 .prs-merge-ready-btn,
760 .prs-merge-back-draft { min-height: 44px; }
761 .prs-comment-body { padding: 12px 14px; }
762 .prs-comment-head { padding: 10px 12px; }
763 .prs-files-card { padding: 12px 14px; }
764 }
3c03977Claude765
766 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
767 .live-pill {
768 display: inline-flex;
769 align-items: center;
770 gap: 8px;
771 padding: 4px 10px 4px 8px;
772 margin-left: 6px;
773 background: var(--bg-elevated);
774 border: 1px solid var(--border);
775 border-radius: 9999px;
776 font-size: 12px;
777 color: var(--text-muted);
778 line-height: 1;
779 vertical-align: middle;
780 }
781 .live-pill.is-busy { color: var(--text); }
782 .live-pill-dot {
783 width: 8px; height: 8px;
784 border-radius: 9999px;
785 background: #34d399;
786 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
787 animation: live-pulse 1.6s ease-in-out infinite;
788 }
789 @keyframes live-pulse {
790 0%, 100% { opacity: 1; }
791 50% { opacity: 0.55; }
792 }
793 .live-avatars {
794 display: inline-flex;
795 margin-left: 2px;
796 }
797 .live-avatar {
798 display: inline-flex;
799 align-items: center;
800 justify-content: center;
801 width: 22px; height: 22px;
802 border-radius: 9999px;
803 font-size: 10px;
804 font-weight: 700;
805 color: #0b1020;
806 margin-left: -6px;
807 border: 2px solid var(--bg-elevated);
808 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
809 }
810 .live-avatar:first-child { margin-left: 0; }
811 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
812 .live-cursor-host {
813 position: relative;
814 }
815 .live-cursor-overlay {
816 position: absolute;
817 inset: 0;
818 pointer-events: none;
819 overflow: hidden;
820 border-radius: inherit;
821 }
822 .live-cursor {
823 position: absolute;
824 width: 2px;
825 height: 18px;
826 border-radius: 2px;
827 transform: translate(-1px, 0);
828 transition: transform 80ms linear, opacity 200ms ease;
829 }
830 .live-cursor::after {
831 content: attr(data-label);
832 position: absolute;
833 top: -16px;
834 left: -2px;
835 font-size: 10px;
836 line-height: 1;
837 color: #0b1020;
838 background: inherit;
839 padding: 2px 5px;
840 border-radius: 4px 4px 4px 0;
841 white-space: nowrap;
842 font-weight: 600;
843 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
844 }
845 .live-cursor.is-idle { opacity: 0.4; }
846 .live-edit-tag {
847 display: inline-block;
848 margin-left: 6px;
849 padding: 1px 6px;
850 font-size: 10px;
851 font-weight: 600;
852 letter-spacing: 0.02em;
853 color: #0b1020;
854 border-radius: 9999px;
855 }
15db0e0Claude856
857 /* ─── Slash-command pill + composer hint ─── */
858 .slash-hint {
859 display: inline-flex;
860 align-items: center;
861 gap: 6px;
862 margin-top: 6px;
863 padding: 3px 9px;
864 font-size: 11.5px;
865 color: var(--text-muted);
866 background: var(--bg-elevated);
867 border: 1px dashed var(--border);
868 border-radius: 9999px;
869 width: fit-content;
870 }
871 .slash-hint code {
872 background: rgba(110, 168, 255, 0.12);
873 color: var(--text-strong);
874 padding: 0 5px;
875 border-radius: 4px;
876 font-size: 11px;
877 }
878 .slash-pill {
879 display: grid;
880 grid-template-columns: auto 1fr auto;
881 align-items: center;
882 column-gap: 10px;
883 row-gap: 6px;
884 margin: 10px 0;
885 padding: 10px 14px;
886 background: linear-gradient(
887 135deg,
888 rgba(110, 168, 255, 0.08),
889 rgba(163, 113, 247, 0.06)
890 );
891 border: 1px solid rgba(110, 168, 255, 0.32);
892 border-left: 3px solid var(--accent, #6ea8ff);
893 border-radius: var(--radius);
894 font-size: 13px;
895 color: var(--text);
896 }
897 .slash-pill-icon {
898 font-size: 14px;
899 line-height: 1;
900 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
901 }
902 .slash-pill-actor { color: var(--text-muted); }
903 .slash-pill-actor strong { color: var(--text-strong); }
904 .slash-pill-cmd {
905 background: rgba(110, 168, 255, 0.16);
906 color: var(--text-strong);
907 padding: 1px 6px;
908 border-radius: 4px;
909 font-size: 12.5px;
910 }
911 .slash-pill-time {
912 color: var(--text-muted);
913 font-size: 12px;
914 justify-self: end;
915 }
916 .slash-pill-body {
917 grid-column: 1 / -1;
918 color: var(--text);
919 font-size: 13px;
920 line-height: 1.55;
921 }
922 .slash-pill-body p:first-child { margin-top: 0; }
923 .slash-pill-body p:last-child { margin-bottom: 0; }
924 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
925 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
926 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
927 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude928
929 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
930 .preview-prpill {
931 display: inline-flex; align-items: center; gap: 6px;
932 padding: 3px 10px;
933 border-radius: 9999px;
934 font-family: var(--font-mono);
935 font-size: 11.5px;
936 font-weight: 600;
937 background: rgba(255,255,255,0.04);
938 color: var(--text-muted);
939 text-decoration: none;
940 border: 1px solid var(--border);
941 }
942 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
943 .preview-prpill .preview-prpill-dot {
944 width: 7px; height: 7px;
945 border-radius: 9999px;
946 background: currentColor;
947 }
948 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
949 .preview-prpill.is-building .preview-prpill-dot {
950 animation: previewPrPulse 1.4s ease-in-out infinite;
951 }
952 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
953 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
954 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
955 @keyframes previewPrPulse {
956 0%, 100% { opacity: 1; }
957 50% { opacity: 0.4; }
958 }
b078860Claude959`;
960
81c73c1Claude961/**
962 * Tiny inline JS that drives the "Suggest description with AI" button.
963 * On click, gathers form values, POSTs JSON to the given endpoint, and
964 * pipes the response into the #pr-body textarea. All DOM lookups are
965 * defensive — element absence is a silent no-op.
966 *
967 * Built as a string template so it lives next to its server-side caller
968 * and there is no bundler dependency. The endpoint URL is JSON-escaped
969 * to avoid </script> breakouts.
970 */
971function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
972 const url = JSON.stringify(endpointUrl)
973 .split("<").join("\\u003C")
974 .split(">").join("\\u003E")
975 .split("&").join("\\u0026");
976 return (
977 "(function(){try{" +
978 "var btn=document.getElementById('ai-suggest-desc');" +
979 "var status=document.getElementById('ai-suggest-status');" +
980 "var body=document.getElementById('pr-body');" +
981 "var form=btn&&btn.closest&&btn.closest('form');" +
982 "if(!btn||!body||!form)return;" +
983 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
984 "var fd=new FormData(form);" +
985 "var title=String(fd.get('title')||'').trim();" +
986 "var base=String(fd.get('base')||'').trim();" +
987 "var head=String(fd.get('head')||'').trim();" +
988 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
989 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
990 "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'})" +
991 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
992 ".then(function(j){btn.disabled=false;" +
993 "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;}}" +
994 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
995 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
996 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
997 "});" +
998 "}catch(e){}})();"
999 );
1000}
1001
3c03977Claude1002/**
1003 * Live co-editing client. Connects to the per-PR SSE feed and:
1004 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1005 * status colour per user).
1006 * - Renders tinted cursor caret overlays inside #pr-body and every
1007 * `[data-live-field]` element.
1008 * - Broadcasts the local user's cursor position (selectionStart /
1009 * selectionEnd) debounced at 100ms.
1010 * - Broadcasts content patches (`replace` of the whole textarea —
1011 * last-write-wins v1) debounced at 250ms.
1012 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1013 * to the matching local field if untouched.
1014 *
1015 * All endpoint URLs are JSON-escaped via safe replacements so they
1016 * can't break out of the <script> tag.
1017 */
1018function LIVE_COEDIT_SCRIPT(prId: string): string {
1019 const idJson = JSON.stringify(prId)
1020 .split("<").join("\\u003C")
1021 .split(">").join("\\u003E")
1022 .split("&").join("\\u0026");
1023 return (
1024 "(function(){try{" +
1025 "if(typeof EventSource==='undefined')return;" +
1026 "var prId=" + idJson + ";" +
1027 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1028 "var pill=document.getElementById('live-pill');" +
1029 "var avEl=document.getElementById('live-avatars');" +
1030 "var countEl=document.getElementById('live-count');" +
1031 "var sessionId=null;var myColor=null;" +
1032 "var presence={};" + // sessionId -> {color,status,userId,initials}
1033 "var lastApplied={};" + // field -> last server value (for echo suppression)
1034 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1035 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1036 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1037 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1038 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1039 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1040 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1041 "avEl.innerHTML=html;}}" +
1042 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1043 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1044 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1045 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1046 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1047 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1048 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1049 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1050 "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);}" +
1051 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1052 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1053 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1054 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1055 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1056 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1057 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1058 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1059 "}catch(e){}" +
1060 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1061 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1062 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1063 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1064 "var es;var delay=1000;" +
1065 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1066 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1067 "(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){}});" +
1068 "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){}});" +
1069 "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){}});" +
1070 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1071 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1072 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1073 "var patch=d.patch;if(!patch||!patch.field)return;" +
1074 "var ta=fieldEl(patch.field);if(!ta)return;" +
1075 "if(document.activeElement===ta)return;" + // don't trample local typing
1076 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1077 "}catch(e){}});" +
1078 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1079 "}connect();" +
1080 "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){}}" +
1081 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1082 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1083 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1084 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1085 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1086 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1087 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1088 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1089 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1090 "}" +
1091 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1092 "var live=document.querySelectorAll('[data-live-field]');" +
1093 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1094 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1095 "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){}});" +
1096 "}catch(e){}})();"
1097 );
1098}
1099
0074234Claude1100async function resolveRepo(ownerName: string, repoName: string) {
1101 const [owner] = await db
1102 .select()
1103 .from(users)
1104 .where(eq(users.username, ownerName))
1105 .limit(1);
1106 if (!owner) return null;
1107 const [repo] = await db
1108 .select()
1109 .from(repositories)
1110 .where(
1111 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1112 )
1113 .limit(1);
1114 if (!repo) return null;
1115 return { owner, repo };
1116}
1117
1118// PR Nav helper
1119const PrNav = ({
1120 owner,
1121 repo,
1122 active,
1123}: {
1124 owner: string;
1125 repo: string;
1126 active: "code" | "issues" | "pulls" | "commits";
1127}) => (
bb0f894Claude1128 <TabNav
1129 tabs={[
1130 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1131 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1132 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1133 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1134 ]}
1135 />
0074234Claude1136);
1137
534f04aClaude1138/**
1139 * Block M3 — pre-merge risk score card. Pure presentational helper.
1140 * Rendered in the conversation tab above the gate checks block. Hidden
1141 * entirely when the PR is closed/merged or there is nothing cached and
1142 * nothing in-flight.
1143 */
1144function PrRiskCard({
1145 risk,
1146 calculating,
1147}: {
1148 risk: PrRiskScore | null;
1149 calculating: boolean;
1150}) {
1151 if (!risk) {
1152 return (
1153 <div
1154 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1155 >
1156 <strong style="font-size: 13px; color: var(--text)">
1157 Risk score: calculating…
1158 </strong>
1159 <div style="font-size: 12px; margin-top: 4px">
1160 Refresh in a moment to see the pre-merge risk score for this PR.
1161 </div>
1162 </div>
1163 );
1164 }
1165
1166 const palette = riskBandPalette(risk.band);
1167 const label = riskBandLabel(risk.band);
1168
1169 return (
1170 <div
1171 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1172 >
1173 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1174 <strong>Risk score:</strong>
1175 <span style={`color:${palette.border};font-weight:600`}>
1176 {palette.icon} {label} ({risk.score}/10)
1177 </span>
1178 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1179 {risk.commitSha.slice(0, 7)}
1180 </span>
1181 </div>
1182 {risk.aiSummary && (
1183 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1184 {risk.aiSummary}
1185 </div>
1186 )}
1187 <details style="margin-top:10px">
1188 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1189 See full signal breakdown
1190 </summary>
1191 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1192 <li>files changed: {risk.signals.filesChanged}</li>
1193 <li>
1194 lines added/removed: {risk.signals.linesAdded} /{" "}
1195 {risk.signals.linesRemoved}
1196 </li>
1197 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1198 <li>
1199 schema migration touched:{" "}
1200 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1201 </li>
1202 <li>
1203 locked / sensitive path touched:{" "}
1204 {risk.signals.lockedPathTouched ? "yes" : "no"}
1205 </li>
1206 <li>
1207 adds new dependency:{" "}
1208 {risk.signals.addsNewDependency ? "yes" : "no"}
1209 </li>
1210 <li>
1211 bumps major dependency:{" "}
1212 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1213 </li>
1214 <li>
1215 tests added for new code:{" "}
1216 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1217 </li>
1218 <li>
1219 diff-minus-test ratio:{" "}
1220 {risk.signals.diffMinusTestRatio.toFixed(2)}
1221 </li>
1222 </ul>
1223 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1224 How is this calculated? The score is a transparent sum of
1225 weighted signals — see <code>src/lib/pr-risk.ts</code>
1226 {" "}<code>computePrRiskScore</code>.
1227 </div>
1228 </details>
1229 {calculating && (
1230 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1231 (recomputing for the latest commit — refresh to update)
1232 </div>
1233 )}
1234 </div>
1235 );
1236}
1237
1238function riskBandPalette(band: PrRiskScore["band"]): {
1239 border: string;
1240 icon: string;
1241} {
1242 switch (band) {
1243 case "low":
1244 return { border: "var(--green)", icon: "" };
1245 case "medium":
1246 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1247 case "high":
1248 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1249 case "critical":
1250 return { border: "var(--red)", icon: "\u{1F6D1}" };
1251 }
1252}
1253
1254function riskBandLabel(band: PrRiskScore["band"]): string {
1255 switch (band) {
1256 case "low":
1257 return "LOW";
1258 case "medium":
1259 return "MEDIUM";
1260 case "high":
1261 return "HIGH";
1262 case "critical":
1263 return "CRITICAL";
1264 }
1265}
1266
0074234Claude1267// List PRs
04f6b7fClaude1268pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1269 const { owner: ownerName, repo: repoName } = c.req.param();
1270 const user = c.get("user");
1271 const state = c.req.query("state") || "open";
1272
ea9ed4cClaude1273 // ── Loading skeleton (flag-gated) ──
1274 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
1275 // the user see the page structure before counts + select resolve.
1276 // Behind a flag for now — we don't ship flashes.
1277 if (c.req.query("skeleton") === "1") {
1278 return c.html(
1279 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1280 <RepoHeader owner={ownerName} repo={repoName} />
1281 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1282 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1283 <style
1284 dangerouslySetInnerHTML={{
1285 __html: `
1286 .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; }
1287 @keyframes prsSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1288 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
1289 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
1290 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
1291 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
1292 .prs-skel-row { height: 76px; border-radius: 12px; }
1293 `,
1294 }}
1295 />
1296 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
1297 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
1298 <div class="prs-skel-list" aria-hidden="true">
1299 {Array.from({ length: 6 }).map(() => (
1300 <div class="prs-skel prs-skel-row" />
1301 ))}
1302 </div>
1303 <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">
1304 Loading pull requests for {ownerName}/{repoName}…
1305 </span>
1306 </Layout>
1307 );
1308 }
1309
0074234Claude1310 const resolved = await resolveRepo(ownerName, repoName);
1311 if (!resolved) return c.notFound();
1312
6fc53bdClaude1313 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
1314 const stateFilter =
1315 state === "draft"
1316 ? and(
1317 eq(pullRequests.state, "open"),
1318 eq(pullRequests.isDraft, true)
1319 )
1320 : eq(pullRequests.state, state);
1321
0074234Claude1322 const prList = await db
1323 .select({
1324 pr: pullRequests,
1325 author: { username: users.username },
1326 })
1327 .from(pullRequests)
1328 .innerJoin(users, eq(pullRequests.authorId, users.id))
1329 .where(
6fc53bdClaude1330 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude1331 )
1332 .orderBy(desc(pullRequests.createdAt));
1333
1334 const [counts] = await db
1335 .select({
1336 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude1337 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude1338 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
1339 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
1340 })
1341 .from(pullRequests)
1342 .where(eq(pullRequests.repositoryId, resolved.repo.id));
1343
b078860Claude1344 const openCount = counts?.open ?? 0;
1345 const mergedCount = counts?.merged ?? 0;
1346 const closedCount = counts?.closed ?? 0;
1347 const draftCount = counts?.draft ?? 0;
1348 const allCount = openCount + mergedCount + closedCount;
1349
1350 // "All" is presentational only — the DB query for state='all' matches
1351 // nothing, so we render a friendlier empty state when picked. We do NOT
1352 // change the query logic to keep this commit purely visual.
1353 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
1354 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
1355 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
1356 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
1357 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
1358 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
1359 ];
1360 const isAllState = state === "all";
1361
0074234Claude1362 return c.html(
1363 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1364 <RepoHeader owner={ownerName} repo={repoName} />
1365 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude1366 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1367
1368 <div class="prs-hero">
1369 <div class="prs-hero-inner">
1370 <div class="prs-hero-text">
1371 <div class="prs-hero-eyebrow">Pull requests</div>
1372 <h1 class="prs-hero-title">
1373 Review, <span class="gradient-text">merge with AI</span>.
1374 </h1>
1375 <p class="prs-hero-sub">
1376 {openCount === 0 && allCount === 0
1377 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
1378 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
1379 </p>
1380 </div>
1381 {user && (
1382 <div class="prs-hero-actions">
1383 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
1384 + New pull request
1385 </a>
1386 </div>
1387 )}
1388 </div>
1389 </div>
1390
1391 <nav class="prs-tabs" aria-label="Pull request filters">
1392 {tabPills.map((t) => {
1393 const isActive =
1394 state === t.key ||
1395 (t.key === "open" &&
1396 state !== "merged" &&
1397 state !== "closed" &&
1398 state !== "all" &&
1399 state !== "draft");
1400 return (
1401 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
1402 <span>{t.label}</span>
1403 <span class="prs-tab-count">{t.count}</span>
1404 </a>
1405 );
1406 })}
1407 </nav>
1408
0074234Claude1409 {prList.length === 0 ? (
b078860Claude1410 <div class="prs-empty">
ea9ed4cClaude1411 <div class="prs-empty-inner">
1412 <strong>
1413 {isAllState
1414 ? "Pick a filter above to browse PRs."
1415 : `No ${state} pull requests.`}
1416 </strong>
1417 <p class="prs-empty-sub">
1418 {state === "open"
1419 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
1420 : isAllState
1421 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1422 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
1423 </p>
1424 <div class="prs-empty-cta">
1425 {user && state === "open" && (
1426 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
1427 + New pull request
1428 </a>
1429 )}
1430 {state !== "open" && (
1431 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
1432 View open PRs
1433 </a>
1434 )}
1435 <a href={`/${ownerName}/${repoName}`} class="btn">
1436 Back to code
1437 </a>
1438 </div>
1439 </div>
b078860Claude1440 </div>
0074234Claude1441 ) : (
b078860Claude1442 <div class="prs-list">
1443 {prList.map(({ pr, author }) => {
1444 const stateClass =
1445 pr.state === "open"
1446 ? pr.isDraft
1447 ? "state-draft"
1448 : "state-open"
1449 : pr.state === "merged"
1450 ? "state-merged"
1451 : "state-closed";
1452 const icon =
1453 pr.state === "open"
1454 ? pr.isDraft
1455 ? "◌"
1456 : "○"
1457 : pr.state === "merged"
1458 ? "⮌"
1459 : "✓";
1460 return (
1461 <a
1462 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1463 class="prs-row"
1464 style="text-decoration:none;color:inherit"
0074234Claude1465 >
b078860Claude1466 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
1467 {icon}
0074234Claude1468 </div>
b078860Claude1469 <div class="prs-row-body">
1470 <h3 class="prs-row-title">
1471 <span>{pr.title}</span>
1472 <span class="prs-row-number">#{pr.number}</span>
1473 </h3>
1474 <div class="prs-row-meta">
1475 <span
1476 class="prs-branch-chips"
1477 title={`${pr.headBranch} into ${pr.baseBranch}`}
1478 >
1479 <span class="prs-branch-chip">{pr.headBranch}</span>
1480 <span class="prs-branch-arrow">{"→"}</span>
1481 <span class="prs-branch-chip">{pr.baseBranch}</span>
1482 </span>
1483 <span>
1484 by{" "}
1485 <strong style="color:var(--text)">
1486 {author.username}
1487 </strong>{" "}
1488 {formatRelative(pr.createdAt)}
1489 </span>
1490 <span class="prs-row-tags">
1491 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
1492 {pr.state === "merged" && (
1493 <span class="prs-tag is-merged">Merged</span>
1494 )}
1495 </span>
1496 </div>
0074234Claude1497 </div>
b078860Claude1498 </a>
1499 );
1500 })}
1501 </div>
0074234Claude1502 )}
1503 </Layout>
1504 );
1505});
1506
1507// New PR form
1508pulls.get(
1509 "/:owner/:repo/pulls/new",
1510 softAuth,
1511 requireAuth,
04f6b7fClaude1512 requireRepoAccess("write"),
0074234Claude1513 async (c) => {
1514 const { owner: ownerName, repo: repoName } = c.req.param();
1515 const user = c.get("user")!;
1516 const branches = await listBranches(ownerName, repoName);
1517 const error = c.req.query("error");
1518 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude1519 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude1520
1521 return c.html(
1522 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
1523 <RepoHeader owner={ownerName} repo={repoName} />
1524 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude1525 <Container maxWidth={800}>
1526 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude1527 {error && (
bb0f894Claude1528 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude1529 )}
0316dbbClaude1530 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
1531 <Flex gap={12} align="center" style="margin-bottom: 16px">
1532 <Select name="base">
0074234Claude1533 {branches.map((b) => (
1534 <option value={b} selected={b === defaultBase}>
1535 {b}
1536 </option>
1537 ))}
bb0f894Claude1538 </Select>
1539 <Text muted>&larr;</Text>
1540 <Select name="head">
0074234Claude1541 {branches
1542 .filter((b) => b !== defaultBase)
1543 .concat(defaultBase === branches[0] ? [] : [branches[0]])
1544 .map((b) => (
1545 <option value={b}>{b}</option>
1546 ))}
bb0f894Claude1547 </Select>
1548 </Flex>
1549 <FormGroup>
1550 <Input
0074234Claude1551 name="title"
1552 required
1553 placeholder="Title"
bb0f894Claude1554 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]1555 aria-label="Pull request title"
0074234Claude1556 />
bb0f894Claude1557 </FormGroup>
1558 <FormGroup>
1559 <TextArea
0074234Claude1560 name="body"
81c73c1Claude1561 id="pr-body"
0074234Claude1562 rows={8}
1563 placeholder="Description (Markdown supported)"
bb0f894Claude1564 mono
0074234Claude1565 />
bb0f894Claude1566 </FormGroup>
81c73c1Claude1567 <Flex gap={8} align="center">
1568 <Button type="submit" variant="primary">
1569 Create pull request
1570 </Button>
1571 <button
1572 type="button"
1573 id="ai-suggest-desc"
1574 class="btn"
1575 style="font-weight:500"
1576 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
1577 >
1578 Suggest description with AI
1579 </button>
1580 <span
1581 id="ai-suggest-status"
1582 style="color:var(--text-muted);font-size:13px"
1583 />
1584 </Flex>
bb0f894Claude1585 </Form>
81c73c1Claude1586 <script
1587 dangerouslySetInnerHTML={{
1588 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
1589 }}
1590 />
bb0f894Claude1591 </Container>
0074234Claude1592 </Layout>
1593 );
1594 }
1595);
1596
81c73c1Claude1597// AI-suggested PR description — JSON endpoint driven by the form button.
1598// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
1599// 200; the inline script reads `ok` to decide what to do.
1600pulls.post(
1601 "/:owner/:repo/ai/pr-description",
1602 softAuth,
1603 requireAuth,
1604 requireRepoAccess("write"),
1605 async (c) => {
1606 const { owner: ownerName, repo: repoName } = c.req.param();
1607 if (!isAiAvailable()) {
1608 return c.json({
1609 ok: false,
1610 error: "AI is not available — set ANTHROPIC_API_KEY.",
1611 });
1612 }
1613 const body = await c.req.parseBody();
1614 const title = String(body.title || "").trim();
1615 const baseBranch = String(body.base || "").trim();
1616 const headBranch = String(body.head || "").trim();
1617 if (!baseBranch || !headBranch) {
1618 return c.json({ ok: false, error: "Pick base + head branches first." });
1619 }
1620 if (baseBranch === headBranch) {
1621 return c.json({ ok: false, error: "Base and head must differ." });
1622 }
1623
1624 let diff = "";
1625 try {
1626 const cwd = getRepoPath(ownerName, repoName);
1627 const proc = Bun.spawn(
1628 [
1629 "git",
1630 "diff",
1631 `${baseBranch}...${headBranch}`,
1632 "--",
1633 ],
1634 { cwd, stdout: "pipe", stderr: "pipe" }
1635 );
6ea2109Claude1636 // 30s ceiling — without this a pathological diff (huge binary or
1637 // a corrupt ref) hangs the request indefinitely.
1638 const killer = setTimeout(() => proc.kill(), 30_000);
1639 try {
1640 diff = await new Response(proc.stdout).text();
1641 await proc.exited;
1642 } finally {
1643 clearTimeout(killer);
1644 }
81c73c1Claude1645 } catch {
1646 diff = "";
1647 }
1648 if (!diff.trim()) {
1649 return c.json({
1650 ok: false,
1651 error: "No diff between branches — nothing to summarise.",
1652 });
1653 }
1654
1655 let summary = "";
1656 try {
1657 summary = await generatePrSummary(title || "(untitled)", diff);
1658 } catch (err) {
1659 const msg = err instanceof Error ? err.message : "AI request failed.";
1660 return c.json({ ok: false, error: msg });
1661 }
1662 if (!summary.trim()) {
1663 return c.json({ ok: false, error: "AI returned an empty draft." });
1664 }
1665 return c.json({ ok: true, body: summary });
1666 }
1667);
1668
0074234Claude1669// Create PR
1670pulls.post(
1671 "/:owner/:repo/pulls/new",
1672 softAuth,
1673 requireAuth,
04f6b7fClaude1674 requireRepoAccess("write"),
0074234Claude1675 async (c) => {
1676 const { owner: ownerName, repo: repoName } = c.req.param();
1677 const user = c.get("user")!;
1678 const body = await c.req.parseBody();
1679 const title = String(body.title || "").trim();
1680 const prBody = String(body.body || "").trim();
1681 const baseBranch = String(body.base || "main");
1682 const headBranch = String(body.head || "");
1683
1684 if (!title || !headBranch) {
1685 return c.redirect(
1686 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
1687 );
1688 }
1689
1690 if (baseBranch === headBranch) {
1691 return c.redirect(
1692 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
1693 );
1694 }
1695
1696 const resolved = await resolveRepo(ownerName, repoName);
1697 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1698
6fc53bdClaude1699 const isDraft = String(body.draft || "") === "1";
1700
0074234Claude1701 const [pr] = await db
1702 .insert(pullRequests)
1703 .values({
1704 repositoryId: resolved.repo.id,
1705 authorId: user.id,
1706 title,
1707 body: prBody || null,
1708 baseBranch,
1709 headBranch,
6fc53bdClaude1710 isDraft,
0074234Claude1711 })
1712 .returning();
1713
6fc53bdClaude1714 // Skip AI review on drafts — it runs again when the PR is marked ready.
1715 if (!isDraft && isAiReviewEnabled()) {
e883329Claude1716 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
1717 (err) => console.error("[ai-review] Failed:", err)
1718 );
1719 }
1720
3cbe3d6Claude1721 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
1722 triggerPrTriage({
1723 ownerName,
1724 repoName,
1725 repositoryId: resolved.repo.id,
1726 prId: pr.id,
1727 prAuthorId: user.id,
1728 title,
1729 body: prBody,
1730 baseBranch,
1731 headBranch,
1732 }).catch((err) => console.error("[pr-triage] Failed:", err));
1733
1d4ff60Claude1734 // Chat notifier — fan out to Slack/Discord/Teams.
1735 import("../lib/chat-notifier")
1736 .then((m) =>
1737 m.notifyChatChannels({
1738 ownerUserId: resolved.repo.ownerId,
1739 repositoryId: resolved.repo.id,
1740 event: {
1741 event: "pr.opened",
1742 repo: `${ownerName}/${repoName}`,
1743 title: `#${pr.number} ${title}`,
1744 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
1745 body: prBody || undefined,
1746 actor: user.username,
1747 },
1748 })
1749 )
1750 .catch((err) =>
1751 console.warn(`[chat-notifier] PR opened notify failed:`, err)
1752 );
1753
9dd96b9Test User1754 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude1755 import("../lib/auto-merge")
1756 .then((m) => m.tryAutoMergeNow(pr.id))
1757 .catch((err) => {
1758 console.warn(
1759 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
1760 err instanceof Error ? err.message : err
1761 );
1762 });
9dd96b9Test User1763
0074234Claude1764 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
1765 }
1766);
1767
1768// View single PR
04f6b7fClaude1769pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1770 const { owner: ownerName, repo: repoName } = c.req.param();
1771 const prNum = parseInt(c.req.param("number"), 10);
1772 const user = c.get("user");
1773 const tab = c.req.query("tab") || "conversation";
1774
1775 const resolved = await resolveRepo(ownerName, repoName);
1776 if (!resolved) return c.notFound();
1777
1778 const [pr] = await db
1779 .select()
1780 .from(pullRequests)
1781 .where(
1782 and(
1783 eq(pullRequests.repositoryId, resolved.repo.id),
1784 eq(pullRequests.number, prNum)
1785 )
1786 )
1787 .limit(1);
1788
1789 if (!pr) return c.notFound();
1790
1791 const [author] = await db
1792 .select()
1793 .from(users)
1794 .where(eq(users.id, pr.authorId))
1795 .limit(1);
1796
1797 const comments = await db
1798 .select({
1799 comment: prComments,
1800 author: { username: users.username },
1801 })
1802 .from(prComments)
1803 .innerJoin(users, eq(prComments.authorId, users.id))
1804 .where(eq(prComments.pullRequestId, pr.id))
1805 .orderBy(asc(prComments.createdAt));
1806
6fc53bdClaude1807 // Reactions for the PR body + each comment, in parallel.
1808 const [prReactions, ...prCommentReactions] = await Promise.all([
1809 summariseReactions("pr", pr.id, user?.id),
1810 ...comments.map((row) =>
1811 summariseReactions("pr_comment", row.comment.id, user?.id)
1812 ),
1813 ]);
1814
0074234Claude1815 const canManage =
1816 user &&
1817 (user.id === resolved.owner.id || user.id === pr.authorId);
1818
1d4ff60Claude1819 // Has any previous AI-test-generator run already tagged this PR? Used
1820 // both to hide the "Generate tests with AI" button and to short-circuit
1821 // the explicit POST handler.
1822 const hasAiTestsMarker = comments.some(({ comment }) =>
1823 (comment.body || "").includes(AI_TESTS_MARKER)
1824 );
1825
e883329Claude1826 const error = c.req.query("error");
c3e0c07Claude1827 const info = c.req.query("info");
e883329Claude1828
1829 // Get gate check status for open PRs
1830 let gateChecks: GateCheckResult[] = [];
1831 if (pr.state === "open") {
1832 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
1833 if (headSha) {
1834 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
1835 const aiApproved = aiComments.length === 0 || aiComments.some(
1836 ({ comment }) => comment.body.includes("**Approved**")
1837 );
1838 const gateResult = await runAllGateChecks(
1839 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
1840 );
1841 gateChecks = gateResult.checks;
1842 }
1843 }
1844
534f04aClaude1845 // Block M3 — pre-merge risk score. Cache-only on the request path so
1846 // the page never waits on Haiku. On a cache miss for an open PR we
1847 // kick off the computation fire-and-forget; the next refresh shows it.
1848 let prRisk: PrRiskScore | null = null;
1849 let prRiskCalculating = false;
1850 if (pr.state === "open") {
1851 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
1852 if (!prRisk) {
1853 prRiskCalculating = true;
a28cedeClaude1854 void computePrRiskForPullRequest(pr.id).catch((err) => {
1855 console.warn(
1856 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
1857 err instanceof Error ? err.message : err
1858 );
1859 });
534f04aClaude1860 }
1861 }
1862
4bbacbeClaude1863 // Migration 0062 — per-branch preview URL. The head branch always
1864 // has a preview row (unless it's the default branch, which never
1865 // happens for an open PR) once it has been pushed at least once.
1866 const preview = await getPreviewForBranch(
1867 (resolved.repo as { id: string }).id,
1868 pr.headBranch
1869 );
1870
0074234Claude1871 // Get diff for "Files changed" tab
1872 let diffRaw = "";
1873 let diffFiles: GitDiffFile[] = [];
1874 if (tab === "files") {
1875 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude1876 // Run the two git diffs in parallel — they're independent reads of
1877 // the same range. Previously sequential, doubling the wall time on
1878 // big PRs (100+ files = 10-30s for no reason).
0074234Claude1879 const proc = Bun.spawn(
1880 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
1881 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1882 );
1883 const statProc = Bun.spawn(
1884 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
1885 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1886 );
6ea2109Claude1887 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
1888 // would otherwise hang the whole request.
1889 const killer = setTimeout(() => {
1890 proc.kill();
1891 statProc.kill();
1892 }, 30_000);
1893 let stat = "";
1894 try {
1895 [diffRaw, stat] = await Promise.all([
1896 new Response(proc.stdout).text(),
1897 new Response(statProc.stdout).text(),
1898 ]);
1899 await Promise.all([proc.exited, statProc.exited]);
1900 } finally {
1901 clearTimeout(killer);
1902 }
0074234Claude1903
1904 diffFiles = stat
1905 .trim()
1906 .split("\n")
1907 .filter(Boolean)
1908 .map((line) => {
1909 const [add, del, filePath] = line.split("\t");
1910 return {
1911 path: filePath,
1912 status: "modified",
1913 additions: add === "-" ? 0 : parseInt(add, 10),
1914 deletions: del === "-" ? 0 : parseInt(del, 10),
1915 patch: "",
1916 };
1917 });
1918 }
1919
b078860Claude1920 // ─── Derived visual state ───
1921 const stateKey =
1922 pr.state === "open"
1923 ? pr.isDraft
1924 ? "draft"
1925 : "open"
1926 : pr.state;
1927 const stateLabel =
1928 stateKey === "open"
1929 ? "Open"
1930 : stateKey === "draft"
1931 ? "Draft"
1932 : stateKey === "merged"
1933 ? "Merged"
1934 : "Closed";
1935 const stateIcon =
1936 stateKey === "open"
1937 ? "○"
1938 : stateKey === "draft"
1939 ? "◌"
1940 : stateKey === "merged"
1941 ? "⮌"
1942 : "✓";
1943 const commentCount = comments.length;
1944 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
1945 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
1946 const mergeBlocked =
1947 gateChecks.length > 0 &&
1948 gateChecks.some(
1949 (c) => !c.passed && c.name !== "Merge check"
1950 );
1951
0074234Claude1952 return c.html(
1953 <Layout
1954 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
1955 user={user}
1956 >
1957 <RepoHeader owner={ownerName} repo={repoName} />
1958 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude1959 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude1960 <div
1961 id="live-comment-banner"
1962 class="alert"
1963 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1964 >
1965 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1966 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1967 reload to view
1968 </a>
1969 </div>
1970 <script
1971 dangerouslySetInnerHTML={{
1972 __html: liveCommentBannerScript({
1973 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
1974 bannerElementId: "live-comment-banner",
1975 }),
1976 }}
1977 />
b078860Claude1978
1979 <div class="prs-detail-hero">
1980 <h1 class="prs-detail-title">
0074234Claude1981 {pr.title}{" "}
b078860Claude1982 <span class="prs-detail-num">#{pr.number}</span>
1983 </h1>
1984 <div class="prs-detail-meta">
1985 <span class={`prs-state-pill state-${stateKey}`}>
1986 <span aria-hidden="true">{stateIcon}</span>
1987 <span>{stateLabel}</span>
1988 </span>
1989 <span>
1990 <strong>{author?.username}</strong> wants to merge
1991 </span>
1992 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
1993 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
1994 <span class="prs-branch-arrow-lg">{"→"}</span>
1995 <span class="prs-branch-pill">{pr.baseBranch}</span>
1996 </span>
1997 <span>opened {formatRelative(pr.createdAt)}</span>
3c03977Claude1998 <span
1999 id="live-pill"
2000 class="live-pill"
2001 title="People editing this PR right now"
2002 >
2003 <span class="live-pill-dot" aria-hidden="true"></span>
2004 <span>
2005 Live: <strong id="live-count">0</strong> editing
2006 </span>
2007 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
2008 </span>
4bbacbeClaude2009 {preview && (
2010 <a
2011 class={`preview-prpill is-${preview.status}`}
2012 href={
2013 preview.status === "ready"
2014 ? preview.previewUrl
2015 : `/${ownerName}/${repoName}/previews`
2016 }
2017 target={preview.status === "ready" ? "_blank" : undefined}
2018 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
2019 title={`Preview · ${previewStatusLabel(preview.status)}`}
2020 >
2021 <span class="preview-prpill-dot" aria-hidden="true"></span>
2022 <span>Preview: </span>
2023 <span>{previewStatusLabel(preview.status)}</span>
2024 </a>
2025 )}
b078860Claude2026 {canManage && pr.state === "open" && pr.isDraft && (
2027 <form
2028 method="post"
2029 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2030 class="prs-inline-form prs-detail-actions"
2031 >
2032 <button type="submit" class="prs-merge-ready-btn">
2033 Ready for review
2034 </button>
2035 </form>
2036 )}
2037 </div>
2038 </div>
3c03977Claude2039 <script
2040 dangerouslySetInnerHTML={{
2041 __html: LIVE_COEDIT_SCRIPT(pr.id),
2042 }}
2043 />
0074234Claude2044
b078860Claude2045 <nav class="prs-detail-tabs" aria-label="Pull request sections">
2046 <a
2047 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
2048 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2049 >
2050 Conversation
2051 <span class="prs-detail-tab-count">{commentCount}</span>
2052 </a>
2053 <a
2054 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
2055 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2056 >
2057 Files changed
2058 {diffFiles.length > 0 && (
2059 <span class="prs-detail-tab-count">{diffFiles.length}</span>
2060 )}
2061 </a>
2062 </nav>
2063
2064 {tab === "files" ? (
ea9ed4cClaude2065 <DiffView
2066 raw={diffRaw}
2067 files={diffFiles}
2068 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
2069 />
b078860Claude2070 ) : (
2071 <>
2072 {pr.body && (
2073 <CommentBox
2074 author={author?.username ?? "unknown"}
2075 date={pr.createdAt}
2076 body={renderMarkdown(pr.body)}
2077 />
2078 )}
2079
15db0e0Claude2080 {comments.map(({ comment, author: commentAuthor }) => {
2081 const slashCmd = detectSlashCmdComment(comment.body);
2082 if (slashCmd) {
2083 const visible = stripSlashCmdMarker(comment.body);
2084 return (
2085 <div class={`slash-pill slash-cmd-${slashCmd}`}>
2086 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
2087 <span class="slash-pill-actor">
2088 <strong>{commentAuthor.username}</strong>
2089 {" ran "}
2090 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude2091 </span>
15db0e0Claude2092 <span class="slash-pill-time">
2093 {formatRelative(comment.createdAt)}
2094 </span>
2095 <div class="slash-pill-body">
2096 <MarkdownContent html={renderMarkdown(visible)} />
2097 </div>
2098 </div>
2099 );
2100 }
2101 return (
2102 <div class={`prs-comment${comment.isAiReview ? " is-ai" : ""}`}>
2103 <div class="prs-comment-head">
2104 <strong>{commentAuthor.username}</strong>
2105 {comment.isAiReview && (
2106 <span class="prs-ai-badge">AI Review</span>
2107 )}
2108 <span class="prs-comment-time">
2109 commented {formatRelative(comment.createdAt)}
2110 </span>
2111 {comment.filePath && (
2112 <span class="prs-comment-loc">
2113 {comment.filePath}
2114 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
2115 </span>
2116 )}
2117 </div>
2118 <div class="prs-comment-body">
2119 <MarkdownContent html={renderMarkdown(comment.body)} />
2120 </div>
0074234Claude2121 </div>
15db0e0Claude2122 );
2123 })}
0074234Claude2124
b078860Claude2125 {/* Quick link to the Files changed tab when there's a diff to look at. */}
2126 {pr.state !== "merged" && (
2127 <a
2128 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2129 class="prs-files-card"
2130 >
2131 <span class="prs-files-card-icon" aria-hidden="true">
2132 {"▤"}
2133 </span>
2134 <div class="prs-files-card-text">
2135 <p class="prs-files-card-title">Files changed</p>
2136 <p class="prs-files-card-sub">
2137 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
2138 </p>
e883329Claude2139 </div>
b078860Claude2140 <span class="prs-files-card-cta">View diff {"→"}</span>
2141 </a>
2142 )}
2143
2144 {error && (
2145 <div
2146 class="auth-error"
2147 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)"
2148 >
2149 {decodeURIComponent(error)}
2150 </div>
2151 )}
2152
2153 {info && (
2154 <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)">
2155 {decodeURIComponent(info)}
2156 </div>
2157 )}
e883329Claude2158
b078860Claude2159 {pr.state === "open" && (prRisk || prRiskCalculating) && (
2160 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
2161 )}
2162
2163 {pr.state === "open" && gateChecks.length > 0 && (
2164 <div class="prs-gate-card">
2165 <div class="prs-gate-head">
2166 <h3>Gate checks</h3>
2167 <span class="prs-gate-summary">
2168 {gatesAllPassed
2169 ? `All ${gateChecks.length} checks passed`
2170 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
2171 </span>
c3e0c07Claude2172 </div>
b078860Claude2173 {gateChecks.map((check) => {
2174 const isAi = /ai.*review/i.test(check.name);
2175 const isSkip = check.skipped === true;
2176 const statusClass = isSkip
2177 ? "is-skip"
2178 : check.passed
2179 ? "is-pass"
2180 : "is-fail";
2181 const statusGlyph = isSkip
2182 ? "—"
2183 : check.passed
2184 ? "✓"
2185 : "✗";
2186 const statusLabel = isSkip
2187 ? "Skipped"
2188 : check.passed
2189 ? "Passed"
2190 : "Failing";
2191 return (
2192 <div
2193 class="prs-gate-row"
2194 style={
2195 isAi
2196 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
2197 : ""
2198 }
2199 >
2200 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
2201 {statusGlyph}
2202 </span>
2203 <span class="prs-gate-name">
2204 {check.name}
2205 {isAi && (
2206 <span
2207 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"
2208 >
2209 AI
2210 </span>
2211 )}
2212 </span>
2213 <span class="prs-gate-details">{check.details}</span>
2214 <span class={`prs-gate-pill ${statusClass}`}>
2215 {statusLabel}
e883329Claude2216 </span>
2217 </div>
b078860Claude2218 );
2219 })}
2220 <div class="prs-gate-footer">
2221 {gatesAllPassed
2222 ? "All checks passed — ready to merge."
2223 : gateChecks.some(
2224 (c) => !c.passed && c.name === "Merge check"
2225 )
2226 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
2227 : "Some checks failed — resolve issues before merging."}
2228 {aiReviewCount > 0 && (
2229 <>
2230 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
2231 </>
2232 )}
2233 </div>
2234 </div>
2235 )}
2236
2237 {/* ─── Merge area / state-aware action card ─────────────── */}
2238 {user && pr.state === "open" && (
2239 <div
2240 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
2241 >
2242 <div class="prs-merge-head">
2243 <strong>
2244 {pr.isDraft
2245 ? "Draft — ready for review?"
2246 : mergeBlocked
2247 ? "Merge blocked"
2248 : "Ready to merge"}
2249 </strong>
e883329Claude2250 </div>
b078860Claude2251 <p class="prs-merge-sub">
2252 {pr.isDraft
2253 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
2254 : mergeBlocked
2255 ? "Resolve the failing gate checks above before this PR can land."
2256 : gateChecks.length > 0
2257 ? gatesAllPassed
2258 ? "All gates green. Merge will fast-forward into the base branch."
2259 : "Conflicts will be auto-resolved by GlueCron AI on merge."
2260 : "Run gate checks by refreshing once your branch has a recent commit."}
2261 </p>
2262 <Form
2263 method="post"
2264 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
2265 >
2266 <FormGroup>
3c03977Claude2267 <div class="live-cursor-host" style="position:relative">
2268 <textarea
2269 name="body"
2270 id="pr-comment-body"
2271 data-live-field="comment_new"
2272 rows={5}
2273 required
2274 placeholder="Leave a comment... (Markdown supported)"
2275 style="font-family:var(--font-mono);font-size:13px;width:100%"
2276 ></textarea>
2277 </div>
15db0e0Claude2278 <span class="slash-hint" title="Type a slash-command as the first line">
2279 Type <code>/</code> for commands —{" "}
2280 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
2281 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
2282 </span>
b078860Claude2283 </FormGroup>
2284 <div class="prs-merge-actions">
2285 <Button type="submit" variant="primary">
2286 Comment
2287 </Button>
2288 {canManage && (
2289 <>
2290 {pr.isDraft ? (
2291 <button
2292 type="submit"
2293 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2294 formnovalidate
2295 class="prs-merge-ready-btn"
2296 >
2297 Ready for review
2298 </button>
2299 ) : (
0074234Claude2300 <button
2301 type="submit"
2302 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
b078860Claude2303 formnovalidate
2304 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
2305 title={
2306 mergeBlocked
2307 ? "Failing gate checks must be resolved before this PR can merge."
2308 : "Merge pull request"
2309 }
0074234Claude2310 >
b078860Claude2311 {"✔"} Merge pull request
0074234Claude2312 </button>
b078860Claude2313 )}
2314 {!pr.isDraft && (
2315 <button
0074234Claude2316 type="submit"
b078860Claude2317 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
2318 formnovalidate
2319 class="prs-merge-back-draft"
2320 title="Convert back to draft"
0074234Claude2321 >
b078860Claude2322 Convert to draft
2323 </button>
2324 )}
2325 {isAiReviewEnabled() && (
2326 <button
2327 type="submit"
2328 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
2329 formnovalidate
2330 class="btn"
2331 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
2332 >
2333 Re-run AI review
2334 </button>
2335 )}
1d4ff60Claude2336 {isAiReviewEnabled() && !hasAiTestsMarker && (
2337 <button
2338 type="submit"
2339 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
2340 formnovalidate
2341 class="btn"
2342 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."
2343 >
2344 Generate tests with AI
2345 </button>
2346 )}
b078860Claude2347 <Button
2348 type="submit"
2349 variant="danger"
2350 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
2351 >
2352 Close
2353 </Button>
2354 </>
2355 )}
2356 </div>
2357 </Form>
2358 </div>
2359 )}
2360
2361 {/* Read-only footers for non-open states. */}
2362 {pr.state === "merged" && (
2363 <div class="prs-merge-card is-merged">
2364 <div class="prs-merge-head">
2365 <strong>{"⮌"} Merged</strong>
0074234Claude2366 </div>
b078860Claude2367 <p class="prs-merge-sub">
2368 This pull request was merged into{" "}
2369 <code>{pr.baseBranch}</code>.
2370 </p>
2371 </div>
2372 )}
2373 {pr.state === "closed" && (
2374 <div class="prs-merge-card is-closed">
2375 <div class="prs-merge-head">
2376 <strong>{"✕"} Closed without merging</strong>
2377 </div>
2378 <p class="prs-merge-sub">
2379 This pull request was closed and not merged.
2380 </p>
2381 </div>
2382 )}
2383 </>
2384 )}
0074234Claude2385 </Layout>
2386 );
2387});
2388
2389// Add comment to PR
2390pulls.post(
2391 "/:owner/:repo/pulls/:number/comment",
2392 softAuth,
2393 requireAuth,
04f6b7fClaude2394 requireRepoAccess("write"),
0074234Claude2395 async (c) => {
2396 const { owner: ownerName, repo: repoName } = c.req.param();
2397 const prNum = parseInt(c.req.param("number"), 10);
2398 const user = c.get("user")!;
2399 const body = await c.req.parseBody();
2400 const commentBody = String(body.body || "").trim();
2401
2402 if (!commentBody) {
2403 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2404 }
2405
2406 const resolved = await resolveRepo(ownerName, repoName);
2407 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2408
2409 const [pr] = await db
2410 .select()
2411 .from(pullRequests)
2412 .where(
2413 and(
2414 eq(pullRequests.repositoryId, resolved.repo.id),
2415 eq(pullRequests.number, prNum)
2416 )
2417 )
2418 .limit(1);
2419
2420 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2421
d4ac5c3Claude2422 const [inserted] = await db
2423 .insert(prComments)
2424 .values({
2425 pullRequestId: pr.id,
2426 authorId: user.id,
2427 body: commentBody,
2428 })
2429 .returning();
2430
2431 // Live update: nudge any browser tabs subscribed to this PR.
2432 if (inserted) {
2433 try {
2434 const { publish } = await import("../lib/sse");
2435 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
2436 event: "pr-comment",
2437 data: {
2438 pullRequestId: pr.id,
2439 commentId: inserted.id,
2440 authorId: user.id,
2441 authorUsername: user.username,
2442 },
2443 });
2444 } catch {
2445 /* SSE is best-effort */
2446 }
2447 }
0074234Claude2448
15db0e0Claude2449 // Slash-command handoff. We always store the original comment above
2450 // first so free-form text that happens to start with `/` is preserved
2451 // verbatim; only recognised commands trigger a follow-up bot comment.
2452 const parsed = parseSlashCommand(commentBody);
2453 if (parsed) {
2454 try {
2455 const result = await executeSlashCommand({
2456 command: parsed.command,
2457 args: parsed.args,
2458 prId: pr.id,
2459 userId: user.id,
2460 repositoryId: resolved.repo.id,
2461 });
2462 await db.insert(prComments).values({
2463 pullRequestId: pr.id,
2464 authorId: user.id,
2465 body: result.body,
2466 });
2467 } catch (err) {
2468 // Defence-in-depth — executeSlashCommand promises not to throw,
2469 // but if it ever does we want the PR thread to know.
2470 await db
2471 .insert(prComments)
2472 .values({
2473 pullRequestId: pr.id,
2474 authorId: user.id,
2475 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
2476 })
2477 .catch(() => {});
2478 }
2479 }
2480
0074234Claude2481 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2482 }
2483);
2484
e883329Claude2485// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude2486// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
2487// but we keep it at "write" for v1 so trusted collaborators can ship.
2488// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
2489// surface. Branch-protection rules (evaluated below) are the current mechanism
2490// for locking down merges further on specific branches.
0074234Claude2491pulls.post(
2492 "/:owner/:repo/pulls/:number/merge",
2493 softAuth,
2494 requireAuth,
04f6b7fClaude2495 requireRepoAccess("write"),
0074234Claude2496 async (c) => {
2497 const { owner: ownerName, repo: repoName } = c.req.param();
2498 const prNum = parseInt(c.req.param("number"), 10);
2499 const user = c.get("user")!;
2500
2501 const resolved = await resolveRepo(ownerName, repoName);
2502 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2503
2504 const [pr] = await db
2505 .select()
2506 .from(pullRequests)
2507 .where(
2508 and(
2509 eq(pullRequests.repositoryId, resolved.repo.id),
2510 eq(pullRequests.number, prNum)
2511 )
2512 )
2513 .limit(1);
2514
2515 if (!pr || pr.state !== "open") {
2516 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2517 }
2518
6fc53bdClaude2519 // Draft PRs cannot be merged — must be marked ready first.
2520 if (pr.isDraft) {
2521 return c.redirect(
2522 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2523 "This PR is a draft. Mark it as ready for review before merging."
2524 )}`
2525 );
2526 }
2527
e883329Claude2528 // Resolve head SHA
2529 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2530 if (!headSha) {
2531 return c.redirect(
2532 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
2533 );
2534 }
2535
2536 // Check if AI review approved this PR
2537 const aiComments = await db
2538 .select()
2539 .from(prComments)
2540 .where(
2541 and(
2542 eq(prComments.pullRequestId, pr.id),
2543 eq(prComments.isAiReview, true)
2544 )
2545 );
2546 const aiApproved = aiComments.length === 0 || aiComments.some(
2547 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude2548 );
e883329Claude2549
2550 // Run all green gate checks (GateTest + mergeability + AI review)
2551 const gateResult = await runAllGateChecks(
2552 ownerName,
2553 repoName,
2554 pr.baseBranch,
2555 pr.headBranch,
2556 headSha,
2557 aiApproved
0074234Claude2558 );
2559
e883329Claude2560 // If GateTest or AI review failed (hard blocks), reject the merge
2561 const hardFailures = gateResult.checks.filter(
2562 (check) => !check.passed && check.name !== "Merge check"
2563 );
2564 if (hardFailures.length > 0) {
2565 const errorMsg = hardFailures
2566 .map((f) => `${f.name}: ${f.details}`)
2567 .join("; ");
0074234Claude2568 return c.redirect(
e883329Claude2569 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude2570 );
2571 }
2572
1e162a8Claude2573 // D5 — Branch-protection enforcement. Looks up the matching rule for the
2574 // base branch and blocks the merge if requireAiApproval / requireGreenGates
2575 // / requireHumanReview / requiredApprovals are not satisfied. Independent
2576 // of repo-global settings, so owners can lock specific branches down
2577 // further than the repo default.
2578 const protectionRule = await matchProtection(
2579 resolved.repo.id,
2580 pr.baseBranch
2581 );
2582 if (protectionRule) {
2583 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude2584 const required = await listRequiredChecks(protectionRule.id);
2585 const passingNames = required.length > 0
2586 ? await passingCheckNames(resolved.repo.id, headSha)
2587 : [];
2588 const decision = evaluateProtection(
2589 protectionRule,
2590 {
2591 aiApproved,
2592 humanApprovalCount: humanApprovals,
2593 gateResultGreen: hardFailures.length === 0,
2594 hasFailedGates: hardFailures.length > 0,
2595 passingCheckNames: passingNames,
2596 },
2597 required.map((r) => r.checkName)
2598 );
1e162a8Claude2599 if (!decision.allowed) {
2600 return c.redirect(
2601 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2602 decision.reasons.join(" ")
2603 )}`
2604 );
2605 }
2606 }
2607
e883329Claude2608 // Attempt the merge — with auto conflict resolution if needed
2609 const repoDir = getRepoPath(ownerName, repoName);
2610 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
2611 const hasConflicts = mergeCheck && !mergeCheck.passed;
2612
2613 if (hasConflicts && isAiReviewEnabled()) {
2614 // Use Claude to auto-resolve conflicts
2615 const mergeResult = await mergeWithAutoResolve(
2616 ownerName,
2617 repoName,
2618 pr.baseBranch,
2619 pr.headBranch,
2620 `Merge pull request #${pr.number}: ${pr.title}`
2621 );
2622
2623 if (!mergeResult.success) {
2624 return c.redirect(
2625 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
2626 );
2627 }
2628
2629 // Post a comment about the auto-resolution
2630 if (mergeResult.resolvedFiles.length > 0) {
2631 await db.insert(prComments).values({
2632 pullRequestId: pr.id,
2633 authorId: user.id,
2634 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
2635 isAiReview: true,
2636 });
2637 }
2638 } else {
2639 // Standard merge — fast-forward or clean merge
2640 const ffProc = Bun.spawn(
2641 [
2642 "git",
2643 "update-ref",
2644 `refs/heads/${pr.baseBranch}`,
2645 `refs/heads/${pr.headBranch}`,
2646 ],
2647 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2648 );
2649 const ffExit = await ffProc.exited;
2650
2651 if (ffExit !== 0) {
2652 return c.redirect(
2653 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
2654 );
2655 }
2656 }
2657
0074234Claude2658 await db
2659 .update(pullRequests)
2660 .set({
2661 state: "merged",
2662 mergedAt: new Date(),
2663 mergedBy: user.id,
2664 updatedAt: new Date(),
2665 })
2666 .where(eq(pullRequests.id, pr.id));
2667
8809b87Claude2668 // Chat notifier — fan out merge event to Slack/Discord/Teams.
2669 import("../lib/chat-notifier")
2670 .then((m) =>
2671 m.notifyChatChannels({
2672 ownerUserId: resolved.repo.ownerId,
2673 repositoryId: resolved.repo.id,
2674 event: {
2675 event: "pr.merged",
2676 repo: `${ownerName}/${repoName}`,
2677 title: `#${pr.number} ${pr.title}`,
2678 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
2679 actor: user.username,
2680 },
2681 })
2682 )
2683 .catch((err) =>
2684 console.warn(`[chat-notifier] PR merge notify failed:`, err)
2685 );
2686
d62fb36Claude2687 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
2688 // and auto-close each matching open issue with a back-link comment. Bounded
2689 // to the same repo for v1 (cross-repo refs ignored). Failures never block
2690 // the merge redirect.
2691 try {
2692 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
2693 const refs = extractClosingRefsMulti([pr.title, pr.body]);
2694 for (const n of refs) {
2695 const [issue] = await db
2696 .select()
2697 .from(issues)
2698 .where(
2699 and(
2700 eq(issues.repositoryId, resolved.repo.id),
2701 eq(issues.number, n)
2702 )
2703 )
2704 .limit(1);
2705 if (!issue || issue.state !== "open") continue;
2706 await db
2707 .update(issues)
2708 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
2709 .where(eq(issues.id, issue.id));
2710 await db.insert(issueComments).values({
2711 issueId: issue.id,
2712 authorId: user.id,
2713 body: `Closed by pull request #${pr.number}.`,
2714 });
2715 }
2716 } catch {
2717 // Never block the merge on close-keyword failures.
2718 }
2719
0074234Claude2720 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2721 }
2722);
2723
6fc53bdClaude2724// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
2725// hasn't run yet on this PR.
2726pulls.post(
2727 "/:owner/:repo/pulls/:number/ready",
2728 softAuth,
2729 requireAuth,
04f6b7fClaude2730 requireRepoAccess("write"),
6fc53bdClaude2731 async (c) => {
2732 const { owner: ownerName, repo: repoName } = c.req.param();
2733 const prNum = parseInt(c.req.param("number"), 10);
2734 const user = c.get("user")!;
2735
2736 const resolved = await resolveRepo(ownerName, repoName);
2737 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2738
2739 const [pr] = await db
2740 .select()
2741 .from(pullRequests)
2742 .where(
2743 and(
2744 eq(pullRequests.repositoryId, resolved.repo.id),
2745 eq(pullRequests.number, prNum)
2746 )
2747 )
2748 .limit(1);
2749 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2750
2751 // Only the author or repo owner can toggle draft state.
2752 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2753 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2754 }
2755
2756 if (pr.state === "open" && pr.isDraft) {
2757 await db
2758 .update(pullRequests)
2759 .set({ isDraft: false, updatedAt: new Date() })
2760 .where(eq(pullRequests.id, pr.id));
2761
2762 if (isAiReviewEnabled()) {
2763 triggerAiReview(
2764 ownerName,
2765 repoName,
2766 pr.id,
2767 pr.title,
0316dbbClaude2768 pr.body || "",
6fc53bdClaude2769 pr.baseBranch,
2770 pr.headBranch
2771 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
2772 }
2773 }
2774
2775 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2776 }
2777);
2778
2779// Convert a PR back to draft.
2780pulls.post(
2781 "/:owner/:repo/pulls/:number/draft",
2782 softAuth,
2783 requireAuth,
04f6b7fClaude2784 requireRepoAccess("write"),
6fc53bdClaude2785 async (c) => {
2786 const { owner: ownerName, repo: repoName } = c.req.param();
2787 const prNum = parseInt(c.req.param("number"), 10);
2788 const user = c.get("user")!;
2789
2790 const resolved = await resolveRepo(ownerName, repoName);
2791 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2792
2793 const [pr] = await db
2794 .select()
2795 .from(pullRequests)
2796 .where(
2797 and(
2798 eq(pullRequests.repositoryId, resolved.repo.id),
2799 eq(pullRequests.number, prNum)
2800 )
2801 )
2802 .limit(1);
2803 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2804
2805 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2806 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2807 }
2808
2809 if (pr.state === "open" && !pr.isDraft) {
2810 await db
2811 .update(pullRequests)
2812 .set({ isDraft: true, updatedAt: new Date() })
2813 .where(eq(pullRequests.id, pr.id));
2814 }
2815
2816 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2817 }
2818);
2819
0074234Claude2820// Close PR
2821pulls.post(
2822 "/:owner/:repo/pulls/:number/close",
2823 softAuth,
2824 requireAuth,
04f6b7fClaude2825 requireRepoAccess("write"),
0074234Claude2826 async (c) => {
2827 const { owner: ownerName, repo: repoName } = c.req.param();
2828 const prNum = parseInt(c.req.param("number"), 10);
2829
2830 const resolved = await resolveRepo(ownerName, repoName);
2831 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2832
2833 await db
2834 .update(pullRequests)
2835 .set({
2836 state: "closed",
2837 closedAt: new Date(),
2838 updatedAt: new Date(),
2839 })
2840 .where(
2841 and(
2842 eq(pullRequests.repositoryId, resolved.repo.id),
2843 eq(pullRequests.number, prNum)
2844 )
2845 );
2846
2847 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2848 }
2849);
2850
c3e0c07Claude2851// Re-run AI review on demand (e.g. after a force-push). Bypasses the
2852// idempotency marker via { force: true }. Write-access only.
2853pulls.post(
2854 "/:owner/:repo/pulls/:number/ai-rereview",
2855 softAuth,
2856 requireAuth,
2857 requireRepoAccess("write"),
2858 async (c) => {
2859 const { owner: ownerName, repo: repoName } = c.req.param();
2860 const prNum = parseInt(c.req.param("number"), 10);
2861 const resolved = await resolveRepo(ownerName, repoName);
2862 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2863
2864 const [pr] = await db
2865 .select()
2866 .from(pullRequests)
2867 .where(
2868 and(
2869 eq(pullRequests.repositoryId, resolved.repo.id),
2870 eq(pullRequests.number, prNum)
2871 )
2872 )
2873 .limit(1);
2874 if (!pr) {
2875 return c.redirect(`/${ownerName}/${repoName}/pulls`);
2876 }
2877
2878 if (!isAiReviewEnabled()) {
2879 return c.redirect(
2880 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2881 "AI review is not configured (ANTHROPIC_API_KEY)."
2882 )}`
2883 );
2884 }
2885
2886 // Fire-and-forget but with { force: true } to bypass the
2887 // already-reviewed marker. The function still never throws.
2888 triggerAiReview(
2889 ownerName,
2890 repoName,
2891 pr.id,
2892 pr.title || "",
2893 pr.body || "",
2894 pr.baseBranch,
2895 pr.headBranch,
2896 { force: true }
a28cedeClaude2897 ).catch((err) => {
2898 console.warn(
2899 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
2900 err instanceof Error ? err.message : err
2901 );
2902 });
c3e0c07Claude2903
2904 return c.redirect(
2905 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
2906 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
2907 )}`
2908 );
2909 }
2910);
2911
1d4ff60Claude2912// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
2913// the PR's head branch carrying just the new test files. Write-access only.
2914// Idempotent — if `ai:added-tests` was previously applied we redirect with
2915// an `info` banner instead of re-firing.
2916pulls.post(
2917 "/:owner/:repo/pulls/:number/generate-tests",
2918 softAuth,
2919 requireAuth,
2920 requireRepoAccess("write"),
2921 async (c) => {
2922 const { owner: ownerName, repo: repoName } = c.req.param();
2923 const prNum = parseInt(c.req.param("number"), 10);
2924 const resolved = await resolveRepo(ownerName, repoName);
2925 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2926
2927 const [pr] = await db
2928 .select()
2929 .from(pullRequests)
2930 .where(
2931 and(
2932 eq(pullRequests.repositoryId, resolved.repo.id),
2933 eq(pullRequests.number, prNum)
2934 )
2935 )
2936 .limit(1);
2937 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2938
2939 if (!isAiReviewEnabled()) {
2940 return c.redirect(
2941 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2942 "AI test generation is not configured (ANTHROPIC_API_KEY)."
2943 )}`
2944 );
2945 }
2946
2947 // Fire-and-forget. The lib never throws.
2948 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
2949 .then((res) => {
2950 if (!res.ok) {
2951 console.warn(
2952 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
2953 );
2954 }
2955 })
2956 .catch((err) => {
2957 console.warn(
2958 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
2959 err instanceof Error ? err.message : err
2960 );
2961 });
2962
2963 return c.redirect(
2964 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
2965 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
2966 )}`
2967 );
2968 }
2969);
2970
0074234Claude2971export default pulls;