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.tsxBlame2305 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";
28import { RepoHeader, DiffView } from "../views/components";
6fc53bdClaude29import { ReactionsBar } from "../views/reactions";
30import { summariseReactions } from "../lib/reactions";
24cf2caClaude31import { loadPrTemplate } from "../lib/templates";
0074234Claude32import { renderMarkdown } from "../lib/markdown";
b584e52Claude33import { liveCommentBannerScript } from "../lib/sse-client";
0074234Claude34import { softAuth, requireAuth } from "../middleware/auth";
35import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude36import { requireRepoAccess } from "../middleware/repo-access";
0316dbbClaude37import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
38import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude39import { generatePrSummary } from "../lib/ai-generators";
40import { isAiAvailable } from "../lib/ai-client";
534f04aClaude41import {
42 computePrRiskForPullRequest,
43 getCachedPrRisk,
44 type PrRiskScore,
45} from "../lib/pr-risk";
0316dbbClaude46import { runAllGateChecks } from "../lib/gate";
47import type { GateCheckResult } from "../lib/gate";
48import {
49 matchProtection,
50 countHumanApprovals,
51 listRequiredChecks,
52 passingCheckNames,
53 evaluateProtection,
54} from "../lib/branch-protection";
55import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude56import {
57 listBranches,
58 getRepoPath,
e883329Claude59 resolveRef,
0074234Claude60} from "../git/repository";
61import type { GitDiffFile } from "../git/repository";
62import { html } from "hono/html";
1e162a8Claude63import {
bb0f894Claude64 Flex,
65 Container,
66 Badge,
67 Button,
68 LinkButton,
69 Form,
70 FormGroup,
71 Input,
72 TextArea,
73 Select,
74 EmptyState,
75 FilterTabs,
76 TabNav,
77 List,
78 ListItem,
79 Text,
80 Alert,
81 MarkdownContent,
82 CommentBox,
83 formatRelative,
84} from "../views/ui";
0074234Claude85
86const pulls = new Hono<AuthEnv>();
87
b078860Claude88/* ──────────────────────────────────────────────────────────────────────
89 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
90 * into the issue tracker or any other route. Tokens come from layout.tsx
91 * `:root` so light/dark stays consistent if/when light mode lands.
92 * ──────────────────────────────────────────────────────────────────── */
93const PRS_LIST_STYLES = `
94 .prs-hero {
95 position: relative;
96 margin: 0 0 var(--space-5);
97 padding: 22px 26px 24px;
98 background: var(--bg-elevated);
99 border: 1px solid var(--border);
100 border-radius: 16px;
101 overflow: hidden;
102 }
103 .prs-hero::before {
104 content: '';
105 position: absolute; top: 0; left: 0; right: 0;
106 height: 2px;
107 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
108 opacity: 0.7;
109 pointer-events: none;
110 }
111 .prs-hero-inner {
112 position: relative;
113 display: flex;
114 justify-content: space-between;
115 align-items: flex-end;
116 gap: 20px;
117 flex-wrap: wrap;
118 }
119 .prs-hero-text { flex: 1; min-width: 280px; }
120 .prs-hero-eyebrow {
121 font-size: 12px;
122 color: var(--text-muted);
123 text-transform: uppercase;
124 letter-spacing: 0.08em;
125 font-weight: 600;
126 margin-bottom: 8px;
127 }
128 .prs-hero-title {
129 font-family: var(--font-display);
130 font-size: clamp(26px, 3.4vw, 34px);
131 font-weight: 800;
132 letter-spacing: -0.025em;
133 line-height: 1.06;
134 margin: 0 0 8px;
135 color: var(--text-strong);
136 }
137 .prs-hero-title .gradient-text {
138 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
139 -webkit-background-clip: text;
140 background-clip: text;
141 -webkit-text-fill-color: transparent;
142 color: transparent;
143 }
144 .prs-hero-sub {
145 font-size: 14.5px;
146 color: var(--text-muted);
147 margin: 0;
148 line-height: 1.5;
149 max-width: 620px;
150 }
151 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
152 .prs-cta {
153 display: inline-flex; align-items: center; gap: 6px;
154 padding: 10px 16px;
155 border-radius: 10px;
156 font-size: 13.5px;
157 font-weight: 600;
158 color: #fff;
159 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
160 border: 1px solid rgba(140,109,255,0.55);
161 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
162 text-decoration: none;
163 transition: transform 120ms ease, box-shadow 160ms ease;
164 }
165 .prs-cta:hover {
166 transform: translateY(-1px);
167 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
168 color: #fff;
169 }
170
171 .prs-tabs {
172 display: flex; flex-wrap: wrap; gap: 6px;
173 margin: 0 0 18px;
174 padding: 6px;
175 background: var(--bg-secondary);
176 border: 1px solid var(--border);
177 border-radius: 12px;
178 }
179 .prs-tab {
180 display: inline-flex; align-items: center; gap: 8px;
181 padding: 7px 13px;
182 font-size: 13px;
183 font-weight: 500;
184 color: var(--text-muted);
185 border-radius: 8px;
186 text-decoration: none;
187 transition: background 120ms ease, color 120ms ease;
188 }
189 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
190 .prs-tab.is-active {
191 background: var(--bg-elevated);
192 color: var(--text-strong);
193 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
194 }
195 .prs-tab-count {
196 display: inline-flex; align-items: center; justify-content: center;
197 min-width: 22px; padding: 2px 7px;
198 font-size: 11.5px;
199 font-weight: 600;
200 border-radius: 9999px;
201 background: var(--bg-tertiary);
202 color: var(--text-muted);
203 }
204 .prs-tab.is-active .prs-tab-count {
205 background: rgba(140,109,255,0.18);
206 color: var(--text-link);
207 }
208
209 .prs-list { display: flex; flex-direction: column; gap: 10px; }
210 .prs-row {
211 position: relative;
212 display: flex; align-items: flex-start; gap: 14px;
213 padding: 14px 16px;
214 background: var(--bg-elevated);
215 border: 1px solid var(--border);
216 border-radius: 12px;
217 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
218 }
219 .prs-row:hover {
220 transform: translateY(-1px);
221 border-color: var(--border-strong);
222 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
223 }
224 .prs-row-icon {
225 flex: 0 0 auto;
226 width: 26px; height: 26px;
227 display: inline-flex; align-items: center; justify-content: center;
228 border-radius: 9999px;
229 font-size: 13px;
230 margin-top: 2px;
231 }
232 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
233 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
234 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
235 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
236 .prs-row-body { flex: 1; min-width: 0; }
237 .prs-row-title {
238 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
239 font-size: 15px; font-weight: 600;
240 color: var(--text-strong);
241 line-height: 1.35;
242 margin: 0 0 6px;
243 }
244 .prs-row-number {
245 color: var(--text-muted);
246 font-weight: 400;
247 font-size: 14px;
248 }
249 .prs-row-meta {
250 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
251 font-size: 12.5px;
252 color: var(--text-muted);
253 }
254 .prs-branch-chips {
255 display: inline-flex; align-items: center; gap: 6px;
256 font-family: var(--font-mono);
257 font-size: 11.5px;
258 }
259 .prs-branch-chip {
260 padding: 2px 8px;
261 border-radius: 9999px;
262 background: var(--bg-tertiary);
263 border: 1px solid var(--border);
264 color: var(--text);
265 }
266 .prs-branch-arrow {
267 color: var(--text-faint);
268 font-size: 11px;
269 }
270 .prs-row-tags {
271 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
272 margin-left: auto;
273 }
274 .prs-tag {
275 display: inline-flex; align-items: center; gap: 4px;
276 padding: 2px 8px;
277 font-size: 11px;
278 font-weight: 600;
279 border-radius: 9999px;
280 border: 1px solid var(--border);
281 background: var(--bg-secondary);
282 color: var(--text-muted);
283 line-height: 1.6;
284 }
285 .prs-tag.is-draft {
286 color: var(--text-muted);
287 border-color: var(--border-strong);
288 }
289 .prs-tag.is-merged {
290 color: var(--text-link);
291 border-color: rgba(140,109,255,0.45);
292 background: rgba(140,109,255,0.10);
293 }
294
295 .prs-empty {
296 padding: 36px 24px;
297 text-align: center;
298 border: 1px dashed var(--border);
299 border-radius: 12px;
300 background: var(--bg-secondary);
301 color: var(--text-muted);
302 }
303 .prs-empty strong {
304 display: block;
305 color: var(--text-strong);
306 font-size: 15px;
307 margin-bottom: 4px;
308 }
309
310 @media (max-width: 720px) {
311 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
312 .prs-hero-actions { width: 100%; }
313 .prs-row-tags { margin-left: 0; }
314 }
315`;
316
317/* ──────────────────────────────────────────────────────────────────────
318 * Inline CSS for the detail page. Same `.prs-*` namespace.
319 * ──────────────────────────────────────────────────────────────────── */
320const PRS_DETAIL_STYLES = `
321 .prs-detail-hero {
322 position: relative;
323 margin: 0 0 var(--space-4);
324 padding: 24px 26px;
325 background: var(--bg-elevated);
326 border: 1px solid var(--border);
327 border-radius: 16px;
328 overflow: hidden;
329 }
330 .prs-detail-hero::before {
331 content: '';
332 position: absolute; top: 0; left: 0; right: 0;
333 height: 2px;
334 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
335 opacity: 0.7;
336 pointer-events: none;
337 }
338 .prs-detail-title {
339 font-family: var(--font-display);
340 font-size: clamp(22px, 2.6vw, 28px);
341 font-weight: 700;
342 letter-spacing: -0.022em;
343 line-height: 1.2;
344 color: var(--text-strong);
345 margin: 0 0 12px;
346 }
347 .prs-detail-num {
348 color: var(--text-muted);
349 font-weight: 400;
350 }
351 .prs-state-pill {
352 display: inline-flex; align-items: center; gap: 6px;
353 padding: 6px 12px;
354 border-radius: 9999px;
355 font-size: 12.5px;
356 font-weight: 600;
357 line-height: 1;
358 border: 1px solid transparent;
359 }
360 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
361 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
362 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
363 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
364
365 .prs-detail-meta {
366 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
367 font-size: 13px;
368 color: var(--text-muted);
369 }
370 .prs-detail-meta strong { color: var(--text); }
371 .prs-detail-branches {
372 display: inline-flex; align-items: center; gap: 6px;
373 font-family: var(--font-mono);
374 font-size: 12px;
375 }
376 .prs-branch-pill {
377 padding: 3px 9px;
378 border-radius: 9999px;
379 background: var(--bg-tertiary);
380 border: 1px solid var(--border);
381 color: var(--text);
382 }
383 .prs-branch-pill.is-head { color: var(--text-strong); }
384 .prs-branch-arrow-lg {
385 color: var(--accent);
386 font-size: 14px;
387 font-weight: 700;
388 }
389
390 .prs-detail-actions {
391 display: inline-flex; gap: 8px; margin-left: auto;
392 }
393
394 .prs-detail-tabs {
395 display: flex; gap: 4px;
396 margin: 0 0 16px;
397 border-bottom: 1px solid var(--border);
398 }
399 .prs-detail-tab {
400 padding: 10px 14px;
401 font-size: 13.5px;
402 font-weight: 500;
403 color: var(--text-muted);
404 text-decoration: none;
405 border-bottom: 2px solid transparent;
406 transition: color 120ms ease, border-color 120ms ease;
407 margin-bottom: -1px;
408 }
409 .prs-detail-tab:hover { color: var(--text); }
410 .prs-detail-tab.is-active {
411 color: var(--text-strong);
412 border-bottom-color: var(--accent);
413 }
414 .prs-detail-tab-count {
415 display: inline-flex; align-items: center; justify-content: center;
416 min-width: 20px; padding: 0 6px; margin-left: 6px;
417 height: 18px;
418 font-size: 11px;
419 font-weight: 600;
420 border-radius: 9999px;
421 background: var(--bg-tertiary);
422 color: var(--text-muted);
423 }
424
425 /* Gate / check status section */
426 .prs-gate-card {
427 margin-top: 20px;
428 background: var(--bg-elevated);
429 border: 1px solid var(--border);
430 border-radius: 14px;
431 overflow: hidden;
432 }
433 .prs-gate-head {
434 display: flex; align-items: center; gap: 10px;
435 padding: 14px 18px;
436 border-bottom: 1px solid var(--border);
437 }
438 .prs-gate-head h3 {
439 margin: 0;
440 font-size: 14px;
441 font-weight: 600;
442 color: var(--text-strong);
443 }
444 .prs-gate-summary {
445 margin-left: auto;
446 font-size: 12px;
447 color: var(--text-muted);
448 }
449 .prs-gate-row {
450 display: flex; align-items: center; gap: 12px;
451 padding: 12px 18px;
452 border-bottom: 1px solid var(--border-subtle);
453 }
454 .prs-gate-row:last-child { border-bottom: 0; }
455 .prs-gate-icon {
456 flex: 0 0 auto;
457 width: 22px; height: 22px;
458 display: inline-flex; align-items: center; justify-content: center;
459 border-radius: 9999px;
460 font-size: 12px;
461 font-weight: 700;
462 }
463 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
464 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
465 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
466 .prs-gate-name {
467 font-size: 13px;
468 font-weight: 600;
469 color: var(--text);
470 min-width: 140px;
471 }
472 .prs-gate-details {
473 flex: 1; min-width: 0;
474 font-size: 12.5px;
475 color: var(--text-muted);
476 }
477 .prs-gate-pill {
478 flex: 0 0 auto;
479 padding: 3px 10px;
480 border-radius: 9999px;
481 font-size: 11px;
482 font-weight: 600;
483 line-height: 1.5;
484 border: 1px solid transparent;
485 }
486 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
487 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
488 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
489 .prs-gate-footer {
490 padding: 12px 18px;
491 background: var(--bg-secondary);
492 font-size: 12px;
493 color: var(--text-muted);
494 }
495
496 /* Comment cards */
497 .prs-comment {
498 margin-top: 14px;
499 background: var(--bg-elevated);
500 border: 1px solid var(--border);
501 border-radius: 12px;
502 overflow: hidden;
503 }
504 .prs-comment-head {
505 display: flex; align-items: center; gap: 10px;
506 padding: 10px 14px;
507 background: var(--bg-secondary);
508 border-bottom: 1px solid var(--border);
509 font-size: 13px;
510 flex-wrap: wrap;
511 }
512 .prs-comment-head strong { color: var(--text-strong); }
513 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
514 .prs-comment-loc {
515 font-family: var(--font-mono);
516 font-size: 11.5px;
517 color: var(--text-muted);
518 background: var(--bg-tertiary);
519 padding: 2px 8px;
520 border-radius: 6px;
521 }
522 .prs-comment-body { padding: 14px 18px; }
523 .prs-comment.is-ai {
524 border-color: rgba(140,109,255,0.45);
525 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
526 }
527 .prs-comment.is-ai .prs-comment-head {
528 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
529 border-bottom-color: rgba(140,109,255,0.30);
530 }
531 .prs-ai-badge {
532 display: inline-flex; align-items: center; gap: 4px;
533 padding: 2px 9px;
534 font-size: 10.5px;
535 font-weight: 700;
536 letter-spacing: 0.04em;
537 text-transform: uppercase;
538 color: #fff;
539 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
540 border-radius: 9999px;
541 }
542
543 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
544 .prs-files-card {
545 margin-top: 18px;
546 padding: 14px 18px;
547 display: flex; align-items: center; gap: 14px;
548 background: var(--bg-elevated);
549 border: 1px solid var(--border);
550 border-radius: 12px;
551 text-decoration: none;
552 color: inherit;
553 transition: border-color 120ms ease, transform 140ms ease;
554 }
555 .prs-files-card:hover {
556 border-color: rgba(140,109,255,0.45);
557 transform: translateY(-1px);
558 }
559 .prs-files-card-icon {
560 width: 36px; height: 36px;
561 display: inline-flex; align-items: center; justify-content: center;
562 border-radius: 10px;
563 background: rgba(140,109,255,0.12);
564 color: var(--text-link);
565 font-size: 18px;
566 }
567 .prs-files-card-text { flex: 1; min-width: 0; }
568 .prs-files-card-title {
569 font-size: 14px;
570 font-weight: 600;
571 color: var(--text-strong);
572 margin: 0 0 2px;
573 }
574 .prs-files-card-sub {
575 font-size: 12.5px;
576 color: var(--text-muted);
577 margin: 0;
578 }
579 .prs-files-card-cta {
580 font-size: 12.5px;
581 color: var(--text-link);
582 font-weight: 600;
583 }
584
585 /* Merge area */
586 .prs-merge-card {
587 position: relative;
588 margin-top: 22px;
589 padding: 18px;
590 background: var(--bg-elevated);
591 border-radius: 14px;
592 overflow: hidden;
593 }
594 .prs-merge-card::before {
595 content: '';
596 position: absolute; inset: 0;
597 padding: 1px;
598 border-radius: 14px;
599 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
600 -webkit-mask:
601 linear-gradient(#000 0 0) content-box,
602 linear-gradient(#000 0 0);
603 -webkit-mask-composite: xor;
604 mask-composite: exclude;
605 pointer-events: none;
606 }
607 .prs-merge-card.is-closed::before { background: var(--border-strong); }
608 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
609 .prs-merge-head {
610 display: flex; align-items: center; gap: 12px;
611 margin-bottom: 12px;
612 }
613 .prs-merge-head strong {
614 font-family: var(--font-display);
615 font-size: 15px;
616 color: var(--text-strong);
617 font-weight: 700;
618 }
619 .prs-merge-sub {
620 font-size: 13px;
621 color: var(--text-muted);
622 margin: 0 0 12px;
623 }
624 .prs-merge-actions {
625 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
626 }
627 .prs-merge-btn {
628 display: inline-flex; align-items: center; gap: 6px;
629 padding: 9px 16px;
630 border-radius: 10px;
631 font-size: 13.5px;
632 font-weight: 600;
633 color: #fff;
634 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
635 border: 1px solid rgba(52,211,153,0.55);
636 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
637 cursor: pointer;
638 transition: transform 120ms ease, box-shadow 160ms ease;
639 }
640 .prs-merge-btn:hover {
641 transform: translateY(-1px);
642 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
643 }
644 .prs-merge-btn[disabled],
645 .prs-merge-btn.is-disabled {
646 opacity: 0.55;
647 cursor: not-allowed;
648 transform: none;
649 box-shadow: none;
650 }
651 .prs-merge-ready-btn {
652 display: inline-flex; align-items: center; gap: 6px;
653 padding: 9px 16px;
654 border-radius: 10px;
655 font-size: 13.5px;
656 font-weight: 600;
657 color: #fff;
658 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
659 border: 1px solid rgba(140,109,255,0.55);
660 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
661 cursor: pointer;
662 transition: transform 120ms ease, box-shadow 160ms ease;
663 }
664 .prs-merge-ready-btn:hover {
665 transform: translateY(-1px);
666 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
667 }
668 .prs-merge-back-draft {
669 background: none; border: 1px solid var(--border-strong);
670 color: var(--text-muted);
671 padding: 9px 14px; border-radius: 10px;
672 font-size: 13px; cursor: pointer;
673 }
674 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
675
676 /* Inline form helpers */
677 .prs-inline-form { display: inline-flex; }
678
679 /* Comment composer */
680 .prs-composer { margin-top: 22px; }
681 .prs-composer textarea {
682 border-radius: 12px;
683 }
684
685 @media (max-width: 720px) {
686 .prs-detail-actions { margin-left: 0; }
687 .prs-merge-actions { width: 100%; }
688 .prs-merge-actions > * { flex: 1; min-width: 0; }
689 }
690`;
691
81c73c1Claude692/**
693 * Tiny inline JS that drives the "Suggest description with AI" button.
694 * On click, gathers form values, POSTs JSON to the given endpoint, and
695 * pipes the response into the #pr-body textarea. All DOM lookups are
696 * defensive — element absence is a silent no-op.
697 *
698 * Built as a string template so it lives next to its server-side caller
699 * and there is no bundler dependency. The endpoint URL is JSON-escaped
700 * to avoid </script> breakouts.
701 */
702function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
703 const url = JSON.stringify(endpointUrl)
704 .split("<").join("\\u003C")
705 .split(">").join("\\u003E")
706 .split("&").join("\\u0026");
707 return (
708 "(function(){try{" +
709 "var btn=document.getElementById('ai-suggest-desc');" +
710 "var status=document.getElementById('ai-suggest-status');" +
711 "var body=document.getElementById('pr-body');" +
712 "var form=btn&&btn.closest&&btn.closest('form');" +
713 "if(!btn||!body||!form)return;" +
714 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
715 "var fd=new FormData(form);" +
716 "var title=String(fd.get('title')||'').trim();" +
717 "var base=String(fd.get('base')||'').trim();" +
718 "var head=String(fd.get('head')||'').trim();" +
719 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
720 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
721 "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'})" +
722 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
723 ".then(function(j){btn.disabled=false;" +
724 "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;}}" +
725 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
726 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
727 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
728 "});" +
729 "}catch(e){}})();"
730 );
731}
732
0074234Claude733async function resolveRepo(ownerName: string, repoName: string) {
734 const [owner] = await db
735 .select()
736 .from(users)
737 .where(eq(users.username, ownerName))
738 .limit(1);
739 if (!owner) return null;
740 const [repo] = await db
741 .select()
742 .from(repositories)
743 .where(
744 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
745 )
746 .limit(1);
747 if (!repo) return null;
748 return { owner, repo };
749}
750
751// PR Nav helper
752const PrNav = ({
753 owner,
754 repo,
755 active,
756}: {
757 owner: string;
758 repo: string;
759 active: "code" | "issues" | "pulls" | "commits";
760}) => (
bb0f894Claude761 <TabNav
762 tabs={[
763 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
764 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
765 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
766 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
767 ]}
768 />
0074234Claude769);
770
534f04aClaude771/**
772 * Block M3 — pre-merge risk score card. Pure presentational helper.
773 * Rendered in the conversation tab above the gate checks block. Hidden
774 * entirely when the PR is closed/merged or there is nothing cached and
775 * nothing in-flight.
776 */
777function PrRiskCard({
778 risk,
779 calculating,
780}: {
781 risk: PrRiskScore | null;
782 calculating: boolean;
783}) {
784 if (!risk) {
785 return (
786 <div
787 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
788 >
789 <strong style="font-size: 13px; color: var(--text)">
790 Risk score: calculating…
791 </strong>
792 <div style="font-size: 12px; margin-top: 4px">
793 Refresh in a moment to see the pre-merge risk score for this PR.
794 </div>
795 </div>
796 );
797 }
798
799 const palette = riskBandPalette(risk.band);
800 const label = riskBandLabel(risk.band);
801
802 return (
803 <div
804 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
805 >
806 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
807 <strong>Risk score:</strong>
808 <span style={`color:${palette.border};font-weight:600`}>
809 {palette.icon} {label} ({risk.score}/10)
810 </span>
811 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
812 {risk.commitSha.slice(0, 7)}
813 </span>
814 </div>
815 {risk.aiSummary && (
816 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
817 {risk.aiSummary}
818 </div>
819 )}
820 <details style="margin-top:10px">
821 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
822 See full signal breakdown
823 </summary>
824 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
825 <li>files changed: {risk.signals.filesChanged}</li>
826 <li>
827 lines added/removed: {risk.signals.linesAdded} /{" "}
828 {risk.signals.linesRemoved}
829 </li>
830 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
831 <li>
832 schema migration touched:{" "}
833 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
834 </li>
835 <li>
836 locked / sensitive path touched:{" "}
837 {risk.signals.lockedPathTouched ? "yes" : "no"}
838 </li>
839 <li>
840 adds new dependency:{" "}
841 {risk.signals.addsNewDependency ? "yes" : "no"}
842 </li>
843 <li>
844 bumps major dependency:{" "}
845 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
846 </li>
847 <li>
848 tests added for new code:{" "}
849 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
850 </li>
851 <li>
852 diff-minus-test ratio:{" "}
853 {risk.signals.diffMinusTestRatio.toFixed(2)}
854 </li>
855 </ul>
856 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
857 How is this calculated? The score is a transparent sum of
858 weighted signals — see <code>src/lib/pr-risk.ts</code>
859 {" "}<code>computePrRiskScore</code>.
860 </div>
861 </details>
862 {calculating && (
863 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
864 (recomputing for the latest commit — refresh to update)
865 </div>
866 )}
867 </div>
868 );
869}
870
871function riskBandPalette(band: PrRiskScore["band"]): {
872 border: string;
873 icon: string;
874} {
875 switch (band) {
876 case "low":
877 return { border: "var(--green)", icon: "" };
878 case "medium":
879 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
880 case "high":
881 return { border: "var(--orange, #db6d28)", icon: "⚠" };
882 case "critical":
883 return { border: "var(--red)", icon: "\u{1F6D1}" };
884 }
885}
886
887function riskBandLabel(band: PrRiskScore["band"]): string {
888 switch (band) {
889 case "low":
890 return "LOW";
891 case "medium":
892 return "MEDIUM";
893 case "high":
894 return "HIGH";
895 case "critical":
896 return "CRITICAL";
897 }
898}
899
0074234Claude900// List PRs
04f6b7fClaude901pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude902 const { owner: ownerName, repo: repoName } = c.req.param();
903 const user = c.get("user");
904 const state = c.req.query("state") || "open";
905
906 const resolved = await resolveRepo(ownerName, repoName);
907 if (!resolved) return c.notFound();
908
6fc53bdClaude909 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
910 const stateFilter =
911 state === "draft"
912 ? and(
913 eq(pullRequests.state, "open"),
914 eq(pullRequests.isDraft, true)
915 )
916 : eq(pullRequests.state, state);
917
0074234Claude918 const prList = await db
919 .select({
920 pr: pullRequests,
921 author: { username: users.username },
922 })
923 .from(pullRequests)
924 .innerJoin(users, eq(pullRequests.authorId, users.id))
925 .where(
6fc53bdClaude926 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude927 )
928 .orderBy(desc(pullRequests.createdAt));
929
930 const [counts] = await db
931 .select({
932 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude933 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude934 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
935 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
936 })
937 .from(pullRequests)
938 .where(eq(pullRequests.repositoryId, resolved.repo.id));
939
b078860Claude940 const openCount = counts?.open ?? 0;
941 const mergedCount = counts?.merged ?? 0;
942 const closedCount = counts?.closed ?? 0;
943 const draftCount = counts?.draft ?? 0;
944 const allCount = openCount + mergedCount + closedCount;
945
946 // "All" is presentational only — the DB query for state='all' matches
947 // nothing, so we render a friendlier empty state when picked. We do NOT
948 // change the query logic to keep this commit purely visual.
949 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
950 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
951 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
952 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
953 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
954 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
955 ];
956 const isAllState = state === "all";
957
0074234Claude958 return c.html(
959 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
960 <RepoHeader owner={ownerName} repo={repoName} />
961 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude962 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
963
964 <div class="prs-hero">
965 <div class="prs-hero-inner">
966 <div class="prs-hero-text">
967 <div class="prs-hero-eyebrow">Pull requests</div>
968 <h1 class="prs-hero-title">
969 Review, <span class="gradient-text">merge with AI</span>.
970 </h1>
971 <p class="prs-hero-sub">
972 {openCount === 0 && allCount === 0
973 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
974 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
975 </p>
976 </div>
977 {user && (
978 <div class="prs-hero-actions">
979 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
980 + New pull request
981 </a>
982 </div>
983 )}
984 </div>
985 </div>
986
987 <nav class="prs-tabs" aria-label="Pull request filters">
988 {tabPills.map((t) => {
989 const isActive =
990 state === t.key ||
991 (t.key === "open" &&
992 state !== "merged" &&
993 state !== "closed" &&
994 state !== "all" &&
995 state !== "draft");
996 return (
997 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
998 <span>{t.label}</span>
999 <span class="prs-tab-count">{t.count}</span>
1000 </a>
1001 );
1002 })}
1003 </nav>
1004
0074234Claude1005 {prList.length === 0 ? (
b078860Claude1006 <div class="prs-empty">
1007 <strong>
1008 {isAllState
1009 ? "Pick a filter above to browse PRs."
1010 : `No ${state} pull requests.`}
1011 </strong>
1012 <span>
1013 {state === "open"
1014 ? "Push a branch and open one to kick off AI review + gate checks."
1015 : isAllState
1016 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1017 : "Try a different filter."}
1018 </span>
1019 </div>
0074234Claude1020 ) : (
b078860Claude1021 <div class="prs-list">
1022 {prList.map(({ pr, author }) => {
1023 const stateClass =
1024 pr.state === "open"
1025 ? pr.isDraft
1026 ? "state-draft"
1027 : "state-open"
1028 : pr.state === "merged"
1029 ? "state-merged"
1030 : "state-closed";
1031 const icon =
1032 pr.state === "open"
1033 ? pr.isDraft
1034 ? "◌"
1035 : "○"
1036 : pr.state === "merged"
1037 ? "⮌"
1038 : "✓";
1039 return (
1040 <a
1041 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1042 class="prs-row"
1043 style="text-decoration:none;color:inherit"
0074234Claude1044 >
b078860Claude1045 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
1046 {icon}
0074234Claude1047 </div>
b078860Claude1048 <div class="prs-row-body">
1049 <h3 class="prs-row-title">
1050 <span>{pr.title}</span>
1051 <span class="prs-row-number">#{pr.number}</span>
1052 </h3>
1053 <div class="prs-row-meta">
1054 <span
1055 class="prs-branch-chips"
1056 title={`${pr.headBranch} into ${pr.baseBranch}`}
1057 >
1058 <span class="prs-branch-chip">{pr.headBranch}</span>
1059 <span class="prs-branch-arrow">{"→"}</span>
1060 <span class="prs-branch-chip">{pr.baseBranch}</span>
1061 </span>
1062 <span>
1063 by{" "}
1064 <strong style="color:var(--text)">
1065 {author.username}
1066 </strong>{" "}
1067 {formatRelative(pr.createdAt)}
1068 </span>
1069 <span class="prs-row-tags">
1070 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
1071 {pr.state === "merged" && (
1072 <span class="prs-tag is-merged">Merged</span>
1073 )}
1074 </span>
1075 </div>
0074234Claude1076 </div>
b078860Claude1077 </a>
1078 );
1079 })}
1080 </div>
0074234Claude1081 )}
1082 </Layout>
1083 );
1084});
1085
1086// New PR form
1087pulls.get(
1088 "/:owner/:repo/pulls/new",
1089 softAuth,
1090 requireAuth,
04f6b7fClaude1091 requireRepoAccess("write"),
0074234Claude1092 async (c) => {
1093 const { owner: ownerName, repo: repoName } = c.req.param();
1094 const user = c.get("user")!;
1095 const branches = await listBranches(ownerName, repoName);
1096 const error = c.req.query("error");
1097 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude1098 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude1099
1100 return c.html(
1101 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
1102 <RepoHeader owner={ownerName} repo={repoName} />
1103 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude1104 <Container maxWidth={800}>
1105 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude1106 {error && (
bb0f894Claude1107 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude1108 )}
0316dbbClaude1109 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
1110 <Flex gap={12} align="center" style="margin-bottom: 16px">
1111 <Select name="base">
0074234Claude1112 {branches.map((b) => (
1113 <option value={b} selected={b === defaultBase}>
1114 {b}
1115 </option>
1116 ))}
bb0f894Claude1117 </Select>
1118 <Text muted>&larr;</Text>
1119 <Select name="head">
0074234Claude1120 {branches
1121 .filter((b) => b !== defaultBase)
1122 .concat(defaultBase === branches[0] ? [] : [branches[0]])
1123 .map((b) => (
1124 <option value={b}>{b}</option>
1125 ))}
bb0f894Claude1126 </Select>
1127 </Flex>
1128 <FormGroup>
1129 <Input
0074234Claude1130 name="title"
1131 required
1132 placeholder="Title"
bb0f894Claude1133 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]1134 aria-label="Pull request title"
0074234Claude1135 />
bb0f894Claude1136 </FormGroup>
1137 <FormGroup>
1138 <TextArea
0074234Claude1139 name="body"
81c73c1Claude1140 id="pr-body"
0074234Claude1141 rows={8}
1142 placeholder="Description (Markdown supported)"
bb0f894Claude1143 mono
0074234Claude1144 />
bb0f894Claude1145 </FormGroup>
81c73c1Claude1146 <Flex gap={8} align="center">
1147 <Button type="submit" variant="primary">
1148 Create pull request
1149 </Button>
1150 <button
1151 type="button"
1152 id="ai-suggest-desc"
1153 class="btn"
1154 style="font-weight:500"
1155 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
1156 >
1157 Suggest description with AI
1158 </button>
1159 <span
1160 id="ai-suggest-status"
1161 style="color:var(--text-muted);font-size:13px"
1162 />
1163 </Flex>
bb0f894Claude1164 </Form>
81c73c1Claude1165 <script
1166 dangerouslySetInnerHTML={{
1167 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
1168 }}
1169 />
bb0f894Claude1170 </Container>
0074234Claude1171 </Layout>
1172 );
1173 }
1174);
1175
81c73c1Claude1176// AI-suggested PR description — JSON endpoint driven by the form button.
1177// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
1178// 200; the inline script reads `ok` to decide what to do.
1179pulls.post(
1180 "/:owner/:repo/ai/pr-description",
1181 softAuth,
1182 requireAuth,
1183 requireRepoAccess("write"),
1184 async (c) => {
1185 const { owner: ownerName, repo: repoName } = c.req.param();
1186 if (!isAiAvailable()) {
1187 return c.json({
1188 ok: false,
1189 error: "AI is not available — set ANTHROPIC_API_KEY.",
1190 });
1191 }
1192 const body = await c.req.parseBody();
1193 const title = String(body.title || "").trim();
1194 const baseBranch = String(body.base || "").trim();
1195 const headBranch = String(body.head || "").trim();
1196 if (!baseBranch || !headBranch) {
1197 return c.json({ ok: false, error: "Pick base + head branches first." });
1198 }
1199 if (baseBranch === headBranch) {
1200 return c.json({ ok: false, error: "Base and head must differ." });
1201 }
1202
1203 let diff = "";
1204 try {
1205 const cwd = getRepoPath(ownerName, repoName);
1206 const proc = Bun.spawn(
1207 [
1208 "git",
1209 "diff",
1210 `${baseBranch}...${headBranch}`,
1211 "--",
1212 ],
1213 { cwd, stdout: "pipe", stderr: "pipe" }
1214 );
1215 diff = await new Response(proc.stdout).text();
1216 await proc.exited;
1217 } catch {
1218 diff = "";
1219 }
1220 if (!diff.trim()) {
1221 return c.json({
1222 ok: false,
1223 error: "No diff between branches — nothing to summarise.",
1224 });
1225 }
1226
1227 let summary = "";
1228 try {
1229 summary = await generatePrSummary(title || "(untitled)", diff);
1230 } catch (err) {
1231 const msg = err instanceof Error ? err.message : "AI request failed.";
1232 return c.json({ ok: false, error: msg });
1233 }
1234 if (!summary.trim()) {
1235 return c.json({ ok: false, error: "AI returned an empty draft." });
1236 }
1237 return c.json({ ok: true, body: summary });
1238 }
1239);
1240
0074234Claude1241// Create PR
1242pulls.post(
1243 "/:owner/:repo/pulls/new",
1244 softAuth,
1245 requireAuth,
04f6b7fClaude1246 requireRepoAccess("write"),
0074234Claude1247 async (c) => {
1248 const { owner: ownerName, repo: repoName } = c.req.param();
1249 const user = c.get("user")!;
1250 const body = await c.req.parseBody();
1251 const title = String(body.title || "").trim();
1252 const prBody = String(body.body || "").trim();
1253 const baseBranch = String(body.base || "main");
1254 const headBranch = String(body.head || "");
1255
1256 if (!title || !headBranch) {
1257 return c.redirect(
1258 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
1259 );
1260 }
1261
1262 if (baseBranch === headBranch) {
1263 return c.redirect(
1264 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
1265 );
1266 }
1267
1268 const resolved = await resolveRepo(ownerName, repoName);
1269 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1270
6fc53bdClaude1271 const isDraft = String(body.draft || "") === "1";
1272
0074234Claude1273 const [pr] = await db
1274 .insert(pullRequests)
1275 .values({
1276 repositoryId: resolved.repo.id,
1277 authorId: user.id,
1278 title,
1279 body: prBody || null,
1280 baseBranch,
1281 headBranch,
6fc53bdClaude1282 isDraft,
0074234Claude1283 })
1284 .returning();
1285
6fc53bdClaude1286 // Skip AI review on drafts — it runs again when the PR is marked ready.
1287 if (!isDraft && isAiReviewEnabled()) {
e883329Claude1288 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
1289 (err) => console.error("[ai-review] Failed:", err)
1290 );
1291 }
1292
3cbe3d6Claude1293 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
1294 triggerPrTriage({
1295 ownerName,
1296 repoName,
1297 repositoryId: resolved.repo.id,
1298 prId: pr.id,
1299 prAuthorId: user.id,
1300 title,
1301 body: prBody,
1302 baseBranch,
1303 headBranch,
1304 }).catch((err) => console.error("[pr-triage] Failed:", err));
1305
9dd96b9Test User1306 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude1307 import("../lib/auto-merge")
1308 .then((m) => m.tryAutoMergeNow(pr.id))
1309 .catch((err) => {
1310 console.warn(
1311 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
1312 err instanceof Error ? err.message : err
1313 );
1314 });
9dd96b9Test User1315
0074234Claude1316 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
1317 }
1318);
1319
1320// View single PR
04f6b7fClaude1321pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1322 const { owner: ownerName, repo: repoName } = c.req.param();
1323 const prNum = parseInt(c.req.param("number"), 10);
1324 const user = c.get("user");
1325 const tab = c.req.query("tab") || "conversation";
1326
1327 const resolved = await resolveRepo(ownerName, repoName);
1328 if (!resolved) return c.notFound();
1329
1330 const [pr] = await db
1331 .select()
1332 .from(pullRequests)
1333 .where(
1334 and(
1335 eq(pullRequests.repositoryId, resolved.repo.id),
1336 eq(pullRequests.number, prNum)
1337 )
1338 )
1339 .limit(1);
1340
1341 if (!pr) return c.notFound();
1342
1343 const [author] = await db
1344 .select()
1345 .from(users)
1346 .where(eq(users.id, pr.authorId))
1347 .limit(1);
1348
1349 const comments = await db
1350 .select({
1351 comment: prComments,
1352 author: { username: users.username },
1353 })
1354 .from(prComments)
1355 .innerJoin(users, eq(prComments.authorId, users.id))
1356 .where(eq(prComments.pullRequestId, pr.id))
1357 .orderBy(asc(prComments.createdAt));
1358
6fc53bdClaude1359 // Reactions for the PR body + each comment, in parallel.
1360 const [prReactions, ...prCommentReactions] = await Promise.all([
1361 summariseReactions("pr", pr.id, user?.id),
1362 ...comments.map((row) =>
1363 summariseReactions("pr_comment", row.comment.id, user?.id)
1364 ),
1365 ]);
1366
0074234Claude1367 const canManage =
1368 user &&
1369 (user.id === resolved.owner.id || user.id === pr.authorId);
1370
e883329Claude1371 const error = c.req.query("error");
c3e0c07Claude1372 const info = c.req.query("info");
e883329Claude1373
1374 // Get gate check status for open PRs
1375 let gateChecks: GateCheckResult[] = [];
1376 if (pr.state === "open") {
1377 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
1378 if (headSha) {
1379 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
1380 const aiApproved = aiComments.length === 0 || aiComments.some(
1381 ({ comment }) => comment.body.includes("**Approved**")
1382 );
1383 const gateResult = await runAllGateChecks(
1384 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
1385 );
1386 gateChecks = gateResult.checks;
1387 }
1388 }
1389
534f04aClaude1390 // Block M3 — pre-merge risk score. Cache-only on the request path so
1391 // the page never waits on Haiku. On a cache miss for an open PR we
1392 // kick off the computation fire-and-forget; the next refresh shows it.
1393 let prRisk: PrRiskScore | null = null;
1394 let prRiskCalculating = false;
1395 if (pr.state === "open") {
1396 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
1397 if (!prRisk) {
1398 prRiskCalculating = true;
a28cedeClaude1399 void computePrRiskForPullRequest(pr.id).catch((err) => {
1400 console.warn(
1401 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
1402 err instanceof Error ? err.message : err
1403 );
1404 });
534f04aClaude1405 }
1406 }
1407
0074234Claude1408 // Get diff for "Files changed" tab
1409 let diffRaw = "";
1410 let diffFiles: GitDiffFile[] = [];
1411 if (tab === "files") {
1412 const repoDir = getRepoPath(ownerName, repoName);
1413 const proc = Bun.spawn(
1414 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
1415 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1416 );
1417 diffRaw = await new Response(proc.stdout).text();
1418 await proc.exited;
1419
1420 const statProc = Bun.spawn(
1421 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
1422 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1423 );
1424 const stat = await new Response(statProc.stdout).text();
1425 await statProc.exited;
1426
1427 diffFiles = stat
1428 .trim()
1429 .split("\n")
1430 .filter(Boolean)
1431 .map((line) => {
1432 const [add, del, filePath] = line.split("\t");
1433 return {
1434 path: filePath,
1435 status: "modified",
1436 additions: add === "-" ? 0 : parseInt(add, 10),
1437 deletions: del === "-" ? 0 : parseInt(del, 10),
1438 patch: "",
1439 };
1440 });
1441 }
1442
b078860Claude1443 // ─── Derived visual state ───
1444 const stateKey =
1445 pr.state === "open"
1446 ? pr.isDraft
1447 ? "draft"
1448 : "open"
1449 : pr.state;
1450 const stateLabel =
1451 stateKey === "open"
1452 ? "Open"
1453 : stateKey === "draft"
1454 ? "Draft"
1455 : stateKey === "merged"
1456 ? "Merged"
1457 : "Closed";
1458 const stateIcon =
1459 stateKey === "open"
1460 ? "○"
1461 : stateKey === "draft"
1462 ? "◌"
1463 : stateKey === "merged"
1464 ? "⮌"
1465 : "✓";
1466 const commentCount = comments.length;
1467 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
1468 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
1469 const mergeBlocked =
1470 gateChecks.length > 0 &&
1471 gateChecks.some(
1472 (c) => !c.passed && c.name !== "Merge check"
1473 );
1474
0074234Claude1475 return c.html(
1476 <Layout
1477 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
1478 user={user}
1479 >
1480 <RepoHeader owner={ownerName} repo={repoName} />
1481 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude1482 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude1483 <div
1484 id="live-comment-banner"
1485 class="alert"
1486 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1487 >
1488 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1489 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1490 reload to view
1491 </a>
1492 </div>
1493 <script
1494 dangerouslySetInnerHTML={{
1495 __html: liveCommentBannerScript({
1496 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
1497 bannerElementId: "live-comment-banner",
1498 }),
1499 }}
1500 />
b078860Claude1501
1502 <div class="prs-detail-hero">
1503 <h1 class="prs-detail-title">
0074234Claude1504 {pr.title}{" "}
b078860Claude1505 <span class="prs-detail-num">#{pr.number}</span>
1506 </h1>
1507 <div class="prs-detail-meta">
1508 <span class={`prs-state-pill state-${stateKey}`}>
1509 <span aria-hidden="true">{stateIcon}</span>
1510 <span>{stateLabel}</span>
1511 </span>
1512 <span>
1513 <strong>{author?.username}</strong> wants to merge
1514 </span>
1515 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
1516 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
1517 <span class="prs-branch-arrow-lg">{"→"}</span>
1518 <span class="prs-branch-pill">{pr.baseBranch}</span>
1519 </span>
1520 <span>opened {formatRelative(pr.createdAt)}</span>
1521 {canManage && pr.state === "open" && pr.isDraft && (
1522 <form
1523 method="post"
1524 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
1525 class="prs-inline-form prs-detail-actions"
1526 >
1527 <button type="submit" class="prs-merge-ready-btn">
1528 Ready for review
1529 </button>
1530 </form>
1531 )}
1532 </div>
1533 </div>
0074234Claude1534
b078860Claude1535 <nav class="prs-detail-tabs" aria-label="Pull request sections">
1536 <a
1537 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
1538 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1539 >
1540 Conversation
1541 <span class="prs-detail-tab-count">{commentCount}</span>
1542 </a>
1543 <a
1544 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
1545 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
1546 >
1547 Files changed
1548 {diffFiles.length > 0 && (
1549 <span class="prs-detail-tab-count">{diffFiles.length}</span>
1550 )}
1551 </a>
1552 </nav>
1553
1554 {tab === "files" ? (
1555 <DiffView raw={diffRaw} files={diffFiles} />
1556 ) : (
1557 <>
1558 {pr.body && (
1559 <CommentBox
1560 author={author?.username ?? "unknown"}
1561 date={pr.createdAt}
1562 body={renderMarkdown(pr.body)}
1563 />
1564 )}
1565
1566 {comments.map(({ comment, author: commentAuthor }) => (
1567 <div class={`prs-comment${comment.isAiReview ? " is-ai" : ""}`}>
1568 <div class="prs-comment-head">
1569 <strong>{commentAuthor.username}</strong>
1570 {comment.isAiReview && (
1571 <span class="prs-ai-badge">AI Review</span>
1572 )}
1573 <span class="prs-comment-time">
1574 commented {formatRelative(comment.createdAt)}
1575 </span>
1576 {comment.filePath && (
1577 <span class="prs-comment-loc">
1578 {comment.filePath}
1579 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
1580 </span>
1581 )}
1582 </div>
1583 <div class="prs-comment-body">
bb0f894Claude1584 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude1585 </div>
b078860Claude1586 </div>
1587 ))}
0074234Claude1588
b078860Claude1589 {/* Quick link to the Files changed tab when there's a diff to look at. */}
1590 {pr.state !== "merged" && (
1591 <a
1592 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
1593 class="prs-files-card"
1594 >
1595 <span class="prs-files-card-icon" aria-hidden="true">
1596 {"▤"}
1597 </span>
1598 <div class="prs-files-card-text">
1599 <p class="prs-files-card-title">Files changed</p>
1600 <p class="prs-files-card-sub">
1601 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
1602 </p>
e883329Claude1603 </div>
b078860Claude1604 <span class="prs-files-card-cta">View diff {"→"}</span>
1605 </a>
1606 )}
1607
1608 {error && (
1609 <div
1610 class="auth-error"
1611 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)"
1612 >
1613 {decodeURIComponent(error)}
1614 </div>
1615 )}
1616
1617 {info && (
1618 <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)">
1619 {decodeURIComponent(info)}
1620 </div>
1621 )}
e883329Claude1622
b078860Claude1623 {pr.state === "open" && (prRisk || prRiskCalculating) && (
1624 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
1625 )}
1626
1627 {pr.state === "open" && gateChecks.length > 0 && (
1628 <div class="prs-gate-card">
1629 <div class="prs-gate-head">
1630 <h3>Gate checks</h3>
1631 <span class="prs-gate-summary">
1632 {gatesAllPassed
1633 ? `All ${gateChecks.length} checks passed`
1634 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
1635 </span>
c3e0c07Claude1636 </div>
b078860Claude1637 {gateChecks.map((check) => {
1638 const isAi = /ai.*review/i.test(check.name);
1639 const isSkip = check.skipped === true;
1640 const statusClass = isSkip
1641 ? "is-skip"
1642 : check.passed
1643 ? "is-pass"
1644 : "is-fail";
1645 const statusGlyph = isSkip
1646 ? "—"
1647 : check.passed
1648 ? "✓"
1649 : "✗";
1650 const statusLabel = isSkip
1651 ? "Skipped"
1652 : check.passed
1653 ? "Passed"
1654 : "Failing";
1655 return (
1656 <div
1657 class="prs-gate-row"
1658 style={
1659 isAi
1660 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
1661 : ""
1662 }
1663 >
1664 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
1665 {statusGlyph}
1666 </span>
1667 <span class="prs-gate-name">
1668 {check.name}
1669 {isAi && (
1670 <span
1671 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"
1672 >
1673 AI
1674 </span>
1675 )}
1676 </span>
1677 <span class="prs-gate-details">{check.details}</span>
1678 <span class={`prs-gate-pill ${statusClass}`}>
1679 {statusLabel}
e883329Claude1680 </span>
1681 </div>
b078860Claude1682 );
1683 })}
1684 <div class="prs-gate-footer">
1685 {gatesAllPassed
1686 ? "All checks passed — ready to merge."
1687 : gateChecks.some(
1688 (c) => !c.passed && c.name === "Merge check"
1689 )
1690 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
1691 : "Some checks failed — resolve issues before merging."}
1692 {aiReviewCount > 0 && (
1693 <>
1694 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
1695 </>
1696 )}
1697 </div>
1698 </div>
1699 )}
1700
1701 {/* ─── Merge area / state-aware action card ─────────────── */}
1702 {user && pr.state === "open" && (
1703 <div
1704 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
1705 >
1706 <div class="prs-merge-head">
1707 <strong>
1708 {pr.isDraft
1709 ? "Draft — ready for review?"
1710 : mergeBlocked
1711 ? "Merge blocked"
1712 : "Ready to merge"}
1713 </strong>
e883329Claude1714 </div>
b078860Claude1715 <p class="prs-merge-sub">
1716 {pr.isDraft
1717 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
1718 : mergeBlocked
1719 ? "Resolve the failing gate checks above before this PR can land."
1720 : gateChecks.length > 0
1721 ? gatesAllPassed
1722 ? "All gates green. Merge will fast-forward into the base branch."
1723 : "Conflicts will be auto-resolved by GlueCron AI on merge."
1724 : "Run gate checks by refreshing once your branch has a recent commit."}
1725 </p>
1726 <Form
1727 method="post"
1728 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
1729 >
1730 <FormGroup>
1731 <TextArea
1732 name="body"
1733 rows={5}
1734 required
1735 placeholder="Leave a comment... (Markdown supported)"
1736 mono
1737 />
1738 </FormGroup>
1739 <div class="prs-merge-actions">
1740 <Button type="submit" variant="primary">
1741 Comment
1742 </Button>
1743 {canManage && (
1744 <>
1745 {pr.isDraft ? (
1746 <button
1747 type="submit"
1748 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
1749 formnovalidate
1750 class="prs-merge-ready-btn"
1751 >
1752 Ready for review
1753 </button>
1754 ) : (
0074234Claude1755 <button
1756 type="submit"
1757 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
b078860Claude1758 formnovalidate
1759 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
1760 title={
1761 mergeBlocked
1762 ? "Failing gate checks must be resolved before this PR can merge."
1763 : "Merge pull request"
1764 }
0074234Claude1765 >
b078860Claude1766 {"✔"} Merge pull request
0074234Claude1767 </button>
b078860Claude1768 )}
1769 {!pr.isDraft && (
1770 <button
0074234Claude1771 type="submit"
b078860Claude1772 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
1773 formnovalidate
1774 class="prs-merge-back-draft"
1775 title="Convert back to draft"
0074234Claude1776 >
b078860Claude1777 Convert to draft
1778 </button>
1779 )}
1780 {isAiReviewEnabled() && (
1781 <button
1782 type="submit"
1783 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
1784 formnovalidate
1785 class="btn"
1786 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
1787 >
1788 Re-run AI review
1789 </button>
1790 )}
1791 <Button
1792 type="submit"
1793 variant="danger"
1794 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
1795 >
1796 Close
1797 </Button>
1798 </>
1799 )}
1800 </div>
1801 </Form>
1802 </div>
1803 )}
1804
1805 {/* Read-only footers for non-open states. */}
1806 {pr.state === "merged" && (
1807 <div class="prs-merge-card is-merged">
1808 <div class="prs-merge-head">
1809 <strong>{"⮌"} Merged</strong>
0074234Claude1810 </div>
b078860Claude1811 <p class="prs-merge-sub">
1812 This pull request was merged into{" "}
1813 <code>{pr.baseBranch}</code>.
1814 </p>
1815 </div>
1816 )}
1817 {pr.state === "closed" && (
1818 <div class="prs-merge-card is-closed">
1819 <div class="prs-merge-head">
1820 <strong>{"✕"} Closed without merging</strong>
1821 </div>
1822 <p class="prs-merge-sub">
1823 This pull request was closed and not merged.
1824 </p>
1825 </div>
1826 )}
1827 </>
1828 )}
0074234Claude1829 </Layout>
1830 );
1831});
1832
1833// Add comment to PR
1834pulls.post(
1835 "/:owner/:repo/pulls/:number/comment",
1836 softAuth,
1837 requireAuth,
04f6b7fClaude1838 requireRepoAccess("write"),
0074234Claude1839 async (c) => {
1840 const { owner: ownerName, repo: repoName } = c.req.param();
1841 const prNum = parseInt(c.req.param("number"), 10);
1842 const user = c.get("user")!;
1843 const body = await c.req.parseBody();
1844 const commentBody = String(body.body || "").trim();
1845
1846 if (!commentBody) {
1847 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1848 }
1849
1850 const resolved = await resolveRepo(ownerName, repoName);
1851 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1852
1853 const [pr] = await db
1854 .select()
1855 .from(pullRequests)
1856 .where(
1857 and(
1858 eq(pullRequests.repositoryId, resolved.repo.id),
1859 eq(pullRequests.number, prNum)
1860 )
1861 )
1862 .limit(1);
1863
1864 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1865
d4ac5c3Claude1866 const [inserted] = await db
1867 .insert(prComments)
1868 .values({
1869 pullRequestId: pr.id,
1870 authorId: user.id,
1871 body: commentBody,
1872 })
1873 .returning();
1874
1875 // Live update: nudge any browser tabs subscribed to this PR.
1876 if (inserted) {
1877 try {
1878 const { publish } = await import("../lib/sse");
1879 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
1880 event: "pr-comment",
1881 data: {
1882 pullRequestId: pr.id,
1883 commentId: inserted.id,
1884 authorId: user.id,
1885 authorUsername: user.username,
1886 },
1887 });
1888 } catch {
1889 /* SSE is best-effort */
1890 }
1891 }
0074234Claude1892
1893 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1894 }
1895);
1896
e883329Claude1897// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude1898// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
1899// but we keep it at "write" for v1 so trusted collaborators can ship.
1900// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
1901// surface. Branch-protection rules (evaluated below) are the current mechanism
1902// for locking down merges further on specific branches.
0074234Claude1903pulls.post(
1904 "/:owner/:repo/pulls/:number/merge",
1905 softAuth,
1906 requireAuth,
04f6b7fClaude1907 requireRepoAccess("write"),
0074234Claude1908 async (c) => {
1909 const { owner: ownerName, repo: repoName } = c.req.param();
1910 const prNum = parseInt(c.req.param("number"), 10);
1911 const user = c.get("user")!;
1912
1913 const resolved = await resolveRepo(ownerName, repoName);
1914 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1915
1916 const [pr] = await db
1917 .select()
1918 .from(pullRequests)
1919 .where(
1920 and(
1921 eq(pullRequests.repositoryId, resolved.repo.id),
1922 eq(pullRequests.number, prNum)
1923 )
1924 )
1925 .limit(1);
1926
1927 if (!pr || pr.state !== "open") {
1928 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1929 }
1930
6fc53bdClaude1931 // Draft PRs cannot be merged — must be marked ready first.
1932 if (pr.isDraft) {
1933 return c.redirect(
1934 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1935 "This PR is a draft. Mark it as ready for review before merging."
1936 )}`
1937 );
1938 }
1939
e883329Claude1940 // Resolve head SHA
1941 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
1942 if (!headSha) {
1943 return c.redirect(
1944 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
1945 );
1946 }
1947
1948 // Check if AI review approved this PR
1949 const aiComments = await db
1950 .select()
1951 .from(prComments)
1952 .where(
1953 and(
1954 eq(prComments.pullRequestId, pr.id),
1955 eq(prComments.isAiReview, true)
1956 )
1957 );
1958 const aiApproved = aiComments.length === 0 || aiComments.some(
1959 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude1960 );
e883329Claude1961
1962 // Run all green gate checks (GateTest + mergeability + AI review)
1963 const gateResult = await runAllGateChecks(
1964 ownerName,
1965 repoName,
1966 pr.baseBranch,
1967 pr.headBranch,
1968 headSha,
1969 aiApproved
0074234Claude1970 );
1971
e883329Claude1972 // If GateTest or AI review failed (hard blocks), reject the merge
1973 const hardFailures = gateResult.checks.filter(
1974 (check) => !check.passed && check.name !== "Merge check"
1975 );
1976 if (hardFailures.length > 0) {
1977 const errorMsg = hardFailures
1978 .map((f) => `${f.name}: ${f.details}`)
1979 .join("; ");
0074234Claude1980 return c.redirect(
e883329Claude1981 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude1982 );
1983 }
1984
1e162a8Claude1985 // D5 — Branch-protection enforcement. Looks up the matching rule for the
1986 // base branch and blocks the merge if requireAiApproval / requireGreenGates
1987 // / requireHumanReview / requiredApprovals are not satisfied. Independent
1988 // of repo-global settings, so owners can lock specific branches down
1989 // further than the repo default.
1990 const protectionRule = await matchProtection(
1991 resolved.repo.id,
1992 pr.baseBranch
1993 );
1994 if (protectionRule) {
1995 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude1996 const required = await listRequiredChecks(protectionRule.id);
1997 const passingNames = required.length > 0
1998 ? await passingCheckNames(resolved.repo.id, headSha)
1999 : [];
2000 const decision = evaluateProtection(
2001 protectionRule,
2002 {
2003 aiApproved,
2004 humanApprovalCount: humanApprovals,
2005 gateResultGreen: hardFailures.length === 0,
2006 hasFailedGates: hardFailures.length > 0,
2007 passingCheckNames: passingNames,
2008 },
2009 required.map((r) => r.checkName)
2010 );
1e162a8Claude2011 if (!decision.allowed) {
2012 return c.redirect(
2013 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2014 decision.reasons.join(" ")
2015 )}`
2016 );
2017 }
2018 }
2019
e883329Claude2020 // Attempt the merge — with auto conflict resolution if needed
2021 const repoDir = getRepoPath(ownerName, repoName);
2022 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
2023 const hasConflicts = mergeCheck && !mergeCheck.passed;
2024
2025 if (hasConflicts && isAiReviewEnabled()) {
2026 // Use Claude to auto-resolve conflicts
2027 const mergeResult = await mergeWithAutoResolve(
2028 ownerName,
2029 repoName,
2030 pr.baseBranch,
2031 pr.headBranch,
2032 `Merge pull request #${pr.number}: ${pr.title}`
2033 );
2034
2035 if (!mergeResult.success) {
2036 return c.redirect(
2037 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
2038 );
2039 }
2040
2041 // Post a comment about the auto-resolution
2042 if (mergeResult.resolvedFiles.length > 0) {
2043 await db.insert(prComments).values({
2044 pullRequestId: pr.id,
2045 authorId: user.id,
2046 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
2047 isAiReview: true,
2048 });
2049 }
2050 } else {
2051 // Standard merge — fast-forward or clean merge
2052 const ffProc = Bun.spawn(
2053 [
2054 "git",
2055 "update-ref",
2056 `refs/heads/${pr.baseBranch}`,
2057 `refs/heads/${pr.headBranch}`,
2058 ],
2059 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2060 );
2061 const ffExit = await ffProc.exited;
2062
2063 if (ffExit !== 0) {
2064 return c.redirect(
2065 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
2066 );
2067 }
2068 }
2069
0074234Claude2070 await db
2071 .update(pullRequests)
2072 .set({
2073 state: "merged",
2074 mergedAt: new Date(),
2075 mergedBy: user.id,
2076 updatedAt: new Date(),
2077 })
2078 .where(eq(pullRequests.id, pr.id));
2079
d62fb36Claude2080 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
2081 // and auto-close each matching open issue with a back-link comment. Bounded
2082 // to the same repo for v1 (cross-repo refs ignored). Failures never block
2083 // the merge redirect.
2084 try {
2085 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
2086 const refs = extractClosingRefsMulti([pr.title, pr.body]);
2087 for (const n of refs) {
2088 const [issue] = await db
2089 .select()
2090 .from(issues)
2091 .where(
2092 and(
2093 eq(issues.repositoryId, resolved.repo.id),
2094 eq(issues.number, n)
2095 )
2096 )
2097 .limit(1);
2098 if (!issue || issue.state !== "open") continue;
2099 await db
2100 .update(issues)
2101 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
2102 .where(eq(issues.id, issue.id));
2103 await db.insert(issueComments).values({
2104 issueId: issue.id,
2105 authorId: user.id,
2106 body: `Closed by pull request #${pr.number}.`,
2107 });
2108 }
2109 } catch {
2110 // Never block the merge on close-keyword failures.
2111 }
2112
0074234Claude2113 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2114 }
2115);
2116
6fc53bdClaude2117// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
2118// hasn't run yet on this PR.
2119pulls.post(
2120 "/:owner/:repo/pulls/:number/ready",
2121 softAuth,
2122 requireAuth,
04f6b7fClaude2123 requireRepoAccess("write"),
6fc53bdClaude2124 async (c) => {
2125 const { owner: ownerName, repo: repoName } = c.req.param();
2126 const prNum = parseInt(c.req.param("number"), 10);
2127 const user = c.get("user")!;
2128
2129 const resolved = await resolveRepo(ownerName, repoName);
2130 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2131
2132 const [pr] = await db
2133 .select()
2134 .from(pullRequests)
2135 .where(
2136 and(
2137 eq(pullRequests.repositoryId, resolved.repo.id),
2138 eq(pullRequests.number, prNum)
2139 )
2140 )
2141 .limit(1);
2142 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2143
2144 // Only the author or repo owner can toggle draft state.
2145 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2146 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2147 }
2148
2149 if (pr.state === "open" && pr.isDraft) {
2150 await db
2151 .update(pullRequests)
2152 .set({ isDraft: false, updatedAt: new Date() })
2153 .where(eq(pullRequests.id, pr.id));
2154
2155 if (isAiReviewEnabled()) {
2156 triggerAiReview(
2157 ownerName,
2158 repoName,
2159 pr.id,
2160 pr.title,
0316dbbClaude2161 pr.body || "",
6fc53bdClaude2162 pr.baseBranch,
2163 pr.headBranch
2164 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
2165 }
2166 }
2167
2168 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2169 }
2170);
2171
2172// Convert a PR back to draft.
2173pulls.post(
2174 "/:owner/:repo/pulls/:number/draft",
2175 softAuth,
2176 requireAuth,
04f6b7fClaude2177 requireRepoAccess("write"),
6fc53bdClaude2178 async (c) => {
2179 const { owner: ownerName, repo: repoName } = c.req.param();
2180 const prNum = parseInt(c.req.param("number"), 10);
2181 const user = c.get("user")!;
2182
2183 const resolved = await resolveRepo(ownerName, repoName);
2184 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2185
2186 const [pr] = await db
2187 .select()
2188 .from(pullRequests)
2189 .where(
2190 and(
2191 eq(pullRequests.repositoryId, resolved.repo.id),
2192 eq(pullRequests.number, prNum)
2193 )
2194 )
2195 .limit(1);
2196 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2197
2198 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2199 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2200 }
2201
2202 if (pr.state === "open" && !pr.isDraft) {
2203 await db
2204 .update(pullRequests)
2205 .set({ isDraft: true, updatedAt: new Date() })
2206 .where(eq(pullRequests.id, pr.id));
2207 }
2208
2209 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2210 }
2211);
2212
0074234Claude2213// Close PR
2214pulls.post(
2215 "/:owner/:repo/pulls/:number/close",
2216 softAuth,
2217 requireAuth,
04f6b7fClaude2218 requireRepoAccess("write"),
0074234Claude2219 async (c) => {
2220 const { owner: ownerName, repo: repoName } = c.req.param();
2221 const prNum = parseInt(c.req.param("number"), 10);
2222
2223 const resolved = await resolveRepo(ownerName, repoName);
2224 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2225
2226 await db
2227 .update(pullRequests)
2228 .set({
2229 state: "closed",
2230 closedAt: new Date(),
2231 updatedAt: new Date(),
2232 })
2233 .where(
2234 and(
2235 eq(pullRequests.repositoryId, resolved.repo.id),
2236 eq(pullRequests.number, prNum)
2237 )
2238 );
2239
2240 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2241 }
2242);
2243
c3e0c07Claude2244// Re-run AI review on demand (e.g. after a force-push). Bypasses the
2245// idempotency marker via { force: true }. Write-access only.
2246pulls.post(
2247 "/:owner/:repo/pulls/:number/ai-rereview",
2248 softAuth,
2249 requireAuth,
2250 requireRepoAccess("write"),
2251 async (c) => {
2252 const { owner: ownerName, repo: repoName } = c.req.param();
2253 const prNum = parseInt(c.req.param("number"), 10);
2254 const resolved = await resolveRepo(ownerName, repoName);
2255 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2256
2257 const [pr] = await db
2258 .select()
2259 .from(pullRequests)
2260 .where(
2261 and(
2262 eq(pullRequests.repositoryId, resolved.repo.id),
2263 eq(pullRequests.number, prNum)
2264 )
2265 )
2266 .limit(1);
2267 if (!pr) {
2268 return c.redirect(`/${ownerName}/${repoName}/pulls`);
2269 }
2270
2271 if (!isAiReviewEnabled()) {
2272 return c.redirect(
2273 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2274 "AI review is not configured (ANTHROPIC_API_KEY)."
2275 )}`
2276 );
2277 }
2278
2279 // Fire-and-forget but with { force: true } to bypass the
2280 // already-reviewed marker. The function still never throws.
2281 triggerAiReview(
2282 ownerName,
2283 repoName,
2284 pr.id,
2285 pr.title || "",
2286 pr.body || "",
2287 pr.baseBranch,
2288 pr.headBranch,
2289 { force: true }
a28cedeClaude2290 ).catch((err) => {
2291 console.warn(
2292 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
2293 err instanceof Error ? err.message : err
2294 );
2295 });
c3e0c07Claude2296
2297 return c.redirect(
2298 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
2299 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
2300 )}`
2301 );
2302 }
2303);
2304
0074234Claude2305export default pulls;