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.tsxBlame2326 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 );
6ea2109Claude1215 // 30s ceiling — without this a pathological diff (huge binary or
1216 // a corrupt ref) hangs the request indefinitely.
1217 const killer = setTimeout(() => proc.kill(), 30_000);
1218 try {
1219 diff = await new Response(proc.stdout).text();
1220 await proc.exited;
1221 } finally {
1222 clearTimeout(killer);
1223 }
81c73c1Claude1224 } catch {
1225 diff = "";
1226 }
1227 if (!diff.trim()) {
1228 return c.json({
1229 ok: false,
1230 error: "No diff between branches — nothing to summarise.",
1231 });
1232 }
1233
1234 let summary = "";
1235 try {
1236 summary = await generatePrSummary(title || "(untitled)", diff);
1237 } catch (err) {
1238 const msg = err instanceof Error ? err.message : "AI request failed.";
1239 return c.json({ ok: false, error: msg });
1240 }
1241 if (!summary.trim()) {
1242 return c.json({ ok: false, error: "AI returned an empty draft." });
1243 }
1244 return c.json({ ok: true, body: summary });
1245 }
1246);
1247
0074234Claude1248// Create PR
1249pulls.post(
1250 "/:owner/:repo/pulls/new",
1251 softAuth,
1252 requireAuth,
04f6b7fClaude1253 requireRepoAccess("write"),
0074234Claude1254 async (c) => {
1255 const { owner: ownerName, repo: repoName } = c.req.param();
1256 const user = c.get("user")!;
1257 const body = await c.req.parseBody();
1258 const title = String(body.title || "").trim();
1259 const prBody = String(body.body || "").trim();
1260 const baseBranch = String(body.base || "main");
1261 const headBranch = String(body.head || "");
1262
1263 if (!title || !headBranch) {
1264 return c.redirect(
1265 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
1266 );
1267 }
1268
1269 if (baseBranch === headBranch) {
1270 return c.redirect(
1271 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
1272 );
1273 }
1274
1275 const resolved = await resolveRepo(ownerName, repoName);
1276 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1277
6fc53bdClaude1278 const isDraft = String(body.draft || "") === "1";
1279
0074234Claude1280 const [pr] = await db
1281 .insert(pullRequests)
1282 .values({
1283 repositoryId: resolved.repo.id,
1284 authorId: user.id,
1285 title,
1286 body: prBody || null,
1287 baseBranch,
1288 headBranch,
6fc53bdClaude1289 isDraft,
0074234Claude1290 })
1291 .returning();
1292
6fc53bdClaude1293 // Skip AI review on drafts — it runs again when the PR is marked ready.
1294 if (!isDraft && isAiReviewEnabled()) {
e883329Claude1295 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
1296 (err) => console.error("[ai-review] Failed:", err)
1297 );
1298 }
1299
3cbe3d6Claude1300 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
1301 triggerPrTriage({
1302 ownerName,
1303 repoName,
1304 repositoryId: resolved.repo.id,
1305 prId: pr.id,
1306 prAuthorId: user.id,
1307 title,
1308 body: prBody,
1309 baseBranch,
1310 headBranch,
1311 }).catch((err) => console.error("[pr-triage] Failed:", err));
1312
9dd96b9Test User1313 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude1314 import("../lib/auto-merge")
1315 .then((m) => m.tryAutoMergeNow(pr.id))
1316 .catch((err) => {
1317 console.warn(
1318 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
1319 err instanceof Error ? err.message : err
1320 );
1321 });
9dd96b9Test User1322
0074234Claude1323 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
1324 }
1325);
1326
1327// View single PR
04f6b7fClaude1328pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1329 const { owner: ownerName, repo: repoName } = c.req.param();
1330 const prNum = parseInt(c.req.param("number"), 10);
1331 const user = c.get("user");
1332 const tab = c.req.query("tab") || "conversation";
1333
1334 const resolved = await resolveRepo(ownerName, repoName);
1335 if (!resolved) return c.notFound();
1336
1337 const [pr] = await db
1338 .select()
1339 .from(pullRequests)
1340 .where(
1341 and(
1342 eq(pullRequests.repositoryId, resolved.repo.id),
1343 eq(pullRequests.number, prNum)
1344 )
1345 )
1346 .limit(1);
1347
1348 if (!pr) return c.notFound();
1349
1350 const [author] = await db
1351 .select()
1352 .from(users)
1353 .where(eq(users.id, pr.authorId))
1354 .limit(1);
1355
1356 const comments = await db
1357 .select({
1358 comment: prComments,
1359 author: { username: users.username },
1360 })
1361 .from(prComments)
1362 .innerJoin(users, eq(prComments.authorId, users.id))
1363 .where(eq(prComments.pullRequestId, pr.id))
1364 .orderBy(asc(prComments.createdAt));
1365
6fc53bdClaude1366 // Reactions for the PR body + each comment, in parallel.
1367 const [prReactions, ...prCommentReactions] = await Promise.all([
1368 summariseReactions("pr", pr.id, user?.id),
1369 ...comments.map((row) =>
1370 summariseReactions("pr_comment", row.comment.id, user?.id)
1371 ),
1372 ]);
1373
0074234Claude1374 const canManage =
1375 user &&
1376 (user.id === resolved.owner.id || user.id === pr.authorId);
1377
e883329Claude1378 const error = c.req.query("error");
c3e0c07Claude1379 const info = c.req.query("info");
e883329Claude1380
1381 // Get gate check status for open PRs
1382 let gateChecks: GateCheckResult[] = [];
1383 if (pr.state === "open") {
1384 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
1385 if (headSha) {
1386 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
1387 const aiApproved = aiComments.length === 0 || aiComments.some(
1388 ({ comment }) => comment.body.includes("**Approved**")
1389 );
1390 const gateResult = await runAllGateChecks(
1391 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
1392 );
1393 gateChecks = gateResult.checks;
1394 }
1395 }
1396
534f04aClaude1397 // Block M3 — pre-merge risk score. Cache-only on the request path so
1398 // the page never waits on Haiku. On a cache miss for an open PR we
1399 // kick off the computation fire-and-forget; the next refresh shows it.
1400 let prRisk: PrRiskScore | null = null;
1401 let prRiskCalculating = false;
1402 if (pr.state === "open") {
1403 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
1404 if (!prRisk) {
1405 prRiskCalculating = true;
a28cedeClaude1406 void computePrRiskForPullRequest(pr.id).catch((err) => {
1407 console.warn(
1408 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
1409 err instanceof Error ? err.message : err
1410 );
1411 });
534f04aClaude1412 }
1413 }
1414
0074234Claude1415 // Get diff for "Files changed" tab
1416 let diffRaw = "";
1417 let diffFiles: GitDiffFile[] = [];
1418 if (tab === "files") {
1419 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude1420 // Run the two git diffs in parallel — they're independent reads of
1421 // the same range. Previously sequential, doubling the wall time on
1422 // big PRs (100+ files = 10-30s for no reason).
0074234Claude1423 const proc = Bun.spawn(
1424 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
1425 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1426 );
1427 const statProc = Bun.spawn(
1428 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
1429 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1430 );
6ea2109Claude1431 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
1432 // would otherwise hang the whole request.
1433 const killer = setTimeout(() => {
1434 proc.kill();
1435 statProc.kill();
1436 }, 30_000);
1437 let stat = "";
1438 try {
1439 [diffRaw, stat] = await Promise.all([
1440 new Response(proc.stdout).text(),
1441 new Response(statProc.stdout).text(),
1442 ]);
1443 await Promise.all([proc.exited, statProc.exited]);
1444 } finally {
1445 clearTimeout(killer);
1446 }
0074234Claude1447
1448 diffFiles = stat
1449 .trim()
1450 .split("\n")
1451 .filter(Boolean)
1452 .map((line) => {
1453 const [add, del, filePath] = line.split("\t");
1454 return {
1455 path: filePath,
1456 status: "modified",
1457 additions: add === "-" ? 0 : parseInt(add, 10),
1458 deletions: del === "-" ? 0 : parseInt(del, 10),
1459 patch: "",
1460 };
1461 });
1462 }
1463
b078860Claude1464 // ─── Derived visual state ───
1465 const stateKey =
1466 pr.state === "open"
1467 ? pr.isDraft
1468 ? "draft"
1469 : "open"
1470 : pr.state;
1471 const stateLabel =
1472 stateKey === "open"
1473 ? "Open"
1474 : stateKey === "draft"
1475 ? "Draft"
1476 : stateKey === "merged"
1477 ? "Merged"
1478 : "Closed";
1479 const stateIcon =
1480 stateKey === "open"
1481 ? "○"
1482 : stateKey === "draft"
1483 ? "◌"
1484 : stateKey === "merged"
1485 ? "⮌"
1486 : "✓";
1487 const commentCount = comments.length;
1488 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
1489 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
1490 const mergeBlocked =
1491 gateChecks.length > 0 &&
1492 gateChecks.some(
1493 (c) => !c.passed && c.name !== "Merge check"
1494 );
1495
0074234Claude1496 return c.html(
1497 <Layout
1498 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
1499 user={user}
1500 >
1501 <RepoHeader owner={ownerName} repo={repoName} />
1502 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude1503 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude1504 <div
1505 id="live-comment-banner"
1506 class="alert"
1507 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1508 >
1509 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1510 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1511 reload to view
1512 </a>
1513 </div>
1514 <script
1515 dangerouslySetInnerHTML={{
1516 __html: liveCommentBannerScript({
1517 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
1518 bannerElementId: "live-comment-banner",
1519 }),
1520 }}
1521 />
b078860Claude1522
1523 <div class="prs-detail-hero">
1524 <h1 class="prs-detail-title">
0074234Claude1525 {pr.title}{" "}
b078860Claude1526 <span class="prs-detail-num">#{pr.number}</span>
1527 </h1>
1528 <div class="prs-detail-meta">
1529 <span class={`prs-state-pill state-${stateKey}`}>
1530 <span aria-hidden="true">{stateIcon}</span>
1531 <span>{stateLabel}</span>
1532 </span>
1533 <span>
1534 <strong>{author?.username}</strong> wants to merge
1535 </span>
1536 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
1537 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
1538 <span class="prs-branch-arrow-lg">{"→"}</span>
1539 <span class="prs-branch-pill">{pr.baseBranch}</span>
1540 </span>
1541 <span>opened {formatRelative(pr.createdAt)}</span>
1542 {canManage && pr.state === "open" && pr.isDraft && (
1543 <form
1544 method="post"
1545 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
1546 class="prs-inline-form prs-detail-actions"
1547 >
1548 <button type="submit" class="prs-merge-ready-btn">
1549 Ready for review
1550 </button>
1551 </form>
1552 )}
1553 </div>
1554 </div>
0074234Claude1555
b078860Claude1556 <nav class="prs-detail-tabs" aria-label="Pull request sections">
1557 <a
1558 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
1559 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1560 >
1561 Conversation
1562 <span class="prs-detail-tab-count">{commentCount}</span>
1563 </a>
1564 <a
1565 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
1566 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
1567 >
1568 Files changed
1569 {diffFiles.length > 0 && (
1570 <span class="prs-detail-tab-count">{diffFiles.length}</span>
1571 )}
1572 </a>
1573 </nav>
1574
1575 {tab === "files" ? (
1576 <DiffView raw={diffRaw} files={diffFiles} />
1577 ) : (
1578 <>
1579 {pr.body && (
1580 <CommentBox
1581 author={author?.username ?? "unknown"}
1582 date={pr.createdAt}
1583 body={renderMarkdown(pr.body)}
1584 />
1585 )}
1586
1587 {comments.map(({ comment, author: commentAuthor }) => (
1588 <div class={`prs-comment${comment.isAiReview ? " is-ai" : ""}`}>
1589 <div class="prs-comment-head">
1590 <strong>{commentAuthor.username}</strong>
1591 {comment.isAiReview && (
1592 <span class="prs-ai-badge">AI Review</span>
1593 )}
1594 <span class="prs-comment-time">
1595 commented {formatRelative(comment.createdAt)}
1596 </span>
1597 {comment.filePath && (
1598 <span class="prs-comment-loc">
1599 {comment.filePath}
1600 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
1601 </span>
1602 )}
1603 </div>
1604 <div class="prs-comment-body">
bb0f894Claude1605 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude1606 </div>
b078860Claude1607 </div>
1608 ))}
0074234Claude1609
b078860Claude1610 {/* Quick link to the Files changed tab when there's a diff to look at. */}
1611 {pr.state !== "merged" && (
1612 <a
1613 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
1614 class="prs-files-card"
1615 >
1616 <span class="prs-files-card-icon" aria-hidden="true">
1617 {"▤"}
1618 </span>
1619 <div class="prs-files-card-text">
1620 <p class="prs-files-card-title">Files changed</p>
1621 <p class="prs-files-card-sub">
1622 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
1623 </p>
e883329Claude1624 </div>
b078860Claude1625 <span class="prs-files-card-cta">View diff {"→"}</span>
1626 </a>
1627 )}
1628
1629 {error && (
1630 <div
1631 class="auth-error"
1632 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)"
1633 >
1634 {decodeURIComponent(error)}
1635 </div>
1636 )}
1637
1638 {info && (
1639 <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)">
1640 {decodeURIComponent(info)}
1641 </div>
1642 )}
e883329Claude1643
b078860Claude1644 {pr.state === "open" && (prRisk || prRiskCalculating) && (
1645 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
1646 )}
1647
1648 {pr.state === "open" && gateChecks.length > 0 && (
1649 <div class="prs-gate-card">
1650 <div class="prs-gate-head">
1651 <h3>Gate checks</h3>
1652 <span class="prs-gate-summary">
1653 {gatesAllPassed
1654 ? `All ${gateChecks.length} checks passed`
1655 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
1656 </span>
c3e0c07Claude1657 </div>
b078860Claude1658 {gateChecks.map((check) => {
1659 const isAi = /ai.*review/i.test(check.name);
1660 const isSkip = check.skipped === true;
1661 const statusClass = isSkip
1662 ? "is-skip"
1663 : check.passed
1664 ? "is-pass"
1665 : "is-fail";
1666 const statusGlyph = isSkip
1667 ? "—"
1668 : check.passed
1669 ? "✓"
1670 : "✗";
1671 const statusLabel = isSkip
1672 ? "Skipped"
1673 : check.passed
1674 ? "Passed"
1675 : "Failing";
1676 return (
1677 <div
1678 class="prs-gate-row"
1679 style={
1680 isAi
1681 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
1682 : ""
1683 }
1684 >
1685 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
1686 {statusGlyph}
1687 </span>
1688 <span class="prs-gate-name">
1689 {check.name}
1690 {isAi && (
1691 <span
1692 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"
1693 >
1694 AI
1695 </span>
1696 )}
1697 </span>
1698 <span class="prs-gate-details">{check.details}</span>
1699 <span class={`prs-gate-pill ${statusClass}`}>
1700 {statusLabel}
e883329Claude1701 </span>
1702 </div>
b078860Claude1703 );
1704 })}
1705 <div class="prs-gate-footer">
1706 {gatesAllPassed
1707 ? "All checks passed — ready to merge."
1708 : gateChecks.some(
1709 (c) => !c.passed && c.name === "Merge check"
1710 )
1711 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
1712 : "Some checks failed — resolve issues before merging."}
1713 {aiReviewCount > 0 && (
1714 <>
1715 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
1716 </>
1717 )}
1718 </div>
1719 </div>
1720 )}
1721
1722 {/* ─── Merge area / state-aware action card ─────────────── */}
1723 {user && pr.state === "open" && (
1724 <div
1725 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
1726 >
1727 <div class="prs-merge-head">
1728 <strong>
1729 {pr.isDraft
1730 ? "Draft — ready for review?"
1731 : mergeBlocked
1732 ? "Merge blocked"
1733 : "Ready to merge"}
1734 </strong>
e883329Claude1735 </div>
b078860Claude1736 <p class="prs-merge-sub">
1737 {pr.isDraft
1738 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
1739 : mergeBlocked
1740 ? "Resolve the failing gate checks above before this PR can land."
1741 : gateChecks.length > 0
1742 ? gatesAllPassed
1743 ? "All gates green. Merge will fast-forward into the base branch."
1744 : "Conflicts will be auto-resolved by GlueCron AI on merge."
1745 : "Run gate checks by refreshing once your branch has a recent commit."}
1746 </p>
1747 <Form
1748 method="post"
1749 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
1750 >
1751 <FormGroup>
1752 <TextArea
1753 name="body"
1754 rows={5}
1755 required
1756 placeholder="Leave a comment... (Markdown supported)"
1757 mono
1758 />
1759 </FormGroup>
1760 <div class="prs-merge-actions">
1761 <Button type="submit" variant="primary">
1762 Comment
1763 </Button>
1764 {canManage && (
1765 <>
1766 {pr.isDraft ? (
1767 <button
1768 type="submit"
1769 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
1770 formnovalidate
1771 class="prs-merge-ready-btn"
1772 >
1773 Ready for review
1774 </button>
1775 ) : (
0074234Claude1776 <button
1777 type="submit"
1778 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
b078860Claude1779 formnovalidate
1780 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
1781 title={
1782 mergeBlocked
1783 ? "Failing gate checks must be resolved before this PR can merge."
1784 : "Merge pull request"
1785 }
0074234Claude1786 >
b078860Claude1787 {"✔"} Merge pull request
0074234Claude1788 </button>
b078860Claude1789 )}
1790 {!pr.isDraft && (
1791 <button
0074234Claude1792 type="submit"
b078860Claude1793 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
1794 formnovalidate
1795 class="prs-merge-back-draft"
1796 title="Convert back to draft"
0074234Claude1797 >
b078860Claude1798 Convert to draft
1799 </button>
1800 )}
1801 {isAiReviewEnabled() && (
1802 <button
1803 type="submit"
1804 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
1805 formnovalidate
1806 class="btn"
1807 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
1808 >
1809 Re-run AI review
1810 </button>
1811 )}
1812 <Button
1813 type="submit"
1814 variant="danger"
1815 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
1816 >
1817 Close
1818 </Button>
1819 </>
1820 )}
1821 </div>
1822 </Form>
1823 </div>
1824 )}
1825
1826 {/* Read-only footers for non-open states. */}
1827 {pr.state === "merged" && (
1828 <div class="prs-merge-card is-merged">
1829 <div class="prs-merge-head">
1830 <strong>{"⮌"} Merged</strong>
0074234Claude1831 </div>
b078860Claude1832 <p class="prs-merge-sub">
1833 This pull request was merged into{" "}
1834 <code>{pr.baseBranch}</code>.
1835 </p>
1836 </div>
1837 )}
1838 {pr.state === "closed" && (
1839 <div class="prs-merge-card is-closed">
1840 <div class="prs-merge-head">
1841 <strong>{"✕"} Closed without merging</strong>
1842 </div>
1843 <p class="prs-merge-sub">
1844 This pull request was closed and not merged.
1845 </p>
1846 </div>
1847 )}
1848 </>
1849 )}
0074234Claude1850 </Layout>
1851 );
1852});
1853
1854// Add comment to PR
1855pulls.post(
1856 "/:owner/:repo/pulls/:number/comment",
1857 softAuth,
1858 requireAuth,
04f6b7fClaude1859 requireRepoAccess("write"),
0074234Claude1860 async (c) => {
1861 const { owner: ownerName, repo: repoName } = c.req.param();
1862 const prNum = parseInt(c.req.param("number"), 10);
1863 const user = c.get("user")!;
1864 const body = await c.req.parseBody();
1865 const commentBody = String(body.body || "").trim();
1866
1867 if (!commentBody) {
1868 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1869 }
1870
1871 const resolved = await resolveRepo(ownerName, repoName);
1872 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1873
1874 const [pr] = await db
1875 .select()
1876 .from(pullRequests)
1877 .where(
1878 and(
1879 eq(pullRequests.repositoryId, resolved.repo.id),
1880 eq(pullRequests.number, prNum)
1881 )
1882 )
1883 .limit(1);
1884
1885 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1886
d4ac5c3Claude1887 const [inserted] = await db
1888 .insert(prComments)
1889 .values({
1890 pullRequestId: pr.id,
1891 authorId: user.id,
1892 body: commentBody,
1893 })
1894 .returning();
1895
1896 // Live update: nudge any browser tabs subscribed to this PR.
1897 if (inserted) {
1898 try {
1899 const { publish } = await import("../lib/sse");
1900 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
1901 event: "pr-comment",
1902 data: {
1903 pullRequestId: pr.id,
1904 commentId: inserted.id,
1905 authorId: user.id,
1906 authorUsername: user.username,
1907 },
1908 });
1909 } catch {
1910 /* SSE is best-effort */
1911 }
1912 }
0074234Claude1913
1914 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1915 }
1916);
1917
e883329Claude1918// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude1919// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
1920// but we keep it at "write" for v1 so trusted collaborators can ship.
1921// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
1922// surface. Branch-protection rules (evaluated below) are the current mechanism
1923// for locking down merges further on specific branches.
0074234Claude1924pulls.post(
1925 "/:owner/:repo/pulls/:number/merge",
1926 softAuth,
1927 requireAuth,
04f6b7fClaude1928 requireRepoAccess("write"),
0074234Claude1929 async (c) => {
1930 const { owner: ownerName, repo: repoName } = c.req.param();
1931 const prNum = parseInt(c.req.param("number"), 10);
1932 const user = c.get("user")!;
1933
1934 const resolved = await resolveRepo(ownerName, repoName);
1935 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1936
1937 const [pr] = await db
1938 .select()
1939 .from(pullRequests)
1940 .where(
1941 and(
1942 eq(pullRequests.repositoryId, resolved.repo.id),
1943 eq(pullRequests.number, prNum)
1944 )
1945 )
1946 .limit(1);
1947
1948 if (!pr || pr.state !== "open") {
1949 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1950 }
1951
6fc53bdClaude1952 // Draft PRs cannot be merged — must be marked ready first.
1953 if (pr.isDraft) {
1954 return c.redirect(
1955 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1956 "This PR is a draft. Mark it as ready for review before merging."
1957 )}`
1958 );
1959 }
1960
e883329Claude1961 // Resolve head SHA
1962 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
1963 if (!headSha) {
1964 return c.redirect(
1965 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
1966 );
1967 }
1968
1969 // Check if AI review approved this PR
1970 const aiComments = await db
1971 .select()
1972 .from(prComments)
1973 .where(
1974 and(
1975 eq(prComments.pullRequestId, pr.id),
1976 eq(prComments.isAiReview, true)
1977 )
1978 );
1979 const aiApproved = aiComments.length === 0 || aiComments.some(
1980 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude1981 );
e883329Claude1982
1983 // Run all green gate checks (GateTest + mergeability + AI review)
1984 const gateResult = await runAllGateChecks(
1985 ownerName,
1986 repoName,
1987 pr.baseBranch,
1988 pr.headBranch,
1989 headSha,
1990 aiApproved
0074234Claude1991 );
1992
e883329Claude1993 // If GateTest or AI review failed (hard blocks), reject the merge
1994 const hardFailures = gateResult.checks.filter(
1995 (check) => !check.passed && check.name !== "Merge check"
1996 );
1997 if (hardFailures.length > 0) {
1998 const errorMsg = hardFailures
1999 .map((f) => `${f.name}: ${f.details}`)
2000 .join("; ");
0074234Claude2001 return c.redirect(
e883329Claude2002 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude2003 );
2004 }
2005
1e162a8Claude2006 // D5 — Branch-protection enforcement. Looks up the matching rule for the
2007 // base branch and blocks the merge if requireAiApproval / requireGreenGates
2008 // / requireHumanReview / requiredApprovals are not satisfied. Independent
2009 // of repo-global settings, so owners can lock specific branches down
2010 // further than the repo default.
2011 const protectionRule = await matchProtection(
2012 resolved.repo.id,
2013 pr.baseBranch
2014 );
2015 if (protectionRule) {
2016 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude2017 const required = await listRequiredChecks(protectionRule.id);
2018 const passingNames = required.length > 0
2019 ? await passingCheckNames(resolved.repo.id, headSha)
2020 : [];
2021 const decision = evaluateProtection(
2022 protectionRule,
2023 {
2024 aiApproved,
2025 humanApprovalCount: humanApprovals,
2026 gateResultGreen: hardFailures.length === 0,
2027 hasFailedGates: hardFailures.length > 0,
2028 passingCheckNames: passingNames,
2029 },
2030 required.map((r) => r.checkName)
2031 );
1e162a8Claude2032 if (!decision.allowed) {
2033 return c.redirect(
2034 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2035 decision.reasons.join(" ")
2036 )}`
2037 );
2038 }
2039 }
2040
e883329Claude2041 // Attempt the merge — with auto conflict resolution if needed
2042 const repoDir = getRepoPath(ownerName, repoName);
2043 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
2044 const hasConflicts = mergeCheck && !mergeCheck.passed;
2045
2046 if (hasConflicts && isAiReviewEnabled()) {
2047 // Use Claude to auto-resolve conflicts
2048 const mergeResult = await mergeWithAutoResolve(
2049 ownerName,
2050 repoName,
2051 pr.baseBranch,
2052 pr.headBranch,
2053 `Merge pull request #${pr.number}: ${pr.title}`
2054 );
2055
2056 if (!mergeResult.success) {
2057 return c.redirect(
2058 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
2059 );
2060 }
2061
2062 // Post a comment about the auto-resolution
2063 if (mergeResult.resolvedFiles.length > 0) {
2064 await db.insert(prComments).values({
2065 pullRequestId: pr.id,
2066 authorId: user.id,
2067 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
2068 isAiReview: true,
2069 });
2070 }
2071 } else {
2072 // Standard merge — fast-forward or clean merge
2073 const ffProc = Bun.spawn(
2074 [
2075 "git",
2076 "update-ref",
2077 `refs/heads/${pr.baseBranch}`,
2078 `refs/heads/${pr.headBranch}`,
2079 ],
2080 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2081 );
2082 const ffExit = await ffProc.exited;
2083
2084 if (ffExit !== 0) {
2085 return c.redirect(
2086 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
2087 );
2088 }
2089 }
2090
0074234Claude2091 await db
2092 .update(pullRequests)
2093 .set({
2094 state: "merged",
2095 mergedAt: new Date(),
2096 mergedBy: user.id,
2097 updatedAt: new Date(),
2098 })
2099 .where(eq(pullRequests.id, pr.id));
2100
d62fb36Claude2101 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
2102 // and auto-close each matching open issue with a back-link comment. Bounded
2103 // to the same repo for v1 (cross-repo refs ignored). Failures never block
2104 // the merge redirect.
2105 try {
2106 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
2107 const refs = extractClosingRefsMulti([pr.title, pr.body]);
2108 for (const n of refs) {
2109 const [issue] = await db
2110 .select()
2111 .from(issues)
2112 .where(
2113 and(
2114 eq(issues.repositoryId, resolved.repo.id),
2115 eq(issues.number, n)
2116 )
2117 )
2118 .limit(1);
2119 if (!issue || issue.state !== "open") continue;
2120 await db
2121 .update(issues)
2122 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
2123 .where(eq(issues.id, issue.id));
2124 await db.insert(issueComments).values({
2125 issueId: issue.id,
2126 authorId: user.id,
2127 body: `Closed by pull request #${pr.number}.`,
2128 });
2129 }
2130 } catch {
2131 // Never block the merge on close-keyword failures.
2132 }
2133
0074234Claude2134 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2135 }
2136);
2137
6fc53bdClaude2138// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
2139// hasn't run yet on this PR.
2140pulls.post(
2141 "/:owner/:repo/pulls/:number/ready",
2142 softAuth,
2143 requireAuth,
04f6b7fClaude2144 requireRepoAccess("write"),
6fc53bdClaude2145 async (c) => {
2146 const { owner: ownerName, repo: repoName } = c.req.param();
2147 const prNum = parseInt(c.req.param("number"), 10);
2148 const user = c.get("user")!;
2149
2150 const resolved = await resolveRepo(ownerName, repoName);
2151 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2152
2153 const [pr] = await db
2154 .select()
2155 .from(pullRequests)
2156 .where(
2157 and(
2158 eq(pullRequests.repositoryId, resolved.repo.id),
2159 eq(pullRequests.number, prNum)
2160 )
2161 )
2162 .limit(1);
2163 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2164
2165 // Only the author or repo owner can toggle draft state.
2166 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2167 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2168 }
2169
2170 if (pr.state === "open" && pr.isDraft) {
2171 await db
2172 .update(pullRequests)
2173 .set({ isDraft: false, updatedAt: new Date() })
2174 .where(eq(pullRequests.id, pr.id));
2175
2176 if (isAiReviewEnabled()) {
2177 triggerAiReview(
2178 ownerName,
2179 repoName,
2180 pr.id,
2181 pr.title,
0316dbbClaude2182 pr.body || "",
6fc53bdClaude2183 pr.baseBranch,
2184 pr.headBranch
2185 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
2186 }
2187 }
2188
2189 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2190 }
2191);
2192
2193// Convert a PR back to draft.
2194pulls.post(
2195 "/:owner/:repo/pulls/:number/draft",
2196 softAuth,
2197 requireAuth,
04f6b7fClaude2198 requireRepoAccess("write"),
6fc53bdClaude2199 async (c) => {
2200 const { owner: ownerName, repo: repoName } = c.req.param();
2201 const prNum = parseInt(c.req.param("number"), 10);
2202 const user = c.get("user")!;
2203
2204 const resolved = await resolveRepo(ownerName, repoName);
2205 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2206
2207 const [pr] = await db
2208 .select()
2209 .from(pullRequests)
2210 .where(
2211 and(
2212 eq(pullRequests.repositoryId, resolved.repo.id),
2213 eq(pullRequests.number, prNum)
2214 )
2215 )
2216 .limit(1);
2217 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2218
2219 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2220 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2221 }
2222
2223 if (pr.state === "open" && !pr.isDraft) {
2224 await db
2225 .update(pullRequests)
2226 .set({ isDraft: true, updatedAt: new Date() })
2227 .where(eq(pullRequests.id, pr.id));
2228 }
2229
2230 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2231 }
2232);
2233
0074234Claude2234// Close PR
2235pulls.post(
2236 "/:owner/:repo/pulls/:number/close",
2237 softAuth,
2238 requireAuth,
04f6b7fClaude2239 requireRepoAccess("write"),
0074234Claude2240 async (c) => {
2241 const { owner: ownerName, repo: repoName } = c.req.param();
2242 const prNum = parseInt(c.req.param("number"), 10);
2243
2244 const resolved = await resolveRepo(ownerName, repoName);
2245 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2246
2247 await db
2248 .update(pullRequests)
2249 .set({
2250 state: "closed",
2251 closedAt: new Date(),
2252 updatedAt: new Date(),
2253 })
2254 .where(
2255 and(
2256 eq(pullRequests.repositoryId, resolved.repo.id),
2257 eq(pullRequests.number, prNum)
2258 )
2259 );
2260
2261 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2262 }
2263);
2264
c3e0c07Claude2265// Re-run AI review on demand (e.g. after a force-push). Bypasses the
2266// idempotency marker via { force: true }. Write-access only.
2267pulls.post(
2268 "/:owner/:repo/pulls/:number/ai-rereview",
2269 softAuth,
2270 requireAuth,
2271 requireRepoAccess("write"),
2272 async (c) => {
2273 const { owner: ownerName, repo: repoName } = c.req.param();
2274 const prNum = parseInt(c.req.param("number"), 10);
2275 const resolved = await resolveRepo(ownerName, repoName);
2276 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2277
2278 const [pr] = await db
2279 .select()
2280 .from(pullRequests)
2281 .where(
2282 and(
2283 eq(pullRequests.repositoryId, resolved.repo.id),
2284 eq(pullRequests.number, prNum)
2285 )
2286 )
2287 .limit(1);
2288 if (!pr) {
2289 return c.redirect(`/${ownerName}/${repoName}/pulls`);
2290 }
2291
2292 if (!isAiReviewEnabled()) {
2293 return c.redirect(
2294 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2295 "AI review is not configured (ANTHROPIC_API_KEY)."
2296 )}`
2297 );
2298 }
2299
2300 // Fire-and-forget but with { force: true } to bypass the
2301 // already-reviewed marker. The function still never throws.
2302 triggerAiReview(
2303 ownerName,
2304 repoName,
2305 pr.id,
2306 pr.title || "",
2307 pr.body || "",
2308 pr.baseBranch,
2309 pr.headBranch,
2310 { force: true }
a28cedeClaude2311 ).catch((err) => {
2312 console.warn(
2313 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
2314 err instanceof Error ? err.message : err
2315 );
2316 });
c3e0c07Claude2317
2318 return c.redirect(
2319 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
2320 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
2321 )}`
2322 );
2323 }
2324);
2325
0074234Claude2326export default pulls;