Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

pulls.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

pulls.tsxBlame3162 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";
79ed944Claude45import {
46 TRIO_COMMENT_MARKER,
47 TRIO_SUMMARY_MARKER,
48 type TrioPersona,
49} from "../lib/ai-review-trio";
1d4ff60Claude50import {
51 generateTestsForPr,
52 AI_TESTS_MARKER,
53} from "../lib/ai-test-generator";
0316dbbClaude54import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude55import { generatePrSummary } from "../lib/ai-generators";
56import { isAiAvailable } from "../lib/ai-client";
534f04aClaude57import {
58 computePrRiskForPullRequest,
59 getCachedPrRisk,
60 type PrRiskScore,
61} from "../lib/pr-risk";
0316dbbClaude62import { runAllGateChecks } from "../lib/gate";
63import type { GateCheckResult } from "../lib/gate";
64import {
65 matchProtection,
66 countHumanApprovals,
67 listRequiredChecks,
68 passingCheckNames,
69 evaluateProtection,
70} from "../lib/branch-protection";
71import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude72import {
73 listBranches,
74 getRepoPath,
e883329Claude75 resolveRef,
0074234Claude76} from "../git/repository";
77import type { GitDiffFile } from "../git/repository";
78import { html } from "hono/html";
4bbacbeClaude79import {
80 getPreviewForBranch,
81 previewStatusLabel,
82} from "../lib/branch-previews";
1e162a8Claude83import {
bb0f894Claude84 Flex,
85 Container,
86 Badge,
87 Button,
88 LinkButton,
89 Form,
90 FormGroup,
91 Input,
92 TextArea,
93 Select,
94 EmptyState,
95 FilterTabs,
96 TabNav,
97 List,
98 ListItem,
99 Text,
100 Alert,
101 MarkdownContent,
102 CommentBox,
103 formatRelative,
104} from "../views/ui";
0074234Claude105
106const pulls = new Hono<AuthEnv>();
107
b078860Claude108/* ──────────────────────────────────────────────────────────────────────
109 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
110 * into the issue tracker or any other route. Tokens come from layout.tsx
111 * `:root` so light/dark stays consistent if/when light mode lands.
112 * ──────────────────────────────────────────────────────────────────── */
113const PRS_LIST_STYLES = `
114 .prs-hero {
115 position: relative;
116 margin: 0 0 var(--space-5);
117 padding: 22px 26px 24px;
118 background: var(--bg-elevated);
119 border: 1px solid var(--border);
120 border-radius: 16px;
121 overflow: hidden;
122 }
123 .prs-hero::before {
124 content: '';
125 position: absolute; top: 0; left: 0; right: 0;
126 height: 2px;
127 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
128 opacity: 0.7;
129 pointer-events: none;
130 }
131 .prs-hero-inner {
132 position: relative;
133 display: flex;
134 justify-content: space-between;
135 align-items: flex-end;
136 gap: 20px;
137 flex-wrap: wrap;
138 }
139 .prs-hero-text { flex: 1; min-width: 280px; }
140 .prs-hero-eyebrow {
141 font-size: 12px;
142 color: var(--text-muted);
143 text-transform: uppercase;
144 letter-spacing: 0.08em;
145 font-weight: 600;
146 margin-bottom: 8px;
147 }
148 .prs-hero-title {
149 font-family: var(--font-display);
150 font-size: clamp(26px, 3.4vw, 34px);
151 font-weight: 800;
152 letter-spacing: -0.025em;
153 line-height: 1.06;
154 margin: 0 0 8px;
155 color: var(--text-strong);
156 }
157 .prs-hero-title .gradient-text {
158 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
159 -webkit-background-clip: text;
160 background-clip: text;
161 -webkit-text-fill-color: transparent;
162 color: transparent;
163 }
164 .prs-hero-sub {
165 font-size: 14.5px;
166 color: var(--text-muted);
167 margin: 0;
168 line-height: 1.5;
169 max-width: 620px;
170 }
171 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
172 .prs-cta {
173 display: inline-flex; align-items: center; gap: 6px;
174 padding: 10px 16px;
175 border-radius: 10px;
176 font-size: 13.5px;
177 font-weight: 600;
178 color: #fff;
179 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
180 border: 1px solid rgba(140,109,255,0.55);
181 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
182 text-decoration: none;
183 transition: transform 120ms ease, box-shadow 160ms ease;
184 }
185 .prs-cta:hover {
186 transform: translateY(-1px);
187 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
188 color: #fff;
189 }
190
191 .prs-tabs {
192 display: flex; flex-wrap: wrap; gap: 6px;
193 margin: 0 0 18px;
194 padding: 6px;
195 background: var(--bg-secondary);
196 border: 1px solid var(--border);
197 border-radius: 12px;
198 }
199 .prs-tab {
200 display: inline-flex; align-items: center; gap: 8px;
201 padding: 7px 13px;
202 font-size: 13px;
203 font-weight: 500;
204 color: var(--text-muted);
205 border-radius: 8px;
206 text-decoration: none;
207 transition: background 120ms ease, color 120ms ease;
208 }
209 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
210 .prs-tab.is-active {
211 background: var(--bg-elevated);
212 color: var(--text-strong);
213 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
214 }
215 .prs-tab-count {
216 display: inline-flex; align-items: center; justify-content: center;
217 min-width: 22px; padding: 2px 7px;
218 font-size: 11.5px;
219 font-weight: 600;
220 border-radius: 9999px;
221 background: var(--bg-tertiary);
222 color: var(--text-muted);
223 }
224 .prs-tab.is-active .prs-tab-count {
225 background: rgba(140,109,255,0.18);
226 color: var(--text-link);
227 }
228
229 .prs-list { display: flex; flex-direction: column; gap: 10px; }
230 .prs-row {
231 position: relative;
232 display: flex; align-items: flex-start; gap: 14px;
233 padding: 14px 16px;
234 background: var(--bg-elevated);
235 border: 1px solid var(--border);
236 border-radius: 12px;
237 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
238 }
239 .prs-row:hover {
240 transform: translateY(-1px);
241 border-color: var(--border-strong);
242 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
243 }
244 .prs-row-icon {
245 flex: 0 0 auto;
246 width: 26px; height: 26px;
247 display: inline-flex; align-items: center; justify-content: center;
248 border-radius: 9999px;
249 font-size: 13px;
250 margin-top: 2px;
251 }
252 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
253 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
254 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
255 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
256 .prs-row-body { flex: 1; min-width: 0; }
257 .prs-row-title {
258 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
259 font-size: 15px; font-weight: 600;
260 color: var(--text-strong);
261 line-height: 1.35;
262 margin: 0 0 6px;
263 }
264 .prs-row-number {
265 color: var(--text-muted);
266 font-weight: 400;
267 font-size: 14px;
268 }
269 .prs-row-meta {
270 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
271 font-size: 12.5px;
272 color: var(--text-muted);
273 }
274 .prs-branch-chips {
275 display: inline-flex; align-items: center; gap: 6px;
276 font-family: var(--font-mono);
277 font-size: 11.5px;
278 }
279 .prs-branch-chip {
280 padding: 2px 8px;
281 border-radius: 9999px;
282 background: var(--bg-tertiary);
283 border: 1px solid var(--border);
284 color: var(--text);
285 }
286 .prs-branch-arrow {
287 color: var(--text-faint);
288 font-size: 11px;
289 }
290 .prs-row-tags {
291 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
292 margin-left: auto;
293 }
294 .prs-tag {
295 display: inline-flex; align-items: center; gap: 4px;
296 padding: 2px 8px;
297 font-size: 11px;
298 font-weight: 600;
299 border-radius: 9999px;
300 border: 1px solid var(--border);
301 background: var(--bg-secondary);
302 color: var(--text-muted);
303 line-height: 1.6;
304 }
305 .prs-tag.is-draft {
306 color: var(--text-muted);
307 border-color: var(--border-strong);
308 }
309 .prs-tag.is-merged {
310 color: var(--text-link);
311 border-color: rgba(140,109,255,0.45);
312 background: rgba(140,109,255,0.10);
313 }
314
315 .prs-empty {
ea9ed4cClaude316 position: relative;
317 padding: 56px 32px;
b078860Claude318 text-align: center;
319 border: 1px dashed var(--border);
ea9ed4cClaude320 border-radius: 16px;
321 background: var(--bg-elevated);
b078860Claude322 color: var(--text-muted);
ea9ed4cClaude323 overflow: hidden;
b078860Claude324 }
ea9ed4cClaude325 .prs-empty::before {
326 content: '';
327 position: absolute;
328 inset: -40% -20% auto auto;
329 width: 320px; height: 320px;
330 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
331 filter: blur(70px);
332 opacity: 0.55;
333 pointer-events: none;
334 animation: prsEmptyOrb 16s ease-in-out infinite;
335 }
336 @keyframes prsEmptyOrb {
337 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
338 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
339 }
340 @media (prefers-reduced-motion: reduce) {
341 .prs-empty::before { animation: none; }
342 }
343 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude344 .prs-empty strong {
345 display: block;
346 color: var(--text-strong);
ea9ed4cClaude347 font-family: var(--font-display);
348 font-size: 22px;
349 font-weight: 700;
350 letter-spacing: -0.018em;
351 margin-bottom: 2px;
352 }
353 .prs-empty-sub {
354 font-size: 14.5px;
355 color: var(--text-muted);
356 line-height: 1.55;
357 max-width: 460px;
358 margin: 0 0 18px;
b078860Claude359 }
ea9ed4cClaude360 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude361
362 @media (max-width: 720px) {
363 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
364 .prs-hero-actions { width: 100%; }
365 .prs-row-tags { margin-left: 0; }
366 }
f1dc7c7Claude367
368 /* Additional mobile rules. Additive only. */
369 @media (max-width: 720px) {
370 .prs-hero { padding: 18px 18px 20px; }
371 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
372 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
373 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
374 .prs-row { padding: 12px 14px; gap: 10px; }
375 .prs-row-icon { width: 24px; height: 24px; }
376 }
b078860Claude377`;
378
379/* ──────────────────────────────────────────────────────────────────────
380 * Inline CSS for the detail page. Same `.prs-*` namespace.
381 * ──────────────────────────────────────────────────────────────────── */
382const PRS_DETAIL_STYLES = `
383 .prs-detail-hero {
384 position: relative;
385 margin: 0 0 var(--space-4);
386 padding: 24px 26px;
387 background: var(--bg-elevated);
388 border: 1px solid var(--border);
389 border-radius: 16px;
390 overflow: hidden;
391 }
392 .prs-detail-hero::before {
393 content: '';
394 position: absolute; top: 0; left: 0; right: 0;
395 height: 2px;
396 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
397 opacity: 0.7;
398 pointer-events: none;
399 }
400 .prs-detail-title {
401 font-family: var(--font-display);
402 font-size: clamp(22px, 2.6vw, 28px);
403 font-weight: 700;
404 letter-spacing: -0.022em;
405 line-height: 1.2;
406 color: var(--text-strong);
407 margin: 0 0 12px;
408 }
409 .prs-detail-num {
410 color: var(--text-muted);
411 font-weight: 400;
412 }
413 .prs-state-pill {
414 display: inline-flex; align-items: center; gap: 6px;
415 padding: 6px 12px;
416 border-radius: 9999px;
417 font-size: 12.5px;
418 font-weight: 600;
419 line-height: 1;
420 border: 1px solid transparent;
421 }
422 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
423 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
424 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
425 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
426
427 .prs-detail-meta {
428 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
429 font-size: 13px;
430 color: var(--text-muted);
431 }
432 .prs-detail-meta strong { color: var(--text); }
433 .prs-detail-branches {
434 display: inline-flex; align-items: center; gap: 6px;
435 font-family: var(--font-mono);
436 font-size: 12px;
437 }
438 .prs-branch-pill {
439 padding: 3px 9px;
440 border-radius: 9999px;
441 background: var(--bg-tertiary);
442 border: 1px solid var(--border);
443 color: var(--text);
444 }
445 .prs-branch-pill.is-head { color: var(--text-strong); }
446 .prs-branch-arrow-lg {
447 color: var(--accent);
448 font-size: 14px;
449 font-weight: 700;
450 }
451
452 .prs-detail-actions {
453 display: inline-flex; gap: 8px; margin-left: auto;
454 }
455
456 .prs-detail-tabs {
457 display: flex; gap: 4px;
458 margin: 0 0 16px;
459 border-bottom: 1px solid var(--border);
460 }
461 .prs-detail-tab {
462 padding: 10px 14px;
463 font-size: 13.5px;
464 font-weight: 500;
465 color: var(--text-muted);
466 text-decoration: none;
467 border-bottom: 2px solid transparent;
468 transition: color 120ms ease, border-color 120ms ease;
469 margin-bottom: -1px;
470 }
471 .prs-detail-tab:hover { color: var(--text); }
472 .prs-detail-tab.is-active {
473 color: var(--text-strong);
474 border-bottom-color: var(--accent);
475 }
476 .prs-detail-tab-count {
477 display: inline-flex; align-items: center; justify-content: center;
478 min-width: 20px; padding: 0 6px; margin-left: 6px;
479 height: 18px;
480 font-size: 11px;
481 font-weight: 600;
482 border-radius: 9999px;
483 background: var(--bg-tertiary);
484 color: var(--text-muted);
485 }
486
487 /* Gate / check status section */
488 .prs-gate-card {
489 margin-top: 20px;
490 background: var(--bg-elevated);
491 border: 1px solid var(--border);
492 border-radius: 14px;
493 overflow: hidden;
494 }
495 .prs-gate-head {
496 display: flex; align-items: center; gap: 10px;
497 padding: 14px 18px;
498 border-bottom: 1px solid var(--border);
499 }
500 .prs-gate-head h3 {
501 margin: 0;
502 font-size: 14px;
503 font-weight: 600;
504 color: var(--text-strong);
505 }
506 .prs-gate-summary {
507 margin-left: auto;
508 font-size: 12px;
509 color: var(--text-muted);
510 }
511 .prs-gate-row {
512 display: flex; align-items: center; gap: 12px;
513 padding: 12px 18px;
514 border-bottom: 1px solid var(--border-subtle);
515 }
516 .prs-gate-row:last-child { border-bottom: 0; }
517 .prs-gate-icon {
518 flex: 0 0 auto;
519 width: 22px; height: 22px;
520 display: inline-flex; align-items: center; justify-content: center;
521 border-radius: 9999px;
522 font-size: 12px;
523 font-weight: 700;
524 }
525 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
526 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
527 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
528 .prs-gate-name {
529 font-size: 13px;
530 font-weight: 600;
531 color: var(--text);
532 min-width: 140px;
533 }
534 .prs-gate-details {
535 flex: 1; min-width: 0;
536 font-size: 12.5px;
537 color: var(--text-muted);
538 }
539 .prs-gate-pill {
540 flex: 0 0 auto;
541 padding: 3px 10px;
542 border-radius: 9999px;
543 font-size: 11px;
544 font-weight: 600;
545 line-height: 1.5;
546 border: 1px solid transparent;
547 }
548 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
549 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
550 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
551 .prs-gate-footer {
552 padding: 12px 18px;
553 background: var(--bg-secondary);
554 font-size: 12px;
555 color: var(--text-muted);
556 }
557
558 /* Comment cards */
559 .prs-comment {
560 margin-top: 14px;
561 background: var(--bg-elevated);
562 border: 1px solid var(--border);
563 border-radius: 12px;
564 overflow: hidden;
565 }
566 .prs-comment-head {
567 display: flex; align-items: center; gap: 10px;
568 padding: 10px 14px;
569 background: var(--bg-secondary);
570 border-bottom: 1px solid var(--border);
571 font-size: 13px;
572 flex-wrap: wrap;
573 }
574 .prs-comment-head strong { color: var(--text-strong); }
575 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
576 .prs-comment-loc {
577 font-family: var(--font-mono);
578 font-size: 11.5px;
579 color: var(--text-muted);
580 background: var(--bg-tertiary);
581 padding: 2px 8px;
582 border-radius: 6px;
583 }
584 .prs-comment-body { padding: 14px 18px; }
585 .prs-comment.is-ai {
586 border-color: rgba(140,109,255,0.45);
587 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
588 }
589 .prs-comment.is-ai .prs-comment-head {
590 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
591 border-bottom-color: rgba(140,109,255,0.30);
592 }
593 .prs-ai-badge {
594 display: inline-flex; align-items: center; gap: 4px;
595 padding: 2px 9px;
596 font-size: 10.5px;
597 font-weight: 700;
598 letter-spacing: 0.04em;
599 text-transform: uppercase;
600 color: #fff;
601 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
602 border-radius: 9999px;
603 }
604
605 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
606 .prs-files-card {
607 margin-top: 18px;
608 padding: 14px 18px;
609 display: flex; align-items: center; gap: 14px;
610 background: var(--bg-elevated);
611 border: 1px solid var(--border);
612 border-radius: 12px;
613 text-decoration: none;
614 color: inherit;
615 transition: border-color 120ms ease, transform 140ms ease;
616 }
617 .prs-files-card:hover {
618 border-color: rgba(140,109,255,0.45);
619 transform: translateY(-1px);
620 }
621 .prs-files-card-icon {
622 width: 36px; height: 36px;
623 display: inline-flex; align-items: center; justify-content: center;
624 border-radius: 10px;
625 background: rgba(140,109,255,0.12);
626 color: var(--text-link);
627 font-size: 18px;
628 }
629 .prs-files-card-text { flex: 1; min-width: 0; }
630 .prs-files-card-title {
631 font-size: 14px;
632 font-weight: 600;
633 color: var(--text-strong);
634 margin: 0 0 2px;
635 }
636 .prs-files-card-sub {
637 font-size: 12.5px;
638 color: var(--text-muted);
639 margin: 0;
640 }
641 .prs-files-card-cta {
642 font-size: 12.5px;
643 color: var(--text-link);
644 font-weight: 600;
645 }
646
647 /* Merge area */
648 .prs-merge-card {
649 position: relative;
650 margin-top: 22px;
651 padding: 18px;
652 background: var(--bg-elevated);
653 border-radius: 14px;
654 overflow: hidden;
655 }
656 .prs-merge-card::before {
657 content: '';
658 position: absolute; inset: 0;
659 padding: 1px;
660 border-radius: 14px;
661 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
662 -webkit-mask:
663 linear-gradient(#000 0 0) content-box,
664 linear-gradient(#000 0 0);
665 -webkit-mask-composite: xor;
666 mask-composite: exclude;
667 pointer-events: none;
668 }
669 .prs-merge-card.is-closed::before { background: var(--border-strong); }
670 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
671 .prs-merge-head {
672 display: flex; align-items: center; gap: 12px;
673 margin-bottom: 12px;
674 }
675 .prs-merge-head strong {
676 font-family: var(--font-display);
677 font-size: 15px;
678 color: var(--text-strong);
679 font-weight: 700;
680 }
681 .prs-merge-sub {
682 font-size: 13px;
683 color: var(--text-muted);
684 margin: 0 0 12px;
685 }
686 .prs-merge-actions {
687 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
688 }
689 .prs-merge-btn {
690 display: inline-flex; align-items: center; gap: 6px;
691 padding: 9px 16px;
692 border-radius: 10px;
693 font-size: 13.5px;
694 font-weight: 600;
695 color: #fff;
696 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
697 border: 1px solid rgba(52,211,153,0.55);
698 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
699 cursor: pointer;
700 transition: transform 120ms ease, box-shadow 160ms ease;
701 }
702 .prs-merge-btn:hover {
703 transform: translateY(-1px);
704 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
705 }
706 .prs-merge-btn[disabled],
707 .prs-merge-btn.is-disabled {
708 opacity: 0.55;
709 cursor: not-allowed;
710 transform: none;
711 box-shadow: none;
712 }
713 .prs-merge-ready-btn {
714 display: inline-flex; align-items: center; gap: 6px;
715 padding: 9px 16px;
716 border-radius: 10px;
717 font-size: 13.5px;
718 font-weight: 600;
719 color: #fff;
720 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
721 border: 1px solid rgba(140,109,255,0.55);
722 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
723 cursor: pointer;
724 transition: transform 120ms ease, box-shadow 160ms ease;
725 }
726 .prs-merge-ready-btn:hover {
727 transform: translateY(-1px);
728 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
729 }
730 .prs-merge-back-draft {
731 background: none; border: 1px solid var(--border-strong);
732 color: var(--text-muted);
733 padding: 9px 14px; border-radius: 10px;
734 font-size: 13px; cursor: pointer;
735 }
736 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
737
738 /* Inline form helpers */
739 .prs-inline-form { display: inline-flex; }
740
741 /* Comment composer */
742 .prs-composer { margin-top: 22px; }
743 .prs-composer textarea {
744 border-radius: 12px;
745 }
746
747 @media (max-width: 720px) {
748 .prs-detail-actions { margin-left: 0; }
749 .prs-merge-actions { width: 100%; }
750 .prs-merge-actions > * { flex: 1; min-width: 0; }
751 }
f1dc7c7Claude752
753 /* Additional mobile rules. Additive only. */
754 @media (max-width: 720px) {
755 .prs-detail-hero { padding: 18px; }
756 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
757 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
758 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
759 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
760 .prs-gate-name { min-width: 0; }
761 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
762 .prs-gate-summary { margin-left: 0; }
763 .prs-merge-btn,
764 .prs-merge-ready-btn,
765 .prs-merge-back-draft { min-height: 44px; }
766 .prs-comment-body { padding: 12px 14px; }
767 .prs-comment-head { padding: 10px 12px; }
768 .prs-files-card { padding: 12px 14px; }
769 }
3c03977Claude770
771 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
772 .live-pill {
773 display: inline-flex;
774 align-items: center;
775 gap: 8px;
776 padding: 4px 10px 4px 8px;
777 margin-left: 6px;
778 background: var(--bg-elevated);
779 border: 1px solid var(--border);
780 border-radius: 9999px;
781 font-size: 12px;
782 color: var(--text-muted);
783 line-height: 1;
784 vertical-align: middle;
785 }
786 .live-pill.is-busy { color: var(--text); }
787 .live-pill-dot {
788 width: 8px; height: 8px;
789 border-radius: 9999px;
790 background: #34d399;
791 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
792 animation: live-pulse 1.6s ease-in-out infinite;
793 }
794 @keyframes live-pulse {
795 0%, 100% { opacity: 1; }
796 50% { opacity: 0.55; }
797 }
798 .live-avatars {
799 display: inline-flex;
800 margin-left: 2px;
801 }
802 .live-avatar {
803 display: inline-flex;
804 align-items: center;
805 justify-content: center;
806 width: 22px; height: 22px;
807 border-radius: 9999px;
808 font-size: 10px;
809 font-weight: 700;
810 color: #0b1020;
811 margin-left: -6px;
812 border: 2px solid var(--bg-elevated);
813 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
814 }
815 .live-avatar:first-child { margin-left: 0; }
816 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
817 .live-cursor-host {
818 position: relative;
819 }
820 .live-cursor-overlay {
821 position: absolute;
822 inset: 0;
823 pointer-events: none;
824 overflow: hidden;
825 border-radius: inherit;
826 }
827 .live-cursor {
828 position: absolute;
829 width: 2px;
830 height: 18px;
831 border-radius: 2px;
832 transform: translate(-1px, 0);
833 transition: transform 80ms linear, opacity 200ms ease;
834 }
835 .live-cursor::after {
836 content: attr(data-label);
837 position: absolute;
838 top: -16px;
839 left: -2px;
840 font-size: 10px;
841 line-height: 1;
842 color: #0b1020;
843 background: inherit;
844 padding: 2px 5px;
845 border-radius: 4px 4px 4px 0;
846 white-space: nowrap;
847 font-weight: 600;
848 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
849 }
850 .live-cursor.is-idle { opacity: 0.4; }
851 .live-edit-tag {
852 display: inline-block;
853 margin-left: 6px;
854 padding: 1px 6px;
855 font-size: 10px;
856 font-weight: 600;
857 letter-spacing: 0.02em;
858 color: #0b1020;
859 border-radius: 9999px;
860 }
15db0e0Claude861
862 /* ─── Slash-command pill + composer hint ─── */
863 .slash-hint {
864 display: inline-flex;
865 align-items: center;
866 gap: 6px;
867 margin-top: 6px;
868 padding: 3px 9px;
869 font-size: 11.5px;
870 color: var(--text-muted);
871 background: var(--bg-elevated);
872 border: 1px dashed var(--border);
873 border-radius: 9999px;
874 width: fit-content;
875 }
876 .slash-hint code {
877 background: rgba(110, 168, 255, 0.12);
878 color: var(--text-strong);
879 padding: 0 5px;
880 border-radius: 4px;
881 font-size: 11px;
882 }
883 .slash-pill {
884 display: grid;
885 grid-template-columns: auto 1fr auto;
886 align-items: center;
887 column-gap: 10px;
888 row-gap: 6px;
889 margin: 10px 0;
890 padding: 10px 14px;
891 background: linear-gradient(
892 135deg,
893 rgba(110, 168, 255, 0.08),
894 rgba(163, 113, 247, 0.06)
895 );
896 border: 1px solid rgba(110, 168, 255, 0.32);
897 border-left: 3px solid var(--accent, #6ea8ff);
898 border-radius: var(--radius);
899 font-size: 13px;
900 color: var(--text);
901 }
902 .slash-pill-icon {
903 font-size: 14px;
904 line-height: 1;
905 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
906 }
907 .slash-pill-actor { color: var(--text-muted); }
908 .slash-pill-actor strong { color: var(--text-strong); }
909 .slash-pill-cmd {
910 background: rgba(110, 168, 255, 0.16);
911 color: var(--text-strong);
912 padding: 1px 6px;
913 border-radius: 4px;
914 font-size: 12.5px;
915 }
916 .slash-pill-time {
917 color: var(--text-muted);
918 font-size: 12px;
919 justify-self: end;
920 }
921 .slash-pill-body {
922 grid-column: 1 / -1;
923 color: var(--text);
924 font-size: 13px;
925 line-height: 1.55;
926 }
927 .slash-pill-body p:first-child { margin-top: 0; }
928 .slash-pill-body p:last-child { margin-bottom: 0; }
929 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
930 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
931 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
932 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude933
934 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
935 .preview-prpill {
936 display: inline-flex; align-items: center; gap: 6px;
937 padding: 3px 10px;
938 border-radius: 9999px;
939 font-family: var(--font-mono);
940 font-size: 11.5px;
941 font-weight: 600;
942 background: rgba(255,255,255,0.04);
943 color: var(--text-muted);
944 text-decoration: none;
945 border: 1px solid var(--border);
946 }
947 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
948 .preview-prpill .preview-prpill-dot {
949 width: 7px; height: 7px;
950 border-radius: 9999px;
951 background: currentColor;
952 }
953 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
954 .preview-prpill.is-building .preview-prpill-dot {
955 animation: previewPrPulse 1.4s ease-in-out infinite;
956 }
957 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
958 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
959 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
960 @keyframes previewPrPulse {
961 0%, 100% { opacity: 1; }
962 50% { opacity: 0.4; }
963 }
79ed944Claude964
965 /* ─── AI Trio Review — 3-column verdict cards ─── */
966 .trio-wrap {
967 margin-top: 18px;
968 padding: 16px;
969 background: var(--bg-elevated);
970 border: 1px solid var(--border);
971 border-radius: 14px;
972 }
973 .trio-header {
974 display: flex; align-items: center; gap: 10px;
975 margin: 0 0 12px;
976 font-size: 13.5px;
977 color: var(--text);
978 }
979 .trio-header strong { color: var(--text-strong); }
980 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
981 .trio-header-dot {
982 width: 8px; height: 8px; border-radius: 9999px;
983 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
984 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
985 }
986 .trio-grid {
987 display: grid;
988 grid-template-columns: repeat(3, minmax(0, 1fr));
989 gap: 12px;
990 }
991 .trio-card {
992 background: var(--bg-secondary);
993 border: 1px solid var(--border);
994 border-radius: 12px;
995 overflow: hidden;
996 display: flex; flex-direction: column;
997 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
998 }
999 .trio-card-head {
1000 display: flex; align-items: center; gap: 8px;
1001 padding: 10px 12px;
1002 border-bottom: 1px solid var(--border);
1003 background: rgba(255,255,255,0.02);
1004 font-size: 13px;
1005 }
1006 .trio-card-icon {
1007 display: inline-flex; align-items: center; justify-content: center;
1008 width: 22px; height: 22px;
1009 border-radius: 9999px;
1010 font-size: 12px;
1011 background: rgba(255,255,255,0.05);
1012 }
1013 .trio-card-title {
1014 color: var(--text-strong);
1015 font-weight: 600;
1016 letter-spacing: 0.01em;
1017 }
1018 .trio-card-verdict {
1019 margin-left: auto;
1020 font-size: 11px;
1021 font-weight: 700;
1022 letter-spacing: 0.06em;
1023 text-transform: uppercase;
1024 padding: 3px 9px;
1025 border-radius: 9999px;
1026 background: var(--bg-tertiary);
1027 color: var(--text-muted);
1028 border: 1px solid var(--border-strong);
1029 }
1030 .trio-card-body {
1031 padding: 12px 14px;
1032 font-size: 13px;
1033 color: var(--text);
1034 flex: 1;
1035 min-height: 64px;
1036 line-height: 1.55;
1037 }
1038 .trio-card-body p { margin: 0 0 8px; }
1039 .trio-card-body p:last-child { margin-bottom: 0; }
1040 .trio-card-body ul { margin: 0; padding-left: 18px; }
1041 .trio-card-body code {
1042 font-family: var(--font-mono);
1043 font-size: 12px;
1044 background: var(--bg-tertiary);
1045 padding: 1px 6px;
1046 border-radius: 5px;
1047 }
1048 .trio-card-empty {
1049 color: var(--text-muted);
1050 font-style: italic;
1051 font-size: 12.5px;
1052 }
1053
1054 /* Pass state — neutral, no accent. */
1055 .trio-card.is-pass .trio-card-verdict {
1056 color: var(--green);
1057 border-color: rgba(52,211,153,0.35);
1058 background: rgba(52,211,153,0.12);
1059 }
1060
1061 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1062 .trio-card.trio-security.is-fail {
1063 border-color: rgba(248,113,113,0.55);
1064 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1065 }
1066 .trio-card.trio-security.is-fail .trio-card-head {
1067 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1068 border-bottom-color: rgba(248,113,113,0.30);
1069 }
1070 .trio-card.trio-security.is-fail .trio-card-verdict {
1071 color: #fecaca;
1072 border-color: rgba(248,113,113,0.55);
1073 background: rgba(248,113,113,0.20);
1074 }
1075
1076 .trio-card.trio-correctness.is-fail {
1077 border-color: rgba(251,191,36,0.55);
1078 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1079 }
1080 .trio-card.trio-correctness.is-fail .trio-card-head {
1081 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1082 border-bottom-color: rgba(251,191,36,0.30);
1083 }
1084 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1085 color: #fde68a;
1086 border-color: rgba(251,191,36,0.55);
1087 background: rgba(251,191,36,0.20);
1088 }
1089
1090 .trio-card.trio-style.is-fail {
1091 border-color: rgba(96,165,250,0.55);
1092 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1093 }
1094 .trio-card.trio-style.is-fail .trio-card-head {
1095 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1096 border-bottom-color: rgba(96,165,250,0.30);
1097 }
1098 .trio-card.trio-style.is-fail .trio-card-verdict {
1099 color: #bfdbfe;
1100 border-color: rgba(96,165,250,0.55);
1101 background: rgba(96,165,250,0.20);
1102 }
1103
1104 /* Disagreement callout strip — yellow, prominent. */
1105 .trio-disagreement-strip {
1106 display: flex;
1107 gap: 12px;
1108 margin-top: 14px;
1109 padding: 12px 14px;
1110 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1111 border: 1px solid rgba(251,191,36,0.45);
1112 border-radius: 10px;
1113 color: var(--text);
1114 font-size: 13px;
1115 }
1116 .trio-disagreement-icon {
1117 flex: 0 0 auto;
1118 width: 26px; height: 26px;
1119 display: inline-flex; align-items: center; justify-content: center;
1120 border-radius: 9999px;
1121 background: rgba(251,191,36,0.25);
1122 color: #fde68a;
1123 font-size: 14px;
1124 }
1125 .trio-disagreement-body strong {
1126 display: block;
1127 color: #fde68a;
1128 margin: 0 0 4px;
1129 font-weight: 700;
1130 }
1131 .trio-disagreement-list {
1132 margin: 0;
1133 padding-left: 18px;
1134 color: var(--text);
1135 font-size: 12.5px;
1136 line-height: 1.55;
1137 }
1138 .trio-disagreement-list code {
1139 font-family: var(--font-mono);
1140 font-size: 11.5px;
1141 background: var(--bg-tertiary);
1142 padding: 1px 5px;
1143 border-radius: 4px;
1144 }
1145
1146 @media (max-width: 720px) {
1147 .trio-grid { grid-template-columns: 1fr; }
1148 .trio-wrap { padding: 12px; }
1149 }
b078860Claude1150`;
1151
81c73c1Claude1152/**
1153 * Tiny inline JS that drives the "Suggest description with AI" button.
1154 * On click, gathers form values, POSTs JSON to the given endpoint, and
1155 * pipes the response into the #pr-body textarea. All DOM lookups are
1156 * defensive — element absence is a silent no-op.
1157 *
1158 * Built as a string template so it lives next to its server-side caller
1159 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1160 * to avoid </script> breakouts.
1161 */
1162function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1163 const url = JSON.stringify(endpointUrl)
1164 .split("<").join("\\u003C")
1165 .split(">").join("\\u003E")
1166 .split("&").join("\\u0026");
1167 return (
1168 "(function(){try{" +
1169 "var btn=document.getElementById('ai-suggest-desc');" +
1170 "var status=document.getElementById('ai-suggest-status');" +
1171 "var body=document.getElementById('pr-body');" +
1172 "var form=btn&&btn.closest&&btn.closest('form');" +
1173 "if(!btn||!body||!form)return;" +
1174 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1175 "var fd=new FormData(form);" +
1176 "var title=String(fd.get('title')||'').trim();" +
1177 "var base=String(fd.get('base')||'').trim();" +
1178 "var head=String(fd.get('head')||'').trim();" +
1179 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1180 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1181 "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'})" +
1182 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1183 ".then(function(j){btn.disabled=false;" +
1184 "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;}}" +
1185 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1186 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1187 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1188 "});" +
1189 "}catch(e){}})();"
1190 );
1191}
1192
3c03977Claude1193/**
1194 * Live co-editing client. Connects to the per-PR SSE feed and:
1195 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1196 * status colour per user).
1197 * - Renders tinted cursor caret overlays inside #pr-body and every
1198 * `[data-live-field]` element.
1199 * - Broadcasts the local user's cursor position (selectionStart /
1200 * selectionEnd) debounced at 100ms.
1201 * - Broadcasts content patches (`replace` of the whole textarea —
1202 * last-write-wins v1) debounced at 250ms.
1203 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1204 * to the matching local field if untouched.
1205 *
1206 * All endpoint URLs are JSON-escaped via safe replacements so they
1207 * can't break out of the <script> tag.
1208 */
1209function LIVE_COEDIT_SCRIPT(prId: string): string {
1210 const idJson = JSON.stringify(prId)
1211 .split("<").join("\\u003C")
1212 .split(">").join("\\u003E")
1213 .split("&").join("\\u0026");
1214 return (
1215 "(function(){try{" +
1216 "if(typeof EventSource==='undefined')return;" +
1217 "var prId=" + idJson + ";" +
1218 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1219 "var pill=document.getElementById('live-pill');" +
1220 "var avEl=document.getElementById('live-avatars');" +
1221 "var countEl=document.getElementById('live-count');" +
1222 "var sessionId=null;var myColor=null;" +
1223 "var presence={};" + // sessionId -> {color,status,userId,initials}
1224 "var lastApplied={};" + // field -> last server value (for echo suppression)
1225 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1226 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1227 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1228 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1229 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1230 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1231 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1232 "avEl.innerHTML=html;}}" +
1233 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1234 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1235 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1236 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1237 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1238 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1239 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1240 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1241 "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);}" +
1242 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1243 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1244 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1245 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1246 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1247 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1248 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1249 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1250 "}catch(e){}" +
1251 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1252 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1253 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1254 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1255 "var es;var delay=1000;" +
1256 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1257 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1258 "(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){}});" +
1259 "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){}});" +
1260 "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){}});" +
1261 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1262 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1263 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1264 "var patch=d.patch;if(!patch||!patch.field)return;" +
1265 "var ta=fieldEl(patch.field);if(!ta)return;" +
1266 "if(document.activeElement===ta)return;" + // don't trample local typing
1267 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1268 "}catch(e){}});" +
1269 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1270 "}connect();" +
1271 "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){}}" +
1272 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1273 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1274 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1275 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1276 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1277 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1278 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1279 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1280 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1281 "}" +
1282 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1283 "var live=document.querySelectorAll('[data-live-field]');" +
1284 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1285 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1286 "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){}});" +
1287 "}catch(e){}})();"
1288 );
1289}
1290
0074234Claude1291async function resolveRepo(ownerName: string, repoName: string) {
1292 const [owner] = await db
1293 .select()
1294 .from(users)
1295 .where(eq(users.username, ownerName))
1296 .limit(1);
1297 if (!owner) return null;
1298 const [repo] = await db
1299 .select()
1300 .from(repositories)
1301 .where(
1302 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1303 )
1304 .limit(1);
1305 if (!repo) return null;
1306 return { owner, repo };
1307}
1308
1309// PR Nav helper
1310const PrNav = ({
1311 owner,
1312 repo,
1313 active,
1314}: {
1315 owner: string;
1316 repo: string;
1317 active: "code" | "issues" | "pulls" | "commits";
1318}) => (
bb0f894Claude1319 <TabNav
1320 tabs={[
1321 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1322 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1323 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1324 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1325 ]}
1326 />
0074234Claude1327);
1328
534f04aClaude1329/**
1330 * Block M3 — pre-merge risk score card. Pure presentational helper.
1331 * Rendered in the conversation tab above the gate checks block. Hidden
1332 * entirely when the PR is closed/merged or there is nothing cached and
1333 * nothing in-flight.
1334 */
1335function PrRiskCard({
1336 risk,
1337 calculating,
1338}: {
1339 risk: PrRiskScore | null;
1340 calculating: boolean;
1341}) {
1342 if (!risk) {
1343 return (
1344 <div
1345 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1346 >
1347 <strong style="font-size: 13px; color: var(--text)">
1348 Risk score: calculating…
1349 </strong>
1350 <div style="font-size: 12px; margin-top: 4px">
1351 Refresh in a moment to see the pre-merge risk score for this PR.
1352 </div>
1353 </div>
1354 );
1355 }
1356
1357 const palette = riskBandPalette(risk.band);
1358 const label = riskBandLabel(risk.band);
1359
1360 return (
1361 <div
1362 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1363 >
1364 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1365 <strong>Risk score:</strong>
1366 <span style={`color:${palette.border};font-weight:600`}>
1367 {palette.icon} {label} ({risk.score}/10)
1368 </span>
1369 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1370 {risk.commitSha.slice(0, 7)}
1371 </span>
1372 </div>
1373 {risk.aiSummary && (
1374 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1375 {risk.aiSummary}
1376 </div>
1377 )}
1378 <details style="margin-top:10px">
1379 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1380 See full signal breakdown
1381 </summary>
1382 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1383 <li>files changed: {risk.signals.filesChanged}</li>
1384 <li>
1385 lines added/removed: {risk.signals.linesAdded} /{" "}
1386 {risk.signals.linesRemoved}
1387 </li>
1388 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1389 <li>
1390 schema migration touched:{" "}
1391 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1392 </li>
1393 <li>
1394 locked / sensitive path touched:{" "}
1395 {risk.signals.lockedPathTouched ? "yes" : "no"}
1396 </li>
1397 <li>
1398 adds new dependency:{" "}
1399 {risk.signals.addsNewDependency ? "yes" : "no"}
1400 </li>
1401 <li>
1402 bumps major dependency:{" "}
1403 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1404 </li>
1405 <li>
1406 tests added for new code:{" "}
1407 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1408 </li>
1409 <li>
1410 diff-minus-test ratio:{" "}
1411 {risk.signals.diffMinusTestRatio.toFixed(2)}
1412 </li>
1413 </ul>
1414 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1415 How is this calculated? The score is a transparent sum of
1416 weighted signals — see <code>src/lib/pr-risk.ts</code>
1417 {" "}<code>computePrRiskScore</code>.
1418 </div>
1419 </details>
1420 {calculating && (
1421 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1422 (recomputing for the latest commit — refresh to update)
1423 </div>
1424 )}
1425 </div>
1426 );
1427}
1428
1429function riskBandPalette(band: PrRiskScore["band"]): {
1430 border: string;
1431 icon: string;
1432} {
1433 switch (band) {
1434 case "low":
1435 return { border: "var(--green)", icon: "" };
1436 case "medium":
1437 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1438 case "high":
1439 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1440 case "critical":
1441 return { border: "var(--red)", icon: "\u{1F6D1}" };
1442 }
1443}
1444
1445function riskBandLabel(band: PrRiskScore["band"]): string {
1446 switch (band) {
1447 case "low":
1448 return "LOW";
1449 case "medium":
1450 return "MEDIUM";
1451 case "high":
1452 return "HIGH";
1453 case "critical":
1454 return "CRITICAL";
1455 }
1456}
1457
0074234Claude1458// List PRs
04f6b7fClaude1459pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1460 const { owner: ownerName, repo: repoName } = c.req.param();
1461 const user = c.get("user");
1462 const state = c.req.query("state") || "open";
1463
ea9ed4cClaude1464 // ── Loading skeleton (flag-gated) ──
1465 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
1466 // the user see the page structure before counts + select resolve.
1467 // Behind a flag for now — we don't ship flashes.
1468 if (c.req.query("skeleton") === "1") {
1469 return c.html(
1470 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1471 <RepoHeader owner={ownerName} repo={repoName} />
1472 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1473 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1474 <style
1475 dangerouslySetInnerHTML={{
1476 __html: `
1477 .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; }
1478 @keyframes prsSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1479 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
1480 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
1481 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
1482 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
1483 .prs-skel-row { height: 76px; border-radius: 12px; }
1484 `,
1485 }}
1486 />
1487 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
1488 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
1489 <div class="prs-skel-list" aria-hidden="true">
1490 {Array.from({ length: 6 }).map(() => (
1491 <div class="prs-skel prs-skel-row" />
1492 ))}
1493 </div>
1494 <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">
1495 Loading pull requests for {ownerName}/{repoName}…
1496 </span>
1497 </Layout>
1498 );
1499 }
1500
0074234Claude1501 const resolved = await resolveRepo(ownerName, repoName);
1502 if (!resolved) return c.notFound();
1503
6fc53bdClaude1504 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
1505 const stateFilter =
1506 state === "draft"
1507 ? and(
1508 eq(pullRequests.state, "open"),
1509 eq(pullRequests.isDraft, true)
1510 )
1511 : eq(pullRequests.state, state);
1512
0074234Claude1513 const prList = await db
1514 .select({
1515 pr: pullRequests,
1516 author: { username: users.username },
1517 })
1518 .from(pullRequests)
1519 .innerJoin(users, eq(pullRequests.authorId, users.id))
1520 .where(
6fc53bdClaude1521 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude1522 )
1523 .orderBy(desc(pullRequests.createdAt));
1524
1525 const [counts] = await db
1526 .select({
1527 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude1528 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude1529 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
1530 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
1531 })
1532 .from(pullRequests)
1533 .where(eq(pullRequests.repositoryId, resolved.repo.id));
1534
b078860Claude1535 const openCount = counts?.open ?? 0;
1536 const mergedCount = counts?.merged ?? 0;
1537 const closedCount = counts?.closed ?? 0;
1538 const draftCount = counts?.draft ?? 0;
1539 const allCount = openCount + mergedCount + closedCount;
1540
1541 // "All" is presentational only — the DB query for state='all' matches
1542 // nothing, so we render a friendlier empty state when picked. We do NOT
1543 // change the query logic to keep this commit purely visual.
1544 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
1545 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
1546 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
1547 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
1548 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
1549 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
1550 ];
1551 const isAllState = state === "all";
1552
0074234Claude1553 return c.html(
1554 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1555 <RepoHeader owner={ownerName} repo={repoName} />
1556 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude1557 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1558
1559 <div class="prs-hero">
1560 <div class="prs-hero-inner">
1561 <div class="prs-hero-text">
1562 <div class="prs-hero-eyebrow">Pull requests</div>
1563 <h1 class="prs-hero-title">
1564 Review, <span class="gradient-text">merge with AI</span>.
1565 </h1>
1566 <p class="prs-hero-sub">
1567 {openCount === 0 && allCount === 0
1568 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
1569 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
1570 </p>
1571 </div>
1572 {user && (
1573 <div class="prs-hero-actions">
1574 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
1575 + New pull request
1576 </a>
1577 </div>
1578 )}
1579 </div>
1580 </div>
1581
1582 <nav class="prs-tabs" aria-label="Pull request filters">
1583 {tabPills.map((t) => {
1584 const isActive =
1585 state === t.key ||
1586 (t.key === "open" &&
1587 state !== "merged" &&
1588 state !== "closed" &&
1589 state !== "all" &&
1590 state !== "draft");
1591 return (
1592 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
1593 <span>{t.label}</span>
1594 <span class="prs-tab-count">{t.count}</span>
1595 </a>
1596 );
1597 })}
1598 </nav>
1599
0074234Claude1600 {prList.length === 0 ? (
b078860Claude1601 <div class="prs-empty">
ea9ed4cClaude1602 <div class="prs-empty-inner">
1603 <strong>
1604 {isAllState
1605 ? "Pick a filter above to browse PRs."
1606 : `No ${state} pull requests.`}
1607 </strong>
1608 <p class="prs-empty-sub">
1609 {state === "open"
1610 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
1611 : isAllState
1612 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1613 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
1614 </p>
1615 <div class="prs-empty-cta">
1616 {user && state === "open" && (
1617 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
1618 + New pull request
1619 </a>
1620 )}
1621 {state !== "open" && (
1622 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
1623 View open PRs
1624 </a>
1625 )}
1626 <a href={`/${ownerName}/${repoName}`} class="btn">
1627 Back to code
1628 </a>
1629 </div>
1630 </div>
b078860Claude1631 </div>
0074234Claude1632 ) : (
b078860Claude1633 <div class="prs-list">
1634 {prList.map(({ pr, author }) => {
1635 const stateClass =
1636 pr.state === "open"
1637 ? pr.isDraft
1638 ? "state-draft"
1639 : "state-open"
1640 : pr.state === "merged"
1641 ? "state-merged"
1642 : "state-closed";
1643 const icon =
1644 pr.state === "open"
1645 ? pr.isDraft
1646 ? "◌"
1647 : "○"
1648 : pr.state === "merged"
1649 ? "⮌"
1650 : "✓";
1651 return (
1652 <a
1653 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1654 class="prs-row"
1655 style="text-decoration:none;color:inherit"
0074234Claude1656 >
b078860Claude1657 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
1658 {icon}
0074234Claude1659 </div>
b078860Claude1660 <div class="prs-row-body">
1661 <h3 class="prs-row-title">
1662 <span>{pr.title}</span>
1663 <span class="prs-row-number">#{pr.number}</span>
1664 </h3>
1665 <div class="prs-row-meta">
1666 <span
1667 class="prs-branch-chips"
1668 title={`${pr.headBranch} into ${pr.baseBranch}`}
1669 >
1670 <span class="prs-branch-chip">{pr.headBranch}</span>
1671 <span class="prs-branch-arrow">{"→"}</span>
1672 <span class="prs-branch-chip">{pr.baseBranch}</span>
1673 </span>
1674 <span>
1675 by{" "}
1676 <strong style="color:var(--text)">
1677 {author.username}
1678 </strong>{" "}
1679 {formatRelative(pr.createdAt)}
1680 </span>
1681 <span class="prs-row-tags">
1682 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
1683 {pr.state === "merged" && (
1684 <span class="prs-tag is-merged">Merged</span>
1685 )}
1686 </span>
1687 </div>
0074234Claude1688 </div>
b078860Claude1689 </a>
1690 );
1691 })}
1692 </div>
0074234Claude1693 )}
1694 </Layout>
1695 );
1696});
1697
1698// New PR form
1699pulls.get(
1700 "/:owner/:repo/pulls/new",
1701 softAuth,
1702 requireAuth,
04f6b7fClaude1703 requireRepoAccess("write"),
0074234Claude1704 async (c) => {
1705 const { owner: ownerName, repo: repoName } = c.req.param();
1706 const user = c.get("user")!;
1707 const branches = await listBranches(ownerName, repoName);
1708 const error = c.req.query("error");
1709 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude1710 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude1711
1712 return c.html(
1713 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
1714 <RepoHeader owner={ownerName} repo={repoName} />
1715 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude1716 <Container maxWidth={800}>
1717 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude1718 {error && (
bb0f894Claude1719 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude1720 )}
0316dbbClaude1721 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
1722 <Flex gap={12} align="center" style="margin-bottom: 16px">
1723 <Select name="base">
0074234Claude1724 {branches.map((b) => (
1725 <option value={b} selected={b === defaultBase}>
1726 {b}
1727 </option>
1728 ))}
bb0f894Claude1729 </Select>
1730 <Text muted>&larr;</Text>
1731 <Select name="head">
0074234Claude1732 {branches
1733 .filter((b) => b !== defaultBase)
1734 .concat(defaultBase === branches[0] ? [] : [branches[0]])
1735 .map((b) => (
1736 <option value={b}>{b}</option>
1737 ))}
bb0f894Claude1738 </Select>
1739 </Flex>
1740 <FormGroup>
1741 <Input
0074234Claude1742 name="title"
1743 required
1744 placeholder="Title"
bb0f894Claude1745 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]1746 aria-label="Pull request title"
0074234Claude1747 />
bb0f894Claude1748 </FormGroup>
1749 <FormGroup>
1750 <TextArea
0074234Claude1751 name="body"
81c73c1Claude1752 id="pr-body"
0074234Claude1753 rows={8}
1754 placeholder="Description (Markdown supported)"
bb0f894Claude1755 mono
0074234Claude1756 />
bb0f894Claude1757 </FormGroup>
81c73c1Claude1758 <Flex gap={8} align="center">
1759 <Button type="submit" variant="primary">
1760 Create pull request
1761 </Button>
1762 <button
1763 type="button"
1764 id="ai-suggest-desc"
1765 class="btn"
1766 style="font-weight:500"
1767 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
1768 >
1769 Suggest description with AI
1770 </button>
1771 <span
1772 id="ai-suggest-status"
1773 style="color:var(--text-muted);font-size:13px"
1774 />
1775 </Flex>
bb0f894Claude1776 </Form>
81c73c1Claude1777 <script
1778 dangerouslySetInnerHTML={{
1779 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
1780 }}
1781 />
bb0f894Claude1782 </Container>
0074234Claude1783 </Layout>
1784 );
1785 }
1786);
1787
81c73c1Claude1788// AI-suggested PR description — JSON endpoint driven by the form button.
1789// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
1790// 200; the inline script reads `ok` to decide what to do.
1791pulls.post(
1792 "/:owner/:repo/ai/pr-description",
1793 softAuth,
1794 requireAuth,
1795 requireRepoAccess("write"),
1796 async (c) => {
1797 const { owner: ownerName, repo: repoName } = c.req.param();
1798 if (!isAiAvailable()) {
1799 return c.json({
1800 ok: false,
1801 error: "AI is not available — set ANTHROPIC_API_KEY.",
1802 });
1803 }
1804 const body = await c.req.parseBody();
1805 const title = String(body.title || "").trim();
1806 const baseBranch = String(body.base || "").trim();
1807 const headBranch = String(body.head || "").trim();
1808 if (!baseBranch || !headBranch) {
1809 return c.json({ ok: false, error: "Pick base + head branches first." });
1810 }
1811 if (baseBranch === headBranch) {
1812 return c.json({ ok: false, error: "Base and head must differ." });
1813 }
1814
1815 let diff = "";
1816 try {
1817 const cwd = getRepoPath(ownerName, repoName);
1818 const proc = Bun.spawn(
1819 [
1820 "git",
1821 "diff",
1822 `${baseBranch}...${headBranch}`,
1823 "--",
1824 ],
1825 { cwd, stdout: "pipe", stderr: "pipe" }
1826 );
6ea2109Claude1827 // 30s ceiling — without this a pathological diff (huge binary or
1828 // a corrupt ref) hangs the request indefinitely.
1829 const killer = setTimeout(() => proc.kill(), 30_000);
1830 try {
1831 diff = await new Response(proc.stdout).text();
1832 await proc.exited;
1833 } finally {
1834 clearTimeout(killer);
1835 }
81c73c1Claude1836 } catch {
1837 diff = "";
1838 }
1839 if (!diff.trim()) {
1840 return c.json({
1841 ok: false,
1842 error: "No diff between branches — nothing to summarise.",
1843 });
1844 }
1845
1846 let summary = "";
1847 try {
1848 summary = await generatePrSummary(title || "(untitled)", diff);
1849 } catch (err) {
1850 const msg = err instanceof Error ? err.message : "AI request failed.";
1851 return c.json({ ok: false, error: msg });
1852 }
1853 if (!summary.trim()) {
1854 return c.json({ ok: false, error: "AI returned an empty draft." });
1855 }
1856 return c.json({ ok: true, body: summary });
1857 }
1858);
1859
0074234Claude1860// Create PR
1861pulls.post(
1862 "/:owner/:repo/pulls/new",
1863 softAuth,
1864 requireAuth,
04f6b7fClaude1865 requireRepoAccess("write"),
0074234Claude1866 async (c) => {
1867 const { owner: ownerName, repo: repoName } = c.req.param();
1868 const user = c.get("user")!;
1869 const body = await c.req.parseBody();
1870 const title = String(body.title || "").trim();
1871 const prBody = String(body.body || "").trim();
1872 const baseBranch = String(body.base || "main");
1873 const headBranch = String(body.head || "");
1874
1875 if (!title || !headBranch) {
1876 return c.redirect(
1877 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
1878 );
1879 }
1880
1881 if (baseBranch === headBranch) {
1882 return c.redirect(
1883 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
1884 );
1885 }
1886
1887 const resolved = await resolveRepo(ownerName, repoName);
1888 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1889
6fc53bdClaude1890 const isDraft = String(body.draft || "") === "1";
1891
0074234Claude1892 const [pr] = await db
1893 .insert(pullRequests)
1894 .values({
1895 repositoryId: resolved.repo.id,
1896 authorId: user.id,
1897 title,
1898 body: prBody || null,
1899 baseBranch,
1900 headBranch,
6fc53bdClaude1901 isDraft,
0074234Claude1902 })
1903 .returning();
1904
6fc53bdClaude1905 // Skip AI review on drafts — it runs again when the PR is marked ready.
1906 if (!isDraft && isAiReviewEnabled()) {
e883329Claude1907 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
1908 (err) => console.error("[ai-review] Failed:", err)
1909 );
1910 }
1911
3cbe3d6Claude1912 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
1913 triggerPrTriage({
1914 ownerName,
1915 repoName,
1916 repositoryId: resolved.repo.id,
1917 prId: pr.id,
1918 prAuthorId: user.id,
1919 title,
1920 body: prBody,
1921 baseBranch,
1922 headBranch,
1923 }).catch((err) => console.error("[pr-triage] Failed:", err));
1924
1d4ff60Claude1925 // Chat notifier — fan out to Slack/Discord/Teams.
1926 import("../lib/chat-notifier")
1927 .then((m) =>
1928 m.notifyChatChannels({
1929 ownerUserId: resolved.repo.ownerId,
1930 repositoryId: resolved.repo.id,
1931 event: {
1932 event: "pr.opened",
1933 repo: `${ownerName}/${repoName}`,
1934 title: `#${pr.number} ${title}`,
1935 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
1936 body: prBody || undefined,
1937 actor: user.username,
1938 },
1939 })
1940 )
1941 .catch((err) =>
1942 console.warn(`[chat-notifier] PR opened notify failed:`, err)
1943 );
1944
9dd96b9Test User1945 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude1946 import("../lib/auto-merge")
1947 .then((m) => m.tryAutoMergeNow(pr.id))
1948 .catch((err) => {
1949 console.warn(
1950 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
1951 err instanceof Error ? err.message : err
1952 );
1953 });
9dd96b9Test User1954
0074234Claude1955 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
1956 }
1957);
1958
1959// View single PR
04f6b7fClaude1960pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1961 const { owner: ownerName, repo: repoName } = c.req.param();
1962 const prNum = parseInt(c.req.param("number"), 10);
1963 const user = c.get("user");
1964 const tab = c.req.query("tab") || "conversation";
1965
1966 const resolved = await resolveRepo(ownerName, repoName);
1967 if (!resolved) return c.notFound();
1968
1969 const [pr] = await db
1970 .select()
1971 .from(pullRequests)
1972 .where(
1973 and(
1974 eq(pullRequests.repositoryId, resolved.repo.id),
1975 eq(pullRequests.number, prNum)
1976 )
1977 )
1978 .limit(1);
1979
1980 if (!pr) return c.notFound();
1981
1982 const [author] = await db
1983 .select()
1984 .from(users)
1985 .where(eq(users.id, pr.authorId))
1986 .limit(1);
1987
1988 const comments = await db
1989 .select({
1990 comment: prComments,
1991 author: { username: users.username },
1992 })
1993 .from(prComments)
1994 .innerJoin(users, eq(prComments.authorId, users.id))
1995 .where(eq(prComments.pullRequestId, pr.id))
1996 .orderBy(asc(prComments.createdAt));
1997
6fc53bdClaude1998 // Reactions for the PR body + each comment, in parallel.
1999 const [prReactions, ...prCommentReactions] = await Promise.all([
2000 summariseReactions("pr", pr.id, user?.id),
2001 ...comments.map((row) =>
2002 summariseReactions("pr_comment", row.comment.id, user?.id)
2003 ),
2004 ]);
2005
0074234Claude2006 const canManage =
2007 user &&
2008 (user.id === resolved.owner.id || user.id === pr.authorId);
2009
1d4ff60Claude2010 // Has any previous AI-test-generator run already tagged this PR? Used
2011 // both to hide the "Generate tests with AI" button and to short-circuit
2012 // the explicit POST handler.
2013 const hasAiTestsMarker = comments.some(({ comment }) =>
2014 (comment.body || "").includes(AI_TESTS_MARKER)
2015 );
2016
e883329Claude2017 const error = c.req.query("error");
c3e0c07Claude2018 const info = c.req.query("info");
e883329Claude2019
2020 // Get gate check status for open PRs
2021 let gateChecks: GateCheckResult[] = [];
2022 if (pr.state === "open") {
2023 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2024 if (headSha) {
2025 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
2026 const aiApproved = aiComments.length === 0 || aiComments.some(
2027 ({ comment }) => comment.body.includes("**Approved**")
2028 );
2029 const gateResult = await runAllGateChecks(
2030 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
2031 );
2032 gateChecks = gateResult.checks;
2033 }
2034 }
2035
534f04aClaude2036 // Block M3 — pre-merge risk score. Cache-only on the request path so
2037 // the page never waits on Haiku. On a cache miss for an open PR we
2038 // kick off the computation fire-and-forget; the next refresh shows it.
2039 let prRisk: PrRiskScore | null = null;
2040 let prRiskCalculating = false;
2041 if (pr.state === "open") {
2042 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
2043 if (!prRisk) {
2044 prRiskCalculating = true;
a28cedeClaude2045 void computePrRiskForPullRequest(pr.id).catch((err) => {
2046 console.warn(
2047 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
2048 err instanceof Error ? err.message : err
2049 );
2050 });
534f04aClaude2051 }
2052 }
2053
4bbacbeClaude2054 // Migration 0062 — per-branch preview URL. The head branch always
2055 // has a preview row (unless it's the default branch, which never
2056 // happens for an open PR) once it has been pushed at least once.
2057 const preview = await getPreviewForBranch(
2058 (resolved.repo as { id: string }).id,
2059 pr.headBranch
2060 );
2061
0074234Claude2062 // Get diff for "Files changed" tab
2063 let diffRaw = "";
2064 let diffFiles: GitDiffFile[] = [];
2065 if (tab === "files") {
2066 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude2067 // Run the two git diffs in parallel — they're independent reads of
2068 // the same range. Previously sequential, doubling the wall time on
2069 // big PRs (100+ files = 10-30s for no reason).
0074234Claude2070 const proc = Bun.spawn(
2071 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
2072 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2073 );
2074 const statProc = Bun.spawn(
2075 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
2076 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2077 );
6ea2109Claude2078 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
2079 // would otherwise hang the whole request.
2080 const killer = setTimeout(() => {
2081 proc.kill();
2082 statProc.kill();
2083 }, 30_000);
2084 let stat = "";
2085 try {
2086 [diffRaw, stat] = await Promise.all([
2087 new Response(proc.stdout).text(),
2088 new Response(statProc.stdout).text(),
2089 ]);
2090 await Promise.all([proc.exited, statProc.exited]);
2091 } finally {
2092 clearTimeout(killer);
2093 }
0074234Claude2094
2095 diffFiles = stat
2096 .trim()
2097 .split("\n")
2098 .filter(Boolean)
2099 .map((line) => {
2100 const [add, del, filePath] = line.split("\t");
2101 return {
2102 path: filePath,
2103 status: "modified",
2104 additions: add === "-" ? 0 : parseInt(add, 10),
2105 deletions: del === "-" ? 0 : parseInt(del, 10),
2106 patch: "",
2107 };
2108 });
2109 }
2110
b078860Claude2111 // ─── Derived visual state ───
2112 const stateKey =
2113 pr.state === "open"
2114 ? pr.isDraft
2115 ? "draft"
2116 : "open"
2117 : pr.state;
2118 const stateLabel =
2119 stateKey === "open"
2120 ? "Open"
2121 : stateKey === "draft"
2122 ? "Draft"
2123 : stateKey === "merged"
2124 ? "Merged"
2125 : "Closed";
2126 const stateIcon =
2127 stateKey === "open"
2128 ? "○"
2129 : stateKey === "draft"
2130 ? "◌"
2131 : stateKey === "merged"
2132 ? "⮌"
2133 : "✓";
2134 const commentCount = comments.length;
2135 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
2136 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
2137 const mergeBlocked =
2138 gateChecks.length > 0 &&
2139 gateChecks.some(
2140 (c) => !c.passed && c.name !== "Merge check"
2141 );
2142
0074234Claude2143 return c.html(
2144 <Layout
2145 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
2146 user={user}
2147 >
2148 <RepoHeader owner={ownerName} repo={repoName} />
2149 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b078860Claude2150 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude2151 <div
2152 id="live-comment-banner"
2153 class="alert"
2154 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
2155 >
2156 <strong class="js-live-count">0</strong> new comment(s) —{" "}
2157 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
2158 reload to view
2159 </a>
2160 </div>
2161 <script
2162 dangerouslySetInnerHTML={{
2163 __html: liveCommentBannerScript({
2164 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
2165 bannerElementId: "live-comment-banner",
2166 }),
2167 }}
2168 />
b078860Claude2169
2170 <div class="prs-detail-hero">
2171 <h1 class="prs-detail-title">
0074234Claude2172 {pr.title}{" "}
b078860Claude2173 <span class="prs-detail-num">#{pr.number}</span>
2174 </h1>
2175 <div class="prs-detail-meta">
2176 <span class={`prs-state-pill state-${stateKey}`}>
2177 <span aria-hidden="true">{stateIcon}</span>
2178 <span>{stateLabel}</span>
2179 </span>
2180 <span>
2181 <strong>{author?.username}</strong> wants to merge
2182 </span>
2183 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
2184 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
2185 <span class="prs-branch-arrow-lg">{"→"}</span>
2186 <span class="prs-branch-pill">{pr.baseBranch}</span>
2187 </span>
2188 <span>opened {formatRelative(pr.createdAt)}</span>
3c03977Claude2189 <span
2190 id="live-pill"
2191 class="live-pill"
2192 title="People editing this PR right now"
2193 >
2194 <span class="live-pill-dot" aria-hidden="true"></span>
2195 <span>
2196 Live: <strong id="live-count">0</strong> editing
2197 </span>
2198 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
2199 </span>
4bbacbeClaude2200 {preview && (
2201 <a
2202 class={`preview-prpill is-${preview.status}`}
2203 href={
2204 preview.status === "ready"
2205 ? preview.previewUrl
2206 : `/${ownerName}/${repoName}/previews`
2207 }
2208 target={preview.status === "ready" ? "_blank" : undefined}
2209 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
2210 title={`Preview · ${previewStatusLabel(preview.status)}`}
2211 >
2212 <span class="preview-prpill-dot" aria-hidden="true"></span>
2213 <span>Preview: </span>
2214 <span>{previewStatusLabel(preview.status)}</span>
2215 </a>
2216 )}
b078860Claude2217 {canManage && pr.state === "open" && pr.isDraft && (
2218 <form
2219 method="post"
2220 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2221 class="prs-inline-form prs-detail-actions"
2222 >
2223 <button type="submit" class="prs-merge-ready-btn">
2224 Ready for review
2225 </button>
2226 </form>
2227 )}
2228 </div>
2229 </div>
3c03977Claude2230 <script
2231 dangerouslySetInnerHTML={{
2232 __html: LIVE_COEDIT_SCRIPT(pr.id),
2233 }}
2234 />
0074234Claude2235
b078860Claude2236 <nav class="prs-detail-tabs" aria-label="Pull request sections">
2237 <a
2238 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
2239 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2240 >
2241 Conversation
2242 <span class="prs-detail-tab-count">{commentCount}</span>
2243 </a>
2244 <a
2245 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
2246 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2247 >
2248 Files changed
2249 {diffFiles.length > 0 && (
2250 <span class="prs-detail-tab-count">{diffFiles.length}</span>
2251 )}
2252 </a>
2253 </nav>
2254
2255 {tab === "files" ? (
ea9ed4cClaude2256 <DiffView
2257 raw={diffRaw}
2258 files={diffFiles}
2259 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
2260 />
b078860Claude2261 ) : (
2262 <>
2263 {pr.body && (
2264 <CommentBox
2265 author={author?.username ?? "unknown"}
2266 date={pr.createdAt}
2267 body={renderMarkdown(pr.body)}
2268 />
2269 )}
2270
15db0e0Claude2271 {comments.map(({ comment, author: commentAuthor }) => {
2272 const slashCmd = detectSlashCmdComment(comment.body);
2273 if (slashCmd) {
2274 const visible = stripSlashCmdMarker(comment.body);
2275 return (
2276 <div class={`slash-pill slash-cmd-${slashCmd}`}>
2277 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
2278 <span class="slash-pill-actor">
2279 <strong>{commentAuthor.username}</strong>
2280 {" ran "}
2281 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude2282 </span>
15db0e0Claude2283 <span class="slash-pill-time">
2284 {formatRelative(comment.createdAt)}
2285 </span>
2286 <div class="slash-pill-body">
2287 <MarkdownContent html={renderMarkdown(visible)} />
2288 </div>
2289 </div>
2290 );
2291 }
2292 return (
2293 <div class={`prs-comment${comment.isAiReview ? " is-ai" : ""}`}>
2294 <div class="prs-comment-head">
2295 <strong>{commentAuthor.username}</strong>
2296 {comment.isAiReview && (
2297 <span class="prs-ai-badge">AI Review</span>
2298 )}
2299 <span class="prs-comment-time">
2300 commented {formatRelative(comment.createdAt)}
2301 </span>
2302 {comment.filePath && (
2303 <span class="prs-comment-loc">
2304 {comment.filePath}
2305 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
2306 </span>
2307 )}
2308 </div>
2309 <div class="prs-comment-body">
2310 <MarkdownContent html={renderMarkdown(comment.body)} />
2311 </div>
0074234Claude2312 </div>
15db0e0Claude2313 );
2314 })}
0074234Claude2315
b078860Claude2316 {/* Quick link to the Files changed tab when there's a diff to look at. */}
2317 {pr.state !== "merged" && (
2318 <a
2319 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2320 class="prs-files-card"
2321 >
2322 <span class="prs-files-card-icon" aria-hidden="true">
2323 {"▤"}
2324 </span>
2325 <div class="prs-files-card-text">
2326 <p class="prs-files-card-title">Files changed</p>
2327 <p class="prs-files-card-sub">
2328 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
2329 </p>
e883329Claude2330 </div>
b078860Claude2331 <span class="prs-files-card-cta">View diff {"→"}</span>
2332 </a>
2333 )}
2334
2335 {error && (
2336 <div
2337 class="auth-error"
2338 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)"
2339 >
2340 {decodeURIComponent(error)}
2341 </div>
2342 )}
2343
2344 {info && (
2345 <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)">
2346 {decodeURIComponent(info)}
2347 </div>
2348 )}
e883329Claude2349
b078860Claude2350 {pr.state === "open" && (prRisk || prRiskCalculating) && (
2351 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
2352 )}
2353
2354 {pr.state === "open" && gateChecks.length > 0 && (
2355 <div class="prs-gate-card">
2356 <div class="prs-gate-head">
2357 <h3>Gate checks</h3>
2358 <span class="prs-gate-summary">
2359 {gatesAllPassed
2360 ? `All ${gateChecks.length} checks passed`
2361 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
2362 </span>
c3e0c07Claude2363 </div>
b078860Claude2364 {gateChecks.map((check) => {
2365 const isAi = /ai.*review/i.test(check.name);
2366 const isSkip = check.skipped === true;
2367 const statusClass = isSkip
2368 ? "is-skip"
2369 : check.passed
2370 ? "is-pass"
2371 : "is-fail";
2372 const statusGlyph = isSkip
2373 ? "—"
2374 : check.passed
2375 ? "✓"
2376 : "✗";
2377 const statusLabel = isSkip
2378 ? "Skipped"
2379 : check.passed
2380 ? "Passed"
2381 : "Failing";
2382 return (
2383 <div
2384 class="prs-gate-row"
2385 style={
2386 isAi
2387 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
2388 : ""
2389 }
2390 >
2391 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
2392 {statusGlyph}
2393 </span>
2394 <span class="prs-gate-name">
2395 {check.name}
2396 {isAi && (
2397 <span
2398 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"
2399 >
2400 AI
2401 </span>
2402 )}
2403 </span>
2404 <span class="prs-gate-details">{check.details}</span>
2405 <span class={`prs-gate-pill ${statusClass}`}>
2406 {statusLabel}
e883329Claude2407 </span>
2408 </div>
b078860Claude2409 );
2410 })}
2411 <div class="prs-gate-footer">
2412 {gatesAllPassed
2413 ? "All checks passed — ready to merge."
2414 : gateChecks.some(
2415 (c) => !c.passed && c.name === "Merge check"
2416 )
2417 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
2418 : "Some checks failed — resolve issues before merging."}
2419 {aiReviewCount > 0 && (
2420 <>
2421 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
2422 </>
2423 )}
2424 </div>
2425 </div>
2426 )}
2427
2428 {/* ─── Merge area / state-aware action card ─────────────── */}
2429 {user && pr.state === "open" && (
2430 <div
2431 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
2432 >
2433 <div class="prs-merge-head">
2434 <strong>
2435 {pr.isDraft
2436 ? "Draft — ready for review?"
2437 : mergeBlocked
2438 ? "Merge blocked"
2439 : "Ready to merge"}
2440 </strong>
e883329Claude2441 </div>
b078860Claude2442 <p class="prs-merge-sub">
2443 {pr.isDraft
2444 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
2445 : mergeBlocked
2446 ? "Resolve the failing gate checks above before this PR can land."
2447 : gateChecks.length > 0
2448 ? gatesAllPassed
2449 ? "All gates green. Merge will fast-forward into the base branch."
2450 : "Conflicts will be auto-resolved by GlueCron AI on merge."
2451 : "Run gate checks by refreshing once your branch has a recent commit."}
2452 </p>
2453 <Form
2454 method="post"
2455 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
2456 >
2457 <FormGroup>
3c03977Claude2458 <div class="live-cursor-host" style="position:relative">
2459 <textarea
2460 name="body"
2461 id="pr-comment-body"
2462 data-live-field="comment_new"
2463 rows={5}
2464 required
2465 placeholder="Leave a comment... (Markdown supported)"
2466 style="font-family:var(--font-mono);font-size:13px;width:100%"
2467 ></textarea>
2468 </div>
15db0e0Claude2469 <span class="slash-hint" title="Type a slash-command as the first line">
2470 Type <code>/</code> for commands —{" "}
2471 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
2472 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
2473 </span>
b078860Claude2474 </FormGroup>
2475 <div class="prs-merge-actions">
2476 <Button type="submit" variant="primary">
2477 Comment
2478 </Button>
2479 {canManage && (
2480 <>
2481 {pr.isDraft ? (
2482 <button
2483 type="submit"
2484 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2485 formnovalidate
2486 class="prs-merge-ready-btn"
2487 >
2488 Ready for review
2489 </button>
2490 ) : (
0074234Claude2491 <button
2492 type="submit"
2493 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
b078860Claude2494 formnovalidate
2495 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
2496 title={
2497 mergeBlocked
2498 ? "Failing gate checks must be resolved before this PR can merge."
2499 : "Merge pull request"
2500 }
0074234Claude2501 >
b078860Claude2502 {"✔"} Merge pull request
0074234Claude2503 </button>
b078860Claude2504 )}
2505 {!pr.isDraft && (
2506 <button
0074234Claude2507 type="submit"
b078860Claude2508 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
2509 formnovalidate
2510 class="prs-merge-back-draft"
2511 title="Convert back to draft"
0074234Claude2512 >
b078860Claude2513 Convert to draft
2514 </button>
2515 )}
2516 {isAiReviewEnabled() && (
2517 <button
2518 type="submit"
2519 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
2520 formnovalidate
2521 class="btn"
2522 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
2523 >
2524 Re-run AI review
2525 </button>
2526 )}
1d4ff60Claude2527 {isAiReviewEnabled() && !hasAiTestsMarker && (
2528 <button
2529 type="submit"
2530 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
2531 formnovalidate
2532 class="btn"
2533 title="Ask Claude to read this PR's diff and write tests for the new code. Tests land as a follow-up PR against this branch."
2534 >
2535 Generate tests with AI
2536 </button>
2537 )}
b078860Claude2538 <Button
2539 type="submit"
2540 variant="danger"
2541 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
2542 >
2543 Close
2544 </Button>
2545 </>
2546 )}
2547 </div>
2548 </Form>
2549 </div>
2550 )}
2551
2552 {/* Read-only footers for non-open states. */}
2553 {pr.state === "merged" && (
2554 <div class="prs-merge-card is-merged">
2555 <div class="prs-merge-head">
2556 <strong>{"⮌"} Merged</strong>
0074234Claude2557 </div>
b078860Claude2558 <p class="prs-merge-sub">
2559 This pull request was merged into{" "}
2560 <code>{pr.baseBranch}</code>.
2561 </p>
2562 </div>
2563 )}
2564 {pr.state === "closed" && (
2565 <div class="prs-merge-card is-closed">
2566 <div class="prs-merge-head">
2567 <strong>{"✕"} Closed without merging</strong>
2568 </div>
2569 <p class="prs-merge-sub">
2570 This pull request was closed and not merged.
2571 </p>
2572 </div>
2573 )}
2574 </>
2575 )}
0074234Claude2576 </Layout>
2577 );
2578});
2579
2580// Add comment to PR
2581pulls.post(
2582 "/:owner/:repo/pulls/:number/comment",
2583 softAuth,
2584 requireAuth,
04f6b7fClaude2585 requireRepoAccess("write"),
0074234Claude2586 async (c) => {
2587 const { owner: ownerName, repo: repoName } = c.req.param();
2588 const prNum = parseInt(c.req.param("number"), 10);
2589 const user = c.get("user")!;
2590 const body = await c.req.parseBody();
2591 const commentBody = String(body.body || "").trim();
2592
2593 if (!commentBody) {
2594 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2595 }
2596
2597 const resolved = await resolveRepo(ownerName, repoName);
2598 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2599
2600 const [pr] = await db
2601 .select()
2602 .from(pullRequests)
2603 .where(
2604 and(
2605 eq(pullRequests.repositoryId, resolved.repo.id),
2606 eq(pullRequests.number, prNum)
2607 )
2608 )
2609 .limit(1);
2610
2611 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2612
d4ac5c3Claude2613 const [inserted] = await db
2614 .insert(prComments)
2615 .values({
2616 pullRequestId: pr.id,
2617 authorId: user.id,
2618 body: commentBody,
2619 })
2620 .returning();
2621
2622 // Live update: nudge any browser tabs subscribed to this PR.
2623 if (inserted) {
2624 try {
2625 const { publish } = await import("../lib/sse");
2626 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
2627 event: "pr-comment",
2628 data: {
2629 pullRequestId: pr.id,
2630 commentId: inserted.id,
2631 authorId: user.id,
2632 authorUsername: user.username,
2633 },
2634 });
2635 } catch {
2636 /* SSE is best-effort */
2637 }
2638 }
0074234Claude2639
15db0e0Claude2640 // Slash-command handoff. We always store the original comment above
2641 // first so free-form text that happens to start with `/` is preserved
2642 // verbatim; only recognised commands trigger a follow-up bot comment.
2643 const parsed = parseSlashCommand(commentBody);
2644 if (parsed) {
2645 try {
2646 const result = await executeSlashCommand({
2647 command: parsed.command,
2648 args: parsed.args,
2649 prId: pr.id,
2650 userId: user.id,
2651 repositoryId: resolved.repo.id,
2652 });
2653 await db.insert(prComments).values({
2654 pullRequestId: pr.id,
2655 authorId: user.id,
2656 body: result.body,
2657 });
2658 } catch (err) {
2659 // Defence-in-depth — executeSlashCommand promises not to throw,
2660 // but if it ever does we want the PR thread to know.
2661 await db
2662 .insert(prComments)
2663 .values({
2664 pullRequestId: pr.id,
2665 authorId: user.id,
2666 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
2667 })
2668 .catch(() => {});
2669 }
2670 }
2671
0074234Claude2672 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2673 }
2674);
2675
e883329Claude2676// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude2677// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
2678// but we keep it at "write" for v1 so trusted collaborators can ship.
2679// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
2680// surface. Branch-protection rules (evaluated below) are the current mechanism
2681// for locking down merges further on specific branches.
0074234Claude2682pulls.post(
2683 "/:owner/:repo/pulls/:number/merge",
2684 softAuth,
2685 requireAuth,
04f6b7fClaude2686 requireRepoAccess("write"),
0074234Claude2687 async (c) => {
2688 const { owner: ownerName, repo: repoName } = c.req.param();
2689 const prNum = parseInt(c.req.param("number"), 10);
2690 const user = c.get("user")!;
2691
2692 const resolved = await resolveRepo(ownerName, repoName);
2693 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2694
2695 const [pr] = await db
2696 .select()
2697 .from(pullRequests)
2698 .where(
2699 and(
2700 eq(pullRequests.repositoryId, resolved.repo.id),
2701 eq(pullRequests.number, prNum)
2702 )
2703 )
2704 .limit(1);
2705
2706 if (!pr || pr.state !== "open") {
2707 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2708 }
2709
6fc53bdClaude2710 // Draft PRs cannot be merged — must be marked ready first.
2711 if (pr.isDraft) {
2712 return c.redirect(
2713 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2714 "This PR is a draft. Mark it as ready for review before merging."
2715 )}`
2716 );
2717 }
2718
e883329Claude2719 // Resolve head SHA
2720 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2721 if (!headSha) {
2722 return c.redirect(
2723 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
2724 );
2725 }
2726
2727 // Check if AI review approved this PR
2728 const aiComments = await db
2729 .select()
2730 .from(prComments)
2731 .where(
2732 and(
2733 eq(prComments.pullRequestId, pr.id),
2734 eq(prComments.isAiReview, true)
2735 )
2736 );
2737 const aiApproved = aiComments.length === 0 || aiComments.some(
2738 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude2739 );
e883329Claude2740
2741 // Run all green gate checks (GateTest + mergeability + AI review)
2742 const gateResult = await runAllGateChecks(
2743 ownerName,
2744 repoName,
2745 pr.baseBranch,
2746 pr.headBranch,
2747 headSha,
2748 aiApproved
0074234Claude2749 );
2750
e883329Claude2751 // If GateTest or AI review failed (hard blocks), reject the merge
2752 const hardFailures = gateResult.checks.filter(
2753 (check) => !check.passed && check.name !== "Merge check"
2754 );
2755 if (hardFailures.length > 0) {
2756 const errorMsg = hardFailures
2757 .map((f) => `${f.name}: ${f.details}`)
2758 .join("; ");
0074234Claude2759 return c.redirect(
e883329Claude2760 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude2761 );
2762 }
2763
1e162a8Claude2764 // D5 — Branch-protection enforcement. Looks up the matching rule for the
2765 // base branch and blocks the merge if requireAiApproval / requireGreenGates
2766 // / requireHumanReview / requiredApprovals are not satisfied. Independent
2767 // of repo-global settings, so owners can lock specific branches down
2768 // further than the repo default.
2769 const protectionRule = await matchProtection(
2770 resolved.repo.id,
2771 pr.baseBranch
2772 );
2773 if (protectionRule) {
2774 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude2775 const required = await listRequiredChecks(protectionRule.id);
2776 const passingNames = required.length > 0
2777 ? await passingCheckNames(resolved.repo.id, headSha)
2778 : [];
2779 const decision = evaluateProtection(
2780 protectionRule,
2781 {
2782 aiApproved,
2783 humanApprovalCount: humanApprovals,
2784 gateResultGreen: hardFailures.length === 0,
2785 hasFailedGates: hardFailures.length > 0,
2786 passingCheckNames: passingNames,
2787 },
2788 required.map((r) => r.checkName)
2789 );
1e162a8Claude2790 if (!decision.allowed) {
2791 return c.redirect(
2792 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2793 decision.reasons.join(" ")
2794 )}`
2795 );
2796 }
2797 }
2798
e883329Claude2799 // Attempt the merge — with auto conflict resolution if needed
2800 const repoDir = getRepoPath(ownerName, repoName);
2801 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
2802 const hasConflicts = mergeCheck && !mergeCheck.passed;
2803
2804 if (hasConflicts && isAiReviewEnabled()) {
2805 // Use Claude to auto-resolve conflicts
2806 const mergeResult = await mergeWithAutoResolve(
2807 ownerName,
2808 repoName,
2809 pr.baseBranch,
2810 pr.headBranch,
2811 `Merge pull request #${pr.number}: ${pr.title}`
2812 );
2813
2814 if (!mergeResult.success) {
2815 return c.redirect(
2816 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
2817 );
2818 }
2819
2820 // Post a comment about the auto-resolution
2821 if (mergeResult.resolvedFiles.length > 0) {
2822 await db.insert(prComments).values({
2823 pullRequestId: pr.id,
2824 authorId: user.id,
2825 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
2826 isAiReview: true,
2827 });
2828 }
2829 } else {
2830 // Standard merge — fast-forward or clean merge
2831 const ffProc = Bun.spawn(
2832 [
2833 "git",
2834 "update-ref",
2835 `refs/heads/${pr.baseBranch}`,
2836 `refs/heads/${pr.headBranch}`,
2837 ],
2838 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2839 );
2840 const ffExit = await ffProc.exited;
2841
2842 if (ffExit !== 0) {
2843 return c.redirect(
2844 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
2845 );
2846 }
2847 }
2848
0074234Claude2849 await db
2850 .update(pullRequests)
2851 .set({
2852 state: "merged",
2853 mergedAt: new Date(),
2854 mergedBy: user.id,
2855 updatedAt: new Date(),
2856 })
2857 .where(eq(pullRequests.id, pr.id));
2858
8809b87Claude2859 // Chat notifier — fan out merge event to Slack/Discord/Teams.
2860 import("../lib/chat-notifier")
2861 .then((m) =>
2862 m.notifyChatChannels({
2863 ownerUserId: resolved.repo.ownerId,
2864 repositoryId: resolved.repo.id,
2865 event: {
2866 event: "pr.merged",
2867 repo: `${ownerName}/${repoName}`,
2868 title: `#${pr.number} ${pr.title}`,
2869 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
2870 actor: user.username,
2871 },
2872 })
2873 )
2874 .catch((err) =>
2875 console.warn(`[chat-notifier] PR merge notify failed:`, err)
2876 );
2877
d62fb36Claude2878 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
2879 // and auto-close each matching open issue with a back-link comment. Bounded
2880 // to the same repo for v1 (cross-repo refs ignored). Failures never block
2881 // the merge redirect.
2882 try {
2883 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
2884 const refs = extractClosingRefsMulti([pr.title, pr.body]);
2885 for (const n of refs) {
2886 const [issue] = await db
2887 .select()
2888 .from(issues)
2889 .where(
2890 and(
2891 eq(issues.repositoryId, resolved.repo.id),
2892 eq(issues.number, n)
2893 )
2894 )
2895 .limit(1);
2896 if (!issue || issue.state !== "open") continue;
2897 await db
2898 .update(issues)
2899 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
2900 .where(eq(issues.id, issue.id));
2901 await db.insert(issueComments).values({
2902 issueId: issue.id,
2903 authorId: user.id,
2904 body: `Closed by pull request #${pr.number}.`,
2905 });
2906 }
2907 } catch {
2908 // Never block the merge on close-keyword failures.
2909 }
2910
0074234Claude2911 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2912 }
2913);
2914
6fc53bdClaude2915// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
2916// hasn't run yet on this PR.
2917pulls.post(
2918 "/:owner/:repo/pulls/:number/ready",
2919 softAuth,
2920 requireAuth,
04f6b7fClaude2921 requireRepoAccess("write"),
6fc53bdClaude2922 async (c) => {
2923 const { owner: ownerName, repo: repoName } = c.req.param();
2924 const prNum = parseInt(c.req.param("number"), 10);
2925 const user = c.get("user")!;
2926
2927 const resolved = await resolveRepo(ownerName, repoName);
2928 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2929
2930 const [pr] = await db
2931 .select()
2932 .from(pullRequests)
2933 .where(
2934 and(
2935 eq(pullRequests.repositoryId, resolved.repo.id),
2936 eq(pullRequests.number, prNum)
2937 )
2938 )
2939 .limit(1);
2940 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2941
2942 // Only the author or repo owner can toggle draft state.
2943 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2944 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2945 }
2946
2947 if (pr.state === "open" && pr.isDraft) {
2948 await db
2949 .update(pullRequests)
2950 .set({ isDraft: false, updatedAt: new Date() })
2951 .where(eq(pullRequests.id, pr.id));
2952
2953 if (isAiReviewEnabled()) {
2954 triggerAiReview(
2955 ownerName,
2956 repoName,
2957 pr.id,
2958 pr.title,
0316dbbClaude2959 pr.body || "",
6fc53bdClaude2960 pr.baseBranch,
2961 pr.headBranch
2962 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
2963 }
2964 }
2965
2966 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2967 }
2968);
2969
2970// Convert a PR back to draft.
2971pulls.post(
2972 "/:owner/:repo/pulls/:number/draft",
2973 softAuth,
2974 requireAuth,
04f6b7fClaude2975 requireRepoAccess("write"),
6fc53bdClaude2976 async (c) => {
2977 const { owner: ownerName, repo: repoName } = c.req.param();
2978 const prNum = parseInt(c.req.param("number"), 10);
2979 const user = c.get("user")!;
2980
2981 const resolved = await resolveRepo(ownerName, repoName);
2982 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2983
2984 const [pr] = await db
2985 .select()
2986 .from(pullRequests)
2987 .where(
2988 and(
2989 eq(pullRequests.repositoryId, resolved.repo.id),
2990 eq(pullRequests.number, prNum)
2991 )
2992 )
2993 .limit(1);
2994 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2995
2996 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
2997 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2998 }
2999
3000 if (pr.state === "open" && !pr.isDraft) {
3001 await db
3002 .update(pullRequests)
3003 .set({ isDraft: true, updatedAt: new Date() })
3004 .where(eq(pullRequests.id, pr.id));
3005 }
3006
3007 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3008 }
3009);
3010
0074234Claude3011// Close PR
3012pulls.post(
3013 "/:owner/:repo/pulls/:number/close",
3014 softAuth,
3015 requireAuth,
04f6b7fClaude3016 requireRepoAccess("write"),
0074234Claude3017 async (c) => {
3018 const { owner: ownerName, repo: repoName } = c.req.param();
3019 const prNum = parseInt(c.req.param("number"), 10);
3020
3021 const resolved = await resolveRepo(ownerName, repoName);
3022 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3023
3024 await db
3025 .update(pullRequests)
3026 .set({
3027 state: "closed",
3028 closedAt: new Date(),
3029 updatedAt: new Date(),
3030 })
3031 .where(
3032 and(
3033 eq(pullRequests.repositoryId, resolved.repo.id),
3034 eq(pullRequests.number, prNum)
3035 )
3036 );
3037
3038 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3039 }
3040);
3041
c3e0c07Claude3042// Re-run AI review on demand (e.g. after a force-push). Bypasses the
3043// idempotency marker via { force: true }. Write-access only.
3044pulls.post(
3045 "/:owner/:repo/pulls/:number/ai-rereview",
3046 softAuth,
3047 requireAuth,
3048 requireRepoAccess("write"),
3049 async (c) => {
3050 const { owner: ownerName, repo: repoName } = c.req.param();
3051 const prNum = parseInt(c.req.param("number"), 10);
3052 const resolved = await resolveRepo(ownerName, repoName);
3053 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3054
3055 const [pr] = await db
3056 .select()
3057 .from(pullRequests)
3058 .where(
3059 and(
3060 eq(pullRequests.repositoryId, resolved.repo.id),
3061 eq(pullRequests.number, prNum)
3062 )
3063 )
3064 .limit(1);
3065 if (!pr) {
3066 return c.redirect(`/${ownerName}/${repoName}/pulls`);
3067 }
3068
3069 if (!isAiReviewEnabled()) {
3070 return c.redirect(
3071 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3072 "AI review is not configured (ANTHROPIC_API_KEY)."
3073 )}`
3074 );
3075 }
3076
3077 // Fire-and-forget but with { force: true } to bypass the
3078 // already-reviewed marker. The function still never throws.
3079 triggerAiReview(
3080 ownerName,
3081 repoName,
3082 pr.id,
3083 pr.title || "",
3084 pr.body || "",
3085 pr.baseBranch,
3086 pr.headBranch,
3087 { force: true }
a28cedeClaude3088 ).catch((err) => {
3089 console.warn(
3090 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
3091 err instanceof Error ? err.message : err
3092 );
3093 });
c3e0c07Claude3094
3095 return c.redirect(
3096 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
3097 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
3098 )}`
3099 );
3100 }
3101);
3102
1d4ff60Claude3103// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
3104// the PR's head branch carrying just the new test files. Write-access only.
3105// Idempotent — if `ai:added-tests` was previously applied we redirect with
3106// an `info` banner instead of re-firing.
3107pulls.post(
3108 "/:owner/:repo/pulls/:number/generate-tests",
3109 softAuth,
3110 requireAuth,
3111 requireRepoAccess("write"),
3112 async (c) => {
3113 const { owner: ownerName, repo: repoName } = c.req.param();
3114 const prNum = parseInt(c.req.param("number"), 10);
3115 const resolved = await resolveRepo(ownerName, repoName);
3116 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3117
3118 const [pr] = await db
3119 .select()
3120 .from(pullRequests)
3121 .where(
3122 and(
3123 eq(pullRequests.repositoryId, resolved.repo.id),
3124 eq(pullRequests.number, prNum)
3125 )
3126 )
3127 .limit(1);
3128 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3129
3130 if (!isAiReviewEnabled()) {
3131 return c.redirect(
3132 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3133 "AI test generation is not configured (ANTHROPIC_API_KEY)."
3134 )}`
3135 );
3136 }
3137
3138 // Fire-and-forget. The lib never throws.
3139 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
3140 .then((res) => {
3141 if (!res.ok) {
3142 console.warn(
3143 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
3144 );
3145 }
3146 })
3147 .catch((err) => {
3148 console.warn(
3149 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
3150 err instanceof Error ? err.message : err
3151 );
3152 });
3153
3154 return c.redirect(
3155 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
3156 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
3157 )}`
3158 );
3159 }
3160);
3161
0074234Claude3162export default pulls;