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