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