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.tsxBlame3774 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,
0a67773Claude22 prReviews,
0074234Claude23 repositories,
24 users,
d62fb36Claude25 issues,
26 issueComments,
0074234Claude27} from "../db/schema";
28import { Layout } from "../views/layout";
ea9ed4cClaude29import { RepoHeader } from "../views/components";
cb5a796Claude30import { PendingCommentsBanner } from "../views/pending-comments-banner";
47a7a0aClaude31import { DiffView, type InlineDiffComment } from "../views/diff-view";
6fc53bdClaude32import { ReactionsBar } from "../views/reactions";
33import { summariseReactions } from "../lib/reactions";
24cf2caClaude34import { loadPrTemplate } from "../lib/templates";
0074234Claude35import { renderMarkdown } from "../lib/markdown";
15db0e0Claude36import {
37 parseSlashCommand,
38 executeSlashCommand,
39 detectSlashCmdComment,
40 stripSlashCmdMarker,
41} from "../lib/pr-slash-commands";
b584e52Claude42import { liveCommentBannerScript } from "../lib/sse-client";
0074234Claude43import { softAuth, requireAuth } from "../middleware/auth";
44import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude45import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude46import {
47 decideInitialStatus,
48 notifyOwnerOfPendingComment,
49 countPendingForRepo,
50} from "../lib/comment-moderation";
0316dbbClaude51import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
79ed944Claude52import {
53 TRIO_COMMENT_MARKER,
54 TRIO_SUMMARY_MARKER,
55 type TrioPersona,
56} from "../lib/ai-review-trio";
1d4ff60Claude57import {
58 generateTestsForPr,
59 AI_TESTS_MARKER,
60} from "../lib/ai-test-generator";
0316dbbClaude61import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude62import { generatePrSummary } from "../lib/ai-generators";
63import { isAiAvailable } from "../lib/ai-client";
534f04aClaude64import {
65 computePrRiskForPullRequest,
66 getCachedPrRisk,
67 type PrRiskScore,
68} from "../lib/pr-risk";
0316dbbClaude69import { runAllGateChecks } from "../lib/gate";
70import type { GateCheckResult } from "../lib/gate";
71import {
72 matchProtection,
73 countHumanApprovals,
74 listRequiredChecks,
75 passingCheckNames,
76 evaluateProtection,
77} from "../lib/branch-protection";
78import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude79import {
80 listBranches,
81 getRepoPath,
e883329Claude82 resolveRef,
b5dd694Claude83 getBlob,
84 createOrUpdateFileOnBranch,
0074234Claude85} from "../git/repository";
86import type { GitDiffFile } from "../git/repository";
87import { html } from "hono/html";
4bbacbeClaude88import {
89 getPreviewForBranch,
90 previewStatusLabel,
91} from "../lib/branch-previews";
1e162a8Claude92import {
bb0f894Claude93 Flex,
94 Container,
95 Badge,
96 Button,
97 LinkButton,
98 Form,
99 FormGroup,
100 Input,
101 TextArea,
102 Select,
103 EmptyState,
104 FilterTabs,
105 TabNav,
106 List,
107 ListItem,
108 Text,
109 Alert,
110 MarkdownContent,
111 CommentBox,
112 formatRelative,
113} from "../views/ui";
0074234Claude114
115const pulls = new Hono<AuthEnv>();
116
b078860Claude117/* ──────────────────────────────────────────────────────────────────────
118 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
119 * into the issue tracker or any other route. Tokens come from layout.tsx
120 * `:root` so light/dark stays consistent if/when light mode lands.
121 * ──────────────────────────────────────────────────────────────────── */
122const PRS_LIST_STYLES = `
123 .prs-hero {
124 position: relative;
125 margin: 0 0 var(--space-5);
126 padding: 22px 26px 24px;
127 background: var(--bg-elevated);
128 border: 1px solid var(--border);
129 border-radius: 16px;
130 overflow: hidden;
131 }
132 .prs-hero::before {
133 content: '';
134 position: absolute; top: 0; left: 0; right: 0;
135 height: 2px;
136 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
137 opacity: 0.7;
138 pointer-events: none;
139 }
140 .prs-hero-inner {
141 position: relative;
142 display: flex;
143 justify-content: space-between;
144 align-items: flex-end;
145 gap: 20px;
146 flex-wrap: wrap;
147 }
148 .prs-hero-text { flex: 1; min-width: 280px; }
149 .prs-hero-eyebrow {
150 font-size: 12px;
151 color: var(--text-muted);
152 text-transform: uppercase;
153 letter-spacing: 0.08em;
154 font-weight: 600;
155 margin-bottom: 8px;
156 }
157 .prs-hero-title {
158 font-family: var(--font-display);
159 font-size: clamp(26px, 3.4vw, 34px);
160 font-weight: 800;
161 letter-spacing: -0.025em;
162 line-height: 1.06;
163 margin: 0 0 8px;
164 color: var(--text-strong);
165 }
166 .prs-hero-title .gradient-text {
167 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
168 -webkit-background-clip: text;
169 background-clip: text;
170 -webkit-text-fill-color: transparent;
171 color: transparent;
172 }
173 .prs-hero-sub {
174 font-size: 14.5px;
175 color: var(--text-muted);
176 margin: 0;
177 line-height: 1.5;
178 max-width: 620px;
179 }
180 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
181 .prs-cta {
182 display: inline-flex; align-items: center; gap: 6px;
183 padding: 10px 16px;
184 border-radius: 10px;
185 font-size: 13.5px;
186 font-weight: 600;
187 color: #fff;
188 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
189 border: 1px solid rgba(140,109,255,0.55);
190 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
191 text-decoration: none;
192 transition: transform 120ms ease, box-shadow 160ms ease;
193 }
194 .prs-cta:hover {
195 transform: translateY(-1px);
196 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
197 color: #fff;
198 }
199
200 .prs-tabs {
201 display: flex; flex-wrap: wrap; gap: 6px;
202 margin: 0 0 18px;
203 padding: 6px;
204 background: var(--bg-secondary);
205 border: 1px solid var(--border);
206 border-radius: 12px;
207 }
208 .prs-tab {
209 display: inline-flex; align-items: center; gap: 8px;
210 padding: 7px 13px;
211 font-size: 13px;
212 font-weight: 500;
213 color: var(--text-muted);
214 border-radius: 8px;
215 text-decoration: none;
216 transition: background 120ms ease, color 120ms ease;
217 }
218 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
219 .prs-tab.is-active {
220 background: var(--bg-elevated);
221 color: var(--text-strong);
222 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
223 }
224 .prs-tab-count {
225 display: inline-flex; align-items: center; justify-content: center;
226 min-width: 22px; padding: 2px 7px;
227 font-size: 11.5px;
228 font-weight: 600;
229 border-radius: 9999px;
230 background: var(--bg-tertiary);
231 color: var(--text-muted);
232 }
233 .prs-tab.is-active .prs-tab-count {
234 background: rgba(140,109,255,0.18);
235 color: var(--text-link);
236 }
237
238 .prs-list { display: flex; flex-direction: column; gap: 10px; }
239 .prs-row {
240 position: relative;
241 display: flex; align-items: flex-start; gap: 14px;
242 padding: 14px 16px;
243 background: var(--bg-elevated);
244 border: 1px solid var(--border);
245 border-radius: 12px;
246 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
247 }
248 .prs-row:hover {
249 transform: translateY(-1px);
250 border-color: var(--border-strong);
251 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
252 }
253 .prs-row-icon {
254 flex: 0 0 auto;
255 width: 26px; height: 26px;
256 display: inline-flex; align-items: center; justify-content: center;
257 border-radius: 9999px;
258 font-size: 13px;
259 margin-top: 2px;
260 }
261 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
262 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
263 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
264 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
265 .prs-row-body { flex: 1; min-width: 0; }
266 .prs-row-title {
267 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
268 font-size: 15px; font-weight: 600;
269 color: var(--text-strong);
270 line-height: 1.35;
271 margin: 0 0 6px;
272 }
273 .prs-row-number {
274 color: var(--text-muted);
275 font-weight: 400;
276 font-size: 14px;
277 }
278 .prs-row-meta {
279 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
280 font-size: 12.5px;
281 color: var(--text-muted);
282 }
283 .prs-branch-chips {
284 display: inline-flex; align-items: center; gap: 6px;
285 font-family: var(--font-mono);
286 font-size: 11.5px;
287 }
288 .prs-branch-chip {
289 padding: 2px 8px;
290 border-radius: 9999px;
291 background: var(--bg-tertiary);
292 border: 1px solid var(--border);
293 color: var(--text);
294 }
295 .prs-branch-arrow {
296 color: var(--text-faint);
297 font-size: 11px;
298 }
299 .prs-row-tags {
300 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
301 margin-left: auto;
302 }
303 .prs-tag {
304 display: inline-flex; align-items: center; gap: 4px;
305 padding: 2px 8px;
306 font-size: 11px;
307 font-weight: 600;
308 border-radius: 9999px;
309 border: 1px solid var(--border);
310 background: var(--bg-secondary);
311 color: var(--text-muted);
312 line-height: 1.6;
313 }
314 .prs-tag.is-draft {
315 color: var(--text-muted);
316 border-color: var(--border-strong);
317 }
318 .prs-tag.is-merged {
319 color: var(--text-link);
320 border-color: rgba(140,109,255,0.45);
321 background: rgba(140,109,255,0.10);
322 }
323
324 .prs-empty {
ea9ed4cClaude325 position: relative;
326 padding: 56px 32px;
b078860Claude327 text-align: center;
328 border: 1px dashed var(--border);
ea9ed4cClaude329 border-radius: 16px;
330 background: var(--bg-elevated);
b078860Claude331 color: var(--text-muted);
ea9ed4cClaude332 overflow: hidden;
b078860Claude333 }
ea9ed4cClaude334 .prs-empty::before {
335 content: '';
336 position: absolute;
337 inset: -40% -20% auto auto;
338 width: 320px; height: 320px;
339 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
340 filter: blur(70px);
341 opacity: 0.55;
342 pointer-events: none;
343 animation: prsEmptyOrb 16s ease-in-out infinite;
344 }
345 @keyframes prsEmptyOrb {
346 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
347 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
348 }
349 @media (prefers-reduced-motion: reduce) {
350 .prs-empty::before { animation: none; }
351 }
352 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude353 .prs-empty strong {
354 display: block;
355 color: var(--text-strong);
ea9ed4cClaude356 font-family: var(--font-display);
357 font-size: 22px;
358 font-weight: 700;
359 letter-spacing: -0.018em;
360 margin-bottom: 2px;
361 }
362 .prs-empty-sub {
363 font-size: 14.5px;
364 color: var(--text-muted);
365 line-height: 1.55;
366 max-width: 460px;
367 margin: 0 0 18px;
b078860Claude368 }
ea9ed4cClaude369 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude370
371 @media (max-width: 720px) {
372 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
373 .prs-hero-actions { width: 100%; }
374 .prs-row-tags { margin-left: 0; }
375 }
f1dc7c7Claude376
377 /* Additional mobile rules. Additive only. */
378 @media (max-width: 720px) {
379 .prs-hero { padding: 18px 18px 20px; }
380 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
381 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
382 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
383 .prs-row { padding: 12px 14px; gap: 10px; }
384 .prs-row-icon { width: 24px; height: 24px; }
385 }
b078860Claude386`;
387
388/* ──────────────────────────────────────────────────────────────────────
389 * Inline CSS for the detail page. Same `.prs-*` namespace.
390 * ──────────────────────────────────────────────────────────────────── */
391const PRS_DETAIL_STYLES = `
392 .prs-detail-hero {
393 position: relative;
394 margin: 0 0 var(--space-4);
395 padding: 24px 26px;
396 background: var(--bg-elevated);
397 border: 1px solid var(--border);
398 border-radius: 16px;
399 overflow: hidden;
400 }
401 .prs-detail-hero::before {
402 content: '';
403 position: absolute; top: 0; left: 0; right: 0;
404 height: 2px;
405 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
406 opacity: 0.7;
407 pointer-events: none;
408 }
409 .prs-detail-title {
410 font-family: var(--font-display);
411 font-size: clamp(22px, 2.6vw, 28px);
412 font-weight: 700;
413 letter-spacing: -0.022em;
414 line-height: 1.2;
415 color: var(--text-strong);
416 margin: 0 0 12px;
417 }
418 .prs-detail-num {
419 color: var(--text-muted);
420 font-weight: 400;
421 }
422 .prs-state-pill {
423 display: inline-flex; align-items: center; gap: 6px;
424 padding: 6px 12px;
425 border-radius: 9999px;
426 font-size: 12.5px;
427 font-weight: 600;
428 line-height: 1;
429 border: 1px solid transparent;
430 }
431 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
432 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
433 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
434 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
435
436 .prs-detail-meta {
437 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
438 font-size: 13px;
439 color: var(--text-muted);
440 }
441 .prs-detail-meta strong { color: var(--text); }
442 .prs-detail-branches {
443 display: inline-flex; align-items: center; gap: 6px;
444 font-family: var(--font-mono);
445 font-size: 12px;
446 }
447 .prs-branch-pill {
448 padding: 3px 9px;
449 border-radius: 9999px;
450 background: var(--bg-tertiary);
451 border: 1px solid var(--border);
452 color: var(--text);
453 }
454 .prs-branch-pill.is-head { color: var(--text-strong); }
455 .prs-branch-arrow-lg {
456 color: var(--accent);
457 font-size: 14px;
458 font-weight: 700;
459 }
460
461 .prs-detail-actions {
462 display: inline-flex; gap: 8px; margin-left: auto;
463 }
464
465 .prs-detail-tabs {
466 display: flex; gap: 4px;
467 margin: 0 0 16px;
468 border-bottom: 1px solid var(--border);
469 }
470 .prs-detail-tab {
471 padding: 10px 14px;
472 font-size: 13.5px;
473 font-weight: 500;
474 color: var(--text-muted);
475 text-decoration: none;
476 border-bottom: 2px solid transparent;
477 transition: color 120ms ease, border-color 120ms ease;
478 margin-bottom: -1px;
479 }
480 .prs-detail-tab:hover { color: var(--text); }
481 .prs-detail-tab.is-active {
482 color: var(--text-strong);
483 border-bottom-color: var(--accent);
484 }
485 .prs-detail-tab-count {
486 display: inline-flex; align-items: center; justify-content: center;
487 min-width: 20px; padding: 0 6px; margin-left: 6px;
488 height: 18px;
489 font-size: 11px;
490 font-weight: 600;
491 border-radius: 9999px;
492 background: var(--bg-tertiary);
493 color: var(--text-muted);
494 }
495
496 /* Gate / check status section */
497 .prs-gate-card {
498 margin-top: 20px;
499 background: var(--bg-elevated);
500 border: 1px solid var(--border);
501 border-radius: 14px;
502 overflow: hidden;
503 }
504 .prs-gate-head {
505 display: flex; align-items: center; gap: 10px;
506 padding: 14px 18px;
507 border-bottom: 1px solid var(--border);
508 }
509 .prs-gate-head h3 {
510 margin: 0;
511 font-size: 14px;
512 font-weight: 600;
513 color: var(--text-strong);
514 }
515 .prs-gate-summary {
516 margin-left: auto;
517 font-size: 12px;
518 color: var(--text-muted);
519 }
520 .prs-gate-row {
521 display: flex; align-items: center; gap: 12px;
522 padding: 12px 18px;
523 border-bottom: 1px solid var(--border-subtle);
524 }
525 .prs-gate-row:last-child { border-bottom: 0; }
526 .prs-gate-icon {
527 flex: 0 0 auto;
528 width: 22px; height: 22px;
529 display: inline-flex; align-items: center; justify-content: center;
530 border-radius: 9999px;
531 font-size: 12px;
532 font-weight: 700;
533 }
534 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
535 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
536 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
537 .prs-gate-name {
538 font-size: 13px;
539 font-weight: 600;
540 color: var(--text);
541 min-width: 140px;
542 }
543 .prs-gate-details {
544 flex: 1; min-width: 0;
545 font-size: 12.5px;
546 color: var(--text-muted);
547 }
548 .prs-gate-pill {
549 flex: 0 0 auto;
550 padding: 3px 10px;
551 border-radius: 9999px;
552 font-size: 11px;
553 font-weight: 600;
554 line-height: 1.5;
555 border: 1px solid transparent;
556 }
557 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
558 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
559 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
560 .prs-gate-footer {
561 padding: 12px 18px;
562 background: var(--bg-secondary);
563 font-size: 12px;
564 color: var(--text-muted);
565 }
566
567 /* Comment cards */
568 .prs-comment {
569 margin-top: 14px;
570 background: var(--bg-elevated);
571 border: 1px solid var(--border);
572 border-radius: 12px;
573 overflow: hidden;
574 }
575 .prs-comment-head {
576 display: flex; align-items: center; gap: 10px;
577 padding: 10px 14px;
578 background: var(--bg-secondary);
579 border-bottom: 1px solid var(--border);
580 font-size: 13px;
581 flex-wrap: wrap;
582 }
583 .prs-comment-head strong { color: var(--text-strong); }
584 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
585 .prs-comment-loc {
586 font-family: var(--font-mono);
587 font-size: 11.5px;
588 color: var(--text-muted);
589 background: var(--bg-tertiary);
590 padding: 2px 8px;
591 border-radius: 6px;
592 }
593 .prs-comment-body { padding: 14px 18px; }
594 .prs-comment.is-ai {
595 border-color: rgba(140,109,255,0.45);
596 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
597 }
598 .prs-comment.is-ai .prs-comment-head {
599 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
600 border-bottom-color: rgba(140,109,255,0.30);
601 }
602 .prs-ai-badge {
603 display: inline-flex; align-items: center; gap: 4px;
604 padding: 2px 9px;
605 font-size: 10.5px;
606 font-weight: 700;
607 letter-spacing: 0.04em;
608 text-transform: uppercase;
609 color: #fff;
610 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
611 border-radius: 9999px;
612 }
613
614 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
615 .prs-files-card {
616 margin-top: 18px;
617 padding: 14px 18px;
618 display: flex; align-items: center; gap: 14px;
619 background: var(--bg-elevated);
620 border: 1px solid var(--border);
621 border-radius: 12px;
622 text-decoration: none;
623 color: inherit;
624 transition: border-color 120ms ease, transform 140ms ease;
625 }
626 .prs-files-card:hover {
627 border-color: rgba(140,109,255,0.45);
628 transform: translateY(-1px);
629 }
630 .prs-files-card-icon {
631 width: 36px; height: 36px;
632 display: inline-flex; align-items: center; justify-content: center;
633 border-radius: 10px;
634 background: rgba(140,109,255,0.12);
635 color: var(--text-link);
636 font-size: 18px;
637 }
638 .prs-files-card-text { flex: 1; min-width: 0; }
639 .prs-files-card-title {
640 font-size: 14px;
641 font-weight: 600;
642 color: var(--text-strong);
643 margin: 0 0 2px;
644 }
645 .prs-files-card-sub {
646 font-size: 12.5px;
647 color: var(--text-muted);
648 margin: 0;
649 }
650 .prs-files-card-cta {
651 font-size: 12.5px;
652 color: var(--text-link);
653 font-weight: 600;
654 }
655
656 /* Merge area */
657 .prs-merge-card {
658 position: relative;
659 margin-top: 22px;
660 padding: 18px;
661 background: var(--bg-elevated);
662 border-radius: 14px;
663 overflow: hidden;
664 }
665 .prs-merge-card::before {
666 content: '';
667 position: absolute; inset: 0;
668 padding: 1px;
669 border-radius: 14px;
670 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
671 -webkit-mask:
672 linear-gradient(#000 0 0) content-box,
673 linear-gradient(#000 0 0);
674 -webkit-mask-composite: xor;
675 mask-composite: exclude;
676 pointer-events: none;
677 }
678 .prs-merge-card.is-closed::before { background: var(--border-strong); }
679 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
680 .prs-merge-head {
681 display: flex; align-items: center; gap: 12px;
682 margin-bottom: 12px;
683 }
684 .prs-merge-head strong {
685 font-family: var(--font-display);
686 font-size: 15px;
687 color: var(--text-strong);
688 font-weight: 700;
689 }
690 .prs-merge-sub {
691 font-size: 13px;
692 color: var(--text-muted);
693 margin: 0 0 12px;
694 }
695 .prs-merge-actions {
696 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
697 }
698 .prs-merge-btn {
699 display: inline-flex; align-items: center; gap: 6px;
700 padding: 9px 16px;
701 border-radius: 10px;
702 font-size: 13.5px;
703 font-weight: 600;
704 color: #fff;
705 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
706 border: 1px solid rgba(52,211,153,0.55);
707 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
708 cursor: pointer;
709 transition: transform 120ms ease, box-shadow 160ms ease;
710 }
711 .prs-merge-btn:hover {
712 transform: translateY(-1px);
713 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
714 }
715 .prs-merge-btn[disabled],
716 .prs-merge-btn.is-disabled {
717 opacity: 0.55;
718 cursor: not-allowed;
719 transform: none;
720 box-shadow: none;
721 }
722 .prs-merge-ready-btn {
723 display: inline-flex; align-items: center; gap: 6px;
724 padding: 9px 16px;
725 border-radius: 10px;
726 font-size: 13.5px;
727 font-weight: 600;
728 color: #fff;
729 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
730 border: 1px solid rgba(140,109,255,0.55);
731 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
732 cursor: pointer;
733 transition: transform 120ms ease, box-shadow 160ms ease;
734 }
735 .prs-merge-ready-btn:hover {
736 transform: translateY(-1px);
737 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
738 }
739 .prs-merge-back-draft {
740 background: none; border: 1px solid var(--border-strong);
741 color: var(--text-muted);
742 padding: 9px 14px; border-radius: 10px;
743 font-size: 13px; cursor: pointer;
744 }
745 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
746
0a67773Claude747 /* Review summary banner */
748 .prs-review-summary {
749 display: flex; flex-direction: column; gap: 6px;
750 padding: 12px 16px;
751 background: var(--bg-elevated);
752 border: 1px solid var(--border);
753 border-radius: var(--r-md, 8px);
754 margin-bottom: 12px;
755 }
756 .prs-review-row {
757 display: flex; align-items: center; gap: 10px;
758 font-size: 13px;
759 }
760 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
761 .prs-review-approved .prs-review-icon { color: #34d399; }
762 .prs-review-changes .prs-review-icon { color: #f87171; }
763
764 /* Review action buttons */
765 .prs-review-approve-btn {
766 display: inline-flex; align-items: center; gap: 5px;
767 padding: 8px 14px; border-radius: 8px; font-size: 13px;
768 font-weight: 600; cursor: pointer;
769 background: rgba(52,211,153,0.12);
770 color: #34d399;
771 border: 1px solid rgba(52,211,153,0.35);
772 transition: background 120ms;
773 }
774 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
775 .prs-review-changes-btn {
776 display: inline-flex; align-items: center; gap: 5px;
777 padding: 8px 14px; border-radius: 8px; font-size: 13px;
778 font-weight: 600; cursor: pointer;
779 background: rgba(248,113,113,0.10);
780 color: #f87171;
781 border: 1px solid rgba(248,113,113,0.30);
782 transition: background 120ms;
783 }
784 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
785
b078860Claude786 /* Inline form helpers */
787 .prs-inline-form { display: inline-flex; }
788
789 /* Comment composer */
790 .prs-composer { margin-top: 22px; }
791 .prs-composer textarea {
792 border-radius: 12px;
793 }
794
795 @media (max-width: 720px) {
796 .prs-detail-actions { margin-left: 0; }
797 .prs-merge-actions { width: 100%; }
798 .prs-merge-actions > * { flex: 1; min-width: 0; }
799 }
f1dc7c7Claude800
801 /* Additional mobile rules. Additive only. */
802 @media (max-width: 720px) {
803 .prs-detail-hero { padding: 18px; }
804 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
805 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
806 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
807 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
808 .prs-gate-name { min-width: 0; }
809 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
810 .prs-gate-summary { margin-left: 0; }
811 .prs-merge-btn,
812 .prs-merge-ready-btn,
813 .prs-merge-back-draft { min-height: 44px; }
814 .prs-comment-body { padding: 12px 14px; }
815 .prs-comment-head { padding: 10px 12px; }
816 .prs-files-card { padding: 12px 14px; }
817 }
3c03977Claude818
819 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
820 .live-pill {
821 display: inline-flex;
822 align-items: center;
823 gap: 8px;
824 padding: 4px 10px 4px 8px;
825 margin-left: 6px;
826 background: var(--bg-elevated);
827 border: 1px solid var(--border);
828 border-radius: 9999px;
829 font-size: 12px;
830 color: var(--text-muted);
831 line-height: 1;
832 vertical-align: middle;
833 }
834 .live-pill.is-busy { color: var(--text); }
835 .live-pill-dot {
836 width: 8px; height: 8px;
837 border-radius: 9999px;
838 background: #34d399;
839 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
840 animation: live-pulse 1.6s ease-in-out infinite;
841 }
842 @keyframes live-pulse {
843 0%, 100% { opacity: 1; }
844 50% { opacity: 0.55; }
845 }
846 .live-avatars {
847 display: inline-flex;
848 margin-left: 2px;
849 }
850 .live-avatar {
851 display: inline-flex;
852 align-items: center;
853 justify-content: center;
854 width: 22px; height: 22px;
855 border-radius: 9999px;
856 font-size: 10px;
857 font-weight: 700;
858 color: #0b1020;
859 margin-left: -6px;
860 border: 2px solid var(--bg-elevated);
861 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
862 }
863 .live-avatar:first-child { margin-left: 0; }
864 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
865 .live-cursor-host {
866 position: relative;
867 }
868 .live-cursor-overlay {
869 position: absolute;
870 inset: 0;
871 pointer-events: none;
872 overflow: hidden;
873 border-radius: inherit;
874 }
875 .live-cursor {
876 position: absolute;
877 width: 2px;
878 height: 18px;
879 border-radius: 2px;
880 transform: translate(-1px, 0);
881 transition: transform 80ms linear, opacity 200ms ease;
882 }
883 .live-cursor::after {
884 content: attr(data-label);
885 position: absolute;
886 top: -16px;
887 left: -2px;
888 font-size: 10px;
889 line-height: 1;
890 color: #0b1020;
891 background: inherit;
892 padding: 2px 5px;
893 border-radius: 4px 4px 4px 0;
894 white-space: nowrap;
895 font-weight: 600;
896 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
897 }
898 .live-cursor.is-idle { opacity: 0.4; }
899 .live-edit-tag {
900 display: inline-block;
901 margin-left: 6px;
902 padding: 1px 6px;
903 font-size: 10px;
904 font-weight: 600;
905 letter-spacing: 0.02em;
906 color: #0b1020;
907 border-radius: 9999px;
908 }
15db0e0Claude909
910 /* ─── Slash-command pill + composer hint ─── */
911 .slash-hint {
912 display: inline-flex;
913 align-items: center;
914 gap: 6px;
915 margin-top: 6px;
916 padding: 3px 9px;
917 font-size: 11.5px;
918 color: var(--text-muted);
919 background: var(--bg-elevated);
920 border: 1px dashed var(--border);
921 border-radius: 9999px;
922 width: fit-content;
923 }
924 .slash-hint code {
925 background: rgba(110, 168, 255, 0.12);
926 color: var(--text-strong);
927 padding: 0 5px;
928 border-radius: 4px;
929 font-size: 11px;
930 }
931 .slash-pill {
932 display: grid;
933 grid-template-columns: auto 1fr auto;
934 align-items: center;
935 column-gap: 10px;
936 row-gap: 6px;
937 margin: 10px 0;
938 padding: 10px 14px;
939 background: linear-gradient(
940 135deg,
941 rgba(110, 168, 255, 0.08),
942 rgba(163, 113, 247, 0.06)
943 );
944 border: 1px solid rgba(110, 168, 255, 0.32);
945 border-left: 3px solid var(--accent, #6ea8ff);
946 border-radius: var(--radius);
947 font-size: 13px;
948 color: var(--text);
949 }
950 .slash-pill-icon {
951 font-size: 14px;
952 line-height: 1;
953 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
954 }
955 .slash-pill-actor { color: var(--text-muted); }
956 .slash-pill-actor strong { color: var(--text-strong); }
957 .slash-pill-cmd {
958 background: rgba(110, 168, 255, 0.16);
959 color: var(--text-strong);
960 padding: 1px 6px;
961 border-radius: 4px;
962 font-size: 12.5px;
963 }
964 .slash-pill-time {
965 color: var(--text-muted);
966 font-size: 12px;
967 justify-self: end;
968 }
969 .slash-pill-body {
970 grid-column: 1 / -1;
971 color: var(--text);
972 font-size: 13px;
973 line-height: 1.55;
974 }
975 .slash-pill-body p:first-child { margin-top: 0; }
976 .slash-pill-body p:last-child { margin-bottom: 0; }
977 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
978 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
979 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
980 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude981
982 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
983 .preview-prpill {
984 display: inline-flex; align-items: center; gap: 6px;
985 padding: 3px 10px;
986 border-radius: 9999px;
987 font-family: var(--font-mono);
988 font-size: 11.5px;
989 font-weight: 600;
990 background: rgba(255,255,255,0.04);
991 color: var(--text-muted);
992 text-decoration: none;
993 border: 1px solid var(--border);
994 }
995 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
996 .preview-prpill .preview-prpill-dot {
997 width: 7px; height: 7px;
998 border-radius: 9999px;
999 background: currentColor;
1000 }
1001 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1002 .preview-prpill.is-building .preview-prpill-dot {
1003 animation: previewPrPulse 1.4s ease-in-out infinite;
1004 }
1005 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1006 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1007 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1008 @keyframes previewPrPulse {
1009 0%, 100% { opacity: 1; }
1010 50% { opacity: 0.4; }
1011 }
79ed944Claude1012
1013 /* ─── AI Trio Review — 3-column verdict cards ─── */
1014 .trio-wrap {
1015 margin-top: 18px;
1016 padding: 16px;
1017 background: var(--bg-elevated);
1018 border: 1px solid var(--border);
1019 border-radius: 14px;
1020 }
1021 .trio-header {
1022 display: flex; align-items: center; gap: 10px;
1023 margin: 0 0 12px;
1024 font-size: 13.5px;
1025 color: var(--text);
1026 }
1027 .trio-header strong { color: var(--text-strong); }
1028 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1029 .trio-header-dot {
1030 width: 8px; height: 8px; border-radius: 9999px;
1031 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1032 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1033 }
1034 .trio-grid {
1035 display: grid;
1036 grid-template-columns: repeat(3, minmax(0, 1fr));
1037 gap: 12px;
1038 }
1039 .trio-card {
1040 background: var(--bg-secondary);
1041 border: 1px solid var(--border);
1042 border-radius: 12px;
1043 overflow: hidden;
1044 display: flex; flex-direction: column;
1045 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1046 }
1047 .trio-card-head {
1048 display: flex; align-items: center; gap: 8px;
1049 padding: 10px 12px;
1050 border-bottom: 1px solid var(--border);
1051 background: rgba(255,255,255,0.02);
1052 font-size: 13px;
1053 }
1054 .trio-card-icon {
1055 display: inline-flex; align-items: center; justify-content: center;
1056 width: 22px; height: 22px;
1057 border-radius: 9999px;
1058 font-size: 12px;
1059 background: rgba(255,255,255,0.05);
1060 }
1061 .trio-card-title {
1062 color: var(--text-strong);
1063 font-weight: 600;
1064 letter-spacing: 0.01em;
1065 }
1066 .trio-card-verdict {
1067 margin-left: auto;
1068 font-size: 11px;
1069 font-weight: 700;
1070 letter-spacing: 0.06em;
1071 text-transform: uppercase;
1072 padding: 3px 9px;
1073 border-radius: 9999px;
1074 background: var(--bg-tertiary);
1075 color: var(--text-muted);
1076 border: 1px solid var(--border-strong);
1077 }
1078 .trio-card-body {
1079 padding: 12px 14px;
1080 font-size: 13px;
1081 color: var(--text);
1082 flex: 1;
1083 min-height: 64px;
1084 line-height: 1.55;
1085 }
1086 .trio-card-body p { margin: 0 0 8px; }
1087 .trio-card-body p:last-child { margin-bottom: 0; }
1088 .trio-card-body ul { margin: 0; padding-left: 18px; }
1089 .trio-card-body code {
1090 font-family: var(--font-mono);
1091 font-size: 12px;
1092 background: var(--bg-tertiary);
1093 padding: 1px 6px;
1094 border-radius: 5px;
1095 }
1096 .trio-card-empty {
1097 color: var(--text-muted);
1098 font-style: italic;
1099 font-size: 12.5px;
1100 }
1101
1102 /* Pass state — neutral, no accent. */
1103 .trio-card.is-pass .trio-card-verdict {
1104 color: var(--green);
1105 border-color: rgba(52,211,153,0.35);
1106 background: rgba(52,211,153,0.12);
1107 }
1108
1109 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1110 .trio-card.trio-security.is-fail {
1111 border-color: rgba(248,113,113,0.55);
1112 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1113 }
1114 .trio-card.trio-security.is-fail .trio-card-head {
1115 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1116 border-bottom-color: rgba(248,113,113,0.30);
1117 }
1118 .trio-card.trio-security.is-fail .trio-card-verdict {
1119 color: #fecaca;
1120 border-color: rgba(248,113,113,0.55);
1121 background: rgba(248,113,113,0.20);
1122 }
1123
1124 .trio-card.trio-correctness.is-fail {
1125 border-color: rgba(251,191,36,0.55);
1126 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1127 }
1128 .trio-card.trio-correctness.is-fail .trio-card-head {
1129 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1130 border-bottom-color: rgba(251,191,36,0.30);
1131 }
1132 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1133 color: #fde68a;
1134 border-color: rgba(251,191,36,0.55);
1135 background: rgba(251,191,36,0.20);
1136 }
1137
1138 .trio-card.trio-style.is-fail {
1139 border-color: rgba(96,165,250,0.55);
1140 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1141 }
1142 .trio-card.trio-style.is-fail .trio-card-head {
1143 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1144 border-bottom-color: rgba(96,165,250,0.30);
1145 }
1146 .trio-card.trio-style.is-fail .trio-card-verdict {
1147 color: #bfdbfe;
1148 border-color: rgba(96,165,250,0.55);
1149 background: rgba(96,165,250,0.20);
1150 }
1151
1152 /* Disagreement callout strip — yellow, prominent. */
1153 .trio-disagreement-strip {
1154 display: flex;
1155 gap: 12px;
1156 margin-top: 14px;
1157 padding: 12px 14px;
1158 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1159 border: 1px solid rgba(251,191,36,0.45);
1160 border-radius: 10px;
1161 color: var(--text);
1162 font-size: 13px;
1163 }
1164 .trio-disagreement-icon {
1165 flex: 0 0 auto;
1166 width: 26px; height: 26px;
1167 display: inline-flex; align-items: center; justify-content: center;
1168 border-radius: 9999px;
1169 background: rgba(251,191,36,0.25);
1170 color: #fde68a;
1171 font-size: 14px;
1172 }
1173 .trio-disagreement-body strong {
1174 display: block;
1175 color: #fde68a;
1176 margin: 0 0 4px;
1177 font-weight: 700;
1178 }
1179 .trio-disagreement-list {
1180 margin: 0;
1181 padding-left: 18px;
1182 color: var(--text);
1183 font-size: 12.5px;
1184 line-height: 1.55;
1185 }
1186 .trio-disagreement-list code {
1187 font-family: var(--font-mono);
1188 font-size: 11.5px;
1189 background: var(--bg-tertiary);
1190 padding: 1px 5px;
1191 border-radius: 4px;
1192 }
1193
1194 @media (max-width: 720px) {
1195 .trio-grid { grid-template-columns: 1fr; }
1196 .trio-wrap { padding: 12px; }
1197 }
b078860Claude1198`;
1199
81c73c1Claude1200/**
1201 * Tiny inline JS that drives the "Suggest description with AI" button.
1202 * On click, gathers form values, POSTs JSON to the given endpoint, and
1203 * pipes the response into the #pr-body textarea. All DOM lookups are
1204 * defensive — element absence is a silent no-op.
1205 *
1206 * Built as a string template so it lives next to its server-side caller
1207 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1208 * to avoid </script> breakouts.
1209 */
1210function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1211 const url = JSON.stringify(endpointUrl)
1212 .split("<").join("\\u003C")
1213 .split(">").join("\\u003E")
1214 .split("&").join("\\u0026");
1215 return (
1216 "(function(){try{" +
1217 "var btn=document.getElementById('ai-suggest-desc');" +
1218 "var status=document.getElementById('ai-suggest-status');" +
1219 "var body=document.getElementById('pr-body');" +
1220 "var form=btn&&btn.closest&&btn.closest('form');" +
1221 "if(!btn||!body||!form)return;" +
1222 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1223 "var fd=new FormData(form);" +
1224 "var title=String(fd.get('title')||'').trim();" +
1225 "var base=String(fd.get('base')||'').trim();" +
1226 "var head=String(fd.get('head')||'').trim();" +
1227 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1228 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1229 "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'})" +
1230 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1231 ".then(function(j){btn.disabled=false;" +
1232 "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;}}" +
1233 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1234 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1235 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1236 "});" +
1237 "}catch(e){}})();"
1238 );
1239}
1240
3c03977Claude1241/**
1242 * Live co-editing client. Connects to the per-PR SSE feed and:
1243 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1244 * status colour per user).
1245 * - Renders tinted cursor caret overlays inside #pr-body and every
1246 * `[data-live-field]` element.
1247 * - Broadcasts the local user's cursor position (selectionStart /
1248 * selectionEnd) debounced at 100ms.
1249 * - Broadcasts content patches (`replace` of the whole textarea —
1250 * last-write-wins v1) debounced at 250ms.
1251 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1252 * to the matching local field if untouched.
1253 *
1254 * All endpoint URLs are JSON-escaped via safe replacements so they
1255 * can't break out of the <script> tag.
1256 */
1257function LIVE_COEDIT_SCRIPT(prId: string): string {
1258 const idJson = JSON.stringify(prId)
1259 .split("<").join("\\u003C")
1260 .split(">").join("\\u003E")
1261 .split("&").join("\\u0026");
1262 return (
1263 "(function(){try{" +
1264 "if(typeof EventSource==='undefined')return;" +
1265 "var prId=" + idJson + ";" +
1266 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1267 "var pill=document.getElementById('live-pill');" +
1268 "var avEl=document.getElementById('live-avatars');" +
1269 "var countEl=document.getElementById('live-count');" +
1270 "var sessionId=null;var myColor=null;" +
1271 "var presence={};" + // sessionId -> {color,status,userId,initials}
1272 "var lastApplied={};" + // field -> last server value (for echo suppression)
1273 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1274 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1275 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1276 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1277 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1278 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1279 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1280 "avEl.innerHTML=html;}}" +
1281 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1282 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1283 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1284 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1285 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1286 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1287 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1288 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1289 "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);}" +
1290 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1291 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1292 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1293 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1294 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1295 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1296 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1297 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1298 "}catch(e){}" +
1299 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1300 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1301 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1302 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1303 "var es;var delay=1000;" +
1304 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1305 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1306 "(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){}});" +
1307 "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){}});" +
1308 "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){}});" +
1309 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1310 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1311 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1312 "var patch=d.patch;if(!patch||!patch.field)return;" +
1313 "var ta=fieldEl(patch.field);if(!ta)return;" +
1314 "if(document.activeElement===ta)return;" + // don't trample local typing
1315 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1316 "}catch(e){}});" +
1317 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1318 "}connect();" +
1319 "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){}}" +
1320 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1321 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1322 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1323 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1324 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1325 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1326 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1327 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1328 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1329 "}" +
1330 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1331 "var live=document.querySelectorAll('[data-live-field]');" +
1332 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1333 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1334 "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){}});" +
1335 "}catch(e){}})();"
1336 );
1337}
1338
0074234Claude1339async function resolveRepo(ownerName: string, repoName: string) {
1340 const [owner] = await db
1341 .select()
1342 .from(users)
1343 .where(eq(users.username, ownerName))
1344 .limit(1);
1345 if (!owner) return null;
1346 const [repo] = await db
1347 .select()
1348 .from(repositories)
1349 .where(
1350 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1351 )
1352 .limit(1);
1353 if (!repo) return null;
1354 return { owner, repo };
1355}
1356
1357// PR Nav helper
1358const PrNav = ({
1359 owner,
1360 repo,
1361 active,
1362}: {
1363 owner: string;
1364 repo: string;
1365 active: "code" | "issues" | "pulls" | "commits";
1366}) => (
bb0f894Claude1367 <TabNav
1368 tabs={[
1369 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1370 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1371 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1372 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1373 ]}
1374 />
0074234Claude1375);
1376
534f04aClaude1377/**
1378 * Block M3 — pre-merge risk score card. Pure presentational helper.
1379 * Rendered in the conversation tab above the gate checks block. Hidden
1380 * entirely when the PR is closed/merged or there is nothing cached and
1381 * nothing in-flight.
1382 */
1383function PrRiskCard({
1384 risk,
1385 calculating,
1386}: {
1387 risk: PrRiskScore | null;
1388 calculating: boolean;
1389}) {
1390 if (!risk) {
1391 return (
1392 <div
1393 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1394 >
1395 <strong style="font-size: 13px; color: var(--text)">
1396 Risk score: calculating…
1397 </strong>
1398 <div style="font-size: 12px; margin-top: 4px">
1399 Refresh in a moment to see the pre-merge risk score for this PR.
1400 </div>
1401 </div>
1402 );
1403 }
1404
1405 const palette = riskBandPalette(risk.band);
1406 const label = riskBandLabel(risk.band);
1407
1408 return (
1409 <div
1410 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1411 >
1412 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1413 <strong>Risk score:</strong>
1414 <span style={`color:${palette.border};font-weight:600`}>
1415 {palette.icon} {label} ({risk.score}/10)
1416 </span>
1417 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1418 {risk.commitSha.slice(0, 7)}
1419 </span>
1420 </div>
1421 {risk.aiSummary && (
1422 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1423 {risk.aiSummary}
1424 </div>
1425 )}
1426 <details style="margin-top:10px">
1427 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1428 See full signal breakdown
1429 </summary>
1430 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1431 <li>files changed: {risk.signals.filesChanged}</li>
1432 <li>
1433 lines added/removed: {risk.signals.linesAdded} /{" "}
1434 {risk.signals.linesRemoved}
1435 </li>
1436 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1437 <li>
1438 schema migration touched:{" "}
1439 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1440 </li>
1441 <li>
1442 locked / sensitive path touched:{" "}
1443 {risk.signals.lockedPathTouched ? "yes" : "no"}
1444 </li>
1445 <li>
1446 adds new dependency:{" "}
1447 {risk.signals.addsNewDependency ? "yes" : "no"}
1448 </li>
1449 <li>
1450 bumps major dependency:{" "}
1451 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1452 </li>
1453 <li>
1454 tests added for new code:{" "}
1455 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1456 </li>
1457 <li>
1458 diff-minus-test ratio:{" "}
1459 {risk.signals.diffMinusTestRatio.toFixed(2)}
1460 </li>
1461 </ul>
1462 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1463 How is this calculated? The score is a transparent sum of
1464 weighted signals — see <code>src/lib/pr-risk.ts</code>
1465 {" "}<code>computePrRiskScore</code>.
1466 </div>
1467 </details>
1468 {calculating && (
1469 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1470 (recomputing for the latest commit — refresh to update)
1471 </div>
1472 )}
1473 </div>
1474 );
1475}
1476
1477function riskBandPalette(band: PrRiskScore["band"]): {
1478 border: string;
1479 icon: string;
1480} {
1481 switch (band) {
1482 case "low":
1483 return { border: "var(--green)", icon: "" };
1484 case "medium":
1485 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1486 case "high":
1487 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1488 case "critical":
1489 return { border: "var(--red)", icon: "\u{1F6D1}" };
1490 }
1491}
1492
1493function riskBandLabel(band: PrRiskScore["band"]): string {
1494 switch (band) {
1495 case "low":
1496 return "LOW";
1497 case "medium":
1498 return "MEDIUM";
1499 case "high":
1500 return "HIGH";
1501 case "critical":
1502 return "CRITICAL";
1503 }
1504}
1505
422a2d4Claude1506// ---------------------------------------------------------------------------
1507// AI Trio Review — 3-column card grid + disagreement callout.
1508//
1509// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
1510// per run: one per persona (security/correctness/style) plus a top-level
1511// summary. We surface them here as a single grid above the normal
1512// comment stream so reviewers see the verdicts at a glance.
1513// ---------------------------------------------------------------------------
1514
1515const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
1516
1517interface TrioCommentLike {
1518 body: string;
1519}
1520
1521function isTrioComment(body: string | null | undefined): boolean {
1522 if (!body) return false;
1523 return (
1524 body.includes(TRIO_SUMMARY_MARKER) ||
1525 body.includes(TRIO_COMMENT_MARKER.security) ||
1526 body.includes(TRIO_COMMENT_MARKER.correctness) ||
1527 body.includes(TRIO_COMMENT_MARKER.style)
1528 );
1529}
1530
1531function trioPersonaOfComment(body: string): TrioPersona | null {
1532 for (const p of TRIO_PERSONAS) {
1533 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
1534 }
1535 return null;
1536}
1537
1538/**
1539 * Best-effort verdict parse from a persona comment body. The body shape
1540 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
1541 * we only need the "Pass" / "Fail" word from the H2 heading.
1542 */
1543function trioVerdictOfBody(body: string): "pass" | "fail" | null {
1544 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
1545 if (!m) return null;
1546 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
1547}
1548
1549/**
1550 * Parse the disagreement bullet list out of the summary comment so we
1551 * can render it as a polished callout strip. Returns [] when nothing
1552 * matches — the comment author may have edited the marker out.
1553 */
1554function parseDisagreements(summaryBody: string): Array<{
1555 file: string;
1556 failing: string;
1557 passing: string;
1558}> {
1559 const out: Array<{ file: string; failing: string; passing: string }> = [];
1560 // Each disagreement line looks like:
1561 // - `path:42` — security, style say ✗, correctness say ✓
1562 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
1563 let m: RegExpExecArray | null;
1564 while ((m = re.exec(summaryBody)) !== null) {
1565 out.push({
1566 file: m[1].trim(),
1567 failing: m[2].trim().replace(/[,\s]+$/g, ""),
1568 passing: m[3].trim().replace(/[,\s]+$/g, ""),
1569 });
1570 }
1571 return out;
1572}
1573
1574function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
1575 // Find the most recent persona comments + summary. We iterate from
1576 // the end so re-reviews (multiple runs on the same PR) display the
1577 // freshest verdict.
1578 const latest: Partial<Record<TrioPersona, string>> = {};
1579 let summaryBody: string | null = null;
1580 for (let i = comments.length - 1; i >= 0; i--) {
1581 const body = comments[i].body || "";
1582 if (!isTrioComment(body)) continue;
1583 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
1584 summaryBody = body;
1585 continue;
1586 }
1587 const persona = trioPersonaOfComment(body);
1588 if (persona && !latest[persona]) latest[persona] = body;
1589 }
1590 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1591 if (!anyPersona && !summaryBody) return null;
1592
1593 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
1594
1595 return (
1596 <div class="trio-wrap">
1597 <div class="trio-header">
1598 <span class="trio-header-dot" aria-hidden="true"></span>
1599 <strong>AI Trio Review</strong>
1600 <span class="trio-header-sub">
1601 Three independent reviewers ran in parallel.
1602 </span>
1603 </div>
1604 <div class="trio-grid">
1605 {TRIO_PERSONAS.map((persona) => {
1606 const body = latest[persona];
1607 const verdict = body ? trioVerdictOfBody(body) : null;
1608 const stateClass =
1609 verdict === "fail"
1610 ? "is-fail"
1611 : verdict === "pass"
1612 ? "is-pass"
1613 : "is-pending";
1614 return (
1615 <div class={`trio-card trio-${persona} ${stateClass}`}>
1616 <div class="trio-card-head">
1617 <span class="trio-card-icon" aria-hidden="true">
1618 {persona === "security"
1619 ? "🛡"
1620 : persona === "correctness"
1621 ? "✓"
1622 : "✎"}
1623 </span>
1624 <strong class="trio-card-title">
1625 {persona[0].toUpperCase() + persona.slice(1)}
1626 </strong>
1627 <span class="trio-card-verdict">
1628 {verdict === "pass"
1629 ? "Pass"
1630 : verdict === "fail"
1631 ? "Fail"
1632 : "Pending"}
1633 </span>
1634 </div>
1635 <div class="trio-card-body">
1636 {body ? (
1637 <MarkdownContent
1638 html={renderMarkdown(stripTrioHeading(body))}
1639 />
1640 ) : (
1641 <span class="trio-card-empty">
1642 Awaiting reviewer output.
1643 </span>
1644 )}
1645 </div>
1646 </div>
1647 );
1648 })}
1649 </div>
1650 {disagreements.length > 0 && (
1651 <div class="trio-disagreement-strip" role="note">
1652 <span class="trio-disagreement-icon" aria-hidden="true">
1653
1654 </span>
1655 <div class="trio-disagreement-body">
1656 <strong>Reviewers disagree — review carefully.</strong>
1657 <ul class="trio-disagreement-list">
1658 {disagreements.map((d) => (
1659 <li>
1660 <code>{d.file}</code> — {d.failing} says ✗,{" "}
1661 {d.passing} says ✓
1662 </li>
1663 ))}
1664 </ul>
1665 </div>
1666 </div>
1667 )}
1668 </div>
1669 );
1670}
1671
1672/**
1673 * Strip the marker comment + first H2 heading from a persona body so
1674 * the card body shows just the findings list (verdict is already in
1675 * the card head). Best-effort — malformed bodies render whole.
1676 */
1677function stripTrioHeading(body: string): string {
1678 return body
1679 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
1680 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
1681 .trim();
1682}
1683
0074234Claude1684// List PRs
04f6b7fClaude1685pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1686 const { owner: ownerName, repo: repoName } = c.req.param();
1687 const user = c.get("user");
1688 const state = c.req.query("state") || "open";
1689
ea9ed4cClaude1690 // ── Loading skeleton (flag-gated) ──
1691 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
1692 // the user see the page structure before counts + select resolve.
1693 // Behind a flag for now — we don't ship flashes.
1694 if (c.req.query("skeleton") === "1") {
1695 return c.html(
1696 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1697 <RepoHeader owner={ownerName} repo={repoName} />
1698 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1699 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1700 <style
1701 dangerouslySetInnerHTML={{
1702 __html: `
1703 .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; }
1704 @keyframes prsSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1705 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
1706 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
1707 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
1708 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
1709 .prs-skel-row { height: 76px; border-radius: 12px; }
1710 `,
1711 }}
1712 />
1713 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
1714 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
1715 <div class="prs-skel-list" aria-hidden="true">
1716 {Array.from({ length: 6 }).map(() => (
1717 <div class="prs-skel prs-skel-row" />
1718 ))}
1719 </div>
1720 <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">
1721 Loading pull requests for {ownerName}/{repoName}…
1722 </span>
1723 </Layout>
1724 );
1725 }
1726
0074234Claude1727 const resolved = await resolveRepo(ownerName, repoName);
1728 if (!resolved) return c.notFound();
1729
6fc53bdClaude1730 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
1731 const stateFilter =
1732 state === "draft"
1733 ? and(
1734 eq(pullRequests.state, "open"),
1735 eq(pullRequests.isDraft, true)
1736 )
1737 : eq(pullRequests.state, state);
1738
0074234Claude1739 const prList = await db
1740 .select({
1741 pr: pullRequests,
1742 author: { username: users.username },
1743 })
1744 .from(pullRequests)
1745 .innerJoin(users, eq(pullRequests.authorId, users.id))
1746 .where(
6fc53bdClaude1747 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude1748 )
1749 .orderBy(desc(pullRequests.createdAt));
1750
1751 const [counts] = await db
1752 .select({
1753 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude1754 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude1755 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
1756 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
1757 })
1758 .from(pullRequests)
1759 .where(eq(pullRequests.repositoryId, resolved.repo.id));
1760
b078860Claude1761 const openCount = counts?.open ?? 0;
1762 const mergedCount = counts?.merged ?? 0;
1763 const closedCount = counts?.closed ?? 0;
1764 const draftCount = counts?.draft ?? 0;
1765 const allCount = openCount + mergedCount + closedCount;
1766
1767 // "All" is presentational only — the DB query for state='all' matches
1768 // nothing, so we render a friendlier empty state when picked. We do NOT
1769 // change the query logic to keep this commit purely visual.
1770 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
1771 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
1772 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
1773 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
1774 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
1775 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
1776 ];
1777 const isAllState = state === "all";
cb5a796Claude1778 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
1779 const prListPendingCount = viewerIsOwnerOnPrList
1780 ? await countPendingForRepo(resolved.repo.id)
1781 : 0;
b078860Claude1782
0074234Claude1783 return c.html(
1784 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1785 <RepoHeader owner={ownerName} repo={repoName} />
1786 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude1787 <PendingCommentsBanner
1788 owner={ownerName}
1789 repo={repoName}
1790 count={prListPendingCount}
1791 />
b078860Claude1792 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1793
1794 <div class="prs-hero">
1795 <div class="prs-hero-inner">
1796 <div class="prs-hero-text">
1797 <div class="prs-hero-eyebrow">Pull requests</div>
1798 <h1 class="prs-hero-title">
1799 Review, <span class="gradient-text">merge with AI</span>.
1800 </h1>
1801 <p class="prs-hero-sub">
1802 {openCount === 0 && allCount === 0
1803 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
1804 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
1805 </p>
1806 </div>
1807 {user && (
1808 <div class="prs-hero-actions">
1809 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
1810 + New pull request
1811 </a>
1812 </div>
1813 )}
1814 </div>
1815 </div>
1816
1817 <nav class="prs-tabs" aria-label="Pull request filters">
1818 {tabPills.map((t) => {
1819 const isActive =
1820 state === t.key ||
1821 (t.key === "open" &&
1822 state !== "merged" &&
1823 state !== "closed" &&
1824 state !== "all" &&
1825 state !== "draft");
1826 return (
1827 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
1828 <span>{t.label}</span>
1829 <span class="prs-tab-count">{t.count}</span>
1830 </a>
1831 );
1832 })}
1833 </nav>
1834
0074234Claude1835 {prList.length === 0 ? (
b078860Claude1836 <div class="prs-empty">
ea9ed4cClaude1837 <div class="prs-empty-inner">
1838 <strong>
1839 {isAllState
1840 ? "Pick a filter above to browse PRs."
1841 : `No ${state} pull requests.`}
1842 </strong>
1843 <p class="prs-empty-sub">
1844 {state === "open"
1845 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
1846 : isAllState
1847 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1848 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
1849 </p>
1850 <div class="prs-empty-cta">
1851 {user && state === "open" && (
1852 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
1853 + New pull request
1854 </a>
1855 )}
1856 {state !== "open" && (
1857 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
1858 View open PRs
1859 </a>
1860 )}
1861 <a href={`/${ownerName}/${repoName}`} class="btn">
1862 Back to code
1863 </a>
1864 </div>
1865 </div>
b078860Claude1866 </div>
0074234Claude1867 ) : (
b078860Claude1868 <div class="prs-list">
1869 {prList.map(({ pr, author }) => {
1870 const stateClass =
1871 pr.state === "open"
1872 ? pr.isDraft
1873 ? "state-draft"
1874 : "state-open"
1875 : pr.state === "merged"
1876 ? "state-merged"
1877 : "state-closed";
1878 const icon =
1879 pr.state === "open"
1880 ? pr.isDraft
1881 ? "◌"
1882 : "○"
1883 : pr.state === "merged"
1884 ? "⮌"
1885 : "✓";
1886 return (
1887 <a
1888 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1889 class="prs-row"
1890 style="text-decoration:none;color:inherit"
0074234Claude1891 >
b078860Claude1892 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
1893 {icon}
0074234Claude1894 </div>
b078860Claude1895 <div class="prs-row-body">
1896 <h3 class="prs-row-title">
1897 <span>{pr.title}</span>
1898 <span class="prs-row-number">#{pr.number}</span>
1899 </h3>
1900 <div class="prs-row-meta">
1901 <span
1902 class="prs-branch-chips"
1903 title={`${pr.headBranch} into ${pr.baseBranch}`}
1904 >
1905 <span class="prs-branch-chip">{pr.headBranch}</span>
1906 <span class="prs-branch-arrow">{"→"}</span>
1907 <span class="prs-branch-chip">{pr.baseBranch}</span>
1908 </span>
1909 <span>
1910 by{" "}
1911 <strong style="color:var(--text)">
1912 {author.username}
1913 </strong>{" "}
1914 {formatRelative(pr.createdAt)}
1915 </span>
1916 <span class="prs-row-tags">
1917 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
1918 {pr.state === "merged" && (
1919 <span class="prs-tag is-merged">Merged</span>
1920 )}
1921 </span>
1922 </div>
0074234Claude1923 </div>
b078860Claude1924 </a>
1925 );
1926 })}
1927 </div>
0074234Claude1928 )}
1929 </Layout>
1930 );
1931});
1932
1933// New PR form
1934pulls.get(
1935 "/:owner/:repo/pulls/new",
1936 softAuth,
1937 requireAuth,
04f6b7fClaude1938 requireRepoAccess("write"),
0074234Claude1939 async (c) => {
1940 const { owner: ownerName, repo: repoName } = c.req.param();
1941 const user = c.get("user")!;
1942 const branches = await listBranches(ownerName, repoName);
1943 const error = c.req.query("error");
1944 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude1945 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude1946
1947 return c.html(
1948 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
1949 <RepoHeader owner={ownerName} repo={repoName} />
1950 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude1951 <Container maxWidth={800}>
1952 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude1953 {error && (
bb0f894Claude1954 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude1955 )}
0316dbbClaude1956 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
1957 <Flex gap={12} align="center" style="margin-bottom: 16px">
1958 <Select name="base">
0074234Claude1959 {branches.map((b) => (
1960 <option value={b} selected={b === defaultBase}>
1961 {b}
1962 </option>
1963 ))}
bb0f894Claude1964 </Select>
1965 <Text muted>&larr;</Text>
1966 <Select name="head">
0074234Claude1967 {branches
1968 .filter((b) => b !== defaultBase)
1969 .concat(defaultBase === branches[0] ? [] : [branches[0]])
1970 .map((b) => (
1971 <option value={b}>{b}</option>
1972 ))}
bb0f894Claude1973 </Select>
1974 </Flex>
1975 <FormGroup>
1976 <Input
0074234Claude1977 name="title"
1978 required
1979 placeholder="Title"
bb0f894Claude1980 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]1981 aria-label="Pull request title"
0074234Claude1982 />
bb0f894Claude1983 </FormGroup>
1984 <FormGroup>
1985 <TextArea
0074234Claude1986 name="body"
81c73c1Claude1987 id="pr-body"
0074234Claude1988 rows={8}
1989 placeholder="Description (Markdown supported)"
bb0f894Claude1990 mono
0074234Claude1991 />
bb0f894Claude1992 </FormGroup>
81c73c1Claude1993 <Flex gap={8} align="center">
1994 <Button type="submit" variant="primary">
1995 Create pull request
1996 </Button>
1997 <button
1998 type="button"
1999 id="ai-suggest-desc"
2000 class="btn"
2001 style="font-weight:500"
2002 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
2003 >
2004 Suggest description with AI
2005 </button>
2006 <span
2007 id="ai-suggest-status"
2008 style="color:var(--text-muted);font-size:13px"
2009 />
2010 </Flex>
bb0f894Claude2011 </Form>
81c73c1Claude2012 <script
2013 dangerouslySetInnerHTML={{
2014 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
2015 }}
2016 />
bb0f894Claude2017 </Container>
0074234Claude2018 </Layout>
2019 );
2020 }
2021);
2022
81c73c1Claude2023// AI-suggested PR description — JSON endpoint driven by the form button.
2024// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
2025// 200; the inline script reads `ok` to decide what to do.
2026pulls.post(
2027 "/:owner/:repo/ai/pr-description",
2028 softAuth,
2029 requireAuth,
2030 requireRepoAccess("write"),
2031 async (c) => {
2032 const { owner: ownerName, repo: repoName } = c.req.param();
2033 if (!isAiAvailable()) {
2034 return c.json({
2035 ok: false,
2036 error: "AI is not available — set ANTHROPIC_API_KEY.",
2037 });
2038 }
2039 const body = await c.req.parseBody();
2040 const title = String(body.title || "").trim();
2041 const baseBranch = String(body.base || "").trim();
2042 const headBranch = String(body.head || "").trim();
2043 if (!baseBranch || !headBranch) {
2044 return c.json({ ok: false, error: "Pick base + head branches first." });
2045 }
2046 if (baseBranch === headBranch) {
2047 return c.json({ ok: false, error: "Base and head must differ." });
2048 }
2049
2050 let diff = "";
2051 try {
2052 const cwd = getRepoPath(ownerName, repoName);
2053 const proc = Bun.spawn(
2054 [
2055 "git",
2056 "diff",
2057 `${baseBranch}...${headBranch}`,
2058 "--",
2059 ],
2060 { cwd, stdout: "pipe", stderr: "pipe" }
2061 );
6ea2109Claude2062 // 30s ceiling — without this a pathological diff (huge binary or
2063 // a corrupt ref) hangs the request indefinitely.
2064 const killer = setTimeout(() => proc.kill(), 30_000);
2065 try {
2066 diff = await new Response(proc.stdout).text();
2067 await proc.exited;
2068 } finally {
2069 clearTimeout(killer);
2070 }
81c73c1Claude2071 } catch {
2072 diff = "";
2073 }
2074 if (!diff.trim()) {
2075 return c.json({
2076 ok: false,
2077 error: "No diff between branches — nothing to summarise.",
2078 });
2079 }
2080
2081 let summary = "";
2082 try {
2083 summary = await generatePrSummary(title || "(untitled)", diff);
2084 } catch (err) {
2085 const msg = err instanceof Error ? err.message : "AI request failed.";
2086 return c.json({ ok: false, error: msg });
2087 }
2088 if (!summary.trim()) {
2089 return c.json({ ok: false, error: "AI returned an empty draft." });
2090 }
2091 return c.json({ ok: true, body: summary });
2092 }
2093);
2094
0074234Claude2095// Create PR
2096pulls.post(
2097 "/:owner/:repo/pulls/new",
2098 softAuth,
2099 requireAuth,
04f6b7fClaude2100 requireRepoAccess("write"),
0074234Claude2101 async (c) => {
2102 const { owner: ownerName, repo: repoName } = c.req.param();
2103 const user = c.get("user")!;
2104 const body = await c.req.parseBody();
2105 const title = String(body.title || "").trim();
2106 const prBody = String(body.body || "").trim();
2107 const baseBranch = String(body.base || "main");
2108 const headBranch = String(body.head || "");
2109
2110 if (!title || !headBranch) {
2111 return c.redirect(
2112 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
2113 );
2114 }
2115
2116 if (baseBranch === headBranch) {
2117 return c.redirect(
2118 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
2119 );
2120 }
2121
2122 const resolved = await resolveRepo(ownerName, repoName);
2123 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2124
6fc53bdClaude2125 const isDraft = String(body.draft || "") === "1";
2126
0074234Claude2127 const [pr] = await db
2128 .insert(pullRequests)
2129 .values({
2130 repositoryId: resolved.repo.id,
2131 authorId: user.id,
2132 title,
2133 body: prBody || null,
2134 baseBranch,
2135 headBranch,
6fc53bdClaude2136 isDraft,
0074234Claude2137 })
2138 .returning();
2139
6fc53bdClaude2140 // Skip AI review on drafts — it runs again when the PR is marked ready.
2141 if (!isDraft && isAiReviewEnabled()) {
e883329Claude2142 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
2143 (err) => console.error("[ai-review] Failed:", err)
2144 );
2145 }
2146
3cbe3d6Claude2147 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
2148 triggerPrTriage({
2149 ownerName,
2150 repoName,
2151 repositoryId: resolved.repo.id,
2152 prId: pr.id,
2153 prAuthorId: user.id,
2154 title,
2155 body: prBody,
2156 baseBranch,
2157 headBranch,
2158 }).catch((err) => console.error("[pr-triage] Failed:", err));
2159
1d4ff60Claude2160 // Chat notifier — fan out to Slack/Discord/Teams.
2161 import("../lib/chat-notifier")
2162 .then((m) =>
2163 m.notifyChatChannels({
2164 ownerUserId: resolved.repo.ownerId,
2165 repositoryId: resolved.repo.id,
2166 event: {
2167 event: "pr.opened",
2168 repo: `${ownerName}/${repoName}`,
2169 title: `#${pr.number} ${title}`,
2170 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
2171 body: prBody || undefined,
2172 actor: user.username,
2173 },
2174 })
2175 )
2176 .catch((err) =>
2177 console.warn(`[chat-notifier] PR opened notify failed:`, err)
2178 );
2179
9dd96b9Test User2180 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude2181 import("../lib/auto-merge")
2182 .then((m) => m.tryAutoMergeNow(pr.id))
2183 .catch((err) => {
2184 console.warn(
2185 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
2186 err instanceof Error ? err.message : err
2187 );
2188 });
9dd96b9Test User2189
0074234Claude2190 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
2191 }
2192);
2193
2194// View single PR
04f6b7fClaude2195pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2196 const { owner: ownerName, repo: repoName } = c.req.param();
2197 const prNum = parseInt(c.req.param("number"), 10);
2198 const user = c.get("user");
2199 const tab = c.req.query("tab") || "conversation";
2200
2201 const resolved = await resolveRepo(ownerName, repoName);
2202 if (!resolved) return c.notFound();
2203
2204 const [pr] = await db
2205 .select()
2206 .from(pullRequests)
2207 .where(
2208 and(
2209 eq(pullRequests.repositoryId, resolved.repo.id),
2210 eq(pullRequests.number, prNum)
2211 )
2212 )
2213 .limit(1);
2214
2215 if (!pr) return c.notFound();
2216
2217 const [author] = await db
2218 .select()
2219 .from(users)
2220 .where(eq(users.id, pr.authorId))
2221 .limit(1);
2222
cb5a796Claude2223 const allCommentsRaw = await db
0074234Claude2224 .select({
2225 comment: prComments,
cb5a796Claude2226 author: { id: users.id, username: users.username },
0074234Claude2227 })
2228 .from(prComments)
2229 .innerJoin(users, eq(prComments.authorId, users.id))
2230 .where(eq(prComments.pullRequestId, pr.id))
2231 .orderBy(asc(prComments.createdAt));
2232
cb5a796Claude2233 // Filter pending/rejected/spam for non-owner, non-author viewers.
2234 // Owner always sees everything; comment author sees their own pending
2235 // with an "Awaiting approval" badge in the render below.
2236 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
2237 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
2238 if (viewerIsRepoOwner) return true;
2239 if (comment.moderationStatus === "approved") return true;
2240 if (
2241 user &&
2242 cAuthor.id === user.id &&
2243 comment.moderationStatus === "pending"
2244 ) {
2245 return true;
2246 }
2247 return false;
2248 });
2249 const prPendingCount = viewerIsRepoOwner
2250 ? await countPendingForRepo(resolved.repo.id)
2251 : 0;
2252
6fc53bdClaude2253 // Reactions for the PR body + each comment, in parallel.
2254 const [prReactions, ...prCommentReactions] = await Promise.all([
2255 summariseReactions("pr", pr.id, user?.id),
2256 ...comments.map((row) =>
2257 summariseReactions("pr_comment", row.comment.id, user?.id)
2258 ),
2259 ]);
2260
0a67773Claude2261 // Formal reviews (Approve / Request Changes)
2262 const reviewRows = await db
2263 .select({
2264 id: prReviews.id,
2265 state: prReviews.state,
2266 body: prReviews.body,
2267 isAi: prReviews.isAi,
2268 createdAt: prReviews.createdAt,
2269 reviewerUsername: users.username,
2270 reviewerId: prReviews.reviewerId,
2271 })
2272 .from(prReviews)
2273 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2274 .where(eq(prReviews.pullRequestId, pr.id))
2275 .orderBy(asc(prReviews.createdAt));
2276 // Most recent review per reviewer determines the current state
2277 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
2278 for (const r of reviewRows) {
2279 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
2280 }
2281 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
2282 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
2283 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
2284
0074234Claude2285 const canManage =
2286 user &&
2287 (user.id === resolved.owner.id || user.id === pr.authorId);
2288
1d4ff60Claude2289 // Has any previous AI-test-generator run already tagged this PR? Used
2290 // both to hide the "Generate tests with AI" button and to short-circuit
2291 // the explicit POST handler.
2292 const hasAiTestsMarker = comments.some(({ comment }) =>
2293 (comment.body || "").includes(AI_TESTS_MARKER)
2294 );
2295
e883329Claude2296 const error = c.req.query("error");
c3e0c07Claude2297 const info = c.req.query("info");
e883329Claude2298
2299 // Get gate check status for open PRs
2300 let gateChecks: GateCheckResult[] = [];
2301 if (pr.state === "open") {
2302 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2303 if (headSha) {
2304 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
2305 const aiApproved = aiComments.length === 0 || aiComments.some(
2306 ({ comment }) => comment.body.includes("**Approved**")
2307 );
2308 const gateResult = await runAllGateChecks(
2309 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
2310 );
2311 gateChecks = gateResult.checks;
2312 }
2313 }
2314
534f04aClaude2315 // Block M3 — pre-merge risk score. Cache-only on the request path so
2316 // the page never waits on Haiku. On a cache miss for an open PR we
2317 // kick off the computation fire-and-forget; the next refresh shows it.
2318 let prRisk: PrRiskScore | null = null;
2319 let prRiskCalculating = false;
2320 if (pr.state === "open") {
2321 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
2322 if (!prRisk) {
2323 prRiskCalculating = true;
a28cedeClaude2324 void computePrRiskForPullRequest(pr.id).catch((err) => {
2325 console.warn(
2326 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
2327 err instanceof Error ? err.message : err
2328 );
2329 });
534f04aClaude2330 }
2331 }
2332
4bbacbeClaude2333 // Migration 0062 — per-branch preview URL. The head branch always
2334 // has a preview row (unless it's the default branch, which never
2335 // happens for an open PR) once it has been pushed at least once.
2336 const preview = await getPreviewForBranch(
2337 (resolved.repo as { id: string }).id,
2338 pr.headBranch
2339 );
2340
47a7a0aClaude2341 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude2342 let diffRaw = "";
2343 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude2344 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude2345 if (tab === "files") {
2346 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude2347 // Run the two git diffs in parallel — they're independent reads of
2348 // the same range. Previously sequential, doubling the wall time on
2349 // big PRs (100+ files = 10-30s for no reason).
0074234Claude2350 const proc = Bun.spawn(
2351 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
2352 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2353 );
2354 const statProc = Bun.spawn(
2355 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
2356 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2357 );
6ea2109Claude2358 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
2359 // would otherwise hang the whole request.
2360 const killer = setTimeout(() => {
2361 proc.kill();
2362 statProc.kill();
2363 }, 30_000);
2364 let stat = "";
2365 try {
2366 [diffRaw, stat] = await Promise.all([
2367 new Response(proc.stdout).text(),
2368 new Response(statProc.stdout).text(),
2369 ]);
2370 await Promise.all([proc.exited, statProc.exited]);
2371 } finally {
2372 clearTimeout(killer);
2373 }
0074234Claude2374
2375 diffFiles = stat
2376 .trim()
2377 .split("\n")
2378 .filter(Boolean)
2379 .map((line) => {
2380 const [add, del, filePath] = line.split("\t");
2381 return {
2382 path: filePath,
2383 status: "modified",
2384 additions: add === "-" ? 0 : parseInt(add, 10),
2385 deletions: del === "-" ? 0 : parseInt(del, 10),
2386 patch: "",
2387 };
2388 });
47a7a0aClaude2389
2390 // Fetch inline comments (file+line anchored) for the files tab
2391 const inlineRows = await db
2392 .select({
2393 id: prComments.id,
2394 filePath: prComments.filePath,
2395 lineNumber: prComments.lineNumber,
2396 body: prComments.body,
2397 isAiReview: prComments.isAiReview,
2398 createdAt: prComments.createdAt,
2399 authorUsername: users.username,
2400 })
2401 .from(prComments)
2402 .innerJoin(users, eq(prComments.authorId, users.id))
2403 .where(
2404 and(
2405 eq(prComments.pullRequestId, pr.id),
2406 eq(prComments.moderationStatus, "approved"),
2407 )
2408 )
2409 .orderBy(asc(prComments.createdAt));
2410
2411 diffInlineComments = inlineRows
2412 .filter(r => r.filePath != null && r.lineNumber != null)
2413 .map(r => ({
2414 id: r.id,
2415 filePath: r.filePath!,
2416 lineNumber: r.lineNumber!,
2417 authorUsername: r.authorUsername,
2418 body: renderMarkdown(r.body),
2419 isAiReview: r.isAiReview,
2420 createdAt: r.createdAt.toISOString(),
2421 }));
0074234Claude2422 }
2423
b078860Claude2424 // ─── Derived visual state ───
2425 const stateKey =
2426 pr.state === "open"
2427 ? pr.isDraft
2428 ? "draft"
2429 : "open"
2430 : pr.state;
2431 const stateLabel =
2432 stateKey === "open"
2433 ? "Open"
2434 : stateKey === "draft"
2435 ? "Draft"
2436 : stateKey === "merged"
2437 ? "Merged"
2438 : "Closed";
2439 const stateIcon =
2440 stateKey === "open"
2441 ? "○"
2442 : stateKey === "draft"
2443 ? "◌"
2444 : stateKey === "merged"
2445 ? "⮌"
2446 : "✓";
2447 const commentCount = comments.length;
2448 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
2449 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
2450 const mergeBlocked =
2451 gateChecks.length > 0 &&
2452 gateChecks.some(
2453 (c) => !c.passed && c.name !== "Merge check"
2454 );
2455
0074234Claude2456 return c.html(
2457 <Layout
2458 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
2459 user={user}
2460 >
2461 <RepoHeader owner={ownerName} repo={repoName} />
2462 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2463 <PendingCommentsBanner
2464 owner={ownerName}
2465 repo={repoName}
2466 count={prPendingCount}
2467 />
b078860Claude2468 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude2469 <div
2470 id="live-comment-banner"
2471 class="alert"
2472 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
2473 >
2474 <strong class="js-live-count">0</strong> new comment(s) —{" "}
2475 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
2476 reload to view
2477 </a>
2478 </div>
2479 <script
2480 dangerouslySetInnerHTML={{
2481 __html: liveCommentBannerScript({
2482 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
2483 bannerElementId: "live-comment-banner",
2484 }),
2485 }}
2486 />
b078860Claude2487
2488 <div class="prs-detail-hero">
2489 <h1 class="prs-detail-title">
0074234Claude2490 {pr.title}{" "}
b078860Claude2491 <span class="prs-detail-num">#{pr.number}</span>
2492 </h1>
2493 <div class="prs-detail-meta">
2494 <span class={`prs-state-pill state-${stateKey}`}>
2495 <span aria-hidden="true">{stateIcon}</span>
2496 <span>{stateLabel}</span>
2497 </span>
2498 <span>
2499 <strong>{author?.username}</strong> wants to merge
2500 </span>
2501 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
2502 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
2503 <span class="prs-branch-arrow-lg">{"→"}</span>
2504 <span class="prs-branch-pill">{pr.baseBranch}</span>
2505 </span>
2506 <span>opened {formatRelative(pr.createdAt)}</span>
3c03977Claude2507 <span
2508 id="live-pill"
2509 class="live-pill"
2510 title="People editing this PR right now"
2511 >
2512 <span class="live-pill-dot" aria-hidden="true"></span>
2513 <span>
2514 Live: <strong id="live-count">0</strong> editing
2515 </span>
2516 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
2517 </span>
4bbacbeClaude2518 {preview && (
2519 <a
2520 class={`preview-prpill is-${preview.status}`}
2521 href={
2522 preview.status === "ready"
2523 ? preview.previewUrl
2524 : `/${ownerName}/${repoName}/previews`
2525 }
2526 target={preview.status === "ready" ? "_blank" : undefined}
2527 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
2528 title={`Preview · ${previewStatusLabel(preview.status)}`}
2529 >
2530 <span class="preview-prpill-dot" aria-hidden="true"></span>
2531 <span>Preview: </span>
2532 <span>{previewStatusLabel(preview.status)}</span>
2533 </a>
2534 )}
b078860Claude2535 {canManage && pr.state === "open" && pr.isDraft && (
2536 <form
2537 method="post"
2538 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2539 class="prs-inline-form prs-detail-actions"
2540 >
2541 <button type="submit" class="prs-merge-ready-btn">
2542 Ready for review
2543 </button>
2544 </form>
2545 )}
2546 </div>
2547 </div>
3c03977Claude2548 <script
2549 dangerouslySetInnerHTML={{
2550 __html: LIVE_COEDIT_SCRIPT(pr.id),
2551 }}
2552 />
0074234Claude2553
b078860Claude2554 <nav class="prs-detail-tabs" aria-label="Pull request sections">
2555 <a
2556 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
2557 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2558 >
2559 Conversation
2560 <span class="prs-detail-tab-count">{commentCount}</span>
2561 </a>
2562 <a
2563 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
2564 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2565 >
2566 Files changed
2567 {diffFiles.length > 0 && (
2568 <span class="prs-detail-tab-count">{diffFiles.length}</span>
2569 )}
2570 </a>
2571 </nav>
2572
2573 {tab === "files" ? (
ea9ed4cClaude2574 <DiffView
2575 raw={diffRaw}
2576 files={diffFiles}
2577 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
47a7a0aClaude2578 inlineComments={diffInlineComments}
2579 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
b5dd694Claude2580 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
ea9ed4cClaude2581 />
b078860Claude2582 ) : (
2583 <>
2584 {pr.body && (
2585 <CommentBox
2586 author={author?.username ?? "unknown"}
2587 date={pr.createdAt}
2588 body={renderMarkdown(pr.body)}
2589 />
2590 )}
2591
422a2d4Claude2592 {/* Block H — AI trio review (security/correctness/style). When
2593 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
2594 hoisted into a 3-column card grid above the normal comment
2595 stream so reviewers see verdicts at a glance. Disagreements
2596 are surfaced as a yellow callout. */}
2597 <TrioReviewGrid
2598 comments={comments.map(({ comment }) => comment)}
2599 />
2600
15db0e0Claude2601 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude2602 // Skip trio comments — already rendered in TrioReviewGrid above.
2603 if (isTrioComment(comment.body)) return null;
15db0e0Claude2604 const slashCmd = detectSlashCmdComment(comment.body);
2605 if (slashCmd) {
2606 const visible = stripSlashCmdMarker(comment.body);
2607 return (
2608 <div class={`slash-pill slash-cmd-${slashCmd}`}>
2609 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
2610 <span class="slash-pill-actor">
2611 <strong>{commentAuthor.username}</strong>
2612 {" ran "}
2613 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude2614 </span>
15db0e0Claude2615 <span class="slash-pill-time">
2616 {formatRelative(comment.createdAt)}
2617 </span>
2618 <div class="slash-pill-body">
2619 <MarkdownContent html={renderMarkdown(visible)} />
2620 </div>
2621 </div>
2622 );
2623 }
cb5a796Claude2624 const isPending = comment.moderationStatus === "pending";
15db0e0Claude2625 return (
cb5a796Claude2626 <div
2627 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
2628 >
15db0e0Claude2629 <div class="prs-comment-head">
2630 <strong>{commentAuthor.username}</strong>
2631 {comment.isAiReview && (
2632 <span class="prs-ai-badge">AI Review</span>
2633 )}
cb5a796Claude2634 {isPending && (
2635 <span
2636 class="modq-pending-badge"
2637 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
2638 >
2639 Awaiting approval
2640 </span>
2641 )}
15db0e0Claude2642 <span class="prs-comment-time">
2643 commented {formatRelative(comment.createdAt)}
2644 </span>
2645 {comment.filePath && (
2646 <span class="prs-comment-loc">
2647 {comment.filePath}
2648 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
2649 </span>
2650 )}
2651 </div>
2652 <div class="prs-comment-body">
2653 <MarkdownContent html={renderMarkdown(comment.body)} />
2654 </div>
0074234Claude2655 </div>
15db0e0Claude2656 );
2657 })}
0074234Claude2658
b078860Claude2659 {/* Quick link to the Files changed tab when there's a diff to look at. */}
2660 {pr.state !== "merged" && (
2661 <a
2662 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
2663 class="prs-files-card"
2664 >
2665 <span class="prs-files-card-icon" aria-hidden="true">
2666 {"▤"}
2667 </span>
2668 <div class="prs-files-card-text">
2669 <p class="prs-files-card-title">Files changed</p>
2670 <p class="prs-files-card-sub">
2671 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
2672 </p>
e883329Claude2673 </div>
b078860Claude2674 <span class="prs-files-card-cta">View diff {"→"}</span>
2675 </a>
2676 )}
2677
2678 {error && (
2679 <div
2680 class="auth-error"
2681 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)"
2682 >
2683 {decodeURIComponent(error)}
2684 </div>
2685 )}
2686
2687 {info && (
2688 <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)">
2689 {decodeURIComponent(info)}
2690 </div>
2691 )}
e883329Claude2692
b078860Claude2693 {pr.state === "open" && (prRisk || prRiskCalculating) && (
2694 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
2695 )}
2696
0a67773Claude2697 {/* ─── Review summary ─────────────────────────────────── */}
2698 {(approvals.length > 0 || changesRequested.length > 0) && (
2699 <div class="prs-review-summary">
2700 {approvals.length > 0 && (
2701 <div class="prs-review-row prs-review-approved">
2702 <span class="prs-review-icon">✓</span>
2703 <span>
2704 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
2705 approved this pull request
2706 </span>
2707 </div>
2708 )}
2709 {changesRequested.length > 0 && (
2710 <div class="prs-review-row prs-review-changes">
2711 <span class="prs-review-icon">✗</span>
2712 <span>
2713 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
2714 requested changes
2715 </span>
2716 </div>
2717 )}
2718 </div>
2719 )}
2720
b078860Claude2721 {pr.state === "open" && gateChecks.length > 0 && (
2722 <div class="prs-gate-card">
2723 <div class="prs-gate-head">
2724 <h3>Gate checks</h3>
2725 <span class="prs-gate-summary">
2726 {gatesAllPassed
2727 ? `All ${gateChecks.length} checks passed`
2728 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
2729 </span>
c3e0c07Claude2730 </div>
b078860Claude2731 {gateChecks.map((check) => {
2732 const isAi = /ai.*review/i.test(check.name);
2733 const isSkip = check.skipped === true;
2734 const statusClass = isSkip
2735 ? "is-skip"
2736 : check.passed
2737 ? "is-pass"
2738 : "is-fail";
2739 const statusGlyph = isSkip
2740 ? "—"
2741 : check.passed
2742 ? "✓"
2743 : "✗";
2744 const statusLabel = isSkip
2745 ? "Skipped"
2746 : check.passed
2747 ? "Passed"
2748 : "Failing";
2749 return (
2750 <div
2751 class="prs-gate-row"
2752 style={
2753 isAi
2754 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
2755 : ""
2756 }
2757 >
2758 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
2759 {statusGlyph}
2760 </span>
2761 <span class="prs-gate-name">
2762 {check.name}
2763 {isAi && (
2764 <span
2765 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"
2766 >
2767 AI
2768 </span>
2769 )}
2770 </span>
2771 <span class="prs-gate-details">{check.details}</span>
2772 <span class={`prs-gate-pill ${statusClass}`}>
2773 {statusLabel}
e883329Claude2774 </span>
2775 </div>
b078860Claude2776 );
2777 })}
2778 <div class="prs-gate-footer">
2779 {gatesAllPassed
2780 ? "All checks passed — ready to merge."
2781 : gateChecks.some(
2782 (c) => !c.passed && c.name === "Merge check"
2783 )
2784 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
2785 : "Some checks failed — resolve issues before merging."}
2786 {aiReviewCount > 0 && (
2787 <>
2788 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
2789 </>
2790 )}
2791 </div>
2792 </div>
2793 )}
2794
2795 {/* ─── Merge area / state-aware action card ─────────────── */}
2796 {user && pr.state === "open" && (
2797 <div
2798 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
2799 >
2800 <div class="prs-merge-head">
2801 <strong>
2802 {pr.isDraft
2803 ? "Draft — ready for review?"
2804 : mergeBlocked
2805 ? "Merge blocked"
2806 : "Ready to merge"}
2807 </strong>
e883329Claude2808 </div>
b078860Claude2809 <p class="prs-merge-sub">
2810 {pr.isDraft
2811 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
2812 : mergeBlocked
2813 ? "Resolve the failing gate checks above before this PR can land."
2814 : gateChecks.length > 0
2815 ? gatesAllPassed
2816 ? "All gates green. Merge will fast-forward into the base branch."
2817 : "Conflicts will be auto-resolved by GlueCron AI on merge."
2818 : "Run gate checks by refreshing once your branch has a recent commit."}
2819 </p>
2820 <Form
2821 method="post"
2822 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
2823 >
2824 <FormGroup>
3c03977Claude2825 <div class="live-cursor-host" style="position:relative">
2826 <textarea
2827 name="body"
2828 id="pr-comment-body"
2829 data-live-field="comment_new"
2830 rows={5}
2831 required
2832 placeholder="Leave a comment... (Markdown supported)"
2833 style="font-family:var(--font-mono);font-size:13px;width:100%"
2834 ></textarea>
2835 </div>
15db0e0Claude2836 <span class="slash-hint" title="Type a slash-command as the first line">
2837 Type <code>/</code> for commands —{" "}
2838 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
2839 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
2840 </span>
b078860Claude2841 </FormGroup>
2842 <div class="prs-merge-actions">
2843 <Button type="submit" variant="primary">
2844 Comment
2845 </Button>
0a67773Claude2846 {user && user.id !== pr.authorId && pr.state === "open" && (
2847 <>
2848 <button
2849 type="submit"
2850 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
2851 name="review_state"
2852 value="approved"
2853 class="prs-review-approve-btn"
2854 title="Approve this pull request"
2855 >
2856 ✓ Approve
2857 </button>
2858 <button
2859 type="submit"
2860 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
2861 name="review_state"
2862 value="changes_requested"
2863 class="prs-review-changes-btn"
2864 title="Request changes before merging"
2865 >
2866 ✗ Request changes
2867 </button>
2868 </>
2869 )}
b078860Claude2870 {canManage && (
2871 <>
2872 {pr.isDraft ? (
2873 <button
2874 type="submit"
2875 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
2876 formnovalidate
2877 class="prs-merge-ready-btn"
2878 >
2879 Ready for review
2880 </button>
2881 ) : (
0074234Claude2882 <button
2883 type="submit"
2884 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
b078860Claude2885 formnovalidate
2886 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
2887 title={
2888 mergeBlocked
2889 ? "Failing gate checks must be resolved before this PR can merge."
2890 : "Merge pull request"
2891 }
0074234Claude2892 >
b078860Claude2893 {"✔"} Merge pull request
0074234Claude2894 </button>
b078860Claude2895 )}
2896 {!pr.isDraft && (
2897 <button
0074234Claude2898 type="submit"
b078860Claude2899 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
2900 formnovalidate
2901 class="prs-merge-back-draft"
2902 title="Convert back to draft"
0074234Claude2903 >
b078860Claude2904 Convert to draft
2905 </button>
2906 )}
2907 {isAiReviewEnabled() && (
2908 <button
2909 type="submit"
2910 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
2911 formnovalidate
2912 class="btn"
2913 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
2914 >
2915 Re-run AI review
2916 </button>
2917 )}
1d4ff60Claude2918 {isAiReviewEnabled() && !hasAiTestsMarker && (
2919 <button
2920 type="submit"
2921 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
2922 formnovalidate
2923 class="btn"
2924 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."
2925 >
2926 Generate tests with AI
2927 </button>
2928 )}
b078860Claude2929 <Button
2930 type="submit"
2931 variant="danger"
2932 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
2933 >
2934 Close
2935 </Button>
2936 </>
2937 )}
2938 </div>
2939 </Form>
2940 </div>
2941 )}
2942
2943 {/* Read-only footers for non-open states. */}
2944 {pr.state === "merged" && (
2945 <div class="prs-merge-card is-merged">
2946 <div class="prs-merge-head">
2947 <strong>{"⮌"} Merged</strong>
0074234Claude2948 </div>
b078860Claude2949 <p class="prs-merge-sub">
2950 This pull request was merged into{" "}
2951 <code>{pr.baseBranch}</code>.
2952 </p>
2953 </div>
2954 )}
2955 {pr.state === "closed" && (
2956 <div class="prs-merge-card is-closed">
2957 <div class="prs-merge-head">
2958 <strong>{"✕"} Closed without merging</strong>
2959 </div>
2960 <p class="prs-merge-sub">
2961 This pull request was closed and not merged.
2962 </p>
2963 </div>
2964 )}
2965 </>
2966 )}
0074234Claude2967 </Layout>
2968 );
2969});
2970
cb5a796Claude2971// Add comment to PR.
2972//
2973// Permission model mirrors `issues.tsx`: any logged-in user with read
2974// access can submit; `decideInitialStatus` routes non-collaborators
2975// through the moderation queue. Slash commands only fire when the
2976// comment is auto-approved — we don't want a banned/pending comment to
2977// silently trigger AI work on the PR.
0074234Claude2978pulls.post(
2979 "/:owner/:repo/pulls/:number/comment",
2980 softAuth,
2981 requireAuth,
cb5a796Claude2982 requireRepoAccess("read"),
0074234Claude2983 async (c) => {
2984 const { owner: ownerName, repo: repoName } = c.req.param();
2985 const prNum = parseInt(c.req.param("number"), 10);
2986 const user = c.get("user")!;
2987 const body = await c.req.parseBody();
2988 const commentBody = String(body.body || "").trim();
47a7a0aClaude2989 const filePathRaw = String(body.file_path || "").trim();
2990 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
2991 const inlineFilePath = filePathRaw || undefined;
2992 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude2993
2994 if (!commentBody) {
2995 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
2996 }
2997
2998 const resolved = await resolveRepo(ownerName, repoName);
2999 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3000
3001 const [pr] = await db
3002 .select()
3003 .from(pullRequests)
3004 .where(
3005 and(
3006 eq(pullRequests.repositoryId, resolved.repo.id),
3007 eq(pullRequests.number, prNum)
3008 )
3009 )
3010 .limit(1);
3011
3012 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3013
cb5a796Claude3014 const decision = await decideInitialStatus({
3015 commenterUserId: user.id,
3016 repositoryId: resolved.repo.id,
3017 kind: "pr",
3018 threadId: pr.id,
3019 });
3020
d4ac5c3Claude3021 const [inserted] = await db
3022 .insert(prComments)
3023 .values({
3024 pullRequestId: pr.id,
3025 authorId: user.id,
3026 body: commentBody,
cb5a796Claude3027 moderationStatus: decision.status,
47a7a0aClaude3028 filePath: inlineFilePath,
3029 lineNumber: inlineLineNumber,
d4ac5c3Claude3030 })
3031 .returning();
3032
cb5a796Claude3033 // Live update: only when the comment is actually visible.
3034 if (inserted && decision.status === "approved") {
d4ac5c3Claude3035 try {
3036 const { publish } = await import("../lib/sse");
3037 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
3038 event: "pr-comment",
3039 data: {
3040 pullRequestId: pr.id,
3041 commentId: inserted.id,
3042 authorId: user.id,
3043 authorUsername: user.username,
3044 },
3045 });
3046 } catch {
3047 /* SSE is best-effort */
3048 }
3049 }
0074234Claude3050
cb5a796Claude3051 if (decision.status === "pending") {
3052 void notifyOwnerOfPendingComment({
3053 repositoryId: resolved.repo.id,
3054 commenterUsername: user.username,
3055 kind: "pr",
3056 threadNumber: prNum,
3057 ownerUsername: ownerName,
3058 repoName,
3059 });
3060 return c.redirect(
3061 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
3062 );
3063 }
3064 if (decision.status === "rejected") {
3065 // Silent ban path — same UX as 'pending' so we don't leak the gate.
3066 return c.redirect(
3067 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
3068 );
3069 }
3070
15db0e0Claude3071 // Slash-command handoff. We always store the original comment above
3072 // first so free-form text that happens to start with `/` is preserved
3073 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude3074 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude3075 const parsed = parseSlashCommand(commentBody);
3076 if (parsed) {
3077 try {
3078 const result = await executeSlashCommand({
3079 command: parsed.command,
3080 args: parsed.args,
3081 prId: pr.id,
3082 userId: user.id,
3083 repositoryId: resolved.repo.id,
3084 });
3085 await db.insert(prComments).values({
3086 pullRequestId: pr.id,
3087 authorId: user.id,
3088 body: result.body,
3089 });
3090 } catch (err) {
3091 // Defence-in-depth — executeSlashCommand promises not to throw,
3092 // but if it ever does we want the PR thread to know.
3093 await db
3094 .insert(prComments)
3095 .values({
3096 pullRequestId: pr.id,
3097 authorId: user.id,
3098 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
3099 })
3100 .catch(() => {});
3101 }
3102 }
3103
47a7a0aClaude3104 // Inline comments go back to the files tab; conversation comments to the conversation tab
3105 const redirectTab = inlineFilePath ? "?tab=files" : "";
3106 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude3107 }
3108);
3109
b5dd694Claude3110// Apply a suggestion from a PR comment — commits the suggested code to the
3111// head branch on behalf of the logged-in user.
3112pulls.post(
3113 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
3114 softAuth,
3115 requireAuth,
3116 requireRepoAccess("read"),
3117 async (c) => {
3118 const { owner: ownerName, repo: repoName } = c.req.param();
3119 const prNum = parseInt(c.req.param("number"), 10);
3120 const commentId = c.req.param("commentId"); // UUID
3121 const user = c.get("user")!;
3122
3123 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
3124
3125 const resolved = await resolveRepo(ownerName, repoName);
3126 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3127
3128 const [pr] = await db
3129 .select()
3130 .from(pullRequests)
3131 .where(
3132 and(
3133 eq(pullRequests.repositoryId, resolved.repo.id),
3134 eq(pullRequests.number, prNum)
3135 )
3136 )
3137 .limit(1);
3138
3139 if (!pr || pr.state !== "open") {
3140 return c.redirect(`${backUrl}&error=pr_not_open`);
3141 }
3142
3143 // Only PR author or repo owner may apply suggestions.
3144 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
3145 return c.redirect(`${backUrl}&error=forbidden`);
3146 }
3147
3148 // Load the comment.
3149 const [comment] = await db
3150 .select()
3151 .from(prComments)
3152 .where(
3153 and(
3154 eq(prComments.id, commentId),
3155 eq(prComments.pullRequestId, pr.id)
3156 )
3157 )
3158 .limit(1);
3159
3160 if (!comment) {
3161 return c.redirect(`${backUrl}&error=comment_not_found`);
3162 }
3163
3164 // Parse suggestion block from comment body.
3165 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
3166 if (!m) {
3167 return c.redirect(`${backUrl}&error=no_suggestion`);
3168 }
3169 const suggestionCode = m[1];
3170
3171 // Get the commenter's details for the commit message co-author line.
3172 const [commenter] = await db
3173 .select()
3174 .from(users)
3175 .where(eq(users.id, comment.authorId))
3176 .limit(1);
3177
3178 // Fetch current file content from head branch.
3179 if (!comment.filePath) {
3180 return c.redirect(`${backUrl}&error=file_not_found`);
3181 }
3182 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
3183 if (!blob) {
3184 return c.redirect(`${backUrl}&error=file_not_found`);
3185 }
3186
3187 // Apply the patch — replace the target line(s) with suggestion lines.
3188 const lines = blob.content.split('\n');
3189 const lineIdx = (comment.lineNumber ?? 1) - 1;
3190 if (lineIdx < 0 || lineIdx >= lines.length) {
3191 return c.redirect(`${backUrl}&error=line_out_of_range`);
3192 }
3193 const suggestionLines = suggestionCode.split('\n');
3194 lines.splice(lineIdx, 1, ...suggestionLines);
3195 const newContent = lines.join('\n');
3196
3197 // Commit the change.
3198 const coAuthorLine = commenter
3199 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
3200 : "";
3201 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
3202
3203 const result = await createOrUpdateFileOnBranch({
3204 owner: ownerName,
3205 name: repoName,
3206 branch: pr.headBranch,
3207 filePath: comment.filePath,
3208 bytes: new TextEncoder().encode(newContent),
3209 message: commitMessage,
3210 authorName: user.username,
3211 authorEmail: `${user.username}@users.noreply.gluecron.com`,
3212 });
3213
3214 if ("error" in result) {
3215 return c.redirect(`${backUrl}&error=apply_failed`);
3216 }
3217
3218 // Post a follow-up comment noting the suggestion was applied.
3219 await db.insert(prComments).values({
3220 pullRequestId: pr.id,
3221 authorId: user.id,
3222 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
3223 });
3224
3225 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
3226 }
3227);
3228
0a67773Claude3229// Formal review — Approve / Request Changes / Comment
3230pulls.post(
3231 "/:owner/:repo/pulls/:number/review",
3232 softAuth,
3233 requireAuth,
3234 requireRepoAccess("read"),
3235 async (c) => {
3236 const { owner: ownerName, repo: repoName } = c.req.param();
3237 const prNum = parseInt(c.req.param("number"), 10);
3238 const user = c.get("user")!;
3239 const body = await c.req.parseBody();
3240 const reviewBody = String(body.body || "").trim();
3241 const reviewState = String(body.review_state || "commented");
3242
3243 const validStates = ["approved", "changes_requested", "commented"];
3244 if (!validStates.includes(reviewState)) {
3245 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3246 }
3247
3248 const resolved = await resolveRepo(ownerName, repoName);
3249 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3250
3251 const [pr] = await db
3252 .select()
3253 .from(pullRequests)
3254 .where(
3255 and(
3256 eq(pullRequests.repositoryId, resolved.repo.id),
3257 eq(pullRequests.number, prNum)
3258 )
3259 )
3260 .limit(1);
3261 if (!pr || pr.state !== "open") {
3262 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3263 }
3264 // Authors can't review their own PR
3265 if (pr.authorId === user.id) {
3266 return c.redirect(
3267 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
3268 );
3269 }
3270
3271 await db.insert(prReviews).values({
3272 pullRequestId: pr.id,
3273 reviewerId: user.id,
3274 state: reviewState,
3275 body: reviewBody || null,
3276 });
3277
3278 const stateLabel =
3279 reviewState === "approved" ? "Approved"
3280 : reviewState === "changes_requested" ? "Changes requested"
3281 : "Commented";
3282 return c.redirect(
3283 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
3284 );
3285 }
3286);
3287
e883329Claude3288// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude3289// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
3290// but we keep it at "write" for v1 so trusted collaborators can ship.
3291// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
3292// surface. Branch-protection rules (evaluated below) are the current mechanism
3293// for locking down merges further on specific branches.
0074234Claude3294pulls.post(
3295 "/:owner/:repo/pulls/:number/merge",
3296 softAuth,
3297 requireAuth,
04f6b7fClaude3298 requireRepoAccess("write"),
0074234Claude3299 async (c) => {
3300 const { owner: ownerName, repo: repoName } = c.req.param();
3301 const prNum = parseInt(c.req.param("number"), 10);
3302 const user = c.get("user")!;
3303
3304 const resolved = await resolveRepo(ownerName, repoName);
3305 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3306
3307 const [pr] = await db
3308 .select()
3309 .from(pullRequests)
3310 .where(
3311 and(
3312 eq(pullRequests.repositoryId, resolved.repo.id),
3313 eq(pullRequests.number, prNum)
3314 )
3315 )
3316 .limit(1);
3317
3318 if (!pr || pr.state !== "open") {
3319 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3320 }
3321
6fc53bdClaude3322 // Draft PRs cannot be merged — must be marked ready first.
3323 if (pr.isDraft) {
3324 return c.redirect(
3325 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3326 "This PR is a draft. Mark it as ready for review before merging."
3327 )}`
3328 );
3329 }
3330
e883329Claude3331 // Resolve head SHA
3332 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3333 if (!headSha) {
3334 return c.redirect(
3335 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
3336 );
3337 }
3338
3339 // Check if AI review approved this PR
3340 const aiComments = await db
3341 .select()
3342 .from(prComments)
3343 .where(
3344 and(
3345 eq(prComments.pullRequestId, pr.id),
3346 eq(prComments.isAiReview, true)
3347 )
3348 );
3349 const aiApproved = aiComments.length === 0 || aiComments.some(
3350 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude3351 );
e883329Claude3352
3353 // Run all green gate checks (GateTest + mergeability + AI review)
3354 const gateResult = await runAllGateChecks(
3355 ownerName,
3356 repoName,
3357 pr.baseBranch,
3358 pr.headBranch,
3359 headSha,
3360 aiApproved
0074234Claude3361 );
3362
e883329Claude3363 // If GateTest or AI review failed (hard blocks), reject the merge
3364 const hardFailures = gateResult.checks.filter(
3365 (check) => !check.passed && check.name !== "Merge check"
3366 );
3367 if (hardFailures.length > 0) {
3368 const errorMsg = hardFailures
3369 .map((f) => `${f.name}: ${f.details}`)
3370 .join("; ");
0074234Claude3371 return c.redirect(
e883329Claude3372 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude3373 );
3374 }
3375
1e162a8Claude3376 // D5 — Branch-protection enforcement. Looks up the matching rule for the
3377 // base branch and blocks the merge if requireAiApproval / requireGreenGates
3378 // / requireHumanReview / requiredApprovals are not satisfied. Independent
3379 // of repo-global settings, so owners can lock specific branches down
3380 // further than the repo default.
3381 const protectionRule = await matchProtection(
3382 resolved.repo.id,
3383 pr.baseBranch
3384 );
3385 if (protectionRule) {
3386 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude3387 const required = await listRequiredChecks(protectionRule.id);
3388 const passingNames = required.length > 0
3389 ? await passingCheckNames(resolved.repo.id, headSha)
3390 : [];
3391 const decision = evaluateProtection(
3392 protectionRule,
3393 {
3394 aiApproved,
3395 humanApprovalCount: humanApprovals,
3396 gateResultGreen: hardFailures.length === 0,
3397 hasFailedGates: hardFailures.length > 0,
3398 passingCheckNames: passingNames,
3399 },
3400 required.map((r) => r.checkName)
3401 );
1e162a8Claude3402 if (!decision.allowed) {
3403 return c.redirect(
3404 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3405 decision.reasons.join(" ")
3406 )}`
3407 );
3408 }
3409 }
3410
e883329Claude3411 // Attempt the merge — with auto conflict resolution if needed
3412 const repoDir = getRepoPath(ownerName, repoName);
3413 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
3414 const hasConflicts = mergeCheck && !mergeCheck.passed;
3415
3416 if (hasConflicts && isAiReviewEnabled()) {
3417 // Use Claude to auto-resolve conflicts
3418 const mergeResult = await mergeWithAutoResolve(
3419 ownerName,
3420 repoName,
3421 pr.baseBranch,
3422 pr.headBranch,
3423 `Merge pull request #${pr.number}: ${pr.title}`
3424 );
3425
3426 if (!mergeResult.success) {
3427 return c.redirect(
3428 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
3429 );
3430 }
3431
3432 // Post a comment about the auto-resolution
3433 if (mergeResult.resolvedFiles.length > 0) {
3434 await db.insert(prComments).values({
3435 pullRequestId: pr.id,
3436 authorId: user.id,
3437 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
3438 isAiReview: true,
3439 });
3440 }
3441 } else {
3442 // Standard merge — fast-forward or clean merge
3443 const ffProc = Bun.spawn(
3444 [
3445 "git",
3446 "update-ref",
3447 `refs/heads/${pr.baseBranch}`,
3448 `refs/heads/${pr.headBranch}`,
3449 ],
3450 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3451 );
3452 const ffExit = await ffProc.exited;
3453
3454 if (ffExit !== 0) {
3455 return c.redirect(
3456 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
3457 );
3458 }
3459 }
3460
0074234Claude3461 await db
3462 .update(pullRequests)
3463 .set({
3464 state: "merged",
3465 mergedAt: new Date(),
3466 mergedBy: user.id,
3467 updatedAt: new Date(),
3468 })
3469 .where(eq(pullRequests.id, pr.id));
3470
8809b87Claude3471 // Chat notifier — fan out merge event to Slack/Discord/Teams.
3472 import("../lib/chat-notifier")
3473 .then((m) =>
3474 m.notifyChatChannels({
3475 ownerUserId: resolved.repo.ownerId,
3476 repositoryId: resolved.repo.id,
3477 event: {
3478 event: "pr.merged",
3479 repo: `${ownerName}/${repoName}`,
3480 title: `#${pr.number} ${pr.title}`,
3481 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3482 actor: user.username,
3483 },
3484 })
3485 )
3486 .catch((err) =>
3487 console.warn(`[chat-notifier] PR merge notify failed:`, err)
3488 );
3489
d62fb36Claude3490 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
3491 // and auto-close each matching open issue with a back-link comment. Bounded
3492 // to the same repo for v1 (cross-repo refs ignored). Failures never block
3493 // the merge redirect.
3494 try {
3495 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3496 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3497 for (const n of refs) {
3498 const [issue] = await db
3499 .select()
3500 .from(issues)
3501 .where(
3502 and(
3503 eq(issues.repositoryId, resolved.repo.id),
3504 eq(issues.number, n)
3505 )
3506 )
3507 .limit(1);
3508 if (!issue || issue.state !== "open") continue;
3509 await db
3510 .update(issues)
3511 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
3512 .where(eq(issues.id, issue.id));
3513 await db.insert(issueComments).values({
3514 issueId: issue.id,
3515 authorId: user.id,
3516 body: `Closed by pull request #${pr.number}.`,
3517 });
3518 }
3519 } catch {
3520 // Never block the merge on close-keyword failures.
3521 }
3522
0074234Claude3523 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3524 }
3525);
3526
6fc53bdClaude3527// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
3528// hasn't run yet on this PR.
3529pulls.post(
3530 "/:owner/:repo/pulls/:number/ready",
3531 softAuth,
3532 requireAuth,
04f6b7fClaude3533 requireRepoAccess("write"),
6fc53bdClaude3534 async (c) => {
3535 const { owner: ownerName, repo: repoName } = c.req.param();
3536 const prNum = parseInt(c.req.param("number"), 10);
3537 const user = c.get("user")!;
3538
3539 const resolved = await resolveRepo(ownerName, repoName);
3540 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3541
3542 const [pr] = await db
3543 .select()
3544 .from(pullRequests)
3545 .where(
3546 and(
3547 eq(pullRequests.repositoryId, resolved.repo.id),
3548 eq(pullRequests.number, prNum)
3549 )
3550 )
3551 .limit(1);
3552 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3553
3554 // Only the author or repo owner can toggle draft state.
3555 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
3556 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3557 }
3558
3559 if (pr.state === "open" && pr.isDraft) {
3560 await db
3561 .update(pullRequests)
3562 .set({ isDraft: false, updatedAt: new Date() })
3563 .where(eq(pullRequests.id, pr.id));
3564
3565 if (isAiReviewEnabled()) {
3566 triggerAiReview(
3567 ownerName,
3568 repoName,
3569 pr.id,
3570 pr.title,
0316dbbClaude3571 pr.body || "",
6fc53bdClaude3572 pr.baseBranch,
3573 pr.headBranch
3574 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
3575 }
3576 }
3577
3578 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3579 }
3580);
3581
3582// Convert a PR back to draft.
3583pulls.post(
3584 "/:owner/:repo/pulls/:number/draft",
3585 softAuth,
3586 requireAuth,
04f6b7fClaude3587 requireRepoAccess("write"),
6fc53bdClaude3588 async (c) => {
3589 const { owner: ownerName, repo: repoName } = c.req.param();
3590 const prNum = parseInt(c.req.param("number"), 10);
3591 const user = c.get("user")!;
3592
3593 const resolved = await resolveRepo(ownerName, repoName);
3594 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3595
3596 const [pr] = await db
3597 .select()
3598 .from(pullRequests)
3599 .where(
3600 and(
3601 eq(pullRequests.repositoryId, resolved.repo.id),
3602 eq(pullRequests.number, prNum)
3603 )
3604 )
3605 .limit(1);
3606 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3607
3608 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
3609 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3610 }
3611
3612 if (pr.state === "open" && !pr.isDraft) {
3613 await db
3614 .update(pullRequests)
3615 .set({ isDraft: true, updatedAt: new Date() })
3616 .where(eq(pullRequests.id, pr.id));
3617 }
3618
3619 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3620 }
3621);
3622
0074234Claude3623// Close PR
3624pulls.post(
3625 "/:owner/:repo/pulls/:number/close",
3626 softAuth,
3627 requireAuth,
04f6b7fClaude3628 requireRepoAccess("write"),
0074234Claude3629 async (c) => {
3630 const { owner: ownerName, repo: repoName } = c.req.param();
3631 const prNum = parseInt(c.req.param("number"), 10);
3632
3633 const resolved = await resolveRepo(ownerName, repoName);
3634 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3635
3636 await db
3637 .update(pullRequests)
3638 .set({
3639 state: "closed",
3640 closedAt: new Date(),
3641 updatedAt: new Date(),
3642 })
3643 .where(
3644 and(
3645 eq(pullRequests.repositoryId, resolved.repo.id),
3646 eq(pullRequests.number, prNum)
3647 )
3648 );
3649
3650 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3651 }
3652);
3653
c3e0c07Claude3654// Re-run AI review on demand (e.g. after a force-push). Bypasses the
3655// idempotency marker via { force: true }. Write-access only.
3656pulls.post(
3657 "/:owner/:repo/pulls/:number/ai-rereview",
3658 softAuth,
3659 requireAuth,
3660 requireRepoAccess("write"),
3661 async (c) => {
3662 const { owner: ownerName, repo: repoName } = c.req.param();
3663 const prNum = parseInt(c.req.param("number"), 10);
3664 const resolved = await resolveRepo(ownerName, repoName);
3665 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3666
3667 const [pr] = await db
3668 .select()
3669 .from(pullRequests)
3670 .where(
3671 and(
3672 eq(pullRequests.repositoryId, resolved.repo.id),
3673 eq(pullRequests.number, prNum)
3674 )
3675 )
3676 .limit(1);
3677 if (!pr) {
3678 return c.redirect(`/${ownerName}/${repoName}/pulls`);
3679 }
3680
3681 if (!isAiReviewEnabled()) {
3682 return c.redirect(
3683 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3684 "AI review is not configured (ANTHROPIC_API_KEY)."
3685 )}`
3686 );
3687 }
3688
3689 // Fire-and-forget but with { force: true } to bypass the
3690 // already-reviewed marker. The function still never throws.
3691 triggerAiReview(
3692 ownerName,
3693 repoName,
3694 pr.id,
3695 pr.title || "",
3696 pr.body || "",
3697 pr.baseBranch,
3698 pr.headBranch,
3699 { force: true }
a28cedeClaude3700 ).catch((err) => {
3701 console.warn(
3702 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
3703 err instanceof Error ? err.message : err
3704 );
3705 });
c3e0c07Claude3706
3707 return c.redirect(
3708 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
3709 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
3710 )}`
3711 );
3712 }
3713);
3714
1d4ff60Claude3715// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
3716// the PR's head branch carrying just the new test files. Write-access only.
3717// Idempotent — if `ai:added-tests` was previously applied we redirect with
3718// an `info` banner instead of re-firing.
3719pulls.post(
3720 "/:owner/:repo/pulls/:number/generate-tests",
3721 softAuth,
3722 requireAuth,
3723 requireRepoAccess("write"),
3724 async (c) => {
3725 const { owner: ownerName, repo: repoName } = c.req.param();
3726 const prNum = parseInt(c.req.param("number"), 10);
3727 const resolved = await resolveRepo(ownerName, repoName);
3728 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3729
3730 const [pr] = await db
3731 .select()
3732 .from(pullRequests)
3733 .where(
3734 and(
3735 eq(pullRequests.repositoryId, resolved.repo.id),
3736 eq(pullRequests.number, prNum)
3737 )
3738 )
3739 .limit(1);
3740 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3741
3742 if (!isAiReviewEnabled()) {
3743 return c.redirect(
3744 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3745 "AI test generation is not configured (ANTHROPIC_API_KEY)."
3746 )}`
3747 );
3748 }
3749
3750 // Fire-and-forget. The lib never throws.
3751 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
3752 .then((res) => {
3753 if (!res.ok) {
3754 console.warn(
3755 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
3756 );
3757 }
3758 })
3759 .catch((err) => {
3760 console.warn(
3761 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
3762 err instanceof Error ? err.message : err
3763 );
3764 });
3765
3766 return c.redirect(
3767 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
3768 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
3769 )}`
3770 );
3771 }
3772);
3773
0074234Claude3774export default pulls;