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.tsxBlame2654 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";
b584e52Claude34import { liveCommentBannerScript } from "../lib/sse-client";
0074234Claude35import { softAuth, requireAuth } from "../middleware/auth";
36import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude37import { requireRepoAccess } from "../middleware/repo-access";
0316dbbClaude38import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
39import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude40import { generatePrSummary } from "../lib/ai-generators";
41import { isAiAvailable } from "../lib/ai-client";
534f04aClaude42import {
43 computePrRiskForPullRequest,
44 getCachedPrRisk,
45 type PrRiskScore,
46} from "../lib/pr-risk";
0316dbbClaude47import { runAllGateChecks } from "../lib/gate";
48import type { GateCheckResult } from "../lib/gate";
49import {
50 matchProtection,
51 countHumanApprovals,
52 listRequiredChecks,
53 passingCheckNames,
54 evaluateProtection,
55} from "../lib/branch-protection";
56import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude57import {
58 listBranches,
59 getRepoPath,
e883329Claude60 resolveRef,
0074234Claude61} from "../git/repository";
62import type { GitDiffFile } from "../git/repository";
63import { html } from "hono/html";
1e162a8Claude64import {
bb0f894Claude65 Flex,
66 Container,
67 Badge,
68 Button,
69 LinkButton,
70 Form,
71 FormGroup,
72 Input,
73 TextArea,
74 Select,
75 EmptyState,
76 FilterTabs,
77 TabNav,
78 List,
79 ListItem,
80 Text,
81 Alert,
82 MarkdownContent,
83 CommentBox,
84 formatRelative,
85} from "../views/ui";
0074234Claude86
87const pulls = new Hono<AuthEnv>();
88
b078860Claude89/* ──────────────────────────────────────────────────────────────────────
90 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
91 * into the issue tracker or any other route. Tokens come from layout.tsx
92 * `:root` so light/dark stays consistent if/when light mode lands.
93 * ──────────────────────────────────────────────────────────────────── */
94const PRS_LIST_STYLES = `
95 .prs-hero {
96 position: relative;
97 margin: 0 0 var(--space-5);
98 padding: 22px 26px 24px;
99 background: var(--bg-elevated);
100 border: 1px solid var(--border);
101 border-radius: 16px;
102 overflow: hidden;
103 }
104 .prs-hero::before {
105 content: '';
106 position: absolute; top: 0; left: 0; right: 0;
107 height: 2px;
108 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
109 opacity: 0.7;
110 pointer-events: none;
111 }
112 .prs-hero-inner {
113 position: relative;
114 display: flex;
115 justify-content: space-between;
116 align-items: flex-end;
117 gap: 20px;
118 flex-wrap: wrap;
119 }
120 .prs-hero-text { flex: 1; min-width: 280px; }
121 .prs-hero-eyebrow {
122 font-size: 12px;
123 color: var(--text-muted);
124 text-transform: uppercase;
125 letter-spacing: 0.08em;
126 font-weight: 600;
127 margin-bottom: 8px;
128 }
129 .prs-hero-title {
130 font-family: var(--font-display);
131 font-size: clamp(26px, 3.4vw, 34px);
132 font-weight: 800;
133 letter-spacing: -0.025em;
134 line-height: 1.06;
135 margin: 0 0 8px;
136 color: var(--text-strong);
137 }
138 .prs-hero-title .gradient-text {
139 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
140 -webkit-background-clip: text;
141 background-clip: text;
142 -webkit-text-fill-color: transparent;
143 color: transparent;
144 }
145 .prs-hero-sub {
146 font-size: 14.5px;
147 color: var(--text-muted);
148 margin: 0;
149 line-height: 1.5;
150 max-width: 620px;
151 }
152 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
153 .prs-cta {
154 display: inline-flex; align-items: center; gap: 6px;
155 padding: 10px 16px;
156 border-radius: 10px;
157 font-size: 13.5px;
158 font-weight: 600;
159 color: #fff;
160 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
161 border: 1px solid rgba(140,109,255,0.55);
162 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
163 text-decoration: none;
164 transition: transform 120ms ease, box-shadow 160ms ease;
165 }
166 .prs-cta:hover {
167 transform: translateY(-1px);
168 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
169 color: #fff;
170 }
171
172 .prs-tabs {
173 display: flex; flex-wrap: wrap; gap: 6px;
174 margin: 0 0 18px;
175 padding: 6px;
176 background: var(--bg-secondary);
177 border: 1px solid var(--border);
178 border-radius: 12px;
179 }
180 .prs-tab {
181 display: inline-flex; align-items: center; gap: 8px;
182 padding: 7px 13px;
183 font-size: 13px;
184 font-weight: 500;
185 color: var(--text-muted);
186 border-radius: 8px;
187 text-decoration: none;
188 transition: background 120ms ease, color 120ms ease;
189 }
190 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
191 .prs-tab.is-active {
192 background: var(--bg-elevated);
193 color: var(--text-strong);
194 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
195 }
196 .prs-tab-count {
197 display: inline-flex; align-items: center; justify-content: center;
198 min-width: 22px; padding: 2px 7px;
199 font-size: 11.5px;
200 font-weight: 600;
201 border-radius: 9999px;
202 background: var(--bg-tertiary);
203 color: var(--text-muted);
204 }
205 .prs-tab.is-active .prs-tab-count {
206 background: rgba(140,109,255,0.18);
207 color: var(--text-link);
208 }
209
210 .prs-list { display: flex; flex-direction: column; gap: 10px; }
211 .prs-row {
212 position: relative;
213 display: flex; align-items: flex-start; gap: 14px;
214 padding: 14px 16px;
215 background: var(--bg-elevated);
216 border: 1px solid var(--border);
217 border-radius: 12px;
218 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
219 }
220 .prs-row:hover {
221 transform: translateY(-1px);
222 border-color: var(--border-strong);
223 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
224 }
225 .prs-row-icon {
226 flex: 0 0 auto;
227 width: 26px; height: 26px;
228 display: inline-flex; align-items: center; justify-content: center;
229 border-radius: 9999px;
230 font-size: 13px;
231 margin-top: 2px;
232 }
233 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
234 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
235 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
236 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
237 .prs-row-body { flex: 1; min-width: 0; }
238 .prs-row-title {
239 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
240 font-size: 15px; font-weight: 600;
241 color: var(--text-strong);
242 line-height: 1.35;
243 margin: 0 0 6px;
244 }
245 .prs-row-number {
246 color: var(--text-muted);
247 font-weight: 400;
248 font-size: 14px;
249 }
250 .prs-row-meta {
251 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
252 font-size: 12.5px;
253 color: var(--text-muted);
254 }
255 .prs-branch-chips {
256 display: inline-flex; align-items: center; gap: 6px;
257 font-family: var(--font-mono);
258 font-size: 11.5px;
259 }
260 .prs-branch-chip {
261 padding: 2px 8px;
262 border-radius: 9999px;
263 background: var(--bg-tertiary);
264 border: 1px solid var(--border);
265 color: var(--text);
266 }
267 .prs-branch-arrow {
268 color: var(--text-faint);
269 font-size: 11px;
270 }
271 .prs-row-tags {
272 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
273 margin-left: auto;
274 }
275 .prs-tag {
276 display: inline-flex; align-items: center; gap: 4px;
277 padding: 2px 8px;
278 font-size: 11px;
279 font-weight: 600;
280 border-radius: 9999px;
281 border: 1px solid var(--border);
282 background: var(--bg-secondary);
283 color: var(--text-muted);
284 line-height: 1.6;
285 }
286 .prs-tag.is-draft {
287 color: var(--text-muted);
288 border-color: var(--border-strong);
289 }
290 .prs-tag.is-merged {
291 color: var(--text-link);
292 border-color: rgba(140,109,255,0.45);
293 background: rgba(140,109,255,0.10);
294 }
295
296 .prs-empty {
ea9ed4cClaude297 position: relative;
298 padding: 56px 32px;
b078860Claude299 text-align: center;
300 border: 1px dashed var(--border);
ea9ed4cClaude301 border-radius: 16px;
302 background: var(--bg-elevated);
b078860Claude303 color: var(--text-muted);
ea9ed4cClaude304 overflow: hidden;
b078860Claude305 }
ea9ed4cClaude306 .prs-empty::before {
307 content: '';
308 position: absolute;
309 inset: -40% -20% auto auto;
310 width: 320px; height: 320px;
311 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
312 filter: blur(70px);
313 opacity: 0.55;
314 pointer-events: none;
315 animation: prsEmptyOrb 16s ease-in-out infinite;
316 }
317 @keyframes prsEmptyOrb {
318 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
319 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
320 }
321 @media (prefers-reduced-motion: reduce) {
322 .prs-empty::before { animation: none; }
323 }
324 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude325 .prs-empty strong {
326 display: block;
327 color: var(--text-strong);
ea9ed4cClaude328 font-family: var(--font-display);
329 font-size: 22px;
330 font-weight: 700;
331 letter-spacing: -0.018em;
332 margin-bottom: 2px;
333 }
334 .prs-empty-sub {
335 font-size: 14.5px;
336 color: var(--text-muted);
337 line-height: 1.55;
338 max-width: 460px;
339 margin: 0 0 18px;
b078860Claude340 }
ea9ed4cClaude341 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude342
343 @media (max-width: 720px) {
344 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
345 .prs-hero-actions { width: 100%; }
346 .prs-row-tags { margin-left: 0; }
347 }
f1dc7c7Claude348
349 /* Additional mobile rules. Additive only. */
350 @media (max-width: 720px) {
351 .prs-hero { padding: 18px 18px 20px; }
352 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
353 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
354 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
355 .prs-row { padding: 12px 14px; gap: 10px; }
356 .prs-row-icon { width: 24px; height: 24px; }
357 }
b078860Claude358`;
359
360/* ──────────────────────────────────────────────────────────────────────
361 * Inline CSS for the detail page. Same `.prs-*` namespace.
362 * ──────────────────────────────────────────────────────────────────── */
363const PRS_DETAIL_STYLES = `
364 .prs-detail-hero {
365 position: relative;
366 margin: 0 0 var(--space-4);
367 padding: 24px 26px;
368 background: var(--bg-elevated);
369 border: 1px solid var(--border);
370 border-radius: 16px;
371 overflow: hidden;
372 }
373 .prs-detail-hero::before {
374 content: '';
375 position: absolute; top: 0; left: 0; right: 0;
376 height: 2px;
377 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
378 opacity: 0.7;
379 pointer-events: none;
380 }
381 .prs-detail-title {
382 font-family: var(--font-display);
383 font-size: clamp(22px, 2.6vw, 28px);
384 font-weight: 700;
385 letter-spacing: -0.022em;
386 line-height: 1.2;
387 color: var(--text-strong);
388 margin: 0 0 12px;
389 }
390 .prs-detail-num {
391 color: var(--text-muted);
392 font-weight: 400;
393 }
394 .prs-state-pill {
395 display: inline-flex; align-items: center; gap: 6px;
396 padding: 6px 12px;
397 border-radius: 9999px;
398 font-size: 12.5px;
399 font-weight: 600;
400 line-height: 1;
401 border: 1px solid transparent;
402 }
403 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
404 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
405 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
406 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
407
408 .prs-detail-meta {
409 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
410 font-size: 13px;
411 color: var(--text-muted);
412 }
413 .prs-detail-meta strong { color: var(--text); }
414 .prs-detail-branches {
415 display: inline-flex; align-items: center; gap: 6px;
416 font-family: var(--font-mono);
417 font-size: 12px;
418 }
419 .prs-branch-pill {
420 padding: 3px 9px;
421 border-radius: 9999px;
422 background: var(--bg-tertiary);
423 border: 1px solid var(--border);
424 color: var(--text);
425 }
426 .prs-branch-pill.is-head { color: var(--text-strong); }
427 .prs-branch-arrow-lg {
428 color: var(--accent);
429 font-size: 14px;
430 font-weight: 700;
431 }
432
433 .prs-detail-actions {
434 display: inline-flex; gap: 8px; margin-left: auto;
435 }
436
437 .prs-detail-tabs {
438 display: flex; gap: 4px;
439 margin: 0 0 16px;
440 border-bottom: 1px solid var(--border);
441 }
442 .prs-detail-tab {
443 padding: 10px 14px;
444 font-size: 13.5px;
445 font-weight: 500;
446 color: var(--text-muted);
447 text-decoration: none;
448 border-bottom: 2px solid transparent;
449 transition: color 120ms ease, border-color 120ms ease;
450 margin-bottom: -1px;
451 }
452 .prs-detail-tab:hover { color: var(--text); }
453 .prs-detail-tab.is-active {
454 color: var(--text-strong);
455 border-bottom-color: var(--accent);
456 }
457 .prs-detail-tab-count {
458 display: inline-flex; align-items: center; justify-content: center;
459 min-width: 20px; padding: 0 6px; margin-left: 6px;
460 height: 18px;
461 font-size: 11px;
462 font-weight: 600;
463 border-radius: 9999px;
464 background: var(--bg-tertiary);
465 color: var(--text-muted);
466 }
467
468 /* Gate / check status section */
469 .prs-gate-card {
470 margin-top: 20px;
471 background: var(--bg-elevated);
472 border: 1px solid var(--border);
473 border-radius: 14px;
474 overflow: hidden;
475 }
476 .prs-gate-head {
477 display: flex; align-items: center; gap: 10px;
478 padding: 14px 18px;
479 border-bottom: 1px solid var(--border);
480 }
481 .prs-gate-head h3 {
482 margin: 0;
483 font-size: 14px;
484 font-weight: 600;
485 color: var(--text-strong);
486 }
487 .prs-gate-summary {
488 margin-left: auto;
489 font-size: 12px;
490 color: var(--text-muted);
491 }
492 .prs-gate-row {
493 display: flex; align-items: center; gap: 12px;
494 padding: 12px 18px;
495 border-bottom: 1px solid var(--border-subtle);
496 }
497 .prs-gate-row:last-child { border-bottom: 0; }
498 .prs-gate-icon {
499 flex: 0 0 auto;
500 width: 22px; height: 22px;
501 display: inline-flex; align-items: center; justify-content: center;
502 border-radius: 9999px;
503 font-size: 12px;
504 font-weight: 700;
505 }
506 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
507 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
508 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
509 .prs-gate-name {
510 font-size: 13px;
511 font-weight: 600;
512 color: var(--text);
513 min-width: 140px;
514 }
515 .prs-gate-details {
516 flex: 1; min-width: 0;
517 font-size: 12.5px;
518 color: var(--text-muted);
519 }
520 .prs-gate-pill {
521 flex: 0 0 auto;
522 padding: 3px 10px;
523 border-radius: 9999px;
524 font-size: 11px;
525 font-weight: 600;
526 line-height: 1.5;
527 border: 1px solid transparent;
528 }
529 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
530 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
531 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
532 .prs-gate-footer {
533 padding: 12px 18px;
534 background: var(--bg-secondary);
535 font-size: 12px;
536 color: var(--text-muted);
537 }
538
539 /* Comment cards */
540 .prs-comment {
541 margin-top: 14px;
542 background: var(--bg-elevated);
543 border: 1px solid var(--border);
544 border-radius: 12px;
545 overflow: hidden;
546 }
547 .prs-comment-head {
548 display: flex; align-items: center; gap: 10px;
549 padding: 10px 14px;
550 background: var(--bg-secondary);
551 border-bottom: 1px solid var(--border);
552 font-size: 13px;
553 flex-wrap: wrap;
554 }
555 .prs-comment-head strong { color: var(--text-strong); }
556 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
557 .prs-comment-loc {
558 font-family: var(--font-mono);
559 font-size: 11.5px;
560 color: var(--text-muted);
561 background: var(--bg-tertiary);
562 padding: 2px 8px;
563 border-radius: 6px;
564 }
565 .prs-comment-body { padding: 14px 18px; }
566 .prs-comment.is-ai {
567 border-color: rgba(140,109,255,0.45);
568 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
569 }
570 .prs-comment.is-ai .prs-comment-head {
571 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
572 border-bottom-color: rgba(140,109,255,0.30);
573 }
574 .prs-ai-badge {
575 display: inline-flex; align-items: center; gap: 4px;
576 padding: 2px 9px;
577 font-size: 10.5px;
578 font-weight: 700;
579 letter-spacing: 0.04em;
580 text-transform: uppercase;
581 color: #fff;
582 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
583 border-radius: 9999px;
584 }
585
586 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
587 .prs-files-card {
588 margin-top: 18px;
589 padding: 14px 18px;
590 display: flex; align-items: center; gap: 14px;
591 background: var(--bg-elevated);
592 border: 1px solid var(--border);
593 border-radius: 12px;
594 text-decoration: none;
595 color: inherit;
596 transition: border-color 120ms ease, transform 140ms ease;
597 }
598 .prs-files-card:hover {
599 border-color: rgba(140,109,255,0.45);
600 transform: translateY(-1px);
601 }
602 .prs-files-card-icon {
603 width: 36px; height: 36px;
604 display: inline-flex; align-items: center; justify-content: center;
605 border-radius: 10px;
606 background: rgba(140,109,255,0.12);
607 color: var(--text-link);
608 font-size: 18px;
609 }
610 .prs-files-card-text { flex: 1; min-width: 0; }
611 .prs-files-card-title {
612 font-size: 14px;
613 font-weight: 600;
614 color: var(--text-strong);
615 margin: 0 0 2px;
616 }
617 .prs-files-card-sub {
618 font-size: 12.5px;
619 color: var(--text-muted);
620 margin: 0;
621 }
622 .prs-files-card-cta {
623 font-size: 12.5px;
624 color: var(--text-link);
625 font-weight: 600;
626 }
627
628 /* Merge area */
629 .prs-merge-card {
630 position: relative;
631 margin-top: 22px;
632 padding: 18px;
633 background: var(--bg-elevated);
634 border-radius: 14px;
635 overflow: hidden;
636 }
637 .prs-merge-card::before {
638 content: '';
639 position: absolute; inset: 0;
640 padding: 1px;
641 border-radius: 14px;
642 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
643 -webkit-mask:
644 linear-gradient(#000 0 0) content-box,
645 linear-gradient(#000 0 0);
646 -webkit-mask-composite: xor;
647 mask-composite: exclude;
648 pointer-events: none;
649 }
650 .prs-merge-card.is-closed::before { background: var(--border-strong); }
651 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
652 .prs-merge-head {
653 display: flex; align-items: center; gap: 12px;
654 margin-bottom: 12px;
655 }
656 .prs-merge-head strong {
657 font-family: var(--font-display);
658 font-size: 15px;
659 color: var(--text-strong);
660 font-weight: 700;
661 }
662 .prs-merge-sub {
663 font-size: 13px;
664 color: var(--text-muted);
665 margin: 0 0 12px;
666 }
667 .prs-merge-actions {
668 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
669 }
670 .prs-merge-btn {
671 display: inline-flex; align-items: center; gap: 6px;
672 padding: 9px 16px;
673 border-radius: 10px;
674 font-size: 13.5px;
675 font-weight: 600;
676 color: #fff;
677 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
678 border: 1px solid rgba(52,211,153,0.55);
679 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
680 cursor: pointer;
681 transition: transform 120ms ease, box-shadow 160ms ease;
682 }
683 .prs-merge-btn:hover {
684 transform: translateY(-1px);
685 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
686 }
687 .prs-merge-btn[disabled],
688 .prs-merge-btn.is-disabled {
689 opacity: 0.55;
690 cursor: not-allowed;
691 transform: none;
692 box-shadow: none;
693 }
694 .prs-merge-ready-btn {
695 display: inline-flex; align-items: center; gap: 6px;
696 padding: 9px 16px;
697 border-radius: 10px;
698 font-size: 13.5px;
699 font-weight: 600;
700 color: #fff;
701 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
702 border: 1px solid rgba(140,109,255,0.55);
703 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
704 cursor: pointer;
705 transition: transform 120ms ease, box-shadow 160ms ease;
706 }
707 .prs-merge-ready-btn:hover {
708 transform: translateY(-1px);
709 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
710 }
711 .prs-merge-back-draft {
712 background: none; border: 1px solid var(--border-strong);
713 color: var(--text-muted);
714 padding: 9px 14px; border-radius: 10px;
715 font-size: 13px; cursor: pointer;
716 }
717 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
718
719 /* Inline form helpers */
720 .prs-inline-form { display: inline-flex; }
721
722 /* Comment composer */
723 .prs-composer { margin-top: 22px; }
724 .prs-composer textarea {
725 border-radius: 12px;
726 }
727
728 @media (max-width: 720px) {
729 .prs-detail-actions { margin-left: 0; }
730 .prs-merge-actions { width: 100%; }
731 .prs-merge-actions > * { flex: 1; min-width: 0; }
732 }
f1dc7c7Claude733
734 /* Additional mobile rules. Additive only. */
735 @media (max-width: 720px) {
736 .prs-detail-hero { padding: 18px; }
737 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
738 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
739 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
740 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
741 .prs-gate-name { min-width: 0; }
742 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
743 .prs-gate-summary { margin-left: 0; }
744 .prs-merge-btn,
745 .prs-merge-ready-btn,
746 .prs-merge-back-draft { min-height: 44px; }
747 .prs-comment-body { padding: 12px 14px; }
748 .prs-comment-head { padding: 10px 12px; }
749 .prs-files-card { padding: 12px 14px; }
750 }
3c03977Claude751
752 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
753 .live-pill {
754 display: inline-flex;
755 align-items: center;
756 gap: 8px;
757 padding: 4px 10px 4px 8px;
758 margin-left: 6px;
759 background: var(--bg-elevated);
760 border: 1px solid var(--border);
761 border-radius: 9999px;
762 font-size: 12px;
763 color: var(--text-muted);
764 line-height: 1;
765 vertical-align: middle;
766 }
767 .live-pill.is-busy { color: var(--text); }
768 .live-pill-dot {
769 width: 8px; height: 8px;
770 border-radius: 9999px;
771 background: #34d399;
772 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
773 animation: live-pulse 1.6s ease-in-out infinite;
774 }
775 @keyframes live-pulse {
776 0%, 100% { opacity: 1; }
777 50% { opacity: 0.55; }
778 }
779 .live-avatars {
780 display: inline-flex;
781 margin-left: 2px;
782 }
783 .live-avatar {
784 display: inline-flex;
785 align-items: center;
786 justify-content: center;
787 width: 22px; height: 22px;
788 border-radius: 9999px;
789 font-size: 10px;
790 font-weight: 700;
791 color: #0b1020;
792 margin-left: -6px;
793 border: 2px solid var(--bg-elevated);
794 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
795 }
796 .live-avatar:first-child { margin-left: 0; }
797 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
798 .live-cursor-host {
799 position: relative;
800 }
801 .live-cursor-overlay {
802 position: absolute;
803 inset: 0;
804 pointer-events: none;
805 overflow: hidden;
806 border-radius: inherit;
807 }
808 .live-cursor {
809 position: absolute;
810 width: 2px;
811 height: 18px;
812 border-radius: 2px;
813 transform: translate(-1px, 0);
814 transition: transform 80ms linear, opacity 200ms ease;
815 }
816 .live-cursor::after {
817 content: attr(data-label);
818 position: absolute;
819 top: -16px;
820 left: -2px;
821 font-size: 10px;
822 line-height: 1;
823 color: #0b1020;
824 background: inherit;
825 padding: 2px 5px;
826 border-radius: 4px 4px 4px 0;
827 white-space: nowrap;
828 font-weight: 600;
829 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
830 }
831 .live-cursor.is-idle { opacity: 0.4; }
832 .live-edit-tag {
833 display: inline-block;
834 margin-left: 6px;
835 padding: 1px 6px;
836 font-size: 10px;
837 font-weight: 600;
838 letter-spacing: 0.02em;
839 color: #0b1020;
840 border-radius: 9999px;
841 }
b078860Claude842`;
843
81c73c1Claude844/**
845 * Tiny inline JS that drives the "Suggest description with AI" button.
846 * On click, gathers form values, POSTs JSON to the given endpoint, and
847 * pipes the response into the #pr-body textarea. All DOM lookups are
848 * defensive — element absence is a silent no-op.
849 *
850 * Built as a string template so it lives next to its server-side caller
851 * and there is no bundler dependency. The endpoint URL is JSON-escaped
852 * to avoid </script> breakouts.
853 */
854function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
855 const url = JSON.stringify(endpointUrl)
856 .split("<").join("\\u003C")
857 .split(">").join("\\u003E")
858 .split("&").join("\\u0026");
859 return (
860 "(function(){try{" +
861 "var btn=document.getElementById('ai-suggest-desc');" +
862 "var status=document.getElementById('ai-suggest-status');" +
863 "var body=document.getElementById('pr-body');" +
864 "var form=btn&&btn.closest&&btn.closest('form');" +
865 "if(!btn||!body||!form)return;" +
866 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
867 "var fd=new FormData(form);" +
868 "var title=String(fd.get('title')||'').trim();" +
869 "var base=String(fd.get('base')||'').trim();" +
870 "var head=String(fd.get('head')||'').trim();" +
871 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
872 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
873 "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'})" +
874 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
875 ".then(function(j){btn.disabled=false;" +
876 "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;}}" +
877 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
878 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
879 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
880 "});" +
881 "}catch(e){}})();"
882 );
883}
884
3c03977Claude885/**
886 * Live co-editing client. Connects to the per-PR SSE feed and:
887 * - Maintains a "Live: N editing" pill in the PR header (avatars +
888 * status colour per user).
889 * - Renders tinted cursor caret overlays inside #pr-body and every
890 * `[data-live-field]` element.
891 * - Broadcasts the local user's cursor position (selectionStart /
892 * selectionEnd) debounced at 100ms.
893 * - Broadcasts content patches (`replace` of the whole textarea —
894 * last-write-wins v1) debounced at 250ms.
895 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
896 * to the matching local field if untouched.
897 *
898 * All endpoint URLs are JSON-escaped via safe replacements so they
899 * can't break out of the <script> tag.
900 */
901function LIVE_COEDIT_SCRIPT(prId: string): string {
902 const idJson = JSON.stringify(prId)
903 .split("<").join("\\u003C")
904 .split(">").join("\\u003E")
905 .split("&").join("\\u0026");
906 return (
907 "(function(){try{" +
908 "if(typeof EventSource==='undefined')return;" +
909 "var prId=" + idJson + ";" +
910 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
911 "var pill=document.getElementById('live-pill');" +
912 "var avEl=document.getElementById('live-avatars');" +
913 "var countEl=document.getElementById('live-count');" +
914 "var sessionId=null;var myColor=null;" +
915 "var presence={};" + // sessionId -> {color,status,userId,initials}
916 "var lastApplied={};" + // field -> last server value (for echo suppression)
917 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
918 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
919 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
920 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
921 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
922 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
923 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
924 "avEl.innerHTML=html;}}" +
925 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
926 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
927 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
928 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
929 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
930 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
931 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
932 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
933 "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);}" +
934 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
935 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
936 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
937 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
938 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
939 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
940 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
941 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
942 "}catch(e){}" +
943 "c.style.transform='translate('+x+'px,'+y+'px)';" +
944 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
945 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
946 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
947 "var es;var delay=1000;" +
948 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
949 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
950 "(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){}});" +
951 "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){}});" +
952 "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){}});" +
953 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
954 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
955 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
956 "var patch=d.patch;if(!patch||!patch.field)return;" +
957 "var ta=fieldEl(patch.field);if(!ta)return;" +
958 "if(document.activeElement===ta)return;" + // don't trample local typing
959 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
960 "}catch(e){}});" +
961 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
962 "}connect();" +
963 "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){}}" +
964 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
965 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
966 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
967 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
968 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
969 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
970 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
971 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
972 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
973 "}" +
974 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
975 "var live=document.querySelectorAll('[data-live-field]');" +
976 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
977 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
978 "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){}});" +
979 "}catch(e){}})();"
980 );
981}
982
0074234Claude983async function resolveRepo(ownerName: string, repoName: string) {
984 const [owner] = await db
985 .select()
986 .from(users)
987 .where(eq(users.username, ownerName))
988 .limit(1);
989 if (!owner) return null;
990 const [repo] = await db
991 .select()
992 .from(repositories)
993 .where(
994 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
995 )
996 .limit(1);
997 if (!repo) return null;
998 return { owner, repo };
999}
1000
1001// PR Nav helper
1002const PrNav = ({
1003 owner,
1004 repo,
1005 active,
1006}: {
1007 owner: string;
1008 repo: string;
1009 active: "code" | "issues" | "pulls" | "commits";
1010}) => (
bb0f894Claude1011 <TabNav
1012 tabs={[
1013 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1014 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1015 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1016 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1017 ]}
1018 />
0074234Claude1019);
1020
534f04aClaude1021/**
1022 * Block M3 — pre-merge risk score card. Pure presentational helper.
1023 * Rendered in the conversation tab above the gate checks block. Hidden
1024 * entirely when the PR is closed/merged or there is nothing cached and
1025 * nothing in-flight.
1026 */
1027function PrRiskCard({
1028 risk,
1029 calculating,
1030}: {
1031 risk: PrRiskScore | null;
1032 calculating: boolean;
1033}) {
1034 if (!risk) {
1035 return (
1036 <div
1037 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1038 >
1039 <strong style="font-size: 13px; color: var(--text)">
1040 Risk score: calculating…
1041 </strong>
1042 <div style="font-size: 12px; margin-top: 4px">
1043 Refresh in a moment to see the pre-merge risk score for this PR.
1044 </div>
1045 </div>
1046 );
1047 }
1048
1049 const palette = riskBandPalette(risk.band);
1050 const label = riskBandLabel(risk.band);
1051
1052 return (
1053 <div
1054 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1055 >
1056 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1057 <strong>Risk score:</strong>
1058 <span style={`color:${palette.border};font-weight:600`}>
1059 {palette.icon} {label} ({risk.score}/10)
1060 </span>
1061 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1062 {risk.commitSha.slice(0, 7)}
1063 </span>
1064 </div>
1065 {risk.aiSummary && (
1066 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1067 {risk.aiSummary}
1068 </div>
1069 )}
1070 <details style="margin-top:10px">
1071 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1072 See full signal breakdown
1073 </summary>
1074 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1075 <li>files changed: {risk.signals.filesChanged}</li>
1076 <li>
1077 lines added/removed: {risk.signals.linesAdded} /{" "}
1078 {risk.signals.linesRemoved}
1079 </li>
1080 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1081 <li>
1082 schema migration touched:{" "}
1083 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1084 </li>
1085 <li>
1086 locked / sensitive path touched:{" "}
1087 {risk.signals.lockedPathTouched ? "yes" : "no"}
1088 </li>
1089 <li>
1090 adds new dependency:{" "}
1091 {risk.signals.addsNewDependency ? "yes" : "no"}
1092 </li>
1093 <li>
1094 bumps major dependency:{" "}
1095 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1096 </li>
1097 <li>
1098 tests added for new code:{" "}
1099 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1100 </li>
1101 <li>
1102 diff-minus-test ratio:{" "}
1103 {risk.signals.diffMinusTestRatio.toFixed(2)}
1104 </li>
1105 </ul>
1106 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1107 How is this calculated? The score is a transparent sum of
1108 weighted signals — see <code>src/lib/pr-risk.ts</code>
1109 {" "}<code>computePrRiskScore</code>.
1110 </div>
1111 </details>
1112 {calculating && (
1113 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1114 (recomputing for the latest commit — refresh to update)
1115 </div>
1116 )}
1117 </div>
1118 );
1119}
1120
1121function riskBandPalette(band: PrRiskScore["band"]): {
1122 border: string;
1123 icon: string;
1124} {
1125 switch (band) {
1126 case "low":
1127 return { border: "var(--green)", icon: "" };
1128 case "medium":
1129 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1130 case "high":
1131 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1132 case "critical":
1133 return { border: "var(--red)", icon: "\u{1F6D1}" };
1134 }
1135}
1136
1137function riskBandLabel(band: PrRiskScore["band"]): string {
1138 switch (band) {
1139 case "low":
1140 return "LOW";
1141 case "medium":
1142 return "MEDIUM";
1143 case "high":
1144 return "HIGH";
1145 case "critical":
1146 return "CRITICAL";
1147 }
1148}
1149
0074234Claude1150// List PRs
04f6b7fClaude1151pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1152 const { owner: ownerName, repo: repoName } = c.req.param();
1153 const user = c.get("user");
1154 const state = c.req.query("state") || "open";
1155
ea9ed4cClaude1156 // ── Loading skeleton (flag-gated) ──
1157 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
1158 // the user see the page structure before counts + select resolve.
1159 // Behind a flag for now — we don't ship flashes.
1160 if (c.req.query("skeleton") === "1") {
1161 return c.html(
1162 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1163 <RepoHeader owner={ownerName} repo={repoName} />
1164 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1165 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1166 <style
1167 dangerouslySetInnerHTML={{
1168 __html: `
1169 .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; }
1170 @keyframes prsSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1171 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
1172 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
1173 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
1174 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
1175 .prs-skel-row { height: 76px; border-radius: 12px; }
1176 `,
1177 }}
1178 />
1179 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
1180 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
1181 <div class="prs-skel-list" aria-hidden="true">
1182 {Array.from({ length: 6 }).map(() => (
1183 <div class="prs-skel prs-skel-row" />
1184 ))}
1185 </div>
1186 <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">
1187 Loading pull requests for {ownerName}/{repoName}…
1188 </span>
1189 </Layout>
1190 );
1191 }
1192
0074234Claude1193 const resolved = await resolveRepo(ownerName, repoName);
1194 if (!resolved) return c.notFound();
1195
6fc53bdClaude1196 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
1197 const stateFilter =
1198 state === "draft"
1199 ? and(
1200 eq(pullRequests.state, "open"),
1201 eq(pullRequests.isDraft, true)
1202 )
1203 : eq(pullRequests.state, state);
1204
0074234Claude1205 const prList = await db
1206 .select({
1207 pr: pullRequests,
1208 author: { username: users.username },
1209 })
1210 .from(pullRequests)
1211 .innerJoin(users, eq(pullRequests.authorId, users.id))
1212 .where(
6fc53bdClaude1213 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude1214 )
1215 .orderBy(desc(pullRequests.createdAt));
1216
1217 const [counts] = await db
1218 .select({
1219 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude1220 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude1221 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
1222 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
1223 })
1224 .from(pullRequests)
1225 .where(eq(pullRequests.repositoryId, resolved.repo.id));
1226
b078860Claude1227 const openCount = counts?.open ?? 0;
1228 const mergedCount = counts?.merged ?? 0;
1229 const closedCount = counts?.closed ?? 0;
1230 const draftCount = counts?.draft ?? 0;
1231 const allCount = openCount + mergedCount + closedCount;
1232
1233 // "All" is presentational only — the DB query for state='all' matches
1234 // nothing, so we render a friendlier empty state when picked. We do NOT
1235 // change the query logic to keep this commit purely visual.
1236 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
1237 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
1238 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
1239 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
1240 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
1241 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
1242 ];
1243 const isAllState = state === "all";
1244
0074234Claude1245 return c.html(
1246 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1247 <RepoHeader owner={ownerName} repo={repoName} />
1248 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude1249 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1250
1251 <div class="prs-hero">
1252 <div class="prs-hero-inner">
1253 <div class="prs-hero-text">
1254 <div class="prs-hero-eyebrow">Pull requests</div>
1255 <h1 class="prs-hero-title">
1256 Review, <span class="gradient-text">merge with AI</span>.
1257 </h1>
1258 <p class="prs-hero-sub">
1259 {openCount === 0 && allCount === 0
1260 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
1261 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
1262 </p>
1263 </div>
1264 {user && (
1265 <div class="prs-hero-actions">
1266 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
1267 + New pull request
1268 </a>
1269 </div>
1270 )}
1271 </div>
1272 </div>
1273
1274 <nav class="prs-tabs" aria-label="Pull request filters">
1275 {tabPills.map((t) => {
1276 const isActive =
1277 state === t.key ||
1278 (t.key === "open" &&
1279 state !== "merged" &&
1280 state !== "closed" &&
1281 state !== "all" &&
1282 state !== "draft");
1283 return (
1284 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
1285 <span>{t.label}</span>
1286 <span class="prs-tab-count">{t.count}</span>
1287 </a>
1288 );
1289 })}
1290 </nav>
1291
0074234Claude1292 {prList.length === 0 ? (
b078860Claude1293 <div class="prs-empty">
ea9ed4cClaude1294 <div class="prs-empty-inner">
1295 <strong>
1296 {isAllState
1297 ? "Pick a filter above to browse PRs."
1298 : `No ${state} pull requests.`}
1299 </strong>
1300 <p class="prs-empty-sub">
1301 {state === "open"
1302 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
1303 : isAllState
1304 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1305 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
1306 </p>
1307 <div class="prs-empty-cta">
1308 {user && state === "open" && (
1309 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
1310 + New pull request
1311 </a>
1312 )}
1313 {state !== "open" && (
1314 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
1315 View open PRs
1316 </a>
1317 )}
1318 <a href={`/${ownerName}/${repoName}`} class="btn">
1319 Back to code
1320 </a>
1321 </div>
1322 </div>
b078860Claude1323 </div>
0074234Claude1324 ) : (
b078860Claude1325 <div class="prs-list">
1326 {prList.map(({ pr, author }) => {
1327 const stateClass =
1328 pr.state === "open"
1329 ? pr.isDraft
1330 ? "state-draft"
1331 : "state-open"
1332 : pr.state === "merged"
1333 ? "state-merged"
1334 : "state-closed";
1335 const icon =
1336 pr.state === "open"
1337 ? pr.isDraft
1338 ? "◌"
1339 : "○"
1340 : pr.state === "merged"
1341 ? "⮌"
1342 : "✓";
1343 return (
1344 <a
1345 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1346 class="prs-row"
1347 style="text-decoration:none;color:inherit"
0074234Claude1348 >
b078860Claude1349 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
1350 {icon}
0074234Claude1351 </div>
b078860Claude1352 <div class="prs-row-body">
1353 <h3 class="prs-row-title">
1354 <span>{pr.title}</span>
1355 <span class="prs-row-number">#{pr.number}</span>
1356 </h3>
1357 <div class="prs-row-meta">
1358 <span
1359 class="prs-branch-chips"
1360 title={`${pr.headBranch} into ${pr.baseBranch}`}
1361 >
1362 <span class="prs-branch-chip">{pr.headBranch}</span>
1363 <span class="prs-branch-arrow">{"→"}</span>
1364 <span class="prs-branch-chip">{pr.baseBranch}</span>
1365 </span>
1366 <span>
1367 by{" "}
1368 <strong style="color:var(--text)">
1369 {author.username}
1370 </strong>{" "}
1371 {formatRelative(pr.createdAt)}
1372 </span>
1373 <span class="prs-row-tags">
1374 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
1375 {pr.state === "merged" && (
1376 <span class="prs-tag is-merged">Merged</span>
1377 )}
1378 </span>
1379 </div>
0074234Claude1380 </div>
b078860Claude1381 </a>
1382 );
1383 })}
1384 </div>
0074234Claude1385 )}
1386 </Layout>
1387 );
1388});
1389
1390// New PR form
1391pulls.get(
1392 "/:owner/:repo/pulls/new",
1393 softAuth,
1394 requireAuth,
04f6b7fClaude1395 requireRepoAccess("write"),
0074234Claude1396 async (c) => {
1397 const { owner: ownerName, repo: repoName } = c.req.param();
1398 const user = c.get("user")!;
1399 const branches = await listBranches(ownerName, repoName);
1400 const error = c.req.query("error");
1401 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude1402 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude1403
1404 return c.html(
1405 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
1406 <RepoHeader owner={ownerName} repo={repoName} />
1407 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude1408 <Container maxWidth={800}>
1409 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude1410 {error && (
bb0f894Claude1411 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude1412 )}
0316dbbClaude1413 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
1414 <Flex gap={12} align="center" style="margin-bottom: 16px">
1415 <Select name="base">
0074234Claude1416 {branches.map((b) => (
1417 <option value={b} selected={b === defaultBase}>
1418 {b}
1419 </option>
1420 ))}
bb0f894Claude1421 </Select>
1422 <Text muted>&larr;</Text>
1423 <Select name="head">
0074234Claude1424 {branches
1425 .filter((b) => b !== defaultBase)
1426 .concat(defaultBase === branches[0] ? [] : [branches[0]])
1427 .map((b) => (
1428 <option value={b}>{b}</option>
1429 ))}
bb0f894Claude1430 </Select>
1431 </Flex>
1432 <FormGroup>
1433 <Input
0074234Claude1434 name="title"
1435 required
1436 placeholder="Title"
bb0f894Claude1437 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]1438 aria-label="Pull request title"
0074234Claude1439 />
bb0f894Claude1440 </FormGroup>
1441 <FormGroup>
1442 <TextArea
0074234Claude1443 name="body"
81c73c1Claude1444 id="pr-body"
0074234Claude1445 rows={8}
1446 placeholder="Description (Markdown supported)"
bb0f894Claude1447 mono
0074234Claude1448 />
bb0f894Claude1449 </FormGroup>
81c73c1Claude1450 <Flex gap={8} align="center">
1451 <Button type="submit" variant="primary">
1452 Create pull request
1453 </Button>
1454 <button
1455 type="button"
1456 id="ai-suggest-desc"
1457 class="btn"
1458 style="font-weight:500"
1459 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
1460 >
1461 Suggest description with AI
1462 </button>
1463 <span
1464 id="ai-suggest-status"
1465 style="color:var(--text-muted);font-size:13px"
1466 />
1467 </Flex>
bb0f894Claude1468 </Form>
81c73c1Claude1469 <script
1470 dangerouslySetInnerHTML={{
1471 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
1472 }}
1473 />
bb0f894Claude1474 </Container>
0074234Claude1475 </Layout>
1476 );
1477 }
1478);
1479
81c73c1Claude1480// AI-suggested PR description — JSON endpoint driven by the form button.
1481// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
1482// 200; the inline script reads `ok` to decide what to do.
1483pulls.post(
1484 "/:owner/:repo/ai/pr-description",
1485 softAuth,
1486 requireAuth,
1487 requireRepoAccess("write"),
1488 async (c) => {
1489 const { owner: ownerName, repo: repoName } = c.req.param();
1490 if (!isAiAvailable()) {
1491 return c.json({
1492 ok: false,
1493 error: "AI is not available — set ANTHROPIC_API_KEY.",
1494 });
1495 }
1496 const body = await c.req.parseBody();
1497 const title = String(body.title || "").trim();
1498 const baseBranch = String(body.base || "").trim();
1499 const headBranch = String(body.head || "").trim();
1500 if (!baseBranch || !headBranch) {
1501 return c.json({ ok: false, error: "Pick base + head branches first." });
1502 }
1503 if (baseBranch === headBranch) {
1504 return c.json({ ok: false, error: "Base and head must differ." });
1505 }
1506
1507 let diff = "";
1508 try {
1509 const cwd = getRepoPath(ownerName, repoName);
1510 const proc = Bun.spawn(
1511 [
1512 "git",
1513 "diff",
1514 `${baseBranch}...${headBranch}`,
1515 "--",
1516 ],
1517 { cwd, stdout: "pipe", stderr: "pipe" }
1518 );
6ea2109Claude1519 // 30s ceiling — without this a pathological diff (huge binary or
1520 // a corrupt ref) hangs the request indefinitely.
1521 const killer = setTimeout(() => proc.kill(), 30_000);
1522 try {
1523 diff = await new Response(proc.stdout).text();
1524 await proc.exited;
1525 } finally {
1526 clearTimeout(killer);
1527 }
81c73c1Claude1528 } catch {
1529 diff = "";
1530 }
1531 if (!diff.trim()) {
1532 return c.json({
1533 ok: false,
1534 error: "No diff between branches — nothing to summarise.",
1535 });
1536 }
1537
1538 let summary = "";
1539 try {
1540 summary = await generatePrSummary(title || "(untitled)", diff);
1541 } catch (err) {
1542 const msg = err instanceof Error ? err.message : "AI request failed.";
1543 return c.json({ ok: false, error: msg });
1544 }
1545 if (!summary.trim()) {
1546 return c.json({ ok: false, error: "AI returned an empty draft." });
1547 }
1548 return c.json({ ok: true, body: summary });
1549 }
1550);
1551
0074234Claude1552// Create PR
1553pulls.post(
1554 "/:owner/:repo/pulls/new",
1555 softAuth,
1556 requireAuth,
04f6b7fClaude1557 requireRepoAccess("write"),
0074234Claude1558 async (c) => {
1559 const { owner: ownerName, repo: repoName } = c.req.param();
1560 const user = c.get("user")!;
1561 const body = await c.req.parseBody();
1562 const title = String(body.title || "").trim();
1563 const prBody = String(body.body || "").trim();
1564 const baseBranch = String(body.base || "main");
1565 const headBranch = String(body.head || "");
1566
1567 if (!title || !headBranch) {
1568 return c.redirect(
1569 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
1570 );
1571 }
1572
1573 if (baseBranch === headBranch) {
1574 return c.redirect(
1575 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
1576 );
1577 }
1578
1579 const resolved = await resolveRepo(ownerName, repoName);
1580 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1581
6fc53bdClaude1582 const isDraft = String(body.draft || "") === "1";
1583
0074234Claude1584 const [pr] = await db
1585 .insert(pullRequests)
1586 .values({
1587 repositoryId: resolved.repo.id,
1588 authorId: user.id,
1589 title,
1590 body: prBody || null,
1591 baseBranch,
1592 headBranch,
6fc53bdClaude1593 isDraft,
0074234Claude1594 })
1595 .returning();
1596
6fc53bdClaude1597 // Skip AI review on drafts — it runs again when the PR is marked ready.
1598 if (!isDraft && isAiReviewEnabled()) {
e883329Claude1599 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
1600 (err) => console.error("[ai-review] Failed:", err)
1601 );
1602 }
1603
3cbe3d6Claude1604 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
1605 triggerPrTriage({
1606 ownerName,
1607 repoName,
1608 repositoryId: resolved.repo.id,
1609 prId: pr.id,
1610 prAuthorId: user.id,
1611 title,
1612 body: prBody,
1613 baseBranch,
1614 headBranch,
1615 }).catch((err) => console.error("[pr-triage] Failed:", err));
1616
9dd96b9Test User1617 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude1618 import("../lib/auto-merge")
1619 .then((m) => m.tryAutoMergeNow(pr.id))
1620 .catch((err) => {
1621 console.warn(
1622 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
1623 err instanceof Error ? err.message : err
1624 );
1625 });
9dd96b9Test User1626
0074234Claude1627 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
1628 }
1629);
1630
1631// View single PR
04f6b7fClaude1632pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1633 const { owner: ownerName, repo: repoName } = c.req.param();
1634 const prNum = parseInt(c.req.param("number"), 10);
1635 const user = c.get("user");
1636 const tab = c.req.query("tab") || "conversation";
1637
1638 const resolved = await resolveRepo(ownerName, repoName);
1639 if (!resolved) return c.notFound();
1640
1641 const [pr] = await db
1642 .select()
1643 .from(pullRequests)
1644 .where(
1645 and(
1646 eq(pullRequests.repositoryId, resolved.repo.id),
1647 eq(pullRequests.number, prNum)
1648 )
1649 )
1650 .limit(1);
1651
1652 if (!pr) return c.notFound();
1653
1654 const [author] = await db
1655 .select()
1656 .from(users)
1657 .where(eq(users.id, pr.authorId))
1658 .limit(1);
1659
1660 const comments = await db
1661 .select({
1662 comment: prComments,
1663 author: { username: users.username },
1664 })
1665 .from(prComments)
1666 .innerJoin(users, eq(prComments.authorId, users.id))
1667 .where(eq(prComments.pullRequestId, pr.id))
1668 .orderBy(asc(prComments.createdAt));
1669
6fc53bdClaude1670 // Reactions for the PR body + each comment, in parallel.
1671 const [prReactions, ...prCommentReactions] = await Promise.all([
1672 summariseReactions("pr", pr.id, user?.id),
1673 ...comments.map((row) =>
1674 summariseReactions("pr_comment", row.comment.id, user?.id)
1675 ),
1676 ]);
1677
0074234Claude1678 const canManage =
1679 user &&
1680 (user.id === resolved.owner.id || user.id === pr.authorId);
1681
e883329Claude1682 const error = c.req.query("error");
c3e0c07Claude1683 const info = c.req.query("info");
e883329Claude1684
1685 // Get gate check status for open PRs
1686 let gateChecks: GateCheckResult[] = [];
1687 if (pr.state === "open") {
1688 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
1689 if (headSha) {
1690 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
1691 const aiApproved = aiComments.length === 0 || aiComments.some(
1692 ({ comment }) => comment.body.includes("**Approved**")
1693 );
1694 const gateResult = await runAllGateChecks(
1695 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
1696 );
1697 gateChecks = gateResult.checks;
1698 }
1699 }
1700
534f04aClaude1701 // Block M3 — pre-merge risk score. Cache-only on the request path so
1702 // the page never waits on Haiku. On a cache miss for an open PR we
1703 // kick off the computation fire-and-forget; the next refresh shows it.
1704 let prRisk: PrRiskScore | null = null;
1705 let prRiskCalculating = false;
1706 if (pr.state === "open") {
1707 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
1708 if (!prRisk) {
1709 prRiskCalculating = true;
a28cedeClaude1710 void computePrRiskForPullRequest(pr.id).catch((err) => {
1711 console.warn(
1712 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
1713 err instanceof Error ? err.message : err
1714 );
1715 });
534f04aClaude1716 }
1717 }
1718
0074234Claude1719 // Get diff for "Files changed" tab
1720 let diffRaw = "";
1721 let diffFiles: GitDiffFile[] = [];
1722 if (tab === "files") {
1723 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude1724 // Run the two git diffs in parallel — they're independent reads of
1725 // the same range. Previously sequential, doubling the wall time on
1726 // big PRs (100+ files = 10-30s for no reason).
0074234Claude1727 const proc = Bun.spawn(
1728 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
1729 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1730 );
1731 const statProc = Bun.spawn(
1732 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
1733 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1734 );
6ea2109Claude1735 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
1736 // would otherwise hang the whole request.
1737 const killer = setTimeout(() => {
1738 proc.kill();
1739 statProc.kill();
1740 }, 30_000);
1741 let stat = "";
1742 try {
1743 [diffRaw, stat] = await Promise.all([
1744 new Response(proc.stdout).text(),
1745 new Response(statProc.stdout).text(),
1746 ]);
1747 await Promise.all([proc.exited, statProc.exited]);
1748 } finally {
1749 clearTimeout(killer);
1750 }
0074234Claude1751
1752 diffFiles = stat
1753 .trim()
1754 .split("\n")
1755 .filter(Boolean)
1756 .map((line) => {
1757 const [add, del, filePath] = line.split("\t");
1758 return {
1759 path: filePath,
1760 status: "modified",
1761 additions: add === "-" ? 0 : parseInt(add, 10),
1762 deletions: del === "-" ? 0 : parseInt(del, 10),
1763 patch: "",
1764 };
1765 });
1766 }
1767
b078860Claude1768 // ─── Derived visual state ───
1769 const stateKey =
1770 pr.state === "open"
1771 ? pr.isDraft
1772 ? "draft"
1773 : "open"
1774 : pr.state;
1775 const stateLabel =
1776 stateKey === "open"
1777 ? "Open"
1778 : stateKey === "draft"
1779 ? "Draft"
1780 : stateKey === "merged"
1781 ? "Merged"
1782 : "Closed";
1783 const stateIcon =
1784 stateKey === "open"
1785 ? "○"
1786 : stateKey === "draft"
1787 ? "◌"
1788 : stateKey === "merged"
1789 ? "⮌"
1790 : "✓";
1791 const commentCount = comments.length;
1792 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
1793 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
1794 const mergeBlocked =
1795 gateChecks.length > 0 &&
1796 gateChecks.some(
1797 (c) => !c.passed && c.name !== "Merge check"
1798 );
1799
0074234Claude1800 return c.html(
1801 <Layout
1802 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
1803 user={user}
1804 >
1805 <RepoHeader owner={ownerName} repo={repoName} />
1806 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude1807 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude1808 <div
1809 id="live-comment-banner"
1810 class="alert"
1811 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1812 >
1813 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1814 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1815 reload to view
1816 </a>
1817 </div>
1818 <script
1819 dangerouslySetInnerHTML={{
1820 __html: liveCommentBannerScript({
1821 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
1822 bannerElementId: "live-comment-banner",
1823 }),
1824 }}
1825 />
b078860Claude1826
1827 <div class="prs-detail-hero">
1828 <h1 class="prs-detail-title">
0074234Claude1829 {pr.title}{" "}
b078860Claude1830 <span class="prs-detail-num">#{pr.number}</span>
1831 </h1>
1832 <div class="prs-detail-meta">
1833 <span class={`prs-state-pill state-${stateKey}`}>
1834 <span aria-hidden="true">{stateIcon}</span>
1835 <span>{stateLabel}</span>
1836 </span>
1837 <span>
1838 <strong>{author?.username}</strong> wants to merge
1839 </span>
1840 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
1841 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
1842 <span class="prs-branch-arrow-lg">{"→"}</span>
1843 <span class="prs-branch-pill">{pr.baseBranch}</span>
1844 </span>
1845 <span>opened {formatRelative(pr.createdAt)}</span>
3c03977Claude1846 <span
1847 id="live-pill"
1848 class="live-pill"
1849 title="People editing this PR right now"
1850 >
1851 <span class="live-pill-dot" aria-hidden="true"></span>
1852 <span>
1853 Live: <strong id="live-count">0</strong> editing
1854 </span>
1855 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
1856 </span>
b078860Claude1857 {canManage && pr.state === "open" && pr.isDraft && (
1858 <form
1859 method="post"
1860 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
1861 class="prs-inline-form prs-detail-actions"
1862 >
1863 <button type="submit" class="prs-merge-ready-btn">
1864 Ready for review
1865 </button>
1866 </form>
1867 )}
1868 </div>
1869 </div>
3c03977Claude1870 <script
1871 dangerouslySetInnerHTML={{
1872 __html: LIVE_COEDIT_SCRIPT(pr.id),
1873 }}
1874 />
0074234Claude1875
b078860Claude1876 <nav class="prs-detail-tabs" aria-label="Pull request sections">
1877 <a
1878 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
1879 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1880 >
1881 Conversation
1882 <span class="prs-detail-tab-count">{commentCount}</span>
1883 </a>
1884 <a
1885 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
1886 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
1887 >
1888 Files changed
1889 {diffFiles.length > 0 && (
1890 <span class="prs-detail-tab-count">{diffFiles.length}</span>
1891 )}
1892 </a>
1893 </nav>
1894
1895 {tab === "files" ? (
ea9ed4cClaude1896 <DiffView
1897 raw={diffRaw}
1898 files={diffFiles}
1899 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
1900 />
b078860Claude1901 ) : (
1902 <>
1903 {pr.body && (
1904 <CommentBox
1905 author={author?.username ?? "unknown"}
1906 date={pr.createdAt}
1907 body={renderMarkdown(pr.body)}
1908 />
1909 )}
1910
1911 {comments.map(({ comment, author: commentAuthor }) => (
1912 <div class={`prs-comment${comment.isAiReview ? " is-ai" : ""}`}>
1913 <div class="prs-comment-head">
1914 <strong>{commentAuthor.username}</strong>
1915 {comment.isAiReview && (
1916 <span class="prs-ai-badge">AI Review</span>
1917 )}
1918 <span class="prs-comment-time">
1919 commented {formatRelative(comment.createdAt)}
1920 </span>
1921 {comment.filePath && (
1922 <span class="prs-comment-loc">
1923 {comment.filePath}
1924 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
1925 </span>
1926 )}
1927 </div>
1928 <div class="prs-comment-body">
bb0f894Claude1929 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude1930 </div>
b078860Claude1931 </div>
1932 ))}
0074234Claude1933
b078860Claude1934 {/* Quick link to the Files changed tab when there's a diff to look at. */}
1935 {pr.state !== "merged" && (
1936 <a
1937 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
1938 class="prs-files-card"
1939 >
1940 <span class="prs-files-card-icon" aria-hidden="true">
1941 {"▤"}
1942 </span>
1943 <div class="prs-files-card-text">
1944 <p class="prs-files-card-title">Files changed</p>
1945 <p class="prs-files-card-sub">
1946 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
1947 </p>
e883329Claude1948 </div>
b078860Claude1949 <span class="prs-files-card-cta">View diff {"→"}</span>
1950 </a>
1951 )}
1952
1953 {error && (
1954 <div
1955 class="auth-error"
1956 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)"
1957 >
1958 {decodeURIComponent(error)}
1959 </div>
1960 )}
1961
1962 {info && (
1963 <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)">
1964 {decodeURIComponent(info)}
1965 </div>
1966 )}
e883329Claude1967
b078860Claude1968 {pr.state === "open" && (prRisk || prRiskCalculating) && (
1969 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
1970 )}
1971
1972 {pr.state === "open" && gateChecks.length > 0 && (
1973 <div class="prs-gate-card">
1974 <div class="prs-gate-head">
1975 <h3>Gate checks</h3>
1976 <span class="prs-gate-summary">
1977 {gatesAllPassed
1978 ? `All ${gateChecks.length} checks passed`
1979 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
1980 </span>
c3e0c07Claude1981 </div>
b078860Claude1982 {gateChecks.map((check) => {
1983 const isAi = /ai.*review/i.test(check.name);
1984 const isSkip = check.skipped === true;
1985 const statusClass = isSkip
1986 ? "is-skip"
1987 : check.passed
1988 ? "is-pass"
1989 : "is-fail";
1990 const statusGlyph = isSkip
1991 ? "—"
1992 : check.passed
1993 ? "✓"
1994 : "✗";
1995 const statusLabel = isSkip
1996 ? "Skipped"
1997 : check.passed
1998 ? "Passed"
1999 : "Failing";
2000 return (
2001 <div
2002 class="prs-gate-row"
2003 style={
2004 isAi
2005 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
2006 : ""
2007 }
2008 >
2009 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
2010 {statusGlyph}
2011 </span>
2012 <span class="prs-gate-name">
2013 {check.name}
2014 {isAi && (
2015 <span
2016 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"
2017 >
2018 AI
2019 </span>
2020 )}
2021 </span>
2022 <span class="prs-gate-details">{check.details}</span>
2023 <span class={`prs-gate-pill ${statusClass}`}>
2024 {statusLabel}
e883329Claude2025 </span>
2026 </div>
b078860Claude2027 );
2028 })}
2029 <div class="prs-gate-footer">
2030 {gatesAllPassed
2031 ? "All checks passed — ready to merge."
2032 : gateChecks.some(
2033 (c) => !c.passed && c.name === "Merge check"
2034 )
2035 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
2036 : "Some checks failed — resolve issues before merging."}
2037 {aiReviewCount > 0 && (
2038 <>
2039 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
2040 </>
2041 )}
2042 </div>
2043 </div>
2044 )}
2045
2046 {/* ─── Merge area / state-aware action card ─────────────── */}
2047 {user && pr.state === "open" && (
2048 <div
2049 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
2050 >
2051 <div class="prs-merge-head">
2052 <strong>
2053 {pr.isDraft
2054 ? "Draft — ready for review?"
2055 : mergeBlocked
2056 ? "Merge blocked"
2057 : "Ready to merge"}
2058 </strong>
e883329Claude2059 </div>
b078860Claude2060 <p class="prs-merge-sub">
2061 {pr.isDraft
2062 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
2063 : mergeBlocked
2064 ? "Resolve the failing gate checks above before this PR can land."
2065 : gateChecks.length > 0
2066 ? gatesAllPassed
2067 ? "All gates green. Merge will fast-forward into the base branch."
2068 : "Conflicts will be auto-resolved by GlueCron AI on merge."
2069 : "Run gate checks by refreshing once your branch has a recent commit."}
2070 </p>
2071 <Form
2072 method="post"
2073 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
2074 >
2075 <FormGroup>
3c03977Claude2076 <div class="live-cursor-host" style="position:relative">
2077 <textarea
2078 name="body"
2079 id="pr-comment-body"
2080 data-live-field="comment_new"
2081 rows={5}
2082 required
2083 placeholder="Leave a comment... (Markdown supported)"
2084 style="font-family:var(--font-mono);font-size:13px;width:100%"
2085 ></textarea>
2086 </div>
b078860Claude2087 </FormGroup>
2088 <div class="prs-merge-actions">
2089 <Button type="submit" variant="primary">
2090 Comment
2091 </Button>
2092 {canManage && (
2093 <>
2094 {pr.isDraft ? (
2095 <button
2096 type="submit"
2097 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2098 formnovalidate
2099 class="prs-merge-ready-btn"
2100 >
2101 Ready for review
2102 </button>
2103 ) : (
0074234Claude2104 <button
2105 type="submit"
2106 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
b078860Claude2107 formnovalidate
2108 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
2109 title={
2110 mergeBlocked
2111 ? "Failing gate checks must be resolved before this PR can merge."
2112 : "Merge pull request"
2113 }
0074234Claude2114 >
b078860Claude2115 {"✔"} Merge pull request
0074234Claude2116 </button>
b078860Claude2117 )}
2118 {!pr.isDraft && (
2119 <button
0074234Claude2120 type="submit"
b078860Claude2121 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
2122 formnovalidate
2123 class="prs-merge-back-draft"
2124 title="Convert back to draft"
0074234Claude2125 >
b078860Claude2126 Convert to draft
2127 </button>
2128 )}
2129 {isAiReviewEnabled() && (
2130 <button
2131 type="submit"
2132 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
2133 formnovalidate
2134 class="btn"
2135 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
2136 >
2137 Re-run AI review
2138 </button>
2139 )}
2140 <Button
2141 type="submit"
2142 variant="danger"
2143 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
2144 >
2145 Close
2146 </Button>
2147 </>
2148 )}
2149 </div>
2150 </Form>
2151 </div>
2152 )}
2153
2154 {/* Read-only footers for non-open states. */}
2155 {pr.state === "merged" && (
2156 <div class="prs-merge-card is-merged">
2157 <div class="prs-merge-head">
2158 <strong>{"⮌"} Merged</strong>
0074234Claude2159 </div>
b078860Claude2160 <p class="prs-merge-sub">
2161 This pull request was merged into{" "}
2162 <code>{pr.baseBranch}</code>.
2163 </p>
2164 </div>
2165 )}
2166 {pr.state === "closed" && (
2167 <div class="prs-merge-card is-closed">
2168 <div class="prs-merge-head">
2169 <strong>{"✕"} Closed without merging</strong>
2170 </div>
2171 <p class="prs-merge-sub">
2172 This pull request was closed and not merged.
2173 </p>
2174 </div>
2175 )}
2176 </>
2177 )}
0074234Claude2178 </Layout>
2179 );
2180});
2181
2182// Add comment to PR
2183pulls.post(
2184 "/:owner/:repo/pulls/:number/comment",
2185 softAuth,
2186 requireAuth,
04f6b7fClaude2187 requireRepoAccess("write"),
0074234Claude2188 async (c) => {
2189 const { owner: ownerName, repo: repoName } = c.req.param();
2190 const prNum = parseInt(c.req.param("number"), 10);
2191 const user = c.get("user")!;
2192 const body = await c.req.parseBody();
2193 const commentBody = String(body.body || "").trim();
2194
2195 if (!commentBody) {
2196 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2197 }
2198
2199 const resolved = await resolveRepo(ownerName, repoName);
2200 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2201
2202 const [pr] = await db
2203 .select()
2204 .from(pullRequests)
2205 .where(
2206 and(
2207 eq(pullRequests.repositoryId, resolved.repo.id),
2208 eq(pullRequests.number, prNum)
2209 )
2210 )
2211 .limit(1);
2212
2213 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2214
d4ac5c3Claude2215 const [inserted] = await db
2216 .insert(prComments)
2217 .values({
2218 pullRequestId: pr.id,
2219 authorId: user.id,
2220 body: commentBody,
2221 })
2222 .returning();
2223
2224 // Live update: nudge any browser tabs subscribed to this PR.
2225 if (inserted) {
2226 try {
2227 const { publish } = await import("../lib/sse");
2228 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
2229 event: "pr-comment",
2230 data: {
2231 pullRequestId: pr.id,
2232 commentId: inserted.id,
2233 authorId: user.id,
2234 authorUsername: user.username,
2235 },
2236 });
2237 } catch {
2238 /* SSE is best-effort */
2239 }
2240 }
0074234Claude2241
2242 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2243 }
2244);
2245
e883329Claude2246// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude2247// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
2248// but we keep it at "write" for v1 so trusted collaborators can ship.
2249// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
2250// surface. Branch-protection rules (evaluated below) are the current mechanism
2251// for locking down merges further on specific branches.
0074234Claude2252pulls.post(
2253 "/:owner/:repo/pulls/:number/merge",
2254 softAuth,
2255 requireAuth,
04f6b7fClaude2256 requireRepoAccess("write"),
0074234Claude2257 async (c) => {
2258 const { owner: ownerName, repo: repoName } = c.req.param();
2259 const prNum = parseInt(c.req.param("number"), 10);
2260 const user = c.get("user")!;
2261
2262 const resolved = await resolveRepo(ownerName, repoName);
2263 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2264
2265 const [pr] = await db
2266 .select()
2267 .from(pullRequests)
2268 .where(
2269 and(
2270 eq(pullRequests.repositoryId, resolved.repo.id),
2271 eq(pullRequests.number, prNum)
2272 )
2273 )
2274 .limit(1);
2275
2276 if (!pr || pr.state !== "open") {
2277 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2278 }
2279
6fc53bdClaude2280 // Draft PRs cannot be merged — must be marked ready first.
2281 if (pr.isDraft) {
2282 return c.redirect(
2283 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2284 "This PR is a draft. Mark it as ready for review before merging."
2285 )}`
2286 );
2287 }
2288
e883329Claude2289 // Resolve head SHA
2290 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2291 if (!headSha) {
2292 return c.redirect(
2293 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
2294 );
2295 }
2296
2297 // Check if AI review approved this PR
2298 const aiComments = await db
2299 .select()
2300 .from(prComments)
2301 .where(
2302 and(
2303 eq(prComments.pullRequestId, pr.id),
2304 eq(prComments.isAiReview, true)
2305 )
2306 );
2307 const aiApproved = aiComments.length === 0 || aiComments.some(
2308 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude2309 );
e883329Claude2310
2311 // Run all green gate checks (GateTest + mergeability + AI review)
2312 const gateResult = await runAllGateChecks(
2313 ownerName,
2314 repoName,
2315 pr.baseBranch,
2316 pr.headBranch,
2317 headSha,
2318 aiApproved
0074234Claude2319 );
2320
e883329Claude2321 // If GateTest or AI review failed (hard blocks), reject the merge
2322 const hardFailures = gateResult.checks.filter(
2323 (check) => !check.passed && check.name !== "Merge check"
2324 );
2325 if (hardFailures.length > 0) {
2326 const errorMsg = hardFailures
2327 .map((f) => `${f.name}: ${f.details}`)
2328 .join("; ");
0074234Claude2329 return c.redirect(
e883329Claude2330 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude2331 );
2332 }
2333
1e162a8Claude2334 // D5 — Branch-protection enforcement. Looks up the matching rule for the
2335 // base branch and blocks the merge if requireAiApproval / requireGreenGates
2336 // / requireHumanReview / requiredApprovals are not satisfied. Independent
2337 // of repo-global settings, so owners can lock specific branches down
2338 // further than the repo default.
2339 const protectionRule = await matchProtection(
2340 resolved.repo.id,
2341 pr.baseBranch
2342 );
2343 if (protectionRule) {
2344 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude2345 const required = await listRequiredChecks(protectionRule.id);
2346 const passingNames = required.length > 0
2347 ? await passingCheckNames(resolved.repo.id, headSha)
2348 : [];
2349 const decision = evaluateProtection(
2350 protectionRule,
2351 {
2352 aiApproved,
2353 humanApprovalCount: humanApprovals,
2354 gateResultGreen: hardFailures.length === 0,
2355 hasFailedGates: hardFailures.length > 0,
2356 passingCheckNames: passingNames,
2357 },
2358 required.map((r) => r.checkName)
2359 );
1e162a8Claude2360 if (!decision.allowed) {
2361 return c.redirect(
2362 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2363 decision.reasons.join(" ")
2364 )}`
2365 );
2366 }
2367 }
2368
e883329Claude2369 // Attempt the merge — with auto conflict resolution if needed
2370 const repoDir = getRepoPath(ownerName, repoName);
2371 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
2372 const hasConflicts = mergeCheck && !mergeCheck.passed;
2373
2374 if (hasConflicts && isAiReviewEnabled()) {
2375 // Use Claude to auto-resolve conflicts
2376 const mergeResult = await mergeWithAutoResolve(
2377 ownerName,
2378 repoName,
2379 pr.baseBranch,
2380 pr.headBranch,
2381 `Merge pull request #${pr.number}: ${pr.title}`
2382 );
2383
2384 if (!mergeResult.success) {
2385 return c.redirect(
2386 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
2387 );
2388 }
2389
2390 // Post a comment about the auto-resolution
2391 if (mergeResult.resolvedFiles.length > 0) {
2392 await db.insert(prComments).values({
2393 pullRequestId: pr.id,
2394 authorId: user.id,
2395 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
2396 isAiReview: true,
2397 });
2398 }
2399 } else {
2400 // Standard merge — fast-forward or clean merge
2401 const ffProc = Bun.spawn(
2402 [
2403 "git",
2404 "update-ref",
2405 `refs/heads/${pr.baseBranch}`,
2406 `refs/heads/${pr.headBranch}`,
2407 ],
2408 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2409 );
2410 const ffExit = await ffProc.exited;
2411
2412 if (ffExit !== 0) {
2413 return c.redirect(
2414 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
2415 );
2416 }
2417 }
2418
0074234Claude2419 await db
2420 .update(pullRequests)
2421 .set({
2422 state: "merged",
2423 mergedAt: new Date(),
2424 mergedBy: user.id,
2425 updatedAt: new Date(),
2426 })
2427 .where(eq(pullRequests.id, pr.id));
2428
d62fb36Claude2429 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
2430 // and auto-close each matching open issue with a back-link comment. Bounded
2431 // to the same repo for v1 (cross-repo refs ignored). Failures never block
2432 // the merge redirect.
2433 try {
2434 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
2435 const refs = extractClosingRefsMulti([pr.title, pr.body]);
2436 for (const n of refs) {
2437 const [issue] = await db
2438 .select()
2439 .from(issues)
2440 .where(
2441 and(
2442 eq(issues.repositoryId, resolved.repo.id),
2443 eq(issues.number, n)
2444 )
2445 )
2446 .limit(1);
2447 if (!issue || issue.state !== "open") continue;
2448 await db
2449 .update(issues)
2450 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
2451 .where(eq(issues.id, issue.id));
2452 await db.insert(issueComments).values({
2453 issueId: issue.id,
2454 authorId: user.id,
2455 body: `Closed by pull request #${pr.number}.`,
2456 });
2457 }
2458 } catch {
2459 // Never block the merge on close-keyword failures.
2460 }
2461
0074234Claude2462 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2463 }
2464);
2465
6fc53bdClaude2466// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
2467// hasn't run yet on this PR.
2468pulls.post(
2469 "/:owner/:repo/pulls/:number/ready",
2470 softAuth,
2471 requireAuth,
04f6b7fClaude2472 requireRepoAccess("write"),
6fc53bdClaude2473 async (c) => {
2474 const { owner: ownerName, repo: repoName } = c.req.param();
2475 const prNum = parseInt(c.req.param("number"), 10);
2476 const user = c.get("user")!;
2477
2478 const resolved = await resolveRepo(ownerName, repoName);
2479 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2480
2481 const [pr] = await db
2482 .select()
2483 .from(pullRequests)
2484 .where(
2485 and(
2486 eq(pullRequests.repositoryId, resolved.repo.id),
2487 eq(pullRequests.number, prNum)
2488 )
2489 )
2490 .limit(1);
2491 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2492
2493 // Only the author or repo owner can toggle draft state.
2494 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2495 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2496 }
2497
2498 if (pr.state === "open" && pr.isDraft) {
2499 await db
2500 .update(pullRequests)
2501 .set({ isDraft: false, updatedAt: new Date() })
2502 .where(eq(pullRequests.id, pr.id));
2503
2504 if (isAiReviewEnabled()) {
2505 triggerAiReview(
2506 ownerName,
2507 repoName,
2508 pr.id,
2509 pr.title,
0316dbbClaude2510 pr.body || "",
6fc53bdClaude2511 pr.baseBranch,
2512 pr.headBranch
2513 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
2514 }
2515 }
2516
2517 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2518 }
2519);
2520
2521// Convert a PR back to draft.
2522pulls.post(
2523 "/:owner/:repo/pulls/:number/draft",
2524 softAuth,
2525 requireAuth,
04f6b7fClaude2526 requireRepoAccess("write"),
6fc53bdClaude2527 async (c) => {
2528 const { owner: ownerName, repo: repoName } = c.req.param();
2529 const prNum = parseInt(c.req.param("number"), 10);
2530 const user = c.get("user")!;
2531
2532 const resolved = await resolveRepo(ownerName, repoName);
2533 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2534
2535 const [pr] = await db
2536 .select()
2537 .from(pullRequests)
2538 .where(
2539 and(
2540 eq(pullRequests.repositoryId, resolved.repo.id),
2541 eq(pullRequests.number, prNum)
2542 )
2543 )
2544 .limit(1);
2545 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2546
2547 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2548 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2549 }
2550
2551 if (pr.state === "open" && !pr.isDraft) {
2552 await db
2553 .update(pullRequests)
2554 .set({ isDraft: true, updatedAt: new Date() })
2555 .where(eq(pullRequests.id, pr.id));
2556 }
2557
2558 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2559 }
2560);
2561
0074234Claude2562// Close PR
2563pulls.post(
2564 "/:owner/:repo/pulls/:number/close",
2565 softAuth,
2566 requireAuth,
04f6b7fClaude2567 requireRepoAccess("write"),
0074234Claude2568 async (c) => {
2569 const { owner: ownerName, repo: repoName } = c.req.param();
2570 const prNum = parseInt(c.req.param("number"), 10);
2571
2572 const resolved = await resolveRepo(ownerName, repoName);
2573 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2574
2575 await db
2576 .update(pullRequests)
2577 .set({
2578 state: "closed",
2579 closedAt: new Date(),
2580 updatedAt: new Date(),
2581 })
2582 .where(
2583 and(
2584 eq(pullRequests.repositoryId, resolved.repo.id),
2585 eq(pullRequests.number, prNum)
2586 )
2587 );
2588
2589 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2590 }
2591);
2592
c3e0c07Claude2593// Re-run AI review on demand (e.g. after a force-push). Bypasses the
2594// idempotency marker via { force: true }. Write-access only.
2595pulls.post(
2596 "/:owner/:repo/pulls/:number/ai-rereview",
2597 softAuth,
2598 requireAuth,
2599 requireRepoAccess("write"),
2600 async (c) => {
2601 const { owner: ownerName, repo: repoName } = c.req.param();
2602 const prNum = parseInt(c.req.param("number"), 10);
2603 const resolved = await resolveRepo(ownerName, repoName);
2604 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2605
2606 const [pr] = await db
2607 .select()
2608 .from(pullRequests)
2609 .where(
2610 and(
2611 eq(pullRequests.repositoryId, resolved.repo.id),
2612 eq(pullRequests.number, prNum)
2613 )
2614 )
2615 .limit(1);
2616 if (!pr) {
2617 return c.redirect(`/${ownerName}/${repoName}/pulls`);
2618 }
2619
2620 if (!isAiReviewEnabled()) {
2621 return c.redirect(
2622 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2623 "AI review is not configured (ANTHROPIC_API_KEY)."
2624 )}`
2625 );
2626 }
2627
2628 // Fire-and-forget but with { force: true } to bypass the
2629 // already-reviewed marker. The function still never throws.
2630 triggerAiReview(
2631 ownerName,
2632 repoName,
2633 pr.id,
2634 pr.title || "",
2635 pr.body || "",
2636 pr.baseBranch,
2637 pr.headBranch,
2638 { force: true }
a28cedeClaude2639 ).catch((err) => {
2640 console.warn(
2641 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
2642 err instanceof Error ? err.message : err
2643 );
2644 });
c3e0c07Claude2645
2646 return c.redirect(
2647 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
2648 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
2649 )}`
2650 );
2651 }
2652);
2653
0074234Claude2654export default pulls;