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.tsxBlame2417 lines · 3 contributors
0074234Claude1/**
2 * Pull request routes — create, list, view, merge, close, comment.
b078860Claude3 *
4 * The list view (`GET /:owner/:repo/pulls`) and detail view
5 * (`GET /:owner/:repo/pulls/:number`) carry the 2026 polish: hero with
6 * gradient title + hairline strip, pill-style state tabs, soft-lift
7 * row cards, conversation thread with AI-review accent border, distinct
8 * gate-check rows, and a gradient-bordered "Merge pull request" button.
9 *
10 * All visual styling is scoped via `.prs-*` class prefixes inside inline
11 * <style> blocks so other surfaces are untouched. No business logic was
12 * changed in this polish pass — AI review triggers, auto-merge wiring,
13 * gate evaluation, and the merge handler are preserved exactly.
0074234Claude14 */
15
16import { Hono } from "hono";
17import { eq, and, desc, asc, sql } from "drizzle-orm";
18import { db } from "../db";
19import {
20 pullRequests,
21 prComments,
22 repositories,
23 users,
d62fb36Claude24 issues,
25 issueComments,
0074234Claude26} from "../db/schema";
27import { Layout } from "../views/layout";
ea9ed4cClaude28import { RepoHeader } from "../views/components";
29import { DiffView } from "../views/diff-view";
6fc53bdClaude30import { ReactionsBar } from "../views/reactions";
31import { summariseReactions } from "../lib/reactions";
24cf2caClaude32import { loadPrTemplate } from "../lib/templates";
0074234Claude33import { renderMarkdown } from "../lib/markdown";
b584e52Claude34import { liveCommentBannerScript } from "../lib/sse-client";
0074234Claude35import { softAuth, requireAuth } from "../middleware/auth";
36import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude37import { requireRepoAccess } from "../middleware/repo-access";
0316dbbClaude38import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
39import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude40import { generatePrSummary } from "../lib/ai-generators";
41import { isAiAvailable } from "../lib/ai-client";
534f04aClaude42import {
43 computePrRiskForPullRequest,
44 getCachedPrRisk,
45 type PrRiskScore,
46} from "../lib/pr-risk";
0316dbbClaude47import { runAllGateChecks } from "../lib/gate";
48import type { GateCheckResult } from "../lib/gate";
49import {
50 matchProtection,
51 countHumanApprovals,
52 listRequiredChecks,
53 passingCheckNames,
54 evaluateProtection,
55} from "../lib/branch-protection";
56import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude57import {
58 listBranches,
59 getRepoPath,
e883329Claude60 resolveRef,
0074234Claude61} from "../git/repository";
62import type { GitDiffFile } from "../git/repository";
63import { html } from "hono/html";
1e162a8Claude64import {
bb0f894Claude65 Flex,
66 Container,
67 Badge,
68 Button,
69 LinkButton,
70 Form,
71 FormGroup,
72 Input,
73 TextArea,
74 Select,
75 EmptyState,
76 FilterTabs,
77 TabNav,
78 List,
79 ListItem,
80 Text,
81 Alert,
82 MarkdownContent,
83 CommentBox,
84 formatRelative,
85} from "../views/ui";
0074234Claude86
87const pulls = new Hono<AuthEnv>();
88
b078860Claude89/* ──────────────────────────────────────────────────────────────────────
90 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
91 * into the issue tracker or any other route. Tokens come from layout.tsx
92 * `:root` so light/dark stays consistent if/when light mode lands.
93 * ──────────────────────────────────────────────────────────────────── */
94const PRS_LIST_STYLES = `
95 .prs-hero {
96 position: relative;
97 margin: 0 0 var(--space-5);
98 padding: 22px 26px 24px;
99 background: var(--bg-elevated);
100 border: 1px solid var(--border);
101 border-radius: 16px;
102 overflow: hidden;
103 }
104 .prs-hero::before {
105 content: '';
106 position: absolute; top: 0; left: 0; right: 0;
107 height: 2px;
108 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
109 opacity: 0.7;
110 pointer-events: none;
111 }
112 .prs-hero-inner {
113 position: relative;
114 display: flex;
115 justify-content: space-between;
116 align-items: flex-end;
117 gap: 20px;
118 flex-wrap: wrap;
119 }
120 .prs-hero-text { flex: 1; min-width: 280px; }
121 .prs-hero-eyebrow {
122 font-size: 12px;
123 color: var(--text-muted);
124 text-transform: uppercase;
125 letter-spacing: 0.08em;
126 font-weight: 600;
127 margin-bottom: 8px;
128 }
129 .prs-hero-title {
130 font-family: var(--font-display);
131 font-size: clamp(26px, 3.4vw, 34px);
132 font-weight: 800;
133 letter-spacing: -0.025em;
134 line-height: 1.06;
135 margin: 0 0 8px;
136 color: var(--text-strong);
137 }
138 .prs-hero-title .gradient-text {
139 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
140 -webkit-background-clip: text;
141 background-clip: text;
142 -webkit-text-fill-color: transparent;
143 color: transparent;
144 }
145 .prs-hero-sub {
146 font-size: 14.5px;
147 color: var(--text-muted);
148 margin: 0;
149 line-height: 1.5;
150 max-width: 620px;
151 }
152 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
153 .prs-cta {
154 display: inline-flex; align-items: center; gap: 6px;
155 padding: 10px 16px;
156 border-radius: 10px;
157 font-size: 13.5px;
158 font-weight: 600;
159 color: #fff;
160 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
161 border: 1px solid rgba(140,109,255,0.55);
162 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
163 text-decoration: none;
164 transition: transform 120ms ease, box-shadow 160ms ease;
165 }
166 .prs-cta:hover {
167 transform: translateY(-1px);
168 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
169 color: #fff;
170 }
171
172 .prs-tabs {
173 display: flex; flex-wrap: wrap; gap: 6px;
174 margin: 0 0 18px;
175 padding: 6px;
176 background: var(--bg-secondary);
177 border: 1px solid var(--border);
178 border-radius: 12px;
179 }
180 .prs-tab {
181 display: inline-flex; align-items: center; gap: 8px;
182 padding: 7px 13px;
183 font-size: 13px;
184 font-weight: 500;
185 color: var(--text-muted);
186 border-radius: 8px;
187 text-decoration: none;
188 transition: background 120ms ease, color 120ms ease;
189 }
190 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
191 .prs-tab.is-active {
192 background: var(--bg-elevated);
193 color: var(--text-strong);
194 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
195 }
196 .prs-tab-count {
197 display: inline-flex; align-items: center; justify-content: center;
198 min-width: 22px; padding: 2px 7px;
199 font-size: 11.5px;
200 font-weight: 600;
201 border-radius: 9999px;
202 background: var(--bg-tertiary);
203 color: var(--text-muted);
204 }
205 .prs-tab.is-active .prs-tab-count {
206 background: rgba(140,109,255,0.18);
207 color: var(--text-link);
208 }
209
210 .prs-list { display: flex; flex-direction: column; gap: 10px; }
211 .prs-row {
212 position: relative;
213 display: flex; align-items: flex-start; gap: 14px;
214 padding: 14px 16px;
215 background: var(--bg-elevated);
216 border: 1px solid var(--border);
217 border-radius: 12px;
218 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
219 }
220 .prs-row:hover {
221 transform: translateY(-1px);
222 border-color: var(--border-strong);
223 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
224 }
225 .prs-row-icon {
226 flex: 0 0 auto;
227 width: 26px; height: 26px;
228 display: inline-flex; align-items: center; justify-content: center;
229 border-radius: 9999px;
230 font-size: 13px;
231 margin-top: 2px;
232 }
233 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
234 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
235 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
236 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
237 .prs-row-body { flex: 1; min-width: 0; }
238 .prs-row-title {
239 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
240 font-size: 15px; font-weight: 600;
241 color: var(--text-strong);
242 line-height: 1.35;
243 margin: 0 0 6px;
244 }
245 .prs-row-number {
246 color: var(--text-muted);
247 font-weight: 400;
248 font-size: 14px;
249 }
250 .prs-row-meta {
251 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
252 font-size: 12.5px;
253 color: var(--text-muted);
254 }
255 .prs-branch-chips {
256 display: inline-flex; align-items: center; gap: 6px;
257 font-family: var(--font-mono);
258 font-size: 11.5px;
259 }
260 .prs-branch-chip {
261 padding: 2px 8px;
262 border-radius: 9999px;
263 background: var(--bg-tertiary);
264 border: 1px solid var(--border);
265 color: var(--text);
266 }
267 .prs-branch-arrow {
268 color: var(--text-faint);
269 font-size: 11px;
270 }
271 .prs-row-tags {
272 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
273 margin-left: auto;
274 }
275 .prs-tag {
276 display: inline-flex; align-items: center; gap: 4px;
277 padding: 2px 8px;
278 font-size: 11px;
279 font-weight: 600;
280 border-radius: 9999px;
281 border: 1px solid var(--border);
282 background: var(--bg-secondary);
283 color: var(--text-muted);
284 line-height: 1.6;
285 }
286 .prs-tag.is-draft {
287 color: var(--text-muted);
288 border-color: var(--border-strong);
289 }
290 .prs-tag.is-merged {
291 color: var(--text-link);
292 border-color: rgba(140,109,255,0.45);
293 background: rgba(140,109,255,0.10);
294 }
295
296 .prs-empty {
ea9ed4cClaude297 position: relative;
298 padding: 56px 32px;
b078860Claude299 text-align: center;
300 border: 1px dashed var(--border);
ea9ed4cClaude301 border-radius: 16px;
302 background: var(--bg-elevated);
b078860Claude303 color: var(--text-muted);
ea9ed4cClaude304 overflow: hidden;
b078860Claude305 }
ea9ed4cClaude306 .prs-empty::before {
307 content: '';
308 position: absolute;
309 inset: -40% -20% auto auto;
310 width: 320px; height: 320px;
311 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
312 filter: blur(70px);
313 opacity: 0.55;
314 pointer-events: none;
315 animation: prsEmptyOrb 16s ease-in-out infinite;
316 }
317 @keyframes prsEmptyOrb {
318 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
319 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
320 }
321 @media (prefers-reduced-motion: reduce) {
322 .prs-empty::before { animation: none; }
323 }
324 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude325 .prs-empty strong {
326 display: block;
327 color: var(--text-strong);
ea9ed4cClaude328 font-family: var(--font-display);
329 font-size: 22px;
330 font-weight: 700;
331 letter-spacing: -0.018em;
332 margin-bottom: 2px;
333 }
334 .prs-empty-sub {
335 font-size: 14.5px;
336 color: var(--text-muted);
337 line-height: 1.55;
338 max-width: 460px;
339 margin: 0 0 18px;
b078860Claude340 }
ea9ed4cClaude341 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude342
343 @media (max-width: 720px) {
344 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
345 .prs-hero-actions { width: 100%; }
346 .prs-row-tags { margin-left: 0; }
347 }
348`;
349
350/* ──────────────────────────────────────────────────────────────────────
351 * Inline CSS for the detail page. Same `.prs-*` namespace.
352 * ──────────────────────────────────────────────────────────────────── */
353const PRS_DETAIL_STYLES = `
354 .prs-detail-hero {
355 position: relative;
356 margin: 0 0 var(--space-4);
357 padding: 24px 26px;
358 background: var(--bg-elevated);
359 border: 1px solid var(--border);
360 border-radius: 16px;
361 overflow: hidden;
362 }
363 .prs-detail-hero::before {
364 content: '';
365 position: absolute; top: 0; left: 0; right: 0;
366 height: 2px;
367 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
368 opacity: 0.7;
369 pointer-events: none;
370 }
371 .prs-detail-title {
372 font-family: var(--font-display);
373 font-size: clamp(22px, 2.6vw, 28px);
374 font-weight: 700;
375 letter-spacing: -0.022em;
376 line-height: 1.2;
377 color: var(--text-strong);
378 margin: 0 0 12px;
379 }
380 .prs-detail-num {
381 color: var(--text-muted);
382 font-weight: 400;
383 }
384 .prs-state-pill {
385 display: inline-flex; align-items: center; gap: 6px;
386 padding: 6px 12px;
387 border-radius: 9999px;
388 font-size: 12.5px;
389 font-weight: 600;
390 line-height: 1;
391 border: 1px solid transparent;
392 }
393 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
394 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
395 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
396 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
397
398 .prs-detail-meta {
399 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
400 font-size: 13px;
401 color: var(--text-muted);
402 }
403 .prs-detail-meta strong { color: var(--text); }
404 .prs-detail-branches {
405 display: inline-flex; align-items: center; gap: 6px;
406 font-family: var(--font-mono);
407 font-size: 12px;
408 }
409 .prs-branch-pill {
410 padding: 3px 9px;
411 border-radius: 9999px;
412 background: var(--bg-tertiary);
413 border: 1px solid var(--border);
414 color: var(--text);
415 }
416 .prs-branch-pill.is-head { color: var(--text-strong); }
417 .prs-branch-arrow-lg {
418 color: var(--accent);
419 font-size: 14px;
420 font-weight: 700;
421 }
422
423 .prs-detail-actions {
424 display: inline-flex; gap: 8px; margin-left: auto;
425 }
426
427 .prs-detail-tabs {
428 display: flex; gap: 4px;
429 margin: 0 0 16px;
430 border-bottom: 1px solid var(--border);
431 }
432 .prs-detail-tab {
433 padding: 10px 14px;
434 font-size: 13.5px;
435 font-weight: 500;
436 color: var(--text-muted);
437 text-decoration: none;
438 border-bottom: 2px solid transparent;
439 transition: color 120ms ease, border-color 120ms ease;
440 margin-bottom: -1px;
441 }
442 .prs-detail-tab:hover { color: var(--text); }
443 .prs-detail-tab.is-active {
444 color: var(--text-strong);
445 border-bottom-color: var(--accent);
446 }
447 .prs-detail-tab-count {
448 display: inline-flex; align-items: center; justify-content: center;
449 min-width: 20px; padding: 0 6px; margin-left: 6px;
450 height: 18px;
451 font-size: 11px;
452 font-weight: 600;
453 border-radius: 9999px;
454 background: var(--bg-tertiary);
455 color: var(--text-muted);
456 }
457
458 /* Gate / check status section */
459 .prs-gate-card {
460 margin-top: 20px;
461 background: var(--bg-elevated);
462 border: 1px solid var(--border);
463 border-radius: 14px;
464 overflow: hidden;
465 }
466 .prs-gate-head {
467 display: flex; align-items: center; gap: 10px;
468 padding: 14px 18px;
469 border-bottom: 1px solid var(--border);
470 }
471 .prs-gate-head h3 {
472 margin: 0;
473 font-size: 14px;
474 font-weight: 600;
475 color: var(--text-strong);
476 }
477 .prs-gate-summary {
478 margin-left: auto;
479 font-size: 12px;
480 color: var(--text-muted);
481 }
482 .prs-gate-row {
483 display: flex; align-items: center; gap: 12px;
484 padding: 12px 18px;
485 border-bottom: 1px solid var(--border-subtle);
486 }
487 .prs-gate-row:last-child { border-bottom: 0; }
488 .prs-gate-icon {
489 flex: 0 0 auto;
490 width: 22px; height: 22px;
491 display: inline-flex; align-items: center; justify-content: center;
492 border-radius: 9999px;
493 font-size: 12px;
494 font-weight: 700;
495 }
496 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
497 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
498 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
499 .prs-gate-name {
500 font-size: 13px;
501 font-weight: 600;
502 color: var(--text);
503 min-width: 140px;
504 }
505 .prs-gate-details {
506 flex: 1; min-width: 0;
507 font-size: 12.5px;
508 color: var(--text-muted);
509 }
510 .prs-gate-pill {
511 flex: 0 0 auto;
512 padding: 3px 10px;
513 border-radius: 9999px;
514 font-size: 11px;
515 font-weight: 600;
516 line-height: 1.5;
517 border: 1px solid transparent;
518 }
519 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
520 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
521 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
522 .prs-gate-footer {
523 padding: 12px 18px;
524 background: var(--bg-secondary);
525 font-size: 12px;
526 color: var(--text-muted);
527 }
528
529 /* Comment cards */
530 .prs-comment {
531 margin-top: 14px;
532 background: var(--bg-elevated);
533 border: 1px solid var(--border);
534 border-radius: 12px;
535 overflow: hidden;
536 }
537 .prs-comment-head {
538 display: flex; align-items: center; gap: 10px;
539 padding: 10px 14px;
540 background: var(--bg-secondary);
541 border-bottom: 1px solid var(--border);
542 font-size: 13px;
543 flex-wrap: wrap;
544 }
545 .prs-comment-head strong { color: var(--text-strong); }
546 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
547 .prs-comment-loc {
548 font-family: var(--font-mono);
549 font-size: 11.5px;
550 color: var(--text-muted);
551 background: var(--bg-tertiary);
552 padding: 2px 8px;
553 border-radius: 6px;
554 }
555 .prs-comment-body { padding: 14px 18px; }
556 .prs-comment.is-ai {
557 border-color: rgba(140,109,255,0.45);
558 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
559 }
560 .prs-comment.is-ai .prs-comment-head {
561 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
562 border-bottom-color: rgba(140,109,255,0.30);
563 }
564 .prs-ai-badge {
565 display: inline-flex; align-items: center; gap: 4px;
566 padding: 2px 9px;
567 font-size: 10.5px;
568 font-weight: 700;
569 letter-spacing: 0.04em;
570 text-transform: uppercase;
571 color: #fff;
572 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
573 border-radius: 9999px;
574 }
575
576 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
577 .prs-files-card {
578 margin-top: 18px;
579 padding: 14px 18px;
580 display: flex; align-items: center; gap: 14px;
581 background: var(--bg-elevated);
582 border: 1px solid var(--border);
583 border-radius: 12px;
584 text-decoration: none;
585 color: inherit;
586 transition: border-color 120ms ease, transform 140ms ease;
587 }
588 .prs-files-card:hover {
589 border-color: rgba(140,109,255,0.45);
590 transform: translateY(-1px);
591 }
592 .prs-files-card-icon {
593 width: 36px; height: 36px;
594 display: inline-flex; align-items: center; justify-content: center;
595 border-radius: 10px;
596 background: rgba(140,109,255,0.12);
597 color: var(--text-link);
598 font-size: 18px;
599 }
600 .prs-files-card-text { flex: 1; min-width: 0; }
601 .prs-files-card-title {
602 font-size: 14px;
603 font-weight: 600;
604 color: var(--text-strong);
605 margin: 0 0 2px;
606 }
607 .prs-files-card-sub {
608 font-size: 12.5px;
609 color: var(--text-muted);
610 margin: 0;
611 }
612 .prs-files-card-cta {
613 font-size: 12.5px;
614 color: var(--text-link);
615 font-weight: 600;
616 }
617
618 /* Merge area */
619 .prs-merge-card {
620 position: relative;
621 margin-top: 22px;
622 padding: 18px;
623 background: var(--bg-elevated);
624 border-radius: 14px;
625 overflow: hidden;
626 }
627 .prs-merge-card::before {
628 content: '';
629 position: absolute; inset: 0;
630 padding: 1px;
631 border-radius: 14px;
632 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
633 -webkit-mask:
634 linear-gradient(#000 0 0) content-box,
635 linear-gradient(#000 0 0);
636 -webkit-mask-composite: xor;
637 mask-composite: exclude;
638 pointer-events: none;
639 }
640 .prs-merge-card.is-closed::before { background: var(--border-strong); }
641 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
642 .prs-merge-head {
643 display: flex; align-items: center; gap: 12px;
644 margin-bottom: 12px;
645 }
646 .prs-merge-head strong {
647 font-family: var(--font-display);
648 font-size: 15px;
649 color: var(--text-strong);
650 font-weight: 700;
651 }
652 .prs-merge-sub {
653 font-size: 13px;
654 color: var(--text-muted);
655 margin: 0 0 12px;
656 }
657 .prs-merge-actions {
658 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
659 }
660 .prs-merge-btn {
661 display: inline-flex; align-items: center; gap: 6px;
662 padding: 9px 16px;
663 border-radius: 10px;
664 font-size: 13.5px;
665 font-weight: 600;
666 color: #fff;
667 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
668 border: 1px solid rgba(52,211,153,0.55);
669 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
670 cursor: pointer;
671 transition: transform 120ms ease, box-shadow 160ms ease;
672 }
673 .prs-merge-btn:hover {
674 transform: translateY(-1px);
675 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
676 }
677 .prs-merge-btn[disabled],
678 .prs-merge-btn.is-disabled {
679 opacity: 0.55;
680 cursor: not-allowed;
681 transform: none;
682 box-shadow: none;
683 }
684 .prs-merge-ready-btn {
685 display: inline-flex; align-items: center; gap: 6px;
686 padding: 9px 16px;
687 border-radius: 10px;
688 font-size: 13.5px;
689 font-weight: 600;
690 color: #fff;
691 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
692 border: 1px solid rgba(140,109,255,0.55);
693 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
694 cursor: pointer;
695 transition: transform 120ms ease, box-shadow 160ms ease;
696 }
697 .prs-merge-ready-btn:hover {
698 transform: translateY(-1px);
699 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
700 }
701 .prs-merge-back-draft {
702 background: none; border: 1px solid var(--border-strong);
703 color: var(--text-muted);
704 padding: 9px 14px; border-radius: 10px;
705 font-size: 13px; cursor: pointer;
706 }
707 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
708
709 /* Inline form helpers */
710 .prs-inline-form { display: inline-flex; }
711
712 /* Comment composer */
713 .prs-composer { margin-top: 22px; }
714 .prs-composer textarea {
715 border-radius: 12px;
716 }
717
718 @media (max-width: 720px) {
719 .prs-detail-actions { margin-left: 0; }
720 .prs-merge-actions { width: 100%; }
721 .prs-merge-actions > * { flex: 1; min-width: 0; }
722 }
723`;
724
81c73c1Claude725/**
726 * Tiny inline JS that drives the "Suggest description with AI" button.
727 * On click, gathers form values, POSTs JSON to the given endpoint, and
728 * pipes the response into the #pr-body textarea. All DOM lookups are
729 * defensive — element absence is a silent no-op.
730 *
731 * Built as a string template so it lives next to its server-side caller
732 * and there is no bundler dependency. The endpoint URL is JSON-escaped
733 * to avoid </script> breakouts.
734 */
735function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
736 const url = JSON.stringify(endpointUrl)
737 .split("<").join("\\u003C")
738 .split(">").join("\\u003E")
739 .split("&").join("\\u0026");
740 return (
741 "(function(){try{" +
742 "var btn=document.getElementById('ai-suggest-desc');" +
743 "var status=document.getElementById('ai-suggest-status');" +
744 "var body=document.getElementById('pr-body');" +
745 "var form=btn&&btn.closest&&btn.closest('form');" +
746 "if(!btn||!body||!form)return;" +
747 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
748 "var fd=new FormData(form);" +
749 "var title=String(fd.get('title')||'').trim();" +
750 "var base=String(fd.get('base')||'').trim();" +
751 "var head=String(fd.get('head')||'').trim();" +
752 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
753 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
754 "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'})" +
755 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
756 ".then(function(j){btn.disabled=false;" +
757 "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;}}" +
758 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
759 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
760 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
761 "});" +
762 "}catch(e){}})();"
763 );
764}
765
0074234Claude766async function resolveRepo(ownerName: string, repoName: string) {
767 const [owner] = await db
768 .select()
769 .from(users)
770 .where(eq(users.username, ownerName))
771 .limit(1);
772 if (!owner) return null;
773 const [repo] = await db
774 .select()
775 .from(repositories)
776 .where(
777 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
778 )
779 .limit(1);
780 if (!repo) return null;
781 return { owner, repo };
782}
783
784// PR Nav helper
785const PrNav = ({
786 owner,
787 repo,
788 active,
789}: {
790 owner: string;
791 repo: string;
792 active: "code" | "issues" | "pulls" | "commits";
793}) => (
bb0f894Claude794 <TabNav
795 tabs={[
796 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
797 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
798 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
799 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
800 ]}
801 />
0074234Claude802);
803
534f04aClaude804/**
805 * Block M3 — pre-merge risk score card. Pure presentational helper.
806 * Rendered in the conversation tab above the gate checks block. Hidden
807 * entirely when the PR is closed/merged or there is nothing cached and
808 * nothing in-flight.
809 */
810function PrRiskCard({
811 risk,
812 calculating,
813}: {
814 risk: PrRiskScore | null;
815 calculating: boolean;
816}) {
817 if (!risk) {
818 return (
819 <div
820 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
821 >
822 <strong style="font-size: 13px; color: var(--text)">
823 Risk score: calculating…
824 </strong>
825 <div style="font-size: 12px; margin-top: 4px">
826 Refresh in a moment to see the pre-merge risk score for this PR.
827 </div>
828 </div>
829 );
830 }
831
832 const palette = riskBandPalette(risk.band);
833 const label = riskBandLabel(risk.band);
834
835 return (
836 <div
837 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
838 >
839 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
840 <strong>Risk score:</strong>
841 <span style={`color:${palette.border};font-weight:600`}>
842 {palette.icon} {label} ({risk.score}/10)
843 </span>
844 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
845 {risk.commitSha.slice(0, 7)}
846 </span>
847 </div>
848 {risk.aiSummary && (
849 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
850 {risk.aiSummary}
851 </div>
852 )}
853 <details style="margin-top:10px">
854 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
855 See full signal breakdown
856 </summary>
857 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
858 <li>files changed: {risk.signals.filesChanged}</li>
859 <li>
860 lines added/removed: {risk.signals.linesAdded} /{" "}
861 {risk.signals.linesRemoved}
862 </li>
863 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
864 <li>
865 schema migration touched:{" "}
866 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
867 </li>
868 <li>
869 locked / sensitive path touched:{" "}
870 {risk.signals.lockedPathTouched ? "yes" : "no"}
871 </li>
872 <li>
873 adds new dependency:{" "}
874 {risk.signals.addsNewDependency ? "yes" : "no"}
875 </li>
876 <li>
877 bumps major dependency:{" "}
878 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
879 </li>
880 <li>
881 tests added for new code:{" "}
882 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
883 </li>
884 <li>
885 diff-minus-test ratio:{" "}
886 {risk.signals.diffMinusTestRatio.toFixed(2)}
887 </li>
888 </ul>
889 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
890 How is this calculated? The score is a transparent sum of
891 weighted signals — see <code>src/lib/pr-risk.ts</code>
892 {" "}<code>computePrRiskScore</code>.
893 </div>
894 </details>
895 {calculating && (
896 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
897 (recomputing for the latest commit — refresh to update)
898 </div>
899 )}
900 </div>
901 );
902}
903
904function riskBandPalette(band: PrRiskScore["band"]): {
905 border: string;
906 icon: string;
907} {
908 switch (band) {
909 case "low":
910 return { border: "var(--green)", icon: "" };
911 case "medium":
912 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
913 case "high":
914 return { border: "var(--orange, #db6d28)", icon: "⚠" };
915 case "critical":
916 return { border: "var(--red)", icon: "\u{1F6D1}" };
917 }
918}
919
920function riskBandLabel(band: PrRiskScore["band"]): string {
921 switch (band) {
922 case "low":
923 return "LOW";
924 case "medium":
925 return "MEDIUM";
926 case "high":
927 return "HIGH";
928 case "critical":
929 return "CRITICAL";
930 }
931}
932
0074234Claude933// List PRs
04f6b7fClaude934pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude935 const { owner: ownerName, repo: repoName } = c.req.param();
936 const user = c.get("user");
937 const state = c.req.query("state") || "open";
938
ea9ed4cClaude939 // ── Loading skeleton (flag-gated) ──
940 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
941 // the user see the page structure before counts + select resolve.
942 // Behind a flag for now — we don't ship flashes.
943 if (c.req.query("skeleton") === "1") {
944 return c.html(
945 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
946 <RepoHeader owner={ownerName} repo={repoName} />
947 <PrNav owner={ownerName} repo={repoName} active="pulls" />
948 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
949 <style
950 dangerouslySetInnerHTML={{
951 __html: `
952 .prs-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: prsSkelShimmer 1.4s infinite; border-radius: 6px; display: block; }
953 @keyframes prsSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
954 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
955 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
956 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
957 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
958 .prs-skel-row { height: 76px; border-radius: 12px; }
959 `,
960 }}
961 />
962 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
963 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
964 <div class="prs-skel-list" aria-hidden="true">
965 {Array.from({ length: 6 }).map(() => (
966 <div class="prs-skel prs-skel-row" />
967 ))}
968 </div>
969 <span style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0" role="status" aria-live="polite">
970 Loading pull requests for {ownerName}/{repoName}…
971 </span>
972 </Layout>
973 );
974 }
975
0074234Claude976 const resolved = await resolveRepo(ownerName, repoName);
977 if (!resolved) return c.notFound();
978
6fc53bdClaude979 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
980 const stateFilter =
981 state === "draft"
982 ? and(
983 eq(pullRequests.state, "open"),
984 eq(pullRequests.isDraft, true)
985 )
986 : eq(pullRequests.state, state);
987
0074234Claude988 const prList = await db
989 .select({
990 pr: pullRequests,
991 author: { username: users.username },
992 })
993 .from(pullRequests)
994 .innerJoin(users, eq(pullRequests.authorId, users.id))
995 .where(
6fc53bdClaude996 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude997 )
998 .orderBy(desc(pullRequests.createdAt));
999
1000 const [counts] = await db
1001 .select({
1002 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude1003 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude1004 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
1005 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
1006 })
1007 .from(pullRequests)
1008 .where(eq(pullRequests.repositoryId, resolved.repo.id));
1009
b078860Claude1010 const openCount = counts?.open ?? 0;
1011 const mergedCount = counts?.merged ?? 0;
1012 const closedCount = counts?.closed ?? 0;
1013 const draftCount = counts?.draft ?? 0;
1014 const allCount = openCount + mergedCount + closedCount;
1015
1016 // "All" is presentational only — the DB query for state='all' matches
1017 // nothing, so we render a friendlier empty state when picked. We do NOT
1018 // change the query logic to keep this commit purely visual.
1019 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
1020 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
1021 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
1022 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
1023 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
1024 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
1025 ];
1026 const isAllState = state === "all";
1027
0074234Claude1028 return c.html(
1029 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1030 <RepoHeader owner={ownerName} repo={repoName} />
1031 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude1032 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1033
1034 <div class="prs-hero">
1035 <div class="prs-hero-inner">
1036 <div class="prs-hero-text">
1037 <div class="prs-hero-eyebrow">Pull requests</div>
1038 <h1 class="prs-hero-title">
1039 Review, <span class="gradient-text">merge with AI</span>.
1040 </h1>
1041 <p class="prs-hero-sub">
1042 {openCount === 0 && allCount === 0
1043 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
1044 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
1045 </p>
1046 </div>
1047 {user && (
1048 <div class="prs-hero-actions">
1049 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
1050 + New pull request
1051 </a>
1052 </div>
1053 )}
1054 </div>
1055 </div>
1056
1057 <nav class="prs-tabs" aria-label="Pull request filters">
1058 {tabPills.map((t) => {
1059 const isActive =
1060 state === t.key ||
1061 (t.key === "open" &&
1062 state !== "merged" &&
1063 state !== "closed" &&
1064 state !== "all" &&
1065 state !== "draft");
1066 return (
1067 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
1068 <span>{t.label}</span>
1069 <span class="prs-tab-count">{t.count}</span>
1070 </a>
1071 );
1072 })}
1073 </nav>
1074
0074234Claude1075 {prList.length === 0 ? (
b078860Claude1076 <div class="prs-empty">
ea9ed4cClaude1077 <div class="prs-empty-inner">
1078 <strong>
1079 {isAllState
1080 ? "Pick a filter above to browse PRs."
1081 : `No ${state} pull requests.`}
1082 </strong>
1083 <p class="prs-empty-sub">
1084 {state === "open"
1085 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
1086 : isAllState
1087 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1088 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
1089 </p>
1090 <div class="prs-empty-cta">
1091 {user && state === "open" && (
1092 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
1093 + New pull request
1094 </a>
1095 )}
1096 {state !== "open" && (
1097 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
1098 View open PRs
1099 </a>
1100 )}
1101 <a href={`/${ownerName}/${repoName}`} class="btn">
1102 Back to code
1103 </a>
1104 </div>
1105 </div>
b078860Claude1106 </div>
0074234Claude1107 ) : (
b078860Claude1108 <div class="prs-list">
1109 {prList.map(({ pr, author }) => {
1110 const stateClass =
1111 pr.state === "open"
1112 ? pr.isDraft
1113 ? "state-draft"
1114 : "state-open"
1115 : pr.state === "merged"
1116 ? "state-merged"
1117 : "state-closed";
1118 const icon =
1119 pr.state === "open"
1120 ? pr.isDraft
1121 ? "◌"
1122 : "○"
1123 : pr.state === "merged"
1124 ? "⮌"
1125 : "✓";
1126 return (
1127 <a
1128 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1129 class="prs-row"
1130 style="text-decoration:none;color:inherit"
0074234Claude1131 >
b078860Claude1132 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
1133 {icon}
0074234Claude1134 </div>
b078860Claude1135 <div class="prs-row-body">
1136 <h3 class="prs-row-title">
1137 <span>{pr.title}</span>
1138 <span class="prs-row-number">#{pr.number}</span>
1139 </h3>
1140 <div class="prs-row-meta">
1141 <span
1142 class="prs-branch-chips"
1143 title={`${pr.headBranch} into ${pr.baseBranch}`}
1144 >
1145 <span class="prs-branch-chip">{pr.headBranch}</span>
1146 <span class="prs-branch-arrow">{"→"}</span>
1147 <span class="prs-branch-chip">{pr.baseBranch}</span>
1148 </span>
1149 <span>
1150 by{" "}
1151 <strong style="color:var(--text)">
1152 {author.username}
1153 </strong>{" "}
1154 {formatRelative(pr.createdAt)}
1155 </span>
1156 <span class="prs-row-tags">
1157 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
1158 {pr.state === "merged" && (
1159 <span class="prs-tag is-merged">Merged</span>
1160 )}
1161 </span>
1162 </div>
0074234Claude1163 </div>
b078860Claude1164 </a>
1165 );
1166 })}
1167 </div>
0074234Claude1168 )}
1169 </Layout>
1170 );
1171});
1172
1173// New PR form
1174pulls.get(
1175 "/:owner/:repo/pulls/new",
1176 softAuth,
1177 requireAuth,
04f6b7fClaude1178 requireRepoAccess("write"),
0074234Claude1179 async (c) => {
1180 const { owner: ownerName, repo: repoName } = c.req.param();
1181 const user = c.get("user")!;
1182 const branches = await listBranches(ownerName, repoName);
1183 const error = c.req.query("error");
1184 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude1185 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude1186
1187 return c.html(
1188 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
1189 <RepoHeader owner={ownerName} repo={repoName} />
1190 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude1191 <Container maxWidth={800}>
1192 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude1193 {error && (
bb0f894Claude1194 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude1195 )}
0316dbbClaude1196 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
1197 <Flex gap={12} align="center" style="margin-bottom: 16px">
1198 <Select name="base">
0074234Claude1199 {branches.map((b) => (
1200 <option value={b} selected={b === defaultBase}>
1201 {b}
1202 </option>
1203 ))}
bb0f894Claude1204 </Select>
1205 <Text muted>&larr;</Text>
1206 <Select name="head">
0074234Claude1207 {branches
1208 .filter((b) => b !== defaultBase)
1209 .concat(defaultBase === branches[0] ? [] : [branches[0]])
1210 .map((b) => (
1211 <option value={b}>{b}</option>
1212 ))}
bb0f894Claude1213 </Select>
1214 </Flex>
1215 <FormGroup>
1216 <Input
0074234Claude1217 name="title"
1218 required
1219 placeholder="Title"
bb0f894Claude1220 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]1221 aria-label="Pull request title"
0074234Claude1222 />
bb0f894Claude1223 </FormGroup>
1224 <FormGroup>
1225 <TextArea
0074234Claude1226 name="body"
81c73c1Claude1227 id="pr-body"
0074234Claude1228 rows={8}
1229 placeholder="Description (Markdown supported)"
bb0f894Claude1230 mono
0074234Claude1231 />
bb0f894Claude1232 </FormGroup>
81c73c1Claude1233 <Flex gap={8} align="center">
1234 <Button type="submit" variant="primary">
1235 Create pull request
1236 </Button>
1237 <button
1238 type="button"
1239 id="ai-suggest-desc"
1240 class="btn"
1241 style="font-weight:500"
1242 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
1243 >
1244 Suggest description with AI
1245 </button>
1246 <span
1247 id="ai-suggest-status"
1248 style="color:var(--text-muted);font-size:13px"
1249 />
1250 </Flex>
bb0f894Claude1251 </Form>
81c73c1Claude1252 <script
1253 dangerouslySetInnerHTML={{
1254 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
1255 }}
1256 />
bb0f894Claude1257 </Container>
0074234Claude1258 </Layout>
1259 );
1260 }
1261);
1262
81c73c1Claude1263// AI-suggested PR description — JSON endpoint driven by the form button.
1264// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
1265// 200; the inline script reads `ok` to decide what to do.
1266pulls.post(
1267 "/:owner/:repo/ai/pr-description",
1268 softAuth,
1269 requireAuth,
1270 requireRepoAccess("write"),
1271 async (c) => {
1272 const { owner: ownerName, repo: repoName } = c.req.param();
1273 if (!isAiAvailable()) {
1274 return c.json({
1275 ok: false,
1276 error: "AI is not available — set ANTHROPIC_API_KEY.",
1277 });
1278 }
1279 const body = await c.req.parseBody();
1280 const title = String(body.title || "").trim();
1281 const baseBranch = String(body.base || "").trim();
1282 const headBranch = String(body.head || "").trim();
1283 if (!baseBranch || !headBranch) {
1284 return c.json({ ok: false, error: "Pick base + head branches first." });
1285 }
1286 if (baseBranch === headBranch) {
1287 return c.json({ ok: false, error: "Base and head must differ." });
1288 }
1289
1290 let diff = "";
1291 try {
1292 const cwd = getRepoPath(ownerName, repoName);
1293 const proc = Bun.spawn(
1294 [
1295 "git",
1296 "diff",
1297 `${baseBranch}...${headBranch}`,
1298 "--",
1299 ],
1300 { cwd, stdout: "pipe", stderr: "pipe" }
1301 );
6ea2109Claude1302 // 30s ceiling — without this a pathological diff (huge binary or
1303 // a corrupt ref) hangs the request indefinitely.
1304 const killer = setTimeout(() => proc.kill(), 30_000);
1305 try {
1306 diff = await new Response(proc.stdout).text();
1307 await proc.exited;
1308 } finally {
1309 clearTimeout(killer);
1310 }
81c73c1Claude1311 } catch {
1312 diff = "";
1313 }
1314 if (!diff.trim()) {
1315 return c.json({
1316 ok: false,
1317 error: "No diff between branches — nothing to summarise.",
1318 });
1319 }
1320
1321 let summary = "";
1322 try {
1323 summary = await generatePrSummary(title || "(untitled)", diff);
1324 } catch (err) {
1325 const msg = err instanceof Error ? err.message : "AI request failed.";
1326 return c.json({ ok: false, error: msg });
1327 }
1328 if (!summary.trim()) {
1329 return c.json({ ok: false, error: "AI returned an empty draft." });
1330 }
1331 return c.json({ ok: true, body: summary });
1332 }
1333);
1334
0074234Claude1335// Create PR
1336pulls.post(
1337 "/:owner/:repo/pulls/new",
1338 softAuth,
1339 requireAuth,
04f6b7fClaude1340 requireRepoAccess("write"),
0074234Claude1341 async (c) => {
1342 const { owner: ownerName, repo: repoName } = c.req.param();
1343 const user = c.get("user")!;
1344 const body = await c.req.parseBody();
1345 const title = String(body.title || "").trim();
1346 const prBody = String(body.body || "").trim();
1347 const baseBranch = String(body.base || "main");
1348 const headBranch = String(body.head || "");
1349
1350 if (!title || !headBranch) {
1351 return c.redirect(
1352 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
1353 );
1354 }
1355
1356 if (baseBranch === headBranch) {
1357 return c.redirect(
1358 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
1359 );
1360 }
1361
1362 const resolved = await resolveRepo(ownerName, repoName);
1363 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1364
6fc53bdClaude1365 const isDraft = String(body.draft || "") === "1";
1366
0074234Claude1367 const [pr] = await db
1368 .insert(pullRequests)
1369 .values({
1370 repositoryId: resolved.repo.id,
1371 authorId: user.id,
1372 title,
1373 body: prBody || null,
1374 baseBranch,
1375 headBranch,
6fc53bdClaude1376 isDraft,
0074234Claude1377 })
1378 .returning();
1379
6fc53bdClaude1380 // Skip AI review on drafts — it runs again when the PR is marked ready.
1381 if (!isDraft && isAiReviewEnabled()) {
e883329Claude1382 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
1383 (err) => console.error("[ai-review] Failed:", err)
1384 );
1385 }
1386
3cbe3d6Claude1387 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
1388 triggerPrTriage({
1389 ownerName,
1390 repoName,
1391 repositoryId: resolved.repo.id,
1392 prId: pr.id,
1393 prAuthorId: user.id,
1394 title,
1395 body: prBody,
1396 baseBranch,
1397 headBranch,
1398 }).catch((err) => console.error("[pr-triage] Failed:", err));
1399
9dd96b9Test User1400 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude1401 import("../lib/auto-merge")
1402 .then((m) => m.tryAutoMergeNow(pr.id))
1403 .catch((err) => {
1404 console.warn(
1405 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
1406 err instanceof Error ? err.message : err
1407 );
1408 });
9dd96b9Test User1409
0074234Claude1410 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
1411 }
1412);
1413
1414// View single PR
04f6b7fClaude1415pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1416 const { owner: ownerName, repo: repoName } = c.req.param();
1417 const prNum = parseInt(c.req.param("number"), 10);
1418 const user = c.get("user");
1419 const tab = c.req.query("tab") || "conversation";
1420
1421 const resolved = await resolveRepo(ownerName, repoName);
1422 if (!resolved) return c.notFound();
1423
1424 const [pr] = await db
1425 .select()
1426 .from(pullRequests)
1427 .where(
1428 and(
1429 eq(pullRequests.repositoryId, resolved.repo.id),
1430 eq(pullRequests.number, prNum)
1431 )
1432 )
1433 .limit(1);
1434
1435 if (!pr) return c.notFound();
1436
1437 const [author] = await db
1438 .select()
1439 .from(users)
1440 .where(eq(users.id, pr.authorId))
1441 .limit(1);
1442
1443 const comments = await db
1444 .select({
1445 comment: prComments,
1446 author: { username: users.username },
1447 })
1448 .from(prComments)
1449 .innerJoin(users, eq(prComments.authorId, users.id))
1450 .where(eq(prComments.pullRequestId, pr.id))
1451 .orderBy(asc(prComments.createdAt));
1452
6fc53bdClaude1453 // Reactions for the PR body + each comment, in parallel.
1454 const [prReactions, ...prCommentReactions] = await Promise.all([
1455 summariseReactions("pr", pr.id, user?.id),
1456 ...comments.map((row) =>
1457 summariseReactions("pr_comment", row.comment.id, user?.id)
1458 ),
1459 ]);
1460
0074234Claude1461 const canManage =
1462 user &&
1463 (user.id === resolved.owner.id || user.id === pr.authorId);
1464
e883329Claude1465 const error = c.req.query("error");
c3e0c07Claude1466 const info = c.req.query("info");
e883329Claude1467
1468 // Get gate check status for open PRs
1469 let gateChecks: GateCheckResult[] = [];
1470 if (pr.state === "open") {
1471 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
1472 if (headSha) {
1473 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
1474 const aiApproved = aiComments.length === 0 || aiComments.some(
1475 ({ comment }) => comment.body.includes("**Approved**")
1476 );
1477 const gateResult = await runAllGateChecks(
1478 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
1479 );
1480 gateChecks = gateResult.checks;
1481 }
1482 }
1483
534f04aClaude1484 // Block M3 — pre-merge risk score. Cache-only on the request path so
1485 // the page never waits on Haiku. On a cache miss for an open PR we
1486 // kick off the computation fire-and-forget; the next refresh shows it.
1487 let prRisk: PrRiskScore | null = null;
1488 let prRiskCalculating = false;
1489 if (pr.state === "open") {
1490 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
1491 if (!prRisk) {
1492 prRiskCalculating = true;
a28cedeClaude1493 void computePrRiskForPullRequest(pr.id).catch((err) => {
1494 console.warn(
1495 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
1496 err instanceof Error ? err.message : err
1497 );
1498 });
534f04aClaude1499 }
1500 }
1501
0074234Claude1502 // Get diff for "Files changed" tab
1503 let diffRaw = "";
1504 let diffFiles: GitDiffFile[] = [];
1505 if (tab === "files") {
1506 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude1507 // Run the two git diffs in parallel — they're independent reads of
1508 // the same range. Previously sequential, doubling the wall time on
1509 // big PRs (100+ files = 10-30s for no reason).
0074234Claude1510 const proc = Bun.spawn(
1511 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
1512 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1513 );
1514 const statProc = Bun.spawn(
1515 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
1516 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1517 );
6ea2109Claude1518 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
1519 // would otherwise hang the whole request.
1520 const killer = setTimeout(() => {
1521 proc.kill();
1522 statProc.kill();
1523 }, 30_000);
1524 let stat = "";
1525 try {
1526 [diffRaw, stat] = await Promise.all([
1527 new Response(proc.stdout).text(),
1528 new Response(statProc.stdout).text(),
1529 ]);
1530 await Promise.all([proc.exited, statProc.exited]);
1531 } finally {
1532 clearTimeout(killer);
1533 }
0074234Claude1534
1535 diffFiles = stat
1536 .trim()
1537 .split("\n")
1538 .filter(Boolean)
1539 .map((line) => {
1540 const [add, del, filePath] = line.split("\t");
1541 return {
1542 path: filePath,
1543 status: "modified",
1544 additions: add === "-" ? 0 : parseInt(add, 10),
1545 deletions: del === "-" ? 0 : parseInt(del, 10),
1546 patch: "",
1547 };
1548 });
1549 }
1550
b078860Claude1551 // ─── Derived visual state ───
1552 const stateKey =
1553 pr.state === "open"
1554 ? pr.isDraft
1555 ? "draft"
1556 : "open"
1557 : pr.state;
1558 const stateLabel =
1559 stateKey === "open"
1560 ? "Open"
1561 : stateKey === "draft"
1562 ? "Draft"
1563 : stateKey === "merged"
1564 ? "Merged"
1565 : "Closed";
1566 const stateIcon =
1567 stateKey === "open"
1568 ? "○"
1569 : stateKey === "draft"
1570 ? "◌"
1571 : stateKey === "merged"
1572 ? "⮌"
1573 : "✓";
1574 const commentCount = comments.length;
1575 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
1576 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
1577 const mergeBlocked =
1578 gateChecks.length > 0 &&
1579 gateChecks.some(
1580 (c) => !c.passed && c.name !== "Merge check"
1581 );
1582
0074234Claude1583 return c.html(
1584 <Layout
1585 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
1586 user={user}
1587 >
1588 <RepoHeader owner={ownerName} repo={repoName} />
1589 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude1590 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude1591 <div
1592 id="live-comment-banner"
1593 class="alert"
1594 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
1595 >
1596 <strong class="js-live-count">0</strong> new comment(s) —{" "}
1597 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
1598 reload to view
1599 </a>
1600 </div>
1601 <script
1602 dangerouslySetInnerHTML={{
1603 __html: liveCommentBannerScript({
1604 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
1605 bannerElementId: "live-comment-banner",
1606 }),
1607 }}
1608 />
b078860Claude1609
1610 <div class="prs-detail-hero">
1611 <h1 class="prs-detail-title">
0074234Claude1612 {pr.title}{" "}
b078860Claude1613 <span class="prs-detail-num">#{pr.number}</span>
1614 </h1>
1615 <div class="prs-detail-meta">
1616 <span class={`prs-state-pill state-${stateKey}`}>
1617 <span aria-hidden="true">{stateIcon}</span>
1618 <span>{stateLabel}</span>
1619 </span>
1620 <span>
1621 <strong>{author?.username}</strong> wants to merge
1622 </span>
1623 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
1624 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
1625 <span class="prs-branch-arrow-lg">{"→"}</span>
1626 <span class="prs-branch-pill">{pr.baseBranch}</span>
1627 </span>
1628 <span>opened {formatRelative(pr.createdAt)}</span>
1629 {canManage && pr.state === "open" && pr.isDraft && (
1630 <form
1631 method="post"
1632 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
1633 class="prs-inline-form prs-detail-actions"
1634 >
1635 <button type="submit" class="prs-merge-ready-btn">
1636 Ready for review
1637 </button>
1638 </form>
1639 )}
1640 </div>
1641 </div>
0074234Claude1642
b078860Claude1643 <nav class="prs-detail-tabs" aria-label="Pull request sections">
1644 <a
1645 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
1646 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1647 >
1648 Conversation
1649 <span class="prs-detail-tab-count">{commentCount}</span>
1650 </a>
1651 <a
1652 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
1653 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
1654 >
1655 Files changed
1656 {diffFiles.length > 0 && (
1657 <span class="prs-detail-tab-count">{diffFiles.length}</span>
1658 )}
1659 </a>
1660 </nav>
1661
1662 {tab === "files" ? (
ea9ed4cClaude1663 <DiffView
1664 raw={diffRaw}
1665 files={diffFiles}
1666 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
1667 />
b078860Claude1668 ) : (
1669 <>
1670 {pr.body && (
1671 <CommentBox
1672 author={author?.username ?? "unknown"}
1673 date={pr.createdAt}
1674 body={renderMarkdown(pr.body)}
1675 />
1676 )}
1677
1678 {comments.map(({ comment, author: commentAuthor }) => (
1679 <div class={`prs-comment${comment.isAiReview ? " is-ai" : ""}`}>
1680 <div class="prs-comment-head">
1681 <strong>{commentAuthor.username}</strong>
1682 {comment.isAiReview && (
1683 <span class="prs-ai-badge">AI Review</span>
1684 )}
1685 <span class="prs-comment-time">
1686 commented {formatRelative(comment.createdAt)}
1687 </span>
1688 {comment.filePath && (
1689 <span class="prs-comment-loc">
1690 {comment.filePath}
1691 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
1692 </span>
1693 )}
1694 </div>
1695 <div class="prs-comment-body">
bb0f894Claude1696 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude1697 </div>
b078860Claude1698 </div>
1699 ))}
0074234Claude1700
b078860Claude1701 {/* Quick link to the Files changed tab when there's a diff to look at. */}
1702 {pr.state !== "merged" && (
1703 <a
1704 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
1705 class="prs-files-card"
1706 >
1707 <span class="prs-files-card-icon" aria-hidden="true">
1708 {"▤"}
1709 </span>
1710 <div class="prs-files-card-text">
1711 <p class="prs-files-card-title">Files changed</p>
1712 <p class="prs-files-card-sub">
1713 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
1714 </p>
e883329Claude1715 </div>
b078860Claude1716 <span class="prs-files-card-cta">View diff {"→"}</span>
1717 </a>
1718 )}
1719
1720 {error && (
1721 <div
1722 class="auth-error"
1723 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)"
1724 >
1725 {decodeURIComponent(error)}
1726 </div>
1727 )}
1728
1729 {info && (
1730 <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)">
1731 {decodeURIComponent(info)}
1732 </div>
1733 )}
e883329Claude1734
b078860Claude1735 {pr.state === "open" && (prRisk || prRiskCalculating) && (
1736 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
1737 )}
1738
1739 {pr.state === "open" && gateChecks.length > 0 && (
1740 <div class="prs-gate-card">
1741 <div class="prs-gate-head">
1742 <h3>Gate checks</h3>
1743 <span class="prs-gate-summary">
1744 {gatesAllPassed
1745 ? `All ${gateChecks.length} checks passed`
1746 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
1747 </span>
c3e0c07Claude1748 </div>
b078860Claude1749 {gateChecks.map((check) => {
1750 const isAi = /ai.*review/i.test(check.name);
1751 const isSkip = check.skipped === true;
1752 const statusClass = isSkip
1753 ? "is-skip"
1754 : check.passed
1755 ? "is-pass"
1756 : "is-fail";
1757 const statusGlyph = isSkip
1758 ? "—"
1759 : check.passed
1760 ? "✓"
1761 : "✗";
1762 const statusLabel = isSkip
1763 ? "Skipped"
1764 : check.passed
1765 ? "Passed"
1766 : "Failing";
1767 return (
1768 <div
1769 class="prs-gate-row"
1770 style={
1771 isAi
1772 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
1773 : ""
1774 }
1775 >
1776 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
1777 {statusGlyph}
1778 </span>
1779 <span class="prs-gate-name">
1780 {check.name}
1781 {isAi && (
1782 <span
1783 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"
1784 >
1785 AI
1786 </span>
1787 )}
1788 </span>
1789 <span class="prs-gate-details">{check.details}</span>
1790 <span class={`prs-gate-pill ${statusClass}`}>
1791 {statusLabel}
e883329Claude1792 </span>
1793 </div>
b078860Claude1794 );
1795 })}
1796 <div class="prs-gate-footer">
1797 {gatesAllPassed
1798 ? "All checks passed — ready to merge."
1799 : gateChecks.some(
1800 (c) => !c.passed && c.name === "Merge check"
1801 )
1802 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
1803 : "Some checks failed — resolve issues before merging."}
1804 {aiReviewCount > 0 && (
1805 <>
1806 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
1807 </>
1808 )}
1809 </div>
1810 </div>
1811 )}
1812
1813 {/* ─── Merge area / state-aware action card ─────────────── */}
1814 {user && pr.state === "open" && (
1815 <div
1816 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
1817 >
1818 <div class="prs-merge-head">
1819 <strong>
1820 {pr.isDraft
1821 ? "Draft — ready for review?"
1822 : mergeBlocked
1823 ? "Merge blocked"
1824 : "Ready to merge"}
1825 </strong>
e883329Claude1826 </div>
b078860Claude1827 <p class="prs-merge-sub">
1828 {pr.isDraft
1829 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
1830 : mergeBlocked
1831 ? "Resolve the failing gate checks above before this PR can land."
1832 : gateChecks.length > 0
1833 ? gatesAllPassed
1834 ? "All gates green. Merge will fast-forward into the base branch."
1835 : "Conflicts will be auto-resolved by GlueCron AI on merge."
1836 : "Run gate checks by refreshing once your branch has a recent commit."}
1837 </p>
1838 <Form
1839 method="post"
1840 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
1841 >
1842 <FormGroup>
1843 <TextArea
1844 name="body"
1845 rows={5}
1846 required
1847 placeholder="Leave a comment... (Markdown supported)"
1848 mono
1849 />
1850 </FormGroup>
1851 <div class="prs-merge-actions">
1852 <Button type="submit" variant="primary">
1853 Comment
1854 </Button>
1855 {canManage && (
1856 <>
1857 {pr.isDraft ? (
1858 <button
1859 type="submit"
1860 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
1861 formnovalidate
1862 class="prs-merge-ready-btn"
1863 >
1864 Ready for review
1865 </button>
1866 ) : (
0074234Claude1867 <button
1868 type="submit"
1869 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
b078860Claude1870 formnovalidate
1871 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
1872 title={
1873 mergeBlocked
1874 ? "Failing gate checks must be resolved before this PR can merge."
1875 : "Merge pull request"
1876 }
0074234Claude1877 >
b078860Claude1878 {"✔"} Merge pull request
0074234Claude1879 </button>
b078860Claude1880 )}
1881 {!pr.isDraft && (
1882 <button
0074234Claude1883 type="submit"
b078860Claude1884 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
1885 formnovalidate
1886 class="prs-merge-back-draft"
1887 title="Convert back to draft"
0074234Claude1888 >
b078860Claude1889 Convert to draft
1890 </button>
1891 )}
1892 {isAiReviewEnabled() && (
1893 <button
1894 type="submit"
1895 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
1896 formnovalidate
1897 class="btn"
1898 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
1899 >
1900 Re-run AI review
1901 </button>
1902 )}
1903 <Button
1904 type="submit"
1905 variant="danger"
1906 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
1907 >
1908 Close
1909 </Button>
1910 </>
1911 )}
1912 </div>
1913 </Form>
1914 </div>
1915 )}
1916
1917 {/* Read-only footers for non-open states. */}
1918 {pr.state === "merged" && (
1919 <div class="prs-merge-card is-merged">
1920 <div class="prs-merge-head">
1921 <strong>{"⮌"} Merged</strong>
0074234Claude1922 </div>
b078860Claude1923 <p class="prs-merge-sub">
1924 This pull request was merged into{" "}
1925 <code>{pr.baseBranch}</code>.
1926 </p>
1927 </div>
1928 )}
1929 {pr.state === "closed" && (
1930 <div class="prs-merge-card is-closed">
1931 <div class="prs-merge-head">
1932 <strong>{"✕"} Closed without merging</strong>
1933 </div>
1934 <p class="prs-merge-sub">
1935 This pull request was closed and not merged.
1936 </p>
1937 </div>
1938 )}
1939 </>
1940 )}
0074234Claude1941 </Layout>
1942 );
1943});
1944
1945// Add comment to PR
1946pulls.post(
1947 "/:owner/:repo/pulls/:number/comment",
1948 softAuth,
1949 requireAuth,
04f6b7fClaude1950 requireRepoAccess("write"),
0074234Claude1951 async (c) => {
1952 const { owner: ownerName, repo: repoName } = c.req.param();
1953 const prNum = parseInt(c.req.param("number"), 10);
1954 const user = c.get("user")!;
1955 const body = await c.req.parseBody();
1956 const commentBody = String(body.body || "").trim();
1957
1958 if (!commentBody) {
1959 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1960 }
1961
1962 const resolved = await resolveRepo(ownerName, repoName);
1963 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1964
1965 const [pr] = await db
1966 .select()
1967 .from(pullRequests)
1968 .where(
1969 and(
1970 eq(pullRequests.repositoryId, resolved.repo.id),
1971 eq(pullRequests.number, prNum)
1972 )
1973 )
1974 .limit(1);
1975
1976 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1977
d4ac5c3Claude1978 const [inserted] = await db
1979 .insert(prComments)
1980 .values({
1981 pullRequestId: pr.id,
1982 authorId: user.id,
1983 body: commentBody,
1984 })
1985 .returning();
1986
1987 // Live update: nudge any browser tabs subscribed to this PR.
1988 if (inserted) {
1989 try {
1990 const { publish } = await import("../lib/sse");
1991 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
1992 event: "pr-comment",
1993 data: {
1994 pullRequestId: pr.id,
1995 commentId: inserted.id,
1996 authorId: user.id,
1997 authorUsername: user.username,
1998 },
1999 });
2000 } catch {
2001 /* SSE is best-effort */
2002 }
2003 }
0074234Claude2004
2005 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2006 }
2007);
2008
e883329Claude2009// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude2010// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
2011// but we keep it at "write" for v1 so trusted collaborators can ship.
2012// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
2013// surface. Branch-protection rules (evaluated below) are the current mechanism
2014// for locking down merges further on specific branches.
0074234Claude2015pulls.post(
2016 "/:owner/:repo/pulls/:number/merge",
2017 softAuth,
2018 requireAuth,
04f6b7fClaude2019 requireRepoAccess("write"),
0074234Claude2020 async (c) => {
2021 const { owner: ownerName, repo: repoName } = c.req.param();
2022 const prNum = parseInt(c.req.param("number"), 10);
2023 const user = c.get("user")!;
2024
2025 const resolved = await resolveRepo(ownerName, repoName);
2026 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2027
2028 const [pr] = await db
2029 .select()
2030 .from(pullRequests)
2031 .where(
2032 and(
2033 eq(pullRequests.repositoryId, resolved.repo.id),
2034 eq(pullRequests.number, prNum)
2035 )
2036 )
2037 .limit(1);
2038
2039 if (!pr || pr.state !== "open") {
2040 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2041 }
2042
6fc53bdClaude2043 // Draft PRs cannot be merged — must be marked ready first.
2044 if (pr.isDraft) {
2045 return c.redirect(
2046 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2047 "This PR is a draft. Mark it as ready for review before merging."
2048 )}`
2049 );
2050 }
2051
e883329Claude2052 // Resolve head SHA
2053 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2054 if (!headSha) {
2055 return c.redirect(
2056 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
2057 );
2058 }
2059
2060 // Check if AI review approved this PR
2061 const aiComments = await db
2062 .select()
2063 .from(prComments)
2064 .where(
2065 and(
2066 eq(prComments.pullRequestId, pr.id),
2067 eq(prComments.isAiReview, true)
2068 )
2069 );
2070 const aiApproved = aiComments.length === 0 || aiComments.some(
2071 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude2072 );
e883329Claude2073
2074 // Run all green gate checks (GateTest + mergeability + AI review)
2075 const gateResult = await runAllGateChecks(
2076 ownerName,
2077 repoName,
2078 pr.baseBranch,
2079 pr.headBranch,
2080 headSha,
2081 aiApproved
0074234Claude2082 );
2083
e883329Claude2084 // If GateTest or AI review failed (hard blocks), reject the merge
2085 const hardFailures = gateResult.checks.filter(
2086 (check) => !check.passed && check.name !== "Merge check"
2087 );
2088 if (hardFailures.length > 0) {
2089 const errorMsg = hardFailures
2090 .map((f) => `${f.name}: ${f.details}`)
2091 .join("; ");
0074234Claude2092 return c.redirect(
e883329Claude2093 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude2094 );
2095 }
2096
1e162a8Claude2097 // D5 — Branch-protection enforcement. Looks up the matching rule for the
2098 // base branch and blocks the merge if requireAiApproval / requireGreenGates
2099 // / requireHumanReview / requiredApprovals are not satisfied. Independent
2100 // of repo-global settings, so owners can lock specific branches down
2101 // further than the repo default.
2102 const protectionRule = await matchProtection(
2103 resolved.repo.id,
2104 pr.baseBranch
2105 );
2106 if (protectionRule) {
2107 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude2108 const required = await listRequiredChecks(protectionRule.id);
2109 const passingNames = required.length > 0
2110 ? await passingCheckNames(resolved.repo.id, headSha)
2111 : [];
2112 const decision = evaluateProtection(
2113 protectionRule,
2114 {
2115 aiApproved,
2116 humanApprovalCount: humanApprovals,
2117 gateResultGreen: hardFailures.length === 0,
2118 hasFailedGates: hardFailures.length > 0,
2119 passingCheckNames: passingNames,
2120 },
2121 required.map((r) => r.checkName)
2122 );
1e162a8Claude2123 if (!decision.allowed) {
2124 return c.redirect(
2125 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2126 decision.reasons.join(" ")
2127 )}`
2128 );
2129 }
2130 }
2131
e883329Claude2132 // Attempt the merge — with auto conflict resolution if needed
2133 const repoDir = getRepoPath(ownerName, repoName);
2134 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
2135 const hasConflicts = mergeCheck && !mergeCheck.passed;
2136
2137 if (hasConflicts && isAiReviewEnabled()) {
2138 // Use Claude to auto-resolve conflicts
2139 const mergeResult = await mergeWithAutoResolve(
2140 ownerName,
2141 repoName,
2142 pr.baseBranch,
2143 pr.headBranch,
2144 `Merge pull request #${pr.number}: ${pr.title}`
2145 );
2146
2147 if (!mergeResult.success) {
2148 return c.redirect(
2149 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
2150 );
2151 }
2152
2153 // Post a comment about the auto-resolution
2154 if (mergeResult.resolvedFiles.length > 0) {
2155 await db.insert(prComments).values({
2156 pullRequestId: pr.id,
2157 authorId: user.id,
2158 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
2159 isAiReview: true,
2160 });
2161 }
2162 } else {
2163 // Standard merge — fast-forward or clean merge
2164 const ffProc = Bun.spawn(
2165 [
2166 "git",
2167 "update-ref",
2168 `refs/heads/${pr.baseBranch}`,
2169 `refs/heads/${pr.headBranch}`,
2170 ],
2171 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2172 );
2173 const ffExit = await ffProc.exited;
2174
2175 if (ffExit !== 0) {
2176 return c.redirect(
2177 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
2178 );
2179 }
2180 }
2181
0074234Claude2182 await db
2183 .update(pullRequests)
2184 .set({
2185 state: "merged",
2186 mergedAt: new Date(),
2187 mergedBy: user.id,
2188 updatedAt: new Date(),
2189 })
2190 .where(eq(pullRequests.id, pr.id));
2191
d62fb36Claude2192 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
2193 // and auto-close each matching open issue with a back-link comment. Bounded
2194 // to the same repo for v1 (cross-repo refs ignored). Failures never block
2195 // the merge redirect.
2196 try {
2197 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
2198 const refs = extractClosingRefsMulti([pr.title, pr.body]);
2199 for (const n of refs) {
2200 const [issue] = await db
2201 .select()
2202 .from(issues)
2203 .where(
2204 and(
2205 eq(issues.repositoryId, resolved.repo.id),
2206 eq(issues.number, n)
2207 )
2208 )
2209 .limit(1);
2210 if (!issue || issue.state !== "open") continue;
2211 await db
2212 .update(issues)
2213 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
2214 .where(eq(issues.id, issue.id));
2215 await db.insert(issueComments).values({
2216 issueId: issue.id,
2217 authorId: user.id,
2218 body: `Closed by pull request #${pr.number}.`,
2219 });
2220 }
2221 } catch {
2222 // Never block the merge on close-keyword failures.
2223 }
2224
0074234Claude2225 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2226 }
2227);
2228
6fc53bdClaude2229// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
2230// hasn't run yet on this PR.
2231pulls.post(
2232 "/:owner/:repo/pulls/:number/ready",
2233 softAuth,
2234 requireAuth,
04f6b7fClaude2235 requireRepoAccess("write"),
6fc53bdClaude2236 async (c) => {
2237 const { owner: ownerName, repo: repoName } = c.req.param();
2238 const prNum = parseInt(c.req.param("number"), 10);
2239 const user = c.get("user")!;
2240
2241 const resolved = await resolveRepo(ownerName, repoName);
2242 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2243
2244 const [pr] = await db
2245 .select()
2246 .from(pullRequests)
2247 .where(
2248 and(
2249 eq(pullRequests.repositoryId, resolved.repo.id),
2250 eq(pullRequests.number, prNum)
2251 )
2252 )
2253 .limit(1);
2254 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2255
2256 // Only the author or repo owner can toggle draft state.
2257 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2258 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2259 }
2260
2261 if (pr.state === "open" && pr.isDraft) {
2262 await db
2263 .update(pullRequests)
2264 .set({ isDraft: false, updatedAt: new Date() })
2265 .where(eq(pullRequests.id, pr.id));
2266
2267 if (isAiReviewEnabled()) {
2268 triggerAiReview(
2269 ownerName,
2270 repoName,
2271 pr.id,
2272 pr.title,
0316dbbClaude2273 pr.body || "",
6fc53bdClaude2274 pr.baseBranch,
2275 pr.headBranch
2276 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
2277 }
2278 }
2279
2280 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2281 }
2282);
2283
2284// Convert a PR back to draft.
2285pulls.post(
2286 "/:owner/:repo/pulls/:number/draft",
2287 softAuth,
2288 requireAuth,
04f6b7fClaude2289 requireRepoAccess("write"),
6fc53bdClaude2290 async (c) => {
2291 const { owner: ownerName, repo: repoName } = c.req.param();
2292 const prNum = parseInt(c.req.param("number"), 10);
2293 const user = c.get("user")!;
2294
2295 const resolved = await resolveRepo(ownerName, repoName);
2296 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2297
2298 const [pr] = await db
2299 .select()
2300 .from(pullRequests)
2301 .where(
2302 and(
2303 eq(pullRequests.repositoryId, resolved.repo.id),
2304 eq(pullRequests.number, prNum)
2305 )
2306 )
2307 .limit(1);
2308 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2309
2310 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2311 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2312 }
2313
2314 if (pr.state === "open" && !pr.isDraft) {
2315 await db
2316 .update(pullRequests)
2317 .set({ isDraft: true, updatedAt: new Date() })
2318 .where(eq(pullRequests.id, pr.id));
2319 }
2320
2321 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2322 }
2323);
2324
0074234Claude2325// Close PR
2326pulls.post(
2327 "/:owner/:repo/pulls/:number/close",
2328 softAuth,
2329 requireAuth,
04f6b7fClaude2330 requireRepoAccess("write"),
0074234Claude2331 async (c) => {
2332 const { owner: ownerName, repo: repoName } = c.req.param();
2333 const prNum = parseInt(c.req.param("number"), 10);
2334
2335 const resolved = await resolveRepo(ownerName, repoName);
2336 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2337
2338 await db
2339 .update(pullRequests)
2340 .set({
2341 state: "closed",
2342 closedAt: new Date(),
2343 updatedAt: new Date(),
2344 })
2345 .where(
2346 and(
2347 eq(pullRequests.repositoryId, resolved.repo.id),
2348 eq(pullRequests.number, prNum)
2349 )
2350 );
2351
2352 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2353 }
2354);
2355
c3e0c07Claude2356// Re-run AI review on demand (e.g. after a force-push). Bypasses the
2357// idempotency marker via { force: true }. Write-access only.
2358pulls.post(
2359 "/:owner/:repo/pulls/:number/ai-rereview",
2360 softAuth,
2361 requireAuth,
2362 requireRepoAccess("write"),
2363 async (c) => {
2364 const { owner: ownerName, repo: repoName } = c.req.param();
2365 const prNum = parseInt(c.req.param("number"), 10);
2366 const resolved = await resolveRepo(ownerName, repoName);
2367 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2368
2369 const [pr] = await db
2370 .select()
2371 .from(pullRequests)
2372 .where(
2373 and(
2374 eq(pullRequests.repositoryId, resolved.repo.id),
2375 eq(pullRequests.number, prNum)
2376 )
2377 )
2378 .limit(1);
2379 if (!pr) {
2380 return c.redirect(`/${ownerName}/${repoName}/pulls`);
2381 }
2382
2383 if (!isAiReviewEnabled()) {
2384 return c.redirect(
2385 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2386 "AI review is not configured (ANTHROPIC_API_KEY)."
2387 )}`
2388 );
2389 }
2390
2391 // Fire-and-forget but with { force: true } to bypass the
2392 // already-reviewed marker. The function still never throws.
2393 triggerAiReview(
2394 ownerName,
2395 repoName,
2396 pr.id,
2397 pr.title || "",
2398 pr.body || "",
2399 pr.baseBranch,
2400 pr.headBranch,
2401 { force: true }
a28cedeClaude2402 ).catch((err) => {
2403 console.warn(
2404 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
2405 err instanceof Error ? err.message : err
2406 );
2407 });
c3e0c07Claude2408
2409 return c.redirect(
2410 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
2411 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
2412 )}`
2413 );
2414 }
2415);
2416
0074234Claude2417export default pulls;