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.tsxBlame2791 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";
45import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude46import { generatePrSummary } from "../lib/ai-generators";
47import { isAiAvailable } from "../lib/ai-client";
534f04aClaude48import {
49 computePrRiskForPullRequest,
50 getCachedPrRisk,
51 type PrRiskScore,
52} from "../lib/pr-risk";
0316dbbClaude53import { runAllGateChecks } from "../lib/gate";
54import type { GateCheckResult } from "../lib/gate";
55import {
56 matchProtection,
57 countHumanApprovals,
58 listRequiredChecks,
59 passingCheckNames,
60 evaluateProtection,
61} from "../lib/branch-protection";
62import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude63import {
64 listBranches,
65 getRepoPath,
e883329Claude66 resolveRef,
0074234Claude67} from "../git/repository";
68import type { GitDiffFile } from "../git/repository";
69import { html } from "hono/html";
1e162a8Claude70import {
bb0f894Claude71 Flex,
72 Container,
73 Badge,
74 Button,
75 LinkButton,
76 Form,
77 FormGroup,
78 Input,
79 TextArea,
80 Select,
81 EmptyState,
82 FilterTabs,
83 TabNav,
84 List,
85 ListItem,
86 Text,
87 Alert,
88 MarkdownContent,
89 CommentBox,
90 formatRelative,
91} from "../views/ui";
0074234Claude92
93const pulls = new Hono<AuthEnv>();
94
b078860Claude95/* ──────────────────────────────────────────────────────────────────────
96 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
97 * into the issue tracker or any other route. Tokens come from layout.tsx
98 * `:root` so light/dark stays consistent if/when light mode lands.
99 * ──────────────────────────────────────────────────────────────────── */
100const PRS_LIST_STYLES = `
101 .prs-hero {
102 position: relative;
103 margin: 0 0 var(--space-5);
104 padding: 22px 26px 24px;
105 background: var(--bg-elevated);
106 border: 1px solid var(--border);
107 border-radius: 16px;
108 overflow: hidden;
109 }
110 .prs-hero::before {
111 content: '';
112 position: absolute; top: 0; left: 0; right: 0;
113 height: 2px;
114 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
115 opacity: 0.7;
116 pointer-events: none;
117 }
118 .prs-hero-inner {
119 position: relative;
120 display: flex;
121 justify-content: space-between;
122 align-items: flex-end;
123 gap: 20px;
124 flex-wrap: wrap;
125 }
126 .prs-hero-text { flex: 1; min-width: 280px; }
127 .prs-hero-eyebrow {
128 font-size: 12px;
129 color: var(--text-muted);
130 text-transform: uppercase;
131 letter-spacing: 0.08em;
132 font-weight: 600;
133 margin-bottom: 8px;
134 }
135 .prs-hero-title {
136 font-family: var(--font-display);
137 font-size: clamp(26px, 3.4vw, 34px);
138 font-weight: 800;
139 letter-spacing: -0.025em;
140 line-height: 1.06;
141 margin: 0 0 8px;
142 color: var(--text-strong);
143 }
144 .prs-hero-title .gradient-text {
145 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
146 -webkit-background-clip: text;
147 background-clip: text;
148 -webkit-text-fill-color: transparent;
149 color: transparent;
150 }
151 .prs-hero-sub {
152 font-size: 14.5px;
153 color: var(--text-muted);
154 margin: 0;
155 line-height: 1.5;
156 max-width: 620px;
157 }
158 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
159 .prs-cta {
160 display: inline-flex; align-items: center; gap: 6px;
161 padding: 10px 16px;
162 border-radius: 10px;
163 font-size: 13.5px;
164 font-weight: 600;
165 color: #fff;
166 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
167 border: 1px solid rgba(140,109,255,0.55);
168 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
169 text-decoration: none;
170 transition: transform 120ms ease, box-shadow 160ms ease;
171 }
172 .prs-cta:hover {
173 transform: translateY(-1px);
174 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
175 color: #fff;
176 }
177
178 .prs-tabs {
179 display: flex; flex-wrap: wrap; gap: 6px;
180 margin: 0 0 18px;
181 padding: 6px;
182 background: var(--bg-secondary);
183 border: 1px solid var(--border);
184 border-radius: 12px;
185 }
186 .prs-tab {
187 display: inline-flex; align-items: center; gap: 8px;
188 padding: 7px 13px;
189 font-size: 13px;
190 font-weight: 500;
191 color: var(--text-muted);
192 border-radius: 8px;
193 text-decoration: none;
194 transition: background 120ms ease, color 120ms ease;
195 }
196 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
197 .prs-tab.is-active {
198 background: var(--bg-elevated);
199 color: var(--text-strong);
200 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
201 }
202 .prs-tab-count {
203 display: inline-flex; align-items: center; justify-content: center;
204 min-width: 22px; padding: 2px 7px;
205 font-size: 11.5px;
206 font-weight: 600;
207 border-radius: 9999px;
208 background: var(--bg-tertiary);
209 color: var(--text-muted);
210 }
211 .prs-tab.is-active .prs-tab-count {
212 background: rgba(140,109,255,0.18);
213 color: var(--text-link);
214 }
215
216 .prs-list { display: flex; flex-direction: column; gap: 10px; }
217 .prs-row {
218 position: relative;
219 display: flex; align-items: flex-start; gap: 14px;
220 padding: 14px 16px;
221 background: var(--bg-elevated);
222 border: 1px solid var(--border);
223 border-radius: 12px;
224 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
225 }
226 .prs-row:hover {
227 transform: translateY(-1px);
228 border-color: var(--border-strong);
229 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
230 }
231 .prs-row-icon {
232 flex: 0 0 auto;
233 width: 26px; height: 26px;
234 display: inline-flex; align-items: center; justify-content: center;
235 border-radius: 9999px;
236 font-size: 13px;
237 margin-top: 2px;
238 }
239 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
240 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
241 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
242 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
243 .prs-row-body { flex: 1; min-width: 0; }
244 .prs-row-title {
245 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
246 font-size: 15px; font-weight: 600;
247 color: var(--text-strong);
248 line-height: 1.35;
249 margin: 0 0 6px;
250 }
251 .prs-row-number {
252 color: var(--text-muted);
253 font-weight: 400;
254 font-size: 14px;
255 }
256 .prs-row-meta {
257 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
258 font-size: 12.5px;
259 color: var(--text-muted);
260 }
261 .prs-branch-chips {
262 display: inline-flex; align-items: center; gap: 6px;
263 font-family: var(--font-mono);
264 font-size: 11.5px;
265 }
266 .prs-branch-chip {
267 padding: 2px 8px;
268 border-radius: 9999px;
269 background: var(--bg-tertiary);
270 border: 1px solid var(--border);
271 color: var(--text);
272 }
273 .prs-branch-arrow {
274 color: var(--text-faint);
275 font-size: 11px;
276 }
277 .prs-row-tags {
278 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
279 margin-left: auto;
280 }
281 .prs-tag {
282 display: inline-flex; align-items: center; gap: 4px;
283 padding: 2px 8px;
284 font-size: 11px;
285 font-weight: 600;
286 border-radius: 9999px;
287 border: 1px solid var(--border);
288 background: var(--bg-secondary);
289 color: var(--text-muted);
290 line-height: 1.6;
291 }
292 .prs-tag.is-draft {
293 color: var(--text-muted);
294 border-color: var(--border-strong);
295 }
296 .prs-tag.is-merged {
297 color: var(--text-link);
298 border-color: rgba(140,109,255,0.45);
299 background: rgba(140,109,255,0.10);
300 }
301
302 .prs-empty {
ea9ed4cClaude303 position: relative;
304 padding: 56px 32px;
b078860Claude305 text-align: center;
306 border: 1px dashed var(--border);
ea9ed4cClaude307 border-radius: 16px;
308 background: var(--bg-elevated);
b078860Claude309 color: var(--text-muted);
ea9ed4cClaude310 overflow: hidden;
b078860Claude311 }
ea9ed4cClaude312 .prs-empty::before {
313 content: '';
314 position: absolute;
315 inset: -40% -20% auto auto;
316 width: 320px; height: 320px;
317 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
318 filter: blur(70px);
319 opacity: 0.55;
320 pointer-events: none;
321 animation: prsEmptyOrb 16s ease-in-out infinite;
322 }
323 @keyframes prsEmptyOrb {
324 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
325 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
326 }
327 @media (prefers-reduced-motion: reduce) {
328 .prs-empty::before { animation: none; }
329 }
330 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude331 .prs-empty strong {
332 display: block;
333 color: var(--text-strong);
ea9ed4cClaude334 font-family: var(--font-display);
335 font-size: 22px;
336 font-weight: 700;
337 letter-spacing: -0.018em;
338 margin-bottom: 2px;
339 }
340 .prs-empty-sub {
341 font-size: 14.5px;
342 color: var(--text-muted);
343 line-height: 1.55;
344 max-width: 460px;
345 margin: 0 0 18px;
b078860Claude346 }
ea9ed4cClaude347 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude348
349 @media (max-width: 720px) {
350 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
351 .prs-hero-actions { width: 100%; }
352 .prs-row-tags { margin-left: 0; }
353 }
f1dc7c7Claude354
355 /* Additional mobile rules. Additive only. */
356 @media (max-width: 720px) {
357 .prs-hero { padding: 18px 18px 20px; }
358 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
359 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
360 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
361 .prs-row { padding: 12px 14px; gap: 10px; }
362 .prs-row-icon { width: 24px; height: 24px; }
363 }
b078860Claude364`;
365
366/* ──────────────────────────────────────────────────────────────────────
367 * Inline CSS for the detail page. Same `.prs-*` namespace.
368 * ──────────────────────────────────────────────────────────────────── */
369const PRS_DETAIL_STYLES = `
370 .prs-detail-hero {
371 position: relative;
372 margin: 0 0 var(--space-4);
373 padding: 24px 26px;
374 background: var(--bg-elevated);
375 border: 1px solid var(--border);
376 border-radius: 16px;
377 overflow: hidden;
378 }
379 .prs-detail-hero::before {
380 content: '';
381 position: absolute; top: 0; left: 0; right: 0;
382 height: 2px;
383 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
384 opacity: 0.7;
385 pointer-events: none;
386 }
387 .prs-detail-title {
388 font-family: var(--font-display);
389 font-size: clamp(22px, 2.6vw, 28px);
390 font-weight: 700;
391 letter-spacing: -0.022em;
392 line-height: 1.2;
393 color: var(--text-strong);
394 margin: 0 0 12px;
395 }
396 .prs-detail-num {
397 color: var(--text-muted);
398 font-weight: 400;
399 }
400 .prs-state-pill {
401 display: inline-flex; align-items: center; gap: 6px;
402 padding: 6px 12px;
403 border-radius: 9999px;
404 font-size: 12.5px;
405 font-weight: 600;
406 line-height: 1;
407 border: 1px solid transparent;
408 }
409 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
410 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
411 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
412 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
413
414 .prs-detail-meta {
415 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
416 font-size: 13px;
417 color: var(--text-muted);
418 }
419 .prs-detail-meta strong { color: var(--text); }
420 .prs-detail-branches {
421 display: inline-flex; align-items: center; gap: 6px;
422 font-family: var(--font-mono);
423 font-size: 12px;
424 }
425 .prs-branch-pill {
426 padding: 3px 9px;
427 border-radius: 9999px;
428 background: var(--bg-tertiary);
429 border: 1px solid var(--border);
430 color: var(--text);
431 }
432 .prs-branch-pill.is-head { color: var(--text-strong); }
433 .prs-branch-arrow-lg {
434 color: var(--accent);
435 font-size: 14px;
436 font-weight: 700;
437 }
438
439 .prs-detail-actions {
440 display: inline-flex; gap: 8px; margin-left: auto;
441 }
442
443 .prs-detail-tabs {
444 display: flex; gap: 4px;
445 margin: 0 0 16px;
446 border-bottom: 1px solid var(--border);
447 }
448 .prs-detail-tab {
449 padding: 10px 14px;
450 font-size: 13.5px;
451 font-weight: 500;
452 color: var(--text-muted);
453 text-decoration: none;
454 border-bottom: 2px solid transparent;
455 transition: color 120ms ease, border-color 120ms ease;
456 margin-bottom: -1px;
457 }
458 .prs-detail-tab:hover { color: var(--text); }
459 .prs-detail-tab.is-active {
460 color: var(--text-strong);
461 border-bottom-color: var(--accent);
462 }
463 .prs-detail-tab-count {
464 display: inline-flex; align-items: center; justify-content: center;
465 min-width: 20px; padding: 0 6px; margin-left: 6px;
466 height: 18px;
467 font-size: 11px;
468 font-weight: 600;
469 border-radius: 9999px;
470 background: var(--bg-tertiary);
471 color: var(--text-muted);
472 }
473
474 /* Gate / check status section */
475 .prs-gate-card {
476 margin-top: 20px;
477 background: var(--bg-elevated);
478 border: 1px solid var(--border);
479 border-radius: 14px;
480 overflow: hidden;
481 }
482 .prs-gate-head {
483 display: flex; align-items: center; gap: 10px;
484 padding: 14px 18px;
485 border-bottom: 1px solid var(--border);
486 }
487 .prs-gate-head h3 {
488 margin: 0;
489 font-size: 14px;
490 font-weight: 600;
491 color: var(--text-strong);
492 }
493 .prs-gate-summary {
494 margin-left: auto;
495 font-size: 12px;
496 color: var(--text-muted);
497 }
498 .prs-gate-row {
499 display: flex; align-items: center; gap: 12px;
500 padding: 12px 18px;
501 border-bottom: 1px solid var(--border-subtle);
502 }
503 .prs-gate-row:last-child { border-bottom: 0; }
504 .prs-gate-icon {
505 flex: 0 0 auto;
506 width: 22px; height: 22px;
507 display: inline-flex; align-items: center; justify-content: center;
508 border-radius: 9999px;
509 font-size: 12px;
510 font-weight: 700;
511 }
512 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
513 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
514 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
515 .prs-gate-name {
516 font-size: 13px;
517 font-weight: 600;
518 color: var(--text);
519 min-width: 140px;
520 }
521 .prs-gate-details {
522 flex: 1; min-width: 0;
523 font-size: 12.5px;
524 color: var(--text-muted);
525 }
526 .prs-gate-pill {
527 flex: 0 0 auto;
528 padding: 3px 10px;
529 border-radius: 9999px;
530 font-size: 11px;
531 font-weight: 600;
532 line-height: 1.5;
533 border: 1px solid transparent;
534 }
535 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
536 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
537 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
538 .prs-gate-footer {
539 padding: 12px 18px;
540 background: var(--bg-secondary);
541 font-size: 12px;
542 color: var(--text-muted);
543 }
544
545 /* Comment cards */
546 .prs-comment {
547 margin-top: 14px;
548 background: var(--bg-elevated);
549 border: 1px solid var(--border);
550 border-radius: 12px;
551 overflow: hidden;
552 }
553 .prs-comment-head {
554 display: flex; align-items: center; gap: 10px;
555 padding: 10px 14px;
556 background: var(--bg-secondary);
557 border-bottom: 1px solid var(--border);
558 font-size: 13px;
559 flex-wrap: wrap;
560 }
561 .prs-comment-head strong { color: var(--text-strong); }
562 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
563 .prs-comment-loc {
564 font-family: var(--font-mono);
565 font-size: 11.5px;
566 color: var(--text-muted);
567 background: var(--bg-tertiary);
568 padding: 2px 8px;
569 border-radius: 6px;
570 }
571 .prs-comment-body { padding: 14px 18px; }
572 .prs-comment.is-ai {
573 border-color: rgba(140,109,255,0.45);
574 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
575 }
576 .prs-comment.is-ai .prs-comment-head {
577 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
578 border-bottom-color: rgba(140,109,255,0.30);
579 }
580 .prs-ai-badge {
581 display: inline-flex; align-items: center; gap: 4px;
582 padding: 2px 9px;
583 font-size: 10.5px;
584 font-weight: 700;
585 letter-spacing: 0.04em;
586 text-transform: uppercase;
587 color: #fff;
588 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
589 border-radius: 9999px;
590 }
591
592 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
593 .prs-files-card {
594 margin-top: 18px;
595 padding: 14px 18px;
596 display: flex; align-items: center; gap: 14px;
597 background: var(--bg-elevated);
598 border: 1px solid var(--border);
599 border-radius: 12px;
600 text-decoration: none;
601 color: inherit;
602 transition: border-color 120ms ease, transform 140ms ease;
603 }
604 .prs-files-card:hover {
605 border-color: rgba(140,109,255,0.45);
606 transform: translateY(-1px);
607 }
608 .prs-files-card-icon {
609 width: 36px; height: 36px;
610 display: inline-flex; align-items: center; justify-content: center;
611 border-radius: 10px;
612 background: rgba(140,109,255,0.12);
613 color: var(--text-link);
614 font-size: 18px;
615 }
616 .prs-files-card-text { flex: 1; min-width: 0; }
617 .prs-files-card-title {
618 font-size: 14px;
619 font-weight: 600;
620 color: var(--text-strong);
621 margin: 0 0 2px;
622 }
623 .prs-files-card-sub {
624 font-size: 12.5px;
625 color: var(--text-muted);
626 margin: 0;
627 }
628 .prs-files-card-cta {
629 font-size: 12.5px;
630 color: var(--text-link);
631 font-weight: 600;
632 }
633
634 /* Merge area */
635 .prs-merge-card {
636 position: relative;
637 margin-top: 22px;
638 padding: 18px;
639 background: var(--bg-elevated);
640 border-radius: 14px;
641 overflow: hidden;
642 }
643 .prs-merge-card::before {
644 content: '';
645 position: absolute; inset: 0;
646 padding: 1px;
647 border-radius: 14px;
648 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
649 -webkit-mask:
650 linear-gradient(#000 0 0) content-box,
651 linear-gradient(#000 0 0);
652 -webkit-mask-composite: xor;
653 mask-composite: exclude;
654 pointer-events: none;
655 }
656 .prs-merge-card.is-closed::before { background: var(--border-strong); }
657 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
658 .prs-merge-head {
659 display: flex; align-items: center; gap: 12px;
660 margin-bottom: 12px;
661 }
662 .prs-merge-head strong {
663 font-family: var(--font-display);
664 font-size: 15px;
665 color: var(--text-strong);
666 font-weight: 700;
667 }
668 .prs-merge-sub {
669 font-size: 13px;
670 color: var(--text-muted);
671 margin: 0 0 12px;
672 }
673 .prs-merge-actions {
674 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
675 }
676 .prs-merge-btn {
677 display: inline-flex; align-items: center; gap: 6px;
678 padding: 9px 16px;
679 border-radius: 10px;
680 font-size: 13.5px;
681 font-weight: 600;
682 color: #fff;
683 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
684 border: 1px solid rgba(52,211,153,0.55);
685 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
686 cursor: pointer;
687 transition: transform 120ms ease, box-shadow 160ms ease;
688 }
689 .prs-merge-btn:hover {
690 transform: translateY(-1px);
691 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
692 }
693 .prs-merge-btn[disabled],
694 .prs-merge-btn.is-disabled {
695 opacity: 0.55;
696 cursor: not-allowed;
697 transform: none;
698 box-shadow: none;
699 }
700 .prs-merge-ready-btn {
701 display: inline-flex; align-items: center; gap: 6px;
702 padding: 9px 16px;
703 border-radius: 10px;
704 font-size: 13.5px;
705 font-weight: 600;
706 color: #fff;
707 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
708 border: 1px solid rgba(140,109,255,0.55);
709 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
710 cursor: pointer;
711 transition: transform 120ms ease, box-shadow 160ms ease;
712 }
713 .prs-merge-ready-btn:hover {
714 transform: translateY(-1px);
715 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
716 }
717 .prs-merge-back-draft {
718 background: none; border: 1px solid var(--border-strong);
719 color: var(--text-muted);
720 padding: 9px 14px; border-radius: 10px;
721 font-size: 13px; cursor: pointer;
722 }
723 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
724
725 /* Inline form helpers */
726 .prs-inline-form { display: inline-flex; }
727
728 /* Comment composer */
729 .prs-composer { margin-top: 22px; }
730 .prs-composer textarea {
731 border-radius: 12px;
732 }
733
734 @media (max-width: 720px) {
735 .prs-detail-actions { margin-left: 0; }
736 .prs-merge-actions { width: 100%; }
737 .prs-merge-actions > * { flex: 1; min-width: 0; }
738 }
f1dc7c7Claude739
740 /* Additional mobile rules. Additive only. */
741 @media (max-width: 720px) {
742 .prs-detail-hero { padding: 18px; }
743 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
744 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
745 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
746 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
747 .prs-gate-name { min-width: 0; }
748 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
749 .prs-gate-summary { margin-left: 0; }
750 .prs-merge-btn,
751 .prs-merge-ready-btn,
752 .prs-merge-back-draft { min-height: 44px; }
753 .prs-comment-body { padding: 12px 14px; }
754 .prs-comment-head { padding: 10px 12px; }
755 .prs-files-card { padding: 12px 14px; }
756 }
3c03977Claude757
758 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
759 .live-pill {
760 display: inline-flex;
761 align-items: center;
762 gap: 8px;
763 padding: 4px 10px 4px 8px;
764 margin-left: 6px;
765 background: var(--bg-elevated);
766 border: 1px solid var(--border);
767 border-radius: 9999px;
768 font-size: 12px;
769 color: var(--text-muted);
770 line-height: 1;
771 vertical-align: middle;
772 }
773 .live-pill.is-busy { color: var(--text); }
774 .live-pill-dot {
775 width: 8px; height: 8px;
776 border-radius: 9999px;
777 background: #34d399;
778 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
779 animation: live-pulse 1.6s ease-in-out infinite;
780 }
781 @keyframes live-pulse {
782 0%, 100% { opacity: 1; }
783 50% { opacity: 0.55; }
784 }
785 .live-avatars {
786 display: inline-flex;
787 margin-left: 2px;
788 }
789 .live-avatar {
790 display: inline-flex;
791 align-items: center;
792 justify-content: center;
793 width: 22px; height: 22px;
794 border-radius: 9999px;
795 font-size: 10px;
796 font-weight: 700;
797 color: #0b1020;
798 margin-left: -6px;
799 border: 2px solid var(--bg-elevated);
800 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
801 }
802 .live-avatar:first-child { margin-left: 0; }
803 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
804 .live-cursor-host {
805 position: relative;
806 }
807 .live-cursor-overlay {
808 position: absolute;
809 inset: 0;
810 pointer-events: none;
811 overflow: hidden;
812 border-radius: inherit;
813 }
814 .live-cursor {
815 position: absolute;
816 width: 2px;
817 height: 18px;
818 border-radius: 2px;
819 transform: translate(-1px, 0);
820 transition: transform 80ms linear, opacity 200ms ease;
821 }
822 .live-cursor::after {
823 content: attr(data-label);
824 position: absolute;
825 top: -16px;
826 left: -2px;
827 font-size: 10px;
828 line-height: 1;
829 color: #0b1020;
830 background: inherit;
831 padding: 2px 5px;
832 border-radius: 4px 4px 4px 0;
833 white-space: nowrap;
834 font-weight: 600;
835 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
836 }
837 .live-cursor.is-idle { opacity: 0.4; }
838 .live-edit-tag {
839 display: inline-block;
840 margin-left: 6px;
841 padding: 1px 6px;
842 font-size: 10px;
843 font-weight: 600;
844 letter-spacing: 0.02em;
845 color: #0b1020;
846 border-radius: 9999px;
847 }
15db0e0Claude848
849 /* ─── Slash-command pill + composer hint ─── */
850 .slash-hint {
851 display: inline-flex;
852 align-items: center;
853 gap: 6px;
854 margin-top: 6px;
855 padding: 3px 9px;
856 font-size: 11.5px;
857 color: var(--text-muted);
858 background: var(--bg-elevated);
859 border: 1px dashed var(--border);
860 border-radius: 9999px;
861 width: fit-content;
862 }
863 .slash-hint code {
864 background: rgba(110, 168, 255, 0.12);
865 color: var(--text-strong);
866 padding: 0 5px;
867 border-radius: 4px;
868 font-size: 11px;
869 }
870 .slash-pill {
871 display: grid;
872 grid-template-columns: auto 1fr auto;
873 align-items: center;
874 column-gap: 10px;
875 row-gap: 6px;
876 margin: 10px 0;
877 padding: 10px 14px;
878 background: linear-gradient(
879 135deg,
880 rgba(110, 168, 255, 0.08),
881 rgba(163, 113, 247, 0.06)
882 );
883 border: 1px solid rgba(110, 168, 255, 0.32);
884 border-left: 3px solid var(--accent, #6ea8ff);
885 border-radius: var(--radius);
886 font-size: 13px;
887 color: var(--text);
888 }
889 .slash-pill-icon {
890 font-size: 14px;
891 line-height: 1;
892 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
893 }
894 .slash-pill-actor { color: var(--text-muted); }
895 .slash-pill-actor strong { color: var(--text-strong); }
896 .slash-pill-cmd {
897 background: rgba(110, 168, 255, 0.16);
898 color: var(--text-strong);
899 padding: 1px 6px;
900 border-radius: 4px;
901 font-size: 12.5px;
902 }
903 .slash-pill-time {
904 color: var(--text-muted);
905 font-size: 12px;
906 justify-self: end;
907 }
908 .slash-pill-body {
909 grid-column: 1 / -1;
910 color: var(--text);
911 font-size: 13px;
912 line-height: 1.55;
913 }
914 .slash-pill-body p:first-child { margin-top: 0; }
915 .slash-pill-body p:last-child { margin-bottom: 0; }
916 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
917 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
918 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
919 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
b078860Claude920`;
921
81c73c1Claude922/**
923 * Tiny inline JS that drives the "Suggest description with AI" button.
924 * On click, gathers form values, POSTs JSON to the given endpoint, and
925 * pipes the response into the #pr-body textarea. All DOM lookups are
926 * defensive — element absence is a silent no-op.
927 *
928 * Built as a string template so it lives next to its server-side caller
929 * and there is no bundler dependency. The endpoint URL is JSON-escaped
930 * to avoid </script> breakouts.
931 */
932function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
933 const url = JSON.stringify(endpointUrl)
934 .split("<").join("\\u003C")
935 .split(">").join("\\u003E")
936 .split("&").join("\\u0026");
937 return (
938 "(function(){try{" +
939 "var btn=document.getElementById('ai-suggest-desc');" +
940 "var status=document.getElementById('ai-suggest-status');" +
941 "var body=document.getElementById('pr-body');" +
942 "var form=btn&&btn.closest&&btn.closest('form');" +
943 "if(!btn||!body||!form)return;" +
944 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
945 "var fd=new FormData(form);" +
946 "var title=String(fd.get('title')||'').trim();" +
947 "var base=String(fd.get('base')||'').trim();" +
948 "var head=String(fd.get('head')||'').trim();" +
949 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
950 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
951 "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'})" +
952 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
953 ".then(function(j){btn.disabled=false;" +
954 "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;}}" +
955 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
956 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
957 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
958 "});" +
959 "}catch(e){}})();"
960 );
961}
962
3c03977Claude963/**
964 * Live co-editing client. Connects to the per-PR SSE feed and:
965 * - Maintains a "Live: N editing" pill in the PR header (avatars +
966 * status colour per user).
967 * - Renders tinted cursor caret overlays inside #pr-body and every
968 * `[data-live-field]` element.
969 * - Broadcasts the local user's cursor position (selectionStart /
970 * selectionEnd) debounced at 100ms.
971 * - Broadcasts content patches (`replace` of the whole textarea —
972 * last-write-wins v1) debounced at 250ms.
973 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
974 * to the matching local field if untouched.
975 *
976 * All endpoint URLs are JSON-escaped via safe replacements so they
977 * can't break out of the <script> tag.
978 */
979function LIVE_COEDIT_SCRIPT(prId: string): string {
980 const idJson = JSON.stringify(prId)
981 .split("<").join("\\u003C")
982 .split(">").join("\\u003E")
983 .split("&").join("\\u0026");
984 return (
985 "(function(){try{" +
986 "if(typeof EventSource==='undefined')return;" +
987 "var prId=" + idJson + ";" +
988 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
989 "var pill=document.getElementById('live-pill');" +
990 "var avEl=document.getElementById('live-avatars');" +
991 "var countEl=document.getElementById('live-count');" +
992 "var sessionId=null;var myColor=null;" +
993 "var presence={};" + // sessionId -> {color,status,userId,initials}
994 "var lastApplied={};" + // field -> last server value (for echo suppression)
995 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
996 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
997 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
998 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
999 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1000 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1001 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1002 "avEl.innerHTML=html;}}" +
1003 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1004 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1005 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1006 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1007 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1008 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1009 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1010 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1011 "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);}" +
1012 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1013 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1014 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1015 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1016 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1017 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1018 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1019 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1020 "}catch(e){}" +
1021 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1022 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1023 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1024 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1025 "var es;var delay=1000;" +
1026 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1027 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1028 "(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){}});" +
1029 "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){}});" +
1030 "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){}});" +
1031 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1032 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1033 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1034 "var patch=d.patch;if(!patch||!patch.field)return;" +
1035 "var ta=fieldEl(patch.field);if(!ta)return;" +
1036 "if(document.activeElement===ta)return;" + // don't trample local typing
1037 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1038 "}catch(e){}});" +
1039 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1040 "}connect();" +
1041 "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){}}" +
1042 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1043 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1044 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1045 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1046 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1047 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1048 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1049 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1050 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1051 "}" +
1052 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1053 "var live=document.querySelectorAll('[data-live-field]');" +
1054 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1055 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1056 "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){}});" +
1057 "}catch(e){}})();"
1058 );
1059}
1060
0074234Claude1061async function resolveRepo(ownerName: string, repoName: string) {
1062 const [owner] = await db
1063 .select()
1064 .from(users)
1065 .where(eq(users.username, ownerName))
1066 .limit(1);
1067 if (!owner) return null;
1068 const [repo] = await db
1069 .select()
1070 .from(repositories)
1071 .where(
1072 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1073 )
1074 .limit(1);
1075 if (!repo) return null;
1076 return { owner, repo };
1077}
1078
1079// PR Nav helper
1080const PrNav = ({
1081 owner,
1082 repo,
1083 active,
1084}: {
1085 owner: string;
1086 repo: string;
1087 active: "code" | "issues" | "pulls" | "commits";
1088}) => (
bb0f894Claude1089 <TabNav
1090 tabs={[
1091 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1092 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1093 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1094 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1095 ]}
1096 />
0074234Claude1097);
1098
534f04aClaude1099/**
1100 * Block M3 — pre-merge risk score card. Pure presentational helper.
1101 * Rendered in the conversation tab above the gate checks block. Hidden
1102 * entirely when the PR is closed/merged or there is nothing cached and
1103 * nothing in-flight.
1104 */
1105function PrRiskCard({
1106 risk,
1107 calculating,
1108}: {
1109 risk: PrRiskScore | null;
1110 calculating: boolean;
1111}) {
1112 if (!risk) {
1113 return (
1114 <div
1115 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1116 >
1117 <strong style="font-size: 13px; color: var(--text)">
1118 Risk score: calculating…
1119 </strong>
1120 <div style="font-size: 12px; margin-top: 4px">
1121 Refresh in a moment to see the pre-merge risk score for this PR.
1122 </div>
1123 </div>
1124 );
1125 }
1126
1127 const palette = riskBandPalette(risk.band);
1128 const label = riskBandLabel(risk.band);
1129
1130 return (
1131 <div
1132 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1133 >
1134 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1135 <strong>Risk score:</strong>
1136 <span style={`color:${palette.border};font-weight:600`}>
1137 {palette.icon} {label} ({risk.score}/10)
1138 </span>
1139 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1140 {risk.commitSha.slice(0, 7)}
1141 </span>
1142 </div>
1143 {risk.aiSummary && (
1144 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1145 {risk.aiSummary}
1146 </div>
1147 )}
1148 <details style="margin-top:10px">
1149 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1150 See full signal breakdown
1151 </summary>
1152 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1153 <li>files changed: {risk.signals.filesChanged}</li>
1154 <li>
1155 lines added/removed: {risk.signals.linesAdded} /{" "}
1156 {risk.signals.linesRemoved}
1157 </li>
1158 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1159 <li>
1160 schema migration touched:{" "}
1161 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1162 </li>
1163 <li>
1164 locked / sensitive path touched:{" "}
1165 {risk.signals.lockedPathTouched ? "yes" : "no"}
1166 </li>
1167 <li>
1168 adds new dependency:{" "}
1169 {risk.signals.addsNewDependency ? "yes" : "no"}
1170 </li>
1171 <li>
1172 bumps major dependency:{" "}
1173 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1174 </li>
1175 <li>
1176 tests added for new code:{" "}
1177 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1178 </li>
1179 <li>
1180 diff-minus-test ratio:{" "}
1181 {risk.signals.diffMinusTestRatio.toFixed(2)}
1182 </li>
1183 </ul>
1184 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1185 How is this calculated? The score is a transparent sum of
1186 weighted signals — see <code>src/lib/pr-risk.ts</code>
1187 {" "}<code>computePrRiskScore</code>.
1188 </div>
1189 </details>
1190 {calculating && (
1191 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1192 (recomputing for the latest commit — refresh to update)
1193 </div>
1194 )}
1195 </div>
1196 );
1197}
1198
1199function riskBandPalette(band: PrRiskScore["band"]): {
1200 border: string;
1201 icon: string;
1202} {
1203 switch (band) {
1204 case "low":
1205 return { border: "var(--green)", icon: "" };
1206 case "medium":
1207 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1208 case "high":
1209 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1210 case "critical":
1211 return { border: "var(--red)", icon: "\u{1F6D1}" };
1212 }
1213}
1214
1215function riskBandLabel(band: PrRiskScore["band"]): string {
1216 switch (band) {
1217 case "low":
1218 return "LOW";
1219 case "medium":
1220 return "MEDIUM";
1221 case "high":
1222 return "HIGH";
1223 case "critical":
1224 return "CRITICAL";
1225 }
1226}
1227
0074234Claude1228// List PRs
04f6b7fClaude1229pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1230 const { owner: ownerName, repo: repoName } = c.req.param();
1231 const user = c.get("user");
1232 const state = c.req.query("state") || "open";
1233
ea9ed4cClaude1234 // ── Loading skeleton (flag-gated) ──
1235 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
1236 // the user see the page structure before counts + select resolve.
1237 // Behind a flag for now — we don't ship flashes.
1238 if (c.req.query("skeleton") === "1") {
1239 return c.html(
1240 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1241 <RepoHeader owner={ownerName} repo={repoName} />
1242 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1243 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1244 <style
1245 dangerouslySetInnerHTML={{
1246 __html: `
1247 .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; }
1248 @keyframes prsSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1249 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
1250 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
1251 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
1252 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
1253 .prs-skel-row { height: 76px; border-radius: 12px; }
1254 `,
1255 }}
1256 />
1257 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
1258 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
1259 <div class="prs-skel-list" aria-hidden="true">
1260 {Array.from({ length: 6 }).map(() => (
1261 <div class="prs-skel prs-skel-row" />
1262 ))}
1263 </div>
1264 <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">
1265 Loading pull requests for {ownerName}/{repoName}…
1266 </span>
1267 </Layout>
1268 );
1269 }
1270
0074234Claude1271 const resolved = await resolveRepo(ownerName, repoName);
1272 if (!resolved) return c.notFound();
1273
6fc53bdClaude1274 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
1275 const stateFilter =
1276 state === "draft"
1277 ? and(
1278 eq(pullRequests.state, "open"),
1279 eq(pullRequests.isDraft, true)
1280 )
1281 : eq(pullRequests.state, state);
1282
0074234Claude1283 const prList = await db
1284 .select({
1285 pr: pullRequests,
1286 author: { username: users.username },
1287 })
1288 .from(pullRequests)
1289 .innerJoin(users, eq(pullRequests.authorId, users.id))
1290 .where(
6fc53bdClaude1291 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude1292 )
1293 .orderBy(desc(pullRequests.createdAt));
1294
1295 const [counts] = await db
1296 .select({
1297 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude1298 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude1299 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
1300 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
1301 })
1302 .from(pullRequests)
1303 .where(eq(pullRequests.repositoryId, resolved.repo.id));
1304
b078860Claude1305 const openCount = counts?.open ?? 0;
1306 const mergedCount = counts?.merged ?? 0;
1307 const closedCount = counts?.closed ?? 0;
1308 const draftCount = counts?.draft ?? 0;
1309 const allCount = openCount + mergedCount + closedCount;
1310
1311 // "All" is presentational only — the DB query for state='all' matches
1312 // nothing, so we render a friendlier empty state when picked. We do NOT
1313 // change the query logic to keep this commit purely visual.
1314 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
1315 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
1316 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
1317 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
1318 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
1319 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
1320 ];
1321 const isAllState = state === "all";
1322
0074234Claude1323 return c.html(
1324 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1325 <RepoHeader owner={ownerName} repo={repoName} />
1326 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude1327 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1328
1329 <div class="prs-hero">
1330 <div class="prs-hero-inner">
1331 <div class="prs-hero-text">
1332 <div class="prs-hero-eyebrow">Pull requests</div>
1333 <h1 class="prs-hero-title">
1334 Review, <span class="gradient-text">merge with AI</span>.
1335 </h1>
1336 <p class="prs-hero-sub">
1337 {openCount === 0 && allCount === 0
1338 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
1339 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
1340 </p>
1341 </div>
1342 {user && (
1343 <div class="prs-hero-actions">
1344 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
1345 + New pull request
1346 </a>
1347 </div>
1348 )}
1349 </div>
1350 </div>
1351
1352 <nav class="prs-tabs" aria-label="Pull request filters">
1353 {tabPills.map((t) => {
1354 const isActive =
1355 state === t.key ||
1356 (t.key === "open" &&
1357 state !== "merged" &&
1358 state !== "closed" &&
1359 state !== "all" &&
1360 state !== "draft");
1361 return (
1362 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
1363 <span>{t.label}</span>
1364 <span class="prs-tab-count">{t.count}</span>
1365 </a>
1366 );
1367 })}
1368 </nav>
1369
0074234Claude1370 {prList.length === 0 ? (
b078860Claude1371 <div class="prs-empty">
ea9ed4cClaude1372 <div class="prs-empty-inner">
1373 <strong>
1374 {isAllState
1375 ? "Pick a filter above to browse PRs."
1376 : `No ${state} pull requests.`}
1377 </strong>
1378 <p class="prs-empty-sub">
1379 {state === "open"
1380 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
1381 : isAllState
1382 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1383 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
1384 </p>
1385 <div class="prs-empty-cta">
1386 {user && state === "open" && (
1387 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
1388 + New pull request
1389 </a>
1390 )}
1391 {state !== "open" && (
1392 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
1393 View open PRs
1394 </a>
1395 )}
1396 <a href={`/${ownerName}/${repoName}`} class="btn">
1397 Back to code
1398 </a>
1399 </div>
1400 </div>
b078860Claude1401 </div>
0074234Claude1402 ) : (
b078860Claude1403 <div class="prs-list">
1404 {prList.map(({ pr, author }) => {
1405 const stateClass =
1406 pr.state === "open"
1407 ? pr.isDraft
1408 ? "state-draft"
1409 : "state-open"
1410 : pr.state === "merged"
1411 ? "state-merged"
1412 : "state-closed";
1413 const icon =
1414 pr.state === "open"
1415 ? pr.isDraft
1416 ? "◌"
1417 : "○"
1418 : pr.state === "merged"
1419 ? "⮌"
1420 : "✓";
1421 return (
1422 <a
1423 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1424 class="prs-row"
1425 style="text-decoration:none;color:inherit"
0074234Claude1426 >
b078860Claude1427 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
1428 {icon}
0074234Claude1429 </div>
b078860Claude1430 <div class="prs-row-body">
1431 <h3 class="prs-row-title">
1432 <span>{pr.title}</span>
1433 <span class="prs-row-number">#{pr.number}</span>
1434 </h3>
1435 <div class="prs-row-meta">
1436 <span
1437 class="prs-branch-chips"
1438 title={`${pr.headBranch} into ${pr.baseBranch}`}
1439 >
1440 <span class="prs-branch-chip">{pr.headBranch}</span>
1441 <span class="prs-branch-arrow">{"→"}</span>
1442 <span class="prs-branch-chip">{pr.baseBranch}</span>
1443 </span>
1444 <span>
1445 by{" "}
1446 <strong style="color:var(--text)">
1447 {author.username}
1448 </strong>{" "}
1449 {formatRelative(pr.createdAt)}
1450 </span>
1451 <span class="prs-row-tags">
1452 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
1453 {pr.state === "merged" && (
1454 <span class="prs-tag is-merged">Merged</span>
1455 )}
1456 </span>
1457 </div>
0074234Claude1458 </div>
b078860Claude1459 </a>
1460 );
1461 })}
1462 </div>
0074234Claude1463 )}
1464 </Layout>
1465 );
1466});
1467
1468// New PR form
1469pulls.get(
1470 "/:owner/:repo/pulls/new",
1471 softAuth,
1472 requireAuth,
04f6b7fClaude1473 requireRepoAccess("write"),
0074234Claude1474 async (c) => {
1475 const { owner: ownerName, repo: repoName } = c.req.param();
1476 const user = c.get("user")!;
1477 const branches = await listBranches(ownerName, repoName);
1478 const error = c.req.query("error");
1479 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude1480 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude1481
1482 return c.html(
1483 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
1484 <RepoHeader owner={ownerName} repo={repoName} />
1485 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude1486 <Container maxWidth={800}>
1487 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude1488 {error && (
bb0f894Claude1489 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude1490 )}
0316dbbClaude1491 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
1492 <Flex gap={12} align="center" style="margin-bottom: 16px">
1493 <Select name="base">
0074234Claude1494 {branches.map((b) => (
1495 <option value={b} selected={b === defaultBase}>
1496 {b}
1497 </option>
1498 ))}
bb0f894Claude1499 </Select>
1500 <Text muted>&larr;</Text>
1501 <Select name="head">
0074234Claude1502 {branches
1503 .filter((b) => b !== defaultBase)
1504 .concat(defaultBase === branches[0] ? [] : [branches[0]])
1505 .map((b) => (
1506 <option value={b}>{b}</option>
1507 ))}
bb0f894Claude1508 </Select>
1509 </Flex>
1510 <FormGroup>
1511 <Input
0074234Claude1512 name="title"
1513 required
1514 placeholder="Title"
bb0f894Claude1515 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]1516 aria-label="Pull request title"
0074234Claude1517 />
bb0f894Claude1518 </FormGroup>
1519 <FormGroup>
1520 <TextArea
0074234Claude1521 name="body"
81c73c1Claude1522 id="pr-body"
0074234Claude1523 rows={8}
1524 placeholder="Description (Markdown supported)"
bb0f894Claude1525 mono
0074234Claude1526 />
bb0f894Claude1527 </FormGroup>
81c73c1Claude1528 <Flex gap={8} align="center">
1529 <Button type="submit" variant="primary">
1530 Create pull request
1531 </Button>
1532 <button
1533 type="button"
1534 id="ai-suggest-desc"
1535 class="btn"
1536 style="font-weight:500"
1537 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
1538 >
1539 Suggest description with AI
1540 </button>
1541 <span
1542 id="ai-suggest-status"
1543 style="color:var(--text-muted);font-size:13px"
1544 />
1545 </Flex>
bb0f894Claude1546 </Form>
81c73c1Claude1547 <script
1548 dangerouslySetInnerHTML={{
1549 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
1550 }}
1551 />
bb0f894Claude1552 </Container>
0074234Claude1553 </Layout>
1554 );
1555 }
1556);
1557
81c73c1Claude1558// AI-suggested PR description — JSON endpoint driven by the form button.
1559// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
1560// 200; the inline script reads `ok` to decide what to do.
1561pulls.post(
1562 "/:owner/:repo/ai/pr-description",
1563 softAuth,
1564 requireAuth,
1565 requireRepoAccess("write"),
1566 async (c) => {
1567 const { owner: ownerName, repo: repoName } = c.req.param();
1568 if (!isAiAvailable()) {
1569 return c.json({
1570 ok: false,
1571 error: "AI is not available — set ANTHROPIC_API_KEY.",
1572 });
1573 }
1574 const body = await c.req.parseBody();
1575 const title = String(body.title || "").trim();
1576 const baseBranch = String(body.base || "").trim();
1577 const headBranch = String(body.head || "").trim();
1578 if (!baseBranch || !headBranch) {
1579 return c.json({ ok: false, error: "Pick base + head branches first." });
1580 }
1581 if (baseBranch === headBranch) {
1582 return c.json({ ok: false, error: "Base and head must differ." });
1583 }
1584
1585 let diff = "";
1586 try {
1587 const cwd = getRepoPath(ownerName, repoName);
1588 const proc = Bun.spawn(
1589 [
1590 "git",
1591 "diff",
1592 `${baseBranch}...${headBranch}`,
1593 "--",
1594 ],
1595 { cwd, stdout: "pipe", stderr: "pipe" }
1596 );
6ea2109Claude1597 // 30s ceiling — without this a pathological diff (huge binary or
1598 // a corrupt ref) hangs the request indefinitely.
1599 const killer = setTimeout(() => proc.kill(), 30_000);
1600 try {
1601 diff = await new Response(proc.stdout).text();
1602 await proc.exited;
1603 } finally {
1604 clearTimeout(killer);
1605 }
81c73c1Claude1606 } catch {
1607 diff = "";
1608 }
1609 if (!diff.trim()) {
1610 return c.json({
1611 ok: false,
1612 error: "No diff between branches — nothing to summarise.",
1613 });
1614 }
1615
1616 let summary = "";
1617 try {
1618 summary = await generatePrSummary(title || "(untitled)", diff);
1619 } catch (err) {
1620 const msg = err instanceof Error ? err.message : "AI request failed.";
1621 return c.json({ ok: false, error: msg });
1622 }
1623 if (!summary.trim()) {
1624 return c.json({ ok: false, error: "AI returned an empty draft." });
1625 }
1626 return c.json({ ok: true, body: summary });
1627 }
1628);
1629
0074234Claude1630// Create PR
1631pulls.post(
1632 "/:owner/:repo/pulls/new",
1633 softAuth,
1634 requireAuth,
04f6b7fClaude1635 requireRepoAccess("write"),
0074234Claude1636 async (c) => {
1637 const { owner: ownerName, repo: repoName } = c.req.param();
1638 const user = c.get("user")!;
1639 const body = await c.req.parseBody();
1640 const title = String(body.title || "").trim();
1641 const prBody = String(body.body || "").trim();
1642 const baseBranch = String(body.base || "main");
1643 const headBranch = String(body.head || "");
1644
1645 if (!title || !headBranch) {
1646 return c.redirect(
1647 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
1648 );
1649 }
1650
1651 if (baseBranch === headBranch) {
1652 return c.redirect(
1653 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
1654 );
1655 }
1656
1657 const resolved = await resolveRepo(ownerName, repoName);
1658 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1659
6fc53bdClaude1660 const isDraft = String(body.draft || "") === "1";
1661
0074234Claude1662 const [pr] = await db
1663 .insert(pullRequests)
1664 .values({
1665 repositoryId: resolved.repo.id,
1666 authorId: user.id,
1667 title,
1668 body: prBody || null,
1669 baseBranch,
1670 headBranch,
6fc53bdClaude1671 isDraft,
0074234Claude1672 })
1673 .returning();
1674
6fc53bdClaude1675 // Skip AI review on drafts — it runs again when the PR is marked ready.
1676 if (!isDraft && isAiReviewEnabled()) {
e883329Claude1677 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
1678 (err) => console.error("[ai-review] Failed:", err)
1679 );
1680 }
1681
3cbe3d6Claude1682 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
1683 triggerPrTriage({
1684 ownerName,
1685 repoName,
1686 repositoryId: resolved.repo.id,
1687 prId: pr.id,
1688 prAuthorId: user.id,
1689 title,
1690 body: prBody,
1691 baseBranch,
1692 headBranch,
1693 }).catch((err) => console.error("[pr-triage] Failed:", err));
1694
9dd96b9Test User1695 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude1696 import("../lib/auto-merge")
1697 .then((m) => m.tryAutoMergeNow(pr.id))
1698 .catch((err) => {
1699 console.warn(
1700 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
1701 err instanceof Error ? err.message : err
1702 );
1703 });
9dd96b9Test User1704
0074234Claude1705 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
1706 }
1707);
1708
1709// View single PR
04f6b7fClaude1710pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1711 const { owner: ownerName, repo: repoName } = c.req.param();
1712 const prNum = parseInt(c.req.param("number"), 10);
1713 const user = c.get("user");
1714 const tab = c.req.query("tab") || "conversation";
1715
1716 const resolved = await resolveRepo(ownerName, repoName);
1717 if (!resolved) return c.notFound();
1718
1719 const [pr] = await db
1720 .select()
1721 .from(pullRequests)
1722 .where(
1723 and(
1724 eq(pullRequests.repositoryId, resolved.repo.id),
1725 eq(pullRequests.number, prNum)
1726 )
1727 )
1728 .limit(1);
1729
1730 if (!pr) return c.notFound();
1731
1732 const [author] = await db
1733 .select()
1734 .from(users)
1735 .where(eq(users.id, pr.authorId))
1736 .limit(1);
1737
1738 const comments = await db
1739 .select({
1740 comment: prComments,
1741 author: { username: users.username },
1742 })
1743 .from(prComments)
1744 .innerJoin(users, eq(prComments.authorId, users.id))
1745 .where(eq(prComments.pullRequestId, pr.id))
1746 .orderBy(asc(prComments.createdAt));
1747
6fc53bdClaude1748 // Reactions for the PR body + each comment, in parallel.
1749 const [prReactions, ...prCommentReactions] = await Promise.all([
1750 summariseReactions("pr", pr.id, user?.id),
1751 ...comments.map((row) =>
1752 summariseReactions("pr_comment", row.comment.id, user?.id)
1753 ),
1754 ]);
1755
0074234Claude1756 const canManage =
1757 user &&
1758 (user.id === resolved.owner.id || user.id === pr.authorId);
1759
e883329Claude1760 const error = c.req.query("error");
c3e0c07Claude1761 const info = c.req.query("info");
e883329Claude1762
1763 // Get gate check status for open PRs
1764 let gateChecks: GateCheckResult[] = [];
1765 if (pr.state === "open") {
1766 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
1767 if (headSha) {
1768 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
1769 const aiApproved = aiComments.length === 0 || aiComments.some(
1770 ({ comment }) => comment.body.includes("**Approved**")
1771 );
1772 const gateResult = await runAllGateChecks(
1773 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
1774 );
1775 gateChecks = gateResult.checks;
1776 }
1777 }
1778
534f04aClaude1779 // Block M3 — pre-merge risk score. Cache-only on the request path so
1780 // the page never waits on Haiku. On a cache miss for an open PR we
1781 // kick off the computation fire-and-forget; the next refresh shows it.
1782 let prRisk: PrRiskScore | null = null;
1783 let prRiskCalculating = false;
1784 if (pr.state === "open") {
1785 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
1786 if (!prRisk) {
1787 prRiskCalculating = true;
a28cedeClaude1788 void computePrRiskForPullRequest(pr.id).catch((err) => {
1789 console.warn(
1790 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
1791 err instanceof Error ? err.message : err
1792 );
1793 });
534f04aClaude1794 }
1795 }
1796
0074234Claude1797 // Get diff for "Files changed" tab
1798 let diffRaw = "";
1799 let diffFiles: GitDiffFile[] = [];
1800 if (tab === "files") {
1801 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude1802 // Run the two git diffs in parallel — they're independent reads of
1803 // the same range. Previously sequential, doubling the wall time on
1804 // big PRs (100+ files = 10-30s for no reason).
0074234Claude1805 const proc = Bun.spawn(
1806 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
1807 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1808 );
1809 const statProc = Bun.spawn(
1810 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
1811 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1812 );
6ea2109Claude1813 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
1814 // would otherwise hang the whole request.
1815 const killer = setTimeout(() => {
1816 proc.kill();
1817 statProc.kill();
1818 }, 30_000);
1819 let stat = "";
1820 try {
1821 [diffRaw, stat] = await Promise.all([
1822 new Response(proc.stdout).text(),
1823 new Response(statProc.stdout).text(),
1824 ]);
1825 await Promise.all([proc.exited, statProc.exited]);
1826 } finally {
1827 clearTimeout(killer);
1828 }
0074234Claude1829
1830 diffFiles = stat
1831 .trim()
1832 .split("\n")
1833 .filter(Boolean)
1834 .map((line) => {
1835 const [add, del, filePath] = line.split("\t");
1836 return {
1837 path: filePath,
1838 status: "modified",
1839 additions: add === "-" ? 0 : parseInt(add, 10),
1840 deletions: del === "-" ? 0 : parseInt(del, 10),
1841 patch: "",
1842 };
1843 });
1844 }
1845
b078860Claude1846 // ─── Derived visual state ───
1847 const stateKey =
1848 pr.state === "open"
1849 ? pr.isDraft
1850 ? "draft"
1851 : "open"
1852 : pr.state;
1853 const stateLabel =
1854 stateKey === "open"
1855 ? "Open"
1856 : stateKey === "draft"
1857 ? "Draft"
1858 : stateKey === "merged"
1859 ? "Merged"
1860 : "Closed";
1861 const stateIcon =
1862 stateKey === "open"
1863 ? "○"
1864 : stateKey === "draft"
1865 ? "◌"
1866 : stateKey === "merged"
1867 ? "⮌"
1868 : "✓";
1869 const commentCount = comments.length;
1870 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
1871 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
1872 const mergeBlocked =
1873 gateChecks.length > 0 &&
1874 gateChecks.some(
1875 (c) => !c.passed && c.name !== "Merge check"
1876 );
1877
0074234Claude1878 return c.html(
1879 <Layout
1880 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
1881 user={user}
1882 >
1883 <RepoHeader owner={ownerName} repo={repoName} />
1884 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude1885 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude1886 <div
1887 id="live-comment-banner"
1888 class="alert"
1889 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1890 >
1891 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1892 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1893 reload to view
1894 </a>
1895 </div>
1896 <script
1897 dangerouslySetInnerHTML={{
1898 __html: liveCommentBannerScript({
1899 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
1900 bannerElementId: "live-comment-banner",
1901 }),
1902 }}
1903 />
b078860Claude1904
1905 <div class="prs-detail-hero">
1906 <h1 class="prs-detail-title">
0074234Claude1907 {pr.title}{" "}
b078860Claude1908 <span class="prs-detail-num">#{pr.number}</span>
1909 </h1>
1910 <div class="prs-detail-meta">
1911 <span class={`prs-state-pill state-${stateKey}`}>
1912 <span aria-hidden="true">{stateIcon}</span>
1913 <span>{stateLabel}</span>
1914 </span>
1915 <span>
1916 <strong>{author?.username}</strong> wants to merge
1917 </span>
1918 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
1919 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
1920 <span class="prs-branch-arrow-lg">{"→"}</span>
1921 <span class="prs-branch-pill">{pr.baseBranch}</span>
1922 </span>
1923 <span>opened {formatRelative(pr.createdAt)}</span>
3c03977Claude1924 <span
1925 id="live-pill"
1926 class="live-pill"
1927 title="People editing this PR right now"
1928 >
1929 <span class="live-pill-dot" aria-hidden="true"></span>
1930 <span>
1931 Live: <strong id="live-count">0</strong> editing
1932 </span>
1933 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
1934 </span>
b078860Claude1935 {canManage && pr.state === "open" && pr.isDraft && (
1936 <form
1937 method="post"
1938 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
1939 class="prs-inline-form prs-detail-actions"
1940 >
1941 <button type="submit" class="prs-merge-ready-btn">
1942 Ready for review
1943 </button>
1944 </form>
1945 )}
1946 </div>
1947 </div>
3c03977Claude1948 <script
1949 dangerouslySetInnerHTML={{
1950 __html: LIVE_COEDIT_SCRIPT(pr.id),
1951 }}
1952 />
0074234Claude1953
b078860Claude1954 <nav class="prs-detail-tabs" aria-label="Pull request sections">
1955 <a
1956 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
1957 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1958 >
1959 Conversation
1960 <span class="prs-detail-tab-count">{commentCount}</span>
1961 </a>
1962 <a
1963 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
1964 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
1965 >
1966 Files changed
1967 {diffFiles.length > 0 && (
1968 <span class="prs-detail-tab-count">{diffFiles.length}</span>
1969 )}
1970 </a>
1971 </nav>
1972
1973 {tab === "files" ? (
ea9ed4cClaude1974 <DiffView
1975 raw={diffRaw}
1976 files={diffFiles}
1977 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
1978 />
b078860Claude1979 ) : (
1980 <>
1981 {pr.body && (
1982 <CommentBox
1983 author={author?.username ?? "unknown"}
1984 date={pr.createdAt}
1985 body={renderMarkdown(pr.body)}
1986 />
1987 )}
1988
15db0e0Claude1989 {comments.map(({ comment, author: commentAuthor }) => {
1990 const slashCmd = detectSlashCmdComment(comment.body);
1991 if (slashCmd) {
1992 const visible = stripSlashCmdMarker(comment.body);
1993 return (
1994 <div class={`slash-pill slash-cmd-${slashCmd}`}>
1995 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
1996 <span class="slash-pill-actor">
1997 <strong>{commentAuthor.username}</strong>
1998 {" ran "}
1999 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude2000 </span>
15db0e0Claude2001 <span class="slash-pill-time">
2002 {formatRelative(comment.createdAt)}
2003 </span>
2004 <div class="slash-pill-body">
2005 <MarkdownContent html={renderMarkdown(visible)} />
2006 </div>
2007 </div>
2008 );
2009 }
2010 return (
2011 <div class={`prs-comment${comment.isAiReview ? " is-ai" : ""}`}>
2012 <div class="prs-comment-head">
2013 <strong>{commentAuthor.username}</strong>
2014 {comment.isAiReview && (
2015 <span class="prs-ai-badge">AI Review</span>
2016 )}
2017 <span class="prs-comment-time">
2018 commented {formatRelative(comment.createdAt)}
2019 </span>
2020 {comment.filePath && (
2021 <span class="prs-comment-loc">
2022 {comment.filePath}
2023 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
2024 </span>
2025 )}
2026 </div>
2027 <div class="prs-comment-body">
2028 <MarkdownContent html={renderMarkdown(comment.body)} />
2029 </div>
0074234Claude2030 </div>
15db0e0Claude2031 );
2032 })}
0074234Claude2033
b078860Claude2034 {/* Quick link to the Files changed tab when there's a diff to look at. */}
2035 {pr.state !== "merged" && (
2036 <a
2037 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2038 class="prs-files-card"
2039 >
2040 <span class="prs-files-card-icon" aria-hidden="true">
2041 {"▤"}
2042 </span>
2043 <div class="prs-files-card-text">
2044 <p class="prs-files-card-title">Files changed</p>
2045 <p class="prs-files-card-sub">
2046 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
2047 </p>
e883329Claude2048 </div>
b078860Claude2049 <span class="prs-files-card-cta">View diff {"→"}</span>
2050 </a>
2051 )}
2052
2053 {error && (
2054 <div
2055 class="auth-error"
2056 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)"
2057 >
2058 {decodeURIComponent(error)}
2059 </div>
2060 )}
2061
2062 {info && (
2063 <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)">
2064 {decodeURIComponent(info)}
2065 </div>
2066 )}
e883329Claude2067
b078860Claude2068 {pr.state === "open" && (prRisk || prRiskCalculating) && (
2069 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
2070 )}
2071
2072 {pr.state === "open" && gateChecks.length > 0 && (
2073 <div class="prs-gate-card">
2074 <div class="prs-gate-head">
2075 <h3>Gate checks</h3>
2076 <span class="prs-gate-summary">
2077 {gatesAllPassed
2078 ? `All ${gateChecks.length} checks passed`
2079 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
2080 </span>
c3e0c07Claude2081 </div>
b078860Claude2082 {gateChecks.map((check) => {
2083 const isAi = /ai.*review/i.test(check.name);
2084 const isSkip = check.skipped === true;
2085 const statusClass = isSkip
2086 ? "is-skip"
2087 : check.passed
2088 ? "is-pass"
2089 : "is-fail";
2090 const statusGlyph = isSkip
2091 ? "—"
2092 : check.passed
2093 ? "✓"
2094 : "✗";
2095 const statusLabel = isSkip
2096 ? "Skipped"
2097 : check.passed
2098 ? "Passed"
2099 : "Failing";
2100 return (
2101 <div
2102 class="prs-gate-row"
2103 style={
2104 isAi
2105 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
2106 : ""
2107 }
2108 >
2109 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
2110 {statusGlyph}
2111 </span>
2112 <span class="prs-gate-name">
2113 {check.name}
2114 {isAi && (
2115 <span
2116 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"
2117 >
2118 AI
2119 </span>
2120 )}
2121 </span>
2122 <span class="prs-gate-details">{check.details}</span>
2123 <span class={`prs-gate-pill ${statusClass}`}>
2124 {statusLabel}
e883329Claude2125 </span>
2126 </div>
b078860Claude2127 );
2128 })}
2129 <div class="prs-gate-footer">
2130 {gatesAllPassed
2131 ? "All checks passed — ready to merge."
2132 : gateChecks.some(
2133 (c) => !c.passed && c.name === "Merge check"
2134 )
2135 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
2136 : "Some checks failed — resolve issues before merging."}
2137 {aiReviewCount > 0 && (
2138 <>
2139 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
2140 </>
2141 )}
2142 </div>
2143 </div>
2144 )}
2145
2146 {/* ─── Merge area / state-aware action card ─────────────── */}
2147 {user && pr.state === "open" && (
2148 <div
2149 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
2150 >
2151 <div class="prs-merge-head">
2152 <strong>
2153 {pr.isDraft
2154 ? "Draft — ready for review?"
2155 : mergeBlocked
2156 ? "Merge blocked"
2157 : "Ready to merge"}
2158 </strong>
e883329Claude2159 </div>
b078860Claude2160 <p class="prs-merge-sub">
2161 {pr.isDraft
2162 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
2163 : mergeBlocked
2164 ? "Resolve the failing gate checks above before this PR can land."
2165 : gateChecks.length > 0
2166 ? gatesAllPassed
2167 ? "All gates green. Merge will fast-forward into the base branch."
2168 : "Conflicts will be auto-resolved by GlueCron AI on merge."
2169 : "Run gate checks by refreshing once your branch has a recent commit."}
2170 </p>
2171 <Form
2172 method="post"
2173 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
2174 >
2175 <FormGroup>
3c03977Claude2176 <div class="live-cursor-host" style="position:relative">
2177 <textarea
2178 name="body"
2179 id="pr-comment-body"
2180 data-live-field="comment_new"
2181 rows={5}
2182 required
2183 placeholder="Leave a comment... (Markdown supported)"
2184 style="font-family:var(--font-mono);font-size:13px;width:100%"
2185 ></textarea>
2186 </div>
15db0e0Claude2187 <span class="slash-hint" title="Type a slash-command as the first line">
2188 Type <code>/</code> for commands —{" "}
2189 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
2190 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
2191 </span>
b078860Claude2192 </FormGroup>
2193 <div class="prs-merge-actions">
2194 <Button type="submit" variant="primary">
2195 Comment
2196 </Button>
2197 {canManage && (
2198 <>
2199 {pr.isDraft ? (
2200 <button
2201 type="submit"
2202 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2203 formnovalidate
2204 class="prs-merge-ready-btn"
2205 >
2206 Ready for review
2207 </button>
2208 ) : (
0074234Claude2209 <button
2210 type="submit"
2211 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
b078860Claude2212 formnovalidate
2213 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
2214 title={
2215 mergeBlocked
2216 ? "Failing gate checks must be resolved before this PR can merge."
2217 : "Merge pull request"
2218 }
0074234Claude2219 >
b078860Claude2220 {"✔"} Merge pull request
0074234Claude2221 </button>
b078860Claude2222 )}
2223 {!pr.isDraft && (
2224 <button
0074234Claude2225 type="submit"
b078860Claude2226 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
2227 formnovalidate
2228 class="prs-merge-back-draft"
2229 title="Convert back to draft"
0074234Claude2230 >
b078860Claude2231 Convert to draft
2232 </button>
2233 )}
2234 {isAiReviewEnabled() && (
2235 <button
2236 type="submit"
2237 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
2238 formnovalidate
2239 class="btn"
2240 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
2241 >
2242 Re-run AI review
2243 </button>
2244 )}
2245 <Button
2246 type="submit"
2247 variant="danger"
2248 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
2249 >
2250 Close
2251 </Button>
2252 </>
2253 )}
2254 </div>
2255 </Form>
2256 </div>
2257 )}
2258
2259 {/* Read-only footers for non-open states. */}
2260 {pr.state === "merged" && (
2261 <div class="prs-merge-card is-merged">
2262 <div class="prs-merge-head">
2263 <strong>{"⮌"} Merged</strong>
0074234Claude2264 </div>
b078860Claude2265 <p class="prs-merge-sub">
2266 This pull request was merged into{" "}
2267 <code>{pr.baseBranch}</code>.
2268 </p>
2269 </div>
2270 )}
2271 {pr.state === "closed" && (
2272 <div class="prs-merge-card is-closed">
2273 <div class="prs-merge-head">
2274 <strong>{"✕"} Closed without merging</strong>
2275 </div>
2276 <p class="prs-merge-sub">
2277 This pull request was closed and not merged.
2278 </p>
2279 </div>
2280 )}
2281 </>
2282 )}
0074234Claude2283 </Layout>
2284 );
2285});
2286
2287// Add comment to PR
2288pulls.post(
2289 "/:owner/:repo/pulls/:number/comment",
2290 softAuth,
2291 requireAuth,
04f6b7fClaude2292 requireRepoAccess("write"),
0074234Claude2293 async (c) => {
2294 const { owner: ownerName, repo: repoName } = c.req.param();
2295 const prNum = parseInt(c.req.param("number"), 10);
2296 const user = c.get("user")!;
2297 const body = await c.req.parseBody();
2298 const commentBody = String(body.body || "").trim();
2299
2300 if (!commentBody) {
2301 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2302 }
2303
2304 const resolved = await resolveRepo(ownerName, repoName);
2305 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2306
2307 const [pr] = await db
2308 .select()
2309 .from(pullRequests)
2310 .where(
2311 and(
2312 eq(pullRequests.repositoryId, resolved.repo.id),
2313 eq(pullRequests.number, prNum)
2314 )
2315 )
2316 .limit(1);
2317
2318 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2319
d4ac5c3Claude2320 const [inserted] = await db
2321 .insert(prComments)
2322 .values({
2323 pullRequestId: pr.id,
2324 authorId: user.id,
2325 body: commentBody,
2326 })
2327 .returning();
2328
2329 // Live update: nudge any browser tabs subscribed to this PR.
2330 if (inserted) {
2331 try {
2332 const { publish } = await import("../lib/sse");
2333 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
2334 event: "pr-comment",
2335 data: {
2336 pullRequestId: pr.id,
2337 commentId: inserted.id,
2338 authorId: user.id,
2339 authorUsername: user.username,
2340 },
2341 });
2342 } catch {
2343 /* SSE is best-effort */
2344 }
2345 }
0074234Claude2346
15db0e0Claude2347 // Slash-command handoff. We always store the original comment above
2348 // first so free-form text that happens to start with `/` is preserved
2349 // verbatim; only recognised commands trigger a follow-up bot comment.
2350 const parsed = parseSlashCommand(commentBody);
2351 if (parsed) {
2352 try {
2353 const result = await executeSlashCommand({
2354 command: parsed.command,
2355 args: parsed.args,
2356 prId: pr.id,
2357 userId: user.id,
2358 repositoryId: resolved.repo.id,
2359 });
2360 await db.insert(prComments).values({
2361 pullRequestId: pr.id,
2362 authorId: user.id,
2363 body: result.body,
2364 });
2365 } catch (err) {
2366 // Defence-in-depth — executeSlashCommand promises not to throw,
2367 // but if it ever does we want the PR thread to know.
2368 await db
2369 .insert(prComments)
2370 .values({
2371 pullRequestId: pr.id,
2372 authorId: user.id,
2373 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
2374 })
2375 .catch(() => {});
2376 }
2377 }
2378
0074234Claude2379 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2380 }
2381);
2382
e883329Claude2383// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude2384// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
2385// but we keep it at "write" for v1 so trusted collaborators can ship.
2386// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
2387// surface. Branch-protection rules (evaluated below) are the current mechanism
2388// for locking down merges further on specific branches.
0074234Claude2389pulls.post(
2390 "/:owner/:repo/pulls/:number/merge",
2391 softAuth,
2392 requireAuth,
04f6b7fClaude2393 requireRepoAccess("write"),
0074234Claude2394 async (c) => {
2395 const { owner: ownerName, repo: repoName } = c.req.param();
2396 const prNum = parseInt(c.req.param("number"), 10);
2397 const user = c.get("user")!;
2398
2399 const resolved = await resolveRepo(ownerName, repoName);
2400 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2401
2402 const [pr] = await db
2403 .select()
2404 .from(pullRequests)
2405 .where(
2406 and(
2407 eq(pullRequests.repositoryId, resolved.repo.id),
2408 eq(pullRequests.number, prNum)
2409 )
2410 )
2411 .limit(1);
2412
2413 if (!pr || pr.state !== "open") {
2414 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2415 }
2416
6fc53bdClaude2417 // Draft PRs cannot be merged — must be marked ready first.
2418 if (pr.isDraft) {
2419 return c.redirect(
2420 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2421 "This PR is a draft. Mark it as ready for review before merging."
2422 )}`
2423 );
2424 }
2425
e883329Claude2426 // Resolve head SHA
2427 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2428 if (!headSha) {
2429 return c.redirect(
2430 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
2431 );
2432 }
2433
2434 // Check if AI review approved this PR
2435 const aiComments = await db
2436 .select()
2437 .from(prComments)
2438 .where(
2439 and(
2440 eq(prComments.pullRequestId, pr.id),
2441 eq(prComments.isAiReview, true)
2442 )
2443 );
2444 const aiApproved = aiComments.length === 0 || aiComments.some(
2445 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude2446 );
e883329Claude2447
2448 // Run all green gate checks (GateTest + mergeability + AI review)
2449 const gateResult = await runAllGateChecks(
2450 ownerName,
2451 repoName,
2452 pr.baseBranch,
2453 pr.headBranch,
2454 headSha,
2455 aiApproved
0074234Claude2456 );
2457
e883329Claude2458 // If GateTest or AI review failed (hard blocks), reject the merge
2459 const hardFailures = gateResult.checks.filter(
2460 (check) => !check.passed && check.name !== "Merge check"
2461 );
2462 if (hardFailures.length > 0) {
2463 const errorMsg = hardFailures
2464 .map((f) => `${f.name}: ${f.details}`)
2465 .join("; ");
0074234Claude2466 return c.redirect(
e883329Claude2467 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude2468 );
2469 }
2470
1e162a8Claude2471 // D5 — Branch-protection enforcement. Looks up the matching rule for the
2472 // base branch and blocks the merge if requireAiApproval / requireGreenGates
2473 // / requireHumanReview / requiredApprovals are not satisfied. Independent
2474 // of repo-global settings, so owners can lock specific branches down
2475 // further than the repo default.
2476 const protectionRule = await matchProtection(
2477 resolved.repo.id,
2478 pr.baseBranch
2479 );
2480 if (protectionRule) {
2481 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude2482 const required = await listRequiredChecks(protectionRule.id);
2483 const passingNames = required.length > 0
2484 ? await passingCheckNames(resolved.repo.id, headSha)
2485 : [];
2486 const decision = evaluateProtection(
2487 protectionRule,
2488 {
2489 aiApproved,
2490 humanApprovalCount: humanApprovals,
2491 gateResultGreen: hardFailures.length === 0,
2492 hasFailedGates: hardFailures.length > 0,
2493 passingCheckNames: passingNames,
2494 },
2495 required.map((r) => r.checkName)
2496 );
1e162a8Claude2497 if (!decision.allowed) {
2498 return c.redirect(
2499 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2500 decision.reasons.join(" ")
2501 )}`
2502 );
2503 }
2504 }
2505
e883329Claude2506 // Attempt the merge — with auto conflict resolution if needed
2507 const repoDir = getRepoPath(ownerName, repoName);
2508 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
2509 const hasConflicts = mergeCheck && !mergeCheck.passed;
2510
2511 if (hasConflicts && isAiReviewEnabled()) {
2512 // Use Claude to auto-resolve conflicts
2513 const mergeResult = await mergeWithAutoResolve(
2514 ownerName,
2515 repoName,
2516 pr.baseBranch,
2517 pr.headBranch,
2518 `Merge pull request #${pr.number}: ${pr.title}`
2519 );
2520
2521 if (!mergeResult.success) {
2522 return c.redirect(
2523 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
2524 );
2525 }
2526
2527 // Post a comment about the auto-resolution
2528 if (mergeResult.resolvedFiles.length > 0) {
2529 await db.insert(prComments).values({
2530 pullRequestId: pr.id,
2531 authorId: user.id,
2532 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
2533 isAiReview: true,
2534 });
2535 }
2536 } else {
2537 // Standard merge — fast-forward or clean merge
2538 const ffProc = Bun.spawn(
2539 [
2540 "git",
2541 "update-ref",
2542 `refs/heads/${pr.baseBranch}`,
2543 `refs/heads/${pr.headBranch}`,
2544 ],
2545 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2546 );
2547 const ffExit = await ffProc.exited;
2548
2549 if (ffExit !== 0) {
2550 return c.redirect(
2551 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
2552 );
2553 }
2554 }
2555
0074234Claude2556 await db
2557 .update(pullRequests)
2558 .set({
2559 state: "merged",
2560 mergedAt: new Date(),
2561 mergedBy: user.id,
2562 updatedAt: new Date(),
2563 })
2564 .where(eq(pullRequests.id, pr.id));
2565
d62fb36Claude2566 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
2567 // and auto-close each matching open issue with a back-link comment. Bounded
2568 // to the same repo for v1 (cross-repo refs ignored). Failures never block
2569 // the merge redirect.
2570 try {
2571 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
2572 const refs = extractClosingRefsMulti([pr.title, pr.body]);
2573 for (const n of refs) {
2574 const [issue] = await db
2575 .select()
2576 .from(issues)
2577 .where(
2578 and(
2579 eq(issues.repositoryId, resolved.repo.id),
2580 eq(issues.number, n)
2581 )
2582 )
2583 .limit(1);
2584 if (!issue || issue.state !== "open") continue;
2585 await db
2586 .update(issues)
2587 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
2588 .where(eq(issues.id, issue.id));
2589 await db.insert(issueComments).values({
2590 issueId: issue.id,
2591 authorId: user.id,
2592 body: `Closed by pull request #${pr.number}.`,
2593 });
2594 }
2595 } catch {
2596 // Never block the merge on close-keyword failures.
2597 }
2598
0074234Claude2599 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2600 }
2601);
2602
6fc53bdClaude2603// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
2604// hasn't run yet on this PR.
2605pulls.post(
2606 "/:owner/:repo/pulls/:number/ready",
2607 softAuth,
2608 requireAuth,
04f6b7fClaude2609 requireRepoAccess("write"),
6fc53bdClaude2610 async (c) => {
2611 const { owner: ownerName, repo: repoName } = c.req.param();
2612 const prNum = parseInt(c.req.param("number"), 10);
2613 const user = c.get("user")!;
2614
2615 const resolved = await resolveRepo(ownerName, repoName);
2616 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2617
2618 const [pr] = await db
2619 .select()
2620 .from(pullRequests)
2621 .where(
2622 and(
2623 eq(pullRequests.repositoryId, resolved.repo.id),
2624 eq(pullRequests.number, prNum)
2625 )
2626 )
2627 .limit(1);
2628 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2629
2630 // Only the author or repo owner can toggle draft state.
2631 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2632 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2633 }
2634
2635 if (pr.state === "open" && pr.isDraft) {
2636 await db
2637 .update(pullRequests)
2638 .set({ isDraft: false, updatedAt: new Date() })
2639 .where(eq(pullRequests.id, pr.id));
2640
2641 if (isAiReviewEnabled()) {
2642 triggerAiReview(
2643 ownerName,
2644 repoName,
2645 pr.id,
2646 pr.title,
0316dbbClaude2647 pr.body || "",
6fc53bdClaude2648 pr.baseBranch,
2649 pr.headBranch
2650 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
2651 }
2652 }
2653
2654 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2655 }
2656);
2657
2658// Convert a PR back to draft.
2659pulls.post(
2660 "/:owner/:repo/pulls/:number/draft",
2661 softAuth,
2662 requireAuth,
04f6b7fClaude2663 requireRepoAccess("write"),
6fc53bdClaude2664 async (c) => {
2665 const { owner: ownerName, repo: repoName } = c.req.param();
2666 const prNum = parseInt(c.req.param("number"), 10);
2667 const user = c.get("user")!;
2668
2669 const resolved = await resolveRepo(ownerName, repoName);
2670 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2671
2672 const [pr] = await db
2673 .select()
2674 .from(pullRequests)
2675 .where(
2676 and(
2677 eq(pullRequests.repositoryId, resolved.repo.id),
2678 eq(pullRequests.number, prNum)
2679 )
2680 )
2681 .limit(1);
2682 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2683
2684 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2685 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2686 }
2687
2688 if (pr.state === "open" && !pr.isDraft) {
2689 await db
2690 .update(pullRequests)
2691 .set({ isDraft: true, updatedAt: new Date() })
2692 .where(eq(pullRequests.id, pr.id));
2693 }
2694
2695 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2696 }
2697);
2698
0074234Claude2699// Close PR
2700pulls.post(
2701 "/:owner/:repo/pulls/:number/close",
2702 softAuth,
2703 requireAuth,
04f6b7fClaude2704 requireRepoAccess("write"),
0074234Claude2705 async (c) => {
2706 const { owner: ownerName, repo: repoName } = c.req.param();
2707 const prNum = parseInt(c.req.param("number"), 10);
2708
2709 const resolved = await resolveRepo(ownerName, repoName);
2710 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2711
2712 await db
2713 .update(pullRequests)
2714 .set({
2715 state: "closed",
2716 closedAt: new Date(),
2717 updatedAt: new Date(),
2718 })
2719 .where(
2720 and(
2721 eq(pullRequests.repositoryId, resolved.repo.id),
2722 eq(pullRequests.number, prNum)
2723 )
2724 );
2725
2726 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2727 }
2728);
2729
c3e0c07Claude2730// Re-run AI review on demand (e.g. after a force-push). Bypasses the
2731// idempotency marker via { force: true }. Write-access only.
2732pulls.post(
2733 "/:owner/:repo/pulls/:number/ai-rereview",
2734 softAuth,
2735 requireAuth,
2736 requireRepoAccess("write"),
2737 async (c) => {
2738 const { owner: ownerName, repo: repoName } = c.req.param();
2739 const prNum = parseInt(c.req.param("number"), 10);
2740 const resolved = await resolveRepo(ownerName, repoName);
2741 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2742
2743 const [pr] = await db
2744 .select()
2745 .from(pullRequests)
2746 .where(
2747 and(
2748 eq(pullRequests.repositoryId, resolved.repo.id),
2749 eq(pullRequests.number, prNum)
2750 )
2751 )
2752 .limit(1);
2753 if (!pr) {
2754 return c.redirect(`/${ownerName}/${repoName}/pulls`);
2755 }
2756
2757 if (!isAiReviewEnabled()) {
2758 return c.redirect(
2759 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2760 "AI review is not configured (ANTHROPIC_API_KEY)."
2761 )}`
2762 );
2763 }
2764
2765 // Fire-and-forget but with { force: true } to bypass the
2766 // already-reviewed marker. The function still never throws.
2767 triggerAiReview(
2768 ownerName,
2769 repoName,
2770 pr.id,
2771 pr.title || "",
2772 pr.body || "",
2773 pr.baseBranch,
2774 pr.headBranch,
2775 { force: true }
a28cedeClaude2776 ).catch((err) => {
2777 console.warn(
2778 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
2779 err instanceof Error ? err.message : err
2780 );
2781 });
c3e0c07Claude2782
2783 return c.redirect(
2784 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
2785 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
2786 )}`
2787 );
2788 }
2789);
2790
0074234Claude2791export default pulls;