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.tsxBlame4377 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>
7a28902Claude1807 <div class="prs-hero-actions">
1808 <a
1809 href={`/${ownerName}/${repoName}/pulls/insights`}
1810 class="prs-cta"
1811 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
1812 >
1813 Insights
1814 </a>
1815 {user && (
b078860Claude1816 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
1817 + New pull request
1818 </a>
7a28902Claude1819 )}
1820 </div>
b078860Claude1821 </div>
1822 </div>
1823
1824 <nav class="prs-tabs" aria-label="Pull request filters">
1825 {tabPills.map((t) => {
1826 const isActive =
1827 state === t.key ||
1828 (t.key === "open" &&
1829 state !== "merged" &&
1830 state !== "closed" &&
1831 state !== "all" &&
1832 state !== "draft");
1833 return (
1834 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
1835 <span>{t.label}</span>
1836 <span class="prs-tab-count">{t.count}</span>
1837 </a>
1838 );
1839 })}
1840 </nav>
1841
0074234Claude1842 {prList.length === 0 ? (
b078860Claude1843 <div class="prs-empty">
ea9ed4cClaude1844 <div class="prs-empty-inner">
1845 <strong>
1846 {isAllState
1847 ? "Pick a filter above to browse PRs."
1848 : `No ${state} pull requests.`}
1849 </strong>
1850 <p class="prs-empty-sub">
1851 {state === "open"
1852 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
1853 : isAllState
1854 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1855 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
1856 </p>
1857 <div class="prs-empty-cta">
1858 {user && state === "open" && (
1859 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
1860 + New pull request
1861 </a>
1862 )}
1863 {state !== "open" && (
1864 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
1865 View open PRs
1866 </a>
1867 )}
1868 <a href={`/${ownerName}/${repoName}`} class="btn">
1869 Back to code
1870 </a>
1871 </div>
1872 </div>
b078860Claude1873 </div>
0074234Claude1874 ) : (
b078860Claude1875 <div class="prs-list">
1876 {prList.map(({ pr, author }) => {
1877 const stateClass =
1878 pr.state === "open"
1879 ? pr.isDraft
1880 ? "state-draft"
1881 : "state-open"
1882 : pr.state === "merged"
1883 ? "state-merged"
1884 : "state-closed";
1885 const icon =
1886 pr.state === "open"
1887 ? pr.isDraft
1888 ? "◌"
1889 : "○"
1890 : pr.state === "merged"
1891 ? "⮌"
1892 : "✓";
1893 return (
1894 <a
1895 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1896 class="prs-row"
1897 style="text-decoration:none;color:inherit"
0074234Claude1898 >
b078860Claude1899 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
1900 {icon}
0074234Claude1901 </div>
b078860Claude1902 <div class="prs-row-body">
1903 <h3 class="prs-row-title">
1904 <span>{pr.title}</span>
1905 <span class="prs-row-number">#{pr.number}</span>
1906 </h3>
1907 <div class="prs-row-meta">
1908 <span
1909 class="prs-branch-chips"
1910 title={`${pr.headBranch} into ${pr.baseBranch}`}
1911 >
1912 <span class="prs-branch-chip">{pr.headBranch}</span>
1913 <span class="prs-branch-arrow">{"→"}</span>
1914 <span class="prs-branch-chip">{pr.baseBranch}</span>
1915 </span>
1916 <span>
1917 by{" "}
1918 <strong style="color:var(--text)">
1919 {author.username}
1920 </strong>{" "}
1921 {formatRelative(pr.createdAt)}
1922 </span>
1923 <span class="prs-row-tags">
1924 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
1925 {pr.state === "merged" && (
1926 <span class="prs-tag is-merged">Merged</span>
1927 )}
1928 </span>
1929 </div>
0074234Claude1930 </div>
b078860Claude1931 </a>
1932 );
1933 })}
1934 </div>
0074234Claude1935 )}
1936 </Layout>
1937 );
1938});
1939
7a28902Claude1940/* ─────────────────────────────────────────────────────────────────────────
1941 * PR Insights — 90-day analytics for the pull request activity of a repo.
1942 * Route: GET /:owner/:repo/pulls/insights
1943 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
1944 * "insights" is not swallowed by the :number param.
1945 * ───────────────────────────────────────────────────────────────────────── */
1946
1947/** Format a millisecond duration as human-readable string. */
1948function formatMsDuration(ms: number): string {
1949 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
1950 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
1951 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
1952 return `${Math.round(ms / 86_400_000)}d`;
1953}
1954
1955/** Format an ISO week string as "Jan 15". */
1956function formatWeekLabel(isoWeek: string): string {
1957 try {
1958 const d = new Date(isoWeek);
1959 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
1960 } catch {
1961 return isoWeek.slice(5, 10);
1962 }
1963}
1964
1965const PR_INSIGHTS_STYLES = `
1966 .pri-page { padding-bottom: 48px; }
1967 .pri-hero {
1968 position: relative;
1969 margin: 0 0 var(--space-5);
1970 padding: 22px 26px 24px;
1971 background: var(--bg-elevated);
1972 border: 1px solid var(--border);
1973 border-radius: 16px;
1974 overflow: hidden;
1975 }
1976 .pri-hero::before {
1977 content: '';
1978 position: absolute; top: 0; left: 0; right: 0;
1979 height: 2px;
1980 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1981 opacity: 0.7;
1982 pointer-events: none;
1983 }
1984 .pri-hero-eyebrow {
1985 font-size: 12px;
1986 color: var(--text-muted);
1987 text-transform: uppercase;
1988 letter-spacing: 0.08em;
1989 font-weight: 600;
1990 margin-bottom: 8px;
1991 }
1992 .pri-hero-title {
1993 font-family: var(--font-display);
1994 font-size: clamp(26px, 3.4vw, 34px);
1995 font-weight: 800;
1996 letter-spacing: -0.025em;
1997 line-height: 1.06;
1998 margin: 0 0 8px;
1999 color: var(--text-strong);
2000 }
2001 .pri-hero-title .gradient-text {
2002 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2003 -webkit-background-clip: text;
2004 background-clip: text;
2005 -webkit-text-fill-color: transparent;
2006 color: transparent;
2007 }
2008 .pri-hero-sub {
2009 font-size: 14.5px;
2010 color: var(--text-muted);
2011 margin: 0;
2012 line-height: 1.5;
2013 }
2014 .pri-section { margin-bottom: 32px; }
2015 .pri-section-title {
2016 font-size: 13px;
2017 font-weight: 700;
2018 text-transform: uppercase;
2019 letter-spacing: 0.06em;
2020 color: var(--text-muted);
2021 margin: 0 0 14px;
2022 }
2023 .pri-cards {
2024 display: grid;
2025 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
2026 gap: 12px;
2027 }
2028 .pri-card {
2029 padding: 16px 18px;
2030 background: var(--bg-elevated);
2031 border: 1px solid var(--border);
2032 border-radius: 12px;
2033 }
2034 .pri-card-label {
2035 font-size: 12px;
2036 font-weight: 600;
2037 color: var(--text-muted);
2038 text-transform: uppercase;
2039 letter-spacing: 0.05em;
2040 margin-bottom: 6px;
2041 }
2042 .pri-card-value {
2043 font-size: 28px;
2044 font-weight: 800;
2045 letter-spacing: -0.04em;
2046 color: var(--text-strong);
2047 line-height: 1;
2048 }
2049 .pri-card-sub {
2050 font-size: 12px;
2051 color: var(--text-muted);
2052 margin-top: 4px;
2053 }
2054 .pri-chart {
2055 display: flex;
2056 align-items: flex-end;
2057 gap: 6px;
2058 height: 120px;
2059 background: var(--bg-elevated);
2060 border: 1px solid var(--border);
2061 border-radius: 12px;
2062 padding: 16px 16px 0;
2063 }
2064 .pri-bar-col {
2065 flex: 1;
2066 display: flex;
2067 flex-direction: column;
2068 align-items: center;
2069 justify-content: flex-end;
2070 height: 100%;
2071 gap: 4px;
2072 }
2073 .pri-bar {
2074 width: 100%;
2075 min-height: 4px;
2076 border-radius: 4px 4px 0 0;
2077 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 100%);
2078 transition: opacity 140ms;
2079 }
2080 .pri-bar:hover { opacity: 0.8; }
2081 .pri-bar-label {
2082 font-size: 10px;
2083 color: var(--text-muted);
2084 text-align: center;
2085 padding-bottom: 8px;
2086 white-space: nowrap;
2087 overflow: hidden;
2088 text-overflow: ellipsis;
2089 max-width: 100%;
2090 }
2091 .pri-table {
2092 width: 100%;
2093 border-collapse: collapse;
2094 font-size: 13.5px;
2095 }
2096 .pri-table th {
2097 text-align: left;
2098 font-size: 12px;
2099 font-weight: 600;
2100 text-transform: uppercase;
2101 letter-spacing: 0.05em;
2102 color: var(--text-muted);
2103 padding: 8px 12px;
2104 border-bottom: 1px solid var(--border);
2105 }
2106 .pri-table td {
2107 padding: 10px 12px;
2108 border-bottom: 1px solid var(--border);
2109 color: var(--text);
2110 }
2111 .pri-table tr:last-child td { border-bottom: none; }
2112 .pri-table-wrap {
2113 background: var(--bg-elevated);
2114 border: 1px solid var(--border);
2115 border-radius: 12px;
2116 overflow: hidden;
2117 }
2118 .pri-age-row {
2119 display: flex;
2120 align-items: center;
2121 gap: 12px;
2122 padding: 10px 0;
2123 border-bottom: 1px solid var(--border);
2124 font-size: 13.5px;
2125 }
2126 .pri-age-row:last-child { border-bottom: none; }
2127 .pri-age-label {
2128 flex: 0 0 80px;
2129 color: var(--text-muted);
2130 font-size: 12.5px;
2131 font-weight: 600;
2132 }
2133 .pri-age-bar-wrap {
2134 flex: 1;
2135 height: 8px;
2136 background: var(--bg-secondary);
2137 border-radius: 9999px;
2138 overflow: hidden;
2139 }
2140 .pri-age-bar {
2141 height: 100%;
2142 border-radius: 9999px;
2143 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
2144 min-width: 4px;
2145 }
2146 .pri-age-count {
2147 flex: 0 0 32px;
2148 text-align: right;
2149 font-weight: 600;
2150 color: var(--text-strong);
2151 font-size: 13px;
2152 }
2153 .pri-sparkline {
2154 display: flex;
2155 align-items: flex-end;
2156 gap: 3px;
2157 height: 40px;
2158 }
2159 .pri-spark-bar {
2160 flex: 1;
2161 min-height: 2px;
2162 border-radius: 2px 2px 0 0;
2163 background: var(--accent, #8c6dff);
2164 opacity: 0.7;
2165 }
2166 .pri-empty {
2167 color: var(--text-muted);
2168 font-size: 14px;
2169 padding: 24px 0;
2170 text-align: center;
2171 }
2172 @media (max-width: 600px) {
2173 .pri-cards { grid-template-columns: repeat(2, 1fr); }
2174 .pri-hero { padding: 18px 18px 20px; }
2175 }
2176`;
2177
2178pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
2179 const { owner: ownerName, repo: repoName } = c.req.param();
2180 const user = c.get("user");
2181
2182 const resolved = await resolveRepo(ownerName, repoName);
2183 if (!resolved) return c.notFound();
2184
2185 const repoId = resolved.repo.id;
2186 const now = Date.now();
2187
2188 // 1. Merged PRs in last 90 days (avg merge time)
2189 const mergedPRs = await db
2190 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
2191 .from(pullRequests)
2192 .where(and(
2193 eq(pullRequests.repositoryId, repoId),
2194 eq(pullRequests.state, "merged"),
2195 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2196 ));
2197
2198 const avgMergeMs = mergedPRs.length > 0
2199 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
2200 : null;
2201
2202 // 2. PR throughput (last 8 weeks)
2203 const weeklyPRs = await db
2204 .select({
2205 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
2206 count: sql<number>`count(*)::int`,
2207 })
2208 .from(pullRequests)
2209 .where(and(
2210 eq(pullRequests.repositoryId, repoId),
2211 sql`${pullRequests.createdAt} > now() - interval '56 days'`
2212 ))
2213 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
2214 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
2215
2216 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
2217
2218 // 3. PR merge rate (last 90 days)
2219 const [rateCounts] = await db
2220 .select({
2221 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
2222 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
2223 })
2224 .from(pullRequests)
2225 .where(and(
2226 eq(pullRequests.repositoryId, repoId),
2227 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2228 ));
2229
2230 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
2231 const mergeRate = totalResolved > 0
2232 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
2233 : null;
2234
2235 // 4. Top reviewers (last 90 days)
2236 const reviewerCounts = await db
2237 .select({
2238 userId: prReviews.reviewerId,
2239 username: users.username,
2240 count: sql<number>`count(*)::int`,
2241 })
2242 .from(prReviews)
2243 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2244 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
2245 .where(and(
2246 eq(pullRequests.repositoryId, repoId),
2247 sql`${prReviews.createdAt} > now() - interval '90 days'`
2248 ))
2249 .groupBy(prReviews.reviewerId, users.username)
2250 .orderBy(desc(sql`count(*)`))
2251 .limit(5);
2252
2253 // 5. Average reviews per merged PR
2254 const [avgReviewRow] = await db
2255 .select({
2256 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
2257 })
2258 .from(pullRequests)
2259 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2260 .where(and(
2261 eq(pullRequests.repositoryId, repoId),
2262 eq(pullRequests.state, "merged"),
2263 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2264 ));
2265
2266 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
2267 ? Math.round(avgReviewRow.avgReviews * 10) / 10
2268 : null;
2269
2270 // 6. Review turnaround — avg time from PR open to first review
2271 const prsWithReviews = await db
2272 .select({
2273 createdAt: pullRequests.createdAt,
2274 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
2275 })
2276 .from(pullRequests)
2277 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2278 .where(and(
2279 eq(pullRequests.repositoryId, repoId),
2280 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2281 ))
2282 .groupBy(pullRequests.id, pullRequests.createdAt);
2283
2284 const avgReviewTurnaroundMs = prsWithReviews.length > 0
2285 ? prsWithReviews.reduce((s, row) => {
2286 const firstMs = new Date(row.firstReview).getTime();
2287 return s + Math.max(0, firstMs - row.createdAt.getTime());
2288 }, 0) / prsWithReviews.length
2289 : null;
2290
2291 // 7. Open PRs by age bucket
2292 const openPRs = await db
2293 .select({ createdAt: pullRequests.createdAt })
2294 .from(pullRequests)
2295 .where(and(
2296 eq(pullRequests.repositoryId, repoId),
2297 eq(pullRequests.state, "open")
2298 ));
2299
2300 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
2301 for (const { createdAt } of openPRs) {
2302 const ageDays = (now - createdAt.getTime()) / 86_400_000;
2303 if (ageDays < 1) ageBuckets.lt1d++;
2304 else if (ageDays < 3) ageBuckets.d1to3++;
2305 else if (ageDays < 7) ageBuckets.d3to7++;
2306 else if (ageDays < 30) ageBuckets.d7to30++;
2307 else ageBuckets.gt30d++;
2308 }
2309 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
2310
2311 // 8. 7-day merge sparkline
2312 const sparklineRows = await db
2313 .select({
2314 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
2315 count: sql<number>`count(*)::int`,
2316 })
2317 .from(pullRequests)
2318 .where(and(
2319 eq(pullRequests.repositoryId, repoId),
2320 eq(pullRequests.state, "merged"),
2321 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
2322 ))
2323 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
2324 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
2325
2326 const sparkMap = new Map<string, number>();
2327 for (const row of sparklineRows) {
2328 sparkMap.set(row.day.slice(0, 10), row.count);
2329 }
2330 const sparkline: number[] = [];
2331 for (let i = 6; i >= 0; i--) {
2332 const d = new Date(now - i * 86_400_000);
2333 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
2334 }
2335 const maxSpark = Math.max(1, ...sparkline);
2336
2337 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
2338 { label: "< 1 day", key: "lt1d" },
2339 { label: "1–3 days", key: "d1to3" },
2340 { label: "3–7 days", key: "d3to7" },
2341 { label: "7–30 days", key: "d7to30" },
2342 { label: "> 30 days", key: "gt30d" },
2343 ];
2344
2345 return c.html(
2346 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
2347 <RepoHeader owner={ownerName} repo={repoName} />
2348 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2349 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
2350
2351 <div class="pri-page">
2352 {/* Hero */}
2353 <div class="pri-hero">
2354 <div class="pri-hero-eyebrow">Pull requests</div>
2355 <h1 class="pri-hero-title">
2356 PR <span class="gradient-text">Insights</span>
2357 </h1>
2358 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
2359 </div>
2360
2361 {/* Stat cards */}
2362 <div class="pri-section">
2363 <div class="pri-section-title">At a glance</div>
2364 <div class="pri-cards">
2365 <div class="pri-card">
2366 <div class="pri-card-label">Avg merge time</div>
2367 <div class="pri-card-value">
2368 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
2369 </div>
2370 <div class="pri-card-sub">last 90 days</div>
2371 </div>
2372 <div class="pri-card">
2373 <div class="pri-card-label">Total merged</div>
2374 <div class="pri-card-value">{mergedPRs.length}</div>
2375 <div class="pri-card-sub">last 90 days</div>
2376 </div>
2377 <div class="pri-card">
2378 <div class="pri-card-label">Open PRs</div>
2379 <div class="pri-card-value">{openPRs.length}</div>
2380 <div class="pri-card-sub">right now</div>
2381 </div>
2382 <div class="pri-card">
2383 <div class="pri-card-label">Merge rate</div>
2384 <div class="pri-card-value">
2385 {mergeRate != null ? `${mergeRate}%` : "—"}
2386 </div>
2387 <div class="pri-card-sub">merged vs closed</div>
2388 </div>
2389 <div class="pri-card">
2390 <div class="pri-card-label">Avg reviews / PR</div>
2391 <div class="pri-card-value">
2392 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
2393 </div>
2394 <div class="pri-card-sub">merged PRs, 90d</div>
2395 </div>
2396 <div class="pri-card">
2397 <div class="pri-card-label">Top reviewer</div>
2398 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
2399 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
2400 </div>
2401 <div class="pri-card-sub">
2402 {reviewerCounts.length > 0
2403 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
2404 : "no reviews yet"}
2405 </div>
2406 </div>
2407 </div>
2408 </div>
2409
2410 {/* Review turnaround */}
2411 <div class="pri-section">
2412 <div class="pri-section-title">Review turnaround</div>
2413 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
2414 <div class="pri-card">
2415 <div class="pri-card-label">Avg time to first review</div>
2416 <div class="pri-card-value">
2417 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
2418 </div>
2419 <div class="pri-card-sub">
2420 {prsWithReviews.length > 0
2421 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
2422 : "no reviewed PRs in 90d"}
2423 </div>
2424 </div>
2425 </div>
2426 </div>
2427
2428 {/* Weekly throughput bar chart */}
2429 <div class="pri-section">
2430 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
2431 {weeklyPRs.length === 0 ? (
2432 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
2433 ) : (
2434 <div class="pri-chart">
2435 {weeklyPRs.map((w) => (
2436 <div class="pri-bar-col">
2437 <div
2438 class="pri-bar"
2439 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
2440 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
2441 />
2442 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
2443 </div>
2444 ))}
2445 </div>
2446 )}
2447 </div>
2448
2449 {/* 7-day merge sparkline */}
2450 <div class="pri-section">
2451 <div class="pri-section-title">Merges this week (daily)</div>
2452 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
2453 <div class="pri-sparkline">
2454 {sparkline.map((v) => (
2455 <div
2456 class="pri-spark-bar"
2457 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
2458 title={`${v} merge${v === 1 ? "" : "s"}`}
2459 />
2460 ))}
2461 </div>
2462 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
2463 <span>7 days ago</span>
2464 <span>Today</span>
2465 </div>
2466 </div>
2467 </div>
2468
2469 {/* Top reviewers table */}
2470 <div class="pri-section">
2471 <div class="pri-section-title">Top reviewers (last 90 days)</div>
2472 {reviewerCounts.length === 0 ? (
2473 <div class="pri-empty">No reviews posted in the last 90 days.</div>
2474 ) : (
2475 <div class="pri-table-wrap">
2476 <table class="pri-table">
2477 <thead>
2478 <tr>
2479 <th>#</th>
2480 <th>Reviewer</th>
2481 <th>Reviews</th>
2482 </tr>
2483 </thead>
2484 <tbody>
2485 {reviewerCounts.map((r, i) => (
2486 <tr>
2487 <td style="color:var(--text-muted)">{i + 1}</td>
2488 <td>
2489 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
2490 {r.username}
2491 </a>
2492 </td>
2493 <td style="font-weight:600">{r.count}</td>
2494 </tr>
2495 ))}
2496 </tbody>
2497 </table>
2498 </div>
2499 )}
2500 </div>
2501
2502 {/* Open PRs by age */}
2503 <div class="pri-section">
2504 <div class="pri-section-title">Open PRs by age</div>
2505 {openPRs.length === 0 ? (
2506 <div class="pri-empty">No open pull requests.</div>
2507 ) : (
2508 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
2509 {ageBucketDefs.map(({ label, key }) => (
2510 <div class="pri-age-row">
2511 <span class="pri-age-label">{label}</span>
2512 <div class="pri-age-bar-wrap">
2513 <div
2514 class="pri-age-bar"
2515 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
2516 />
2517 </div>
2518 <span class="pri-age-count">{ageBuckets[key]}</span>
2519 </div>
2520 ))}
2521 </div>
2522 )}
2523 </div>
2524
2525 {/* Back link */}
2526 <div>
2527 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
2528 {"←"} Back to pull requests
2529 </a>
2530 </div>
2531 </div>
2532 </Layout>
2533 );
2534});
2535
0074234Claude2536// New PR form
2537pulls.get(
2538 "/:owner/:repo/pulls/new",
2539 softAuth,
2540 requireAuth,
04f6b7fClaude2541 requireRepoAccess("write"),
0074234Claude2542 async (c) => {
2543 const { owner: ownerName, repo: repoName } = c.req.param();
2544 const user = c.get("user")!;
2545 const branches = await listBranches(ownerName, repoName);
2546 const error = c.req.query("error");
2547 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude2548 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude2549
2550 return c.html(
2551 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
2552 <RepoHeader owner={ownerName} repo={repoName} />
2553 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude2554 <Container maxWidth={800}>
2555 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude2556 {error && (
bb0f894Claude2557 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude2558 )}
0316dbbClaude2559 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
2560 <Flex gap={12} align="center" style="margin-bottom: 16px">
2561 <Select name="base">
0074234Claude2562 {branches.map((b) => (
2563 <option value={b} selected={b === defaultBase}>
2564 {b}
2565 </option>
2566 ))}
bb0f894Claude2567 </Select>
2568 <Text muted>&larr;</Text>
2569 <Select name="head">
0074234Claude2570 {branches
2571 .filter((b) => b !== defaultBase)
2572 .concat(defaultBase === branches[0] ? [] : [branches[0]])
2573 .map((b) => (
2574 <option value={b}>{b}</option>
2575 ))}
bb0f894Claude2576 </Select>
2577 </Flex>
2578 <FormGroup>
2579 <Input
0074234Claude2580 name="title"
2581 required
2582 placeholder="Title"
bb0f894Claude2583 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]2584 aria-label="Pull request title"
0074234Claude2585 />
bb0f894Claude2586 </FormGroup>
2587 <FormGroup>
2588 <TextArea
0074234Claude2589 name="body"
81c73c1Claude2590 id="pr-body"
0074234Claude2591 rows={8}
2592 placeholder="Description (Markdown supported)"
bb0f894Claude2593 mono
0074234Claude2594 />
bb0f894Claude2595 </FormGroup>
81c73c1Claude2596 <Flex gap={8} align="center">
2597 <Button type="submit" variant="primary">
2598 Create pull request
2599 </Button>
2600 <button
2601 type="button"
2602 id="ai-suggest-desc"
2603 class="btn"
2604 style="font-weight:500"
2605 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
2606 >
2607 Suggest description with AI
2608 </button>
2609 <span
2610 id="ai-suggest-status"
2611 style="color:var(--text-muted);font-size:13px"
2612 />
2613 </Flex>
bb0f894Claude2614 </Form>
81c73c1Claude2615 <script
2616 dangerouslySetInnerHTML={{
2617 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
2618 }}
2619 />
bb0f894Claude2620 </Container>
0074234Claude2621 </Layout>
2622 );
2623 }
2624);
2625
81c73c1Claude2626// AI-suggested PR description — JSON endpoint driven by the form button.
2627// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
2628// 200; the inline script reads `ok` to decide what to do.
2629pulls.post(
2630 "/:owner/:repo/ai/pr-description",
2631 softAuth,
2632 requireAuth,
2633 requireRepoAccess("write"),
2634 async (c) => {
2635 const { owner: ownerName, repo: repoName } = c.req.param();
2636 if (!isAiAvailable()) {
2637 return c.json({
2638 ok: false,
2639 error: "AI is not available — set ANTHROPIC_API_KEY.",
2640 });
2641 }
2642 const body = await c.req.parseBody();
2643 const title = String(body.title || "").trim();
2644 const baseBranch = String(body.base || "").trim();
2645 const headBranch = String(body.head || "").trim();
2646 if (!baseBranch || !headBranch) {
2647 return c.json({ ok: false, error: "Pick base + head branches first." });
2648 }
2649 if (baseBranch === headBranch) {
2650 return c.json({ ok: false, error: "Base and head must differ." });
2651 }
2652
2653 let diff = "";
2654 try {
2655 const cwd = getRepoPath(ownerName, repoName);
2656 const proc = Bun.spawn(
2657 [
2658 "git",
2659 "diff",
2660 `${baseBranch}...${headBranch}`,
2661 "--",
2662 ],
2663 { cwd, stdout: "pipe", stderr: "pipe" }
2664 );
6ea2109Claude2665 // 30s ceiling — without this a pathological diff (huge binary or
2666 // a corrupt ref) hangs the request indefinitely.
2667 const killer = setTimeout(() => proc.kill(), 30_000);
2668 try {
2669 diff = await new Response(proc.stdout).text();
2670 await proc.exited;
2671 } finally {
2672 clearTimeout(killer);
2673 }
81c73c1Claude2674 } catch {
2675 diff = "";
2676 }
2677 if (!diff.trim()) {
2678 return c.json({
2679 ok: false,
2680 error: "No diff between branches — nothing to summarise.",
2681 });
2682 }
2683
2684 let summary = "";
2685 try {
2686 summary = await generatePrSummary(title || "(untitled)", diff);
2687 } catch (err) {
2688 const msg = err instanceof Error ? err.message : "AI request failed.";
2689 return c.json({ ok: false, error: msg });
2690 }
2691 if (!summary.trim()) {
2692 return c.json({ ok: false, error: "AI returned an empty draft." });
2693 }
2694 return c.json({ ok: true, body: summary });
2695 }
2696);
2697
0074234Claude2698// Create PR
2699pulls.post(
2700 "/:owner/:repo/pulls/new",
2701 softAuth,
2702 requireAuth,
04f6b7fClaude2703 requireRepoAccess("write"),
0074234Claude2704 async (c) => {
2705 const { owner: ownerName, repo: repoName } = c.req.param();
2706 const user = c.get("user")!;
2707 const body = await c.req.parseBody();
2708 const title = String(body.title || "").trim();
2709 const prBody = String(body.body || "").trim();
2710 const baseBranch = String(body.base || "main");
2711 const headBranch = String(body.head || "");
2712
2713 if (!title || !headBranch) {
2714 return c.redirect(
2715 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
2716 );
2717 }
2718
2719 if (baseBranch === headBranch) {
2720 return c.redirect(
2721 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
2722 );
2723 }
2724
2725 const resolved = await resolveRepo(ownerName, repoName);
2726 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2727
6fc53bdClaude2728 const isDraft = String(body.draft || "") === "1";
2729
0074234Claude2730 const [pr] = await db
2731 .insert(pullRequests)
2732 .values({
2733 repositoryId: resolved.repo.id,
2734 authorId: user.id,
2735 title,
2736 body: prBody || null,
2737 baseBranch,
2738 headBranch,
6fc53bdClaude2739 isDraft,
0074234Claude2740 })
2741 .returning();
2742
6fc53bdClaude2743 // Skip AI review on drafts — it runs again when the PR is marked ready.
2744 if (!isDraft && isAiReviewEnabled()) {
e883329Claude2745 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
2746 (err) => console.error("[ai-review] Failed:", err)
2747 );
2748 }
2749
3cbe3d6Claude2750 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
2751 triggerPrTriage({
2752 ownerName,
2753 repoName,
2754 repositoryId: resolved.repo.id,
2755 prId: pr.id,
2756 prAuthorId: user.id,
2757 title,
2758 body: prBody,
2759 baseBranch,
2760 headBranch,
2761 }).catch((err) => console.error("[pr-triage] Failed:", err));
2762
1d4ff60Claude2763 // Chat notifier — fan out to Slack/Discord/Teams.
2764 import("../lib/chat-notifier")
2765 .then((m) =>
2766 m.notifyChatChannels({
2767 ownerUserId: resolved.repo.ownerId,
2768 repositoryId: resolved.repo.id,
2769 event: {
2770 event: "pr.opened",
2771 repo: `${ownerName}/${repoName}`,
2772 title: `#${pr.number} ${title}`,
2773 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
2774 body: prBody || undefined,
2775 actor: user.username,
2776 },
2777 })
2778 )
2779 .catch((err) =>
2780 console.warn(`[chat-notifier] PR opened notify failed:`, err)
2781 );
2782
9dd96b9Test User2783 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude2784 import("../lib/auto-merge")
2785 .then((m) => m.tryAutoMergeNow(pr.id))
2786 .catch((err) => {
2787 console.warn(
2788 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
2789 err instanceof Error ? err.message : err
2790 );
2791 });
9dd96b9Test User2792
0074234Claude2793 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
2794 }
2795);
2796
2797// View single PR
04f6b7fClaude2798pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2799 const { owner: ownerName, repo: repoName } = c.req.param();
2800 const prNum = parseInt(c.req.param("number"), 10);
2801 const user = c.get("user");
2802 const tab = c.req.query("tab") || "conversation";
2803
2804 const resolved = await resolveRepo(ownerName, repoName);
2805 if (!resolved) return c.notFound();
2806
2807 const [pr] = await db
2808 .select()
2809 .from(pullRequests)
2810 .where(
2811 and(
2812 eq(pullRequests.repositoryId, resolved.repo.id),
2813 eq(pullRequests.number, prNum)
2814 )
2815 )
2816 .limit(1);
2817
2818 if (!pr) return c.notFound();
2819
2820 const [author] = await db
2821 .select()
2822 .from(users)
2823 .where(eq(users.id, pr.authorId))
2824 .limit(1);
2825
cb5a796Claude2826 const allCommentsRaw = await db
0074234Claude2827 .select({
2828 comment: prComments,
cb5a796Claude2829 author: { id: users.id, username: users.username },
0074234Claude2830 })
2831 .from(prComments)
2832 .innerJoin(users, eq(prComments.authorId, users.id))
2833 .where(eq(prComments.pullRequestId, pr.id))
2834 .orderBy(asc(prComments.createdAt));
2835
cb5a796Claude2836 // Filter pending/rejected/spam for non-owner, non-author viewers.
2837 // Owner always sees everything; comment author sees their own pending
2838 // with an "Awaiting approval" badge in the render below.
2839 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
2840 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
2841 if (viewerIsRepoOwner) return true;
2842 if (comment.moderationStatus === "approved") return true;
2843 if (
2844 user &&
2845 cAuthor.id === user.id &&
2846 comment.moderationStatus === "pending"
2847 ) {
2848 return true;
2849 }
2850 return false;
2851 });
2852 const prPendingCount = viewerIsRepoOwner
2853 ? await countPendingForRepo(resolved.repo.id)
2854 : 0;
2855
6fc53bdClaude2856 // Reactions for the PR body + each comment, in parallel.
2857 const [prReactions, ...prCommentReactions] = await Promise.all([
2858 summariseReactions("pr", pr.id, user?.id),
2859 ...comments.map((row) =>
2860 summariseReactions("pr_comment", row.comment.id, user?.id)
2861 ),
2862 ]);
2863
0a67773Claude2864 // Formal reviews (Approve / Request Changes)
2865 const reviewRows = await db
2866 .select({
2867 id: prReviews.id,
2868 state: prReviews.state,
2869 body: prReviews.body,
2870 isAi: prReviews.isAi,
2871 createdAt: prReviews.createdAt,
2872 reviewerUsername: users.username,
2873 reviewerId: prReviews.reviewerId,
2874 })
2875 .from(prReviews)
2876 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2877 .where(eq(prReviews.pullRequestId, pr.id))
2878 .orderBy(asc(prReviews.createdAt));
2879 // Most recent review per reviewer determines the current state
2880 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
2881 for (const r of reviewRows) {
2882 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
2883 }
2884 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
2885 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
2886 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
2887
0074234Claude2888 const canManage =
2889 user &&
2890 (user.id === resolved.owner.id || user.id === pr.authorId);
2891
1d4ff60Claude2892 // Has any previous AI-test-generator run already tagged this PR? Used
2893 // both to hide the "Generate tests with AI" button and to short-circuit
2894 // the explicit POST handler.
2895 const hasAiTestsMarker = comments.some(({ comment }) =>
2896 (comment.body || "").includes(AI_TESTS_MARKER)
2897 );
2898
e883329Claude2899 const error = c.req.query("error");
c3e0c07Claude2900 const info = c.req.query("info");
e883329Claude2901
2902 // Get gate check status for open PRs
2903 let gateChecks: GateCheckResult[] = [];
2904 if (pr.state === "open") {
2905 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2906 if (headSha) {
2907 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
2908 const aiApproved = aiComments.length === 0 || aiComments.some(
2909 ({ comment }) => comment.body.includes("**Approved**")
2910 );
2911 const gateResult = await runAllGateChecks(
2912 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
2913 );
2914 gateChecks = gateResult.checks;
2915 }
2916 }
2917
534f04aClaude2918 // Block M3 — pre-merge risk score. Cache-only on the request path so
2919 // the page never waits on Haiku. On a cache miss for an open PR we
2920 // kick off the computation fire-and-forget; the next refresh shows it.
2921 let prRisk: PrRiskScore | null = null;
2922 let prRiskCalculating = false;
2923 if (pr.state === "open") {
2924 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
2925 if (!prRisk) {
2926 prRiskCalculating = true;
a28cedeClaude2927 void computePrRiskForPullRequest(pr.id).catch((err) => {
2928 console.warn(
2929 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
2930 err instanceof Error ? err.message : err
2931 );
2932 });
534f04aClaude2933 }
2934 }
2935
4bbacbeClaude2936 // Migration 0062 — per-branch preview URL. The head branch always
2937 // has a preview row (unless it's the default branch, which never
2938 // happens for an open PR) once it has been pushed at least once.
2939 const preview = await getPreviewForBranch(
2940 (resolved.repo as { id: string }).id,
2941 pr.headBranch
2942 );
2943
47a7a0aClaude2944 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude2945 let diffRaw = "";
2946 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude2947 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude2948 if (tab === "files") {
2949 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude2950 // Run the two git diffs in parallel — they're independent reads of
2951 // the same range. Previously sequential, doubling the wall time on
2952 // big PRs (100+ files = 10-30s for no reason).
0074234Claude2953 const proc = Bun.spawn(
2954 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
2955 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2956 );
2957 const statProc = Bun.spawn(
2958 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
2959 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2960 );
6ea2109Claude2961 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
2962 // would otherwise hang the whole request.
2963 const killer = setTimeout(() => {
2964 proc.kill();
2965 statProc.kill();
2966 }, 30_000);
2967 let stat = "";
2968 try {
2969 [diffRaw, stat] = await Promise.all([
2970 new Response(proc.stdout).text(),
2971 new Response(statProc.stdout).text(),
2972 ]);
2973 await Promise.all([proc.exited, statProc.exited]);
2974 } finally {
2975 clearTimeout(killer);
2976 }
0074234Claude2977
2978 diffFiles = stat
2979 .trim()
2980 .split("\n")
2981 .filter(Boolean)
2982 .map((line) => {
2983 const [add, del, filePath] = line.split("\t");
2984 return {
2985 path: filePath,
2986 status: "modified",
2987 additions: add === "-" ? 0 : parseInt(add, 10),
2988 deletions: del === "-" ? 0 : parseInt(del, 10),
2989 patch: "",
2990 };
2991 });
47a7a0aClaude2992
2993 // Fetch inline comments (file+line anchored) for the files tab
2994 const inlineRows = await db
2995 .select({
2996 id: prComments.id,
2997 filePath: prComments.filePath,
2998 lineNumber: prComments.lineNumber,
2999 body: prComments.body,
3000 isAiReview: prComments.isAiReview,
3001 createdAt: prComments.createdAt,
3002 authorUsername: users.username,
3003 })
3004 .from(prComments)
3005 .innerJoin(users, eq(prComments.authorId, users.id))
3006 .where(
3007 and(
3008 eq(prComments.pullRequestId, pr.id),
3009 eq(prComments.moderationStatus, "approved"),
3010 )
3011 )
3012 .orderBy(asc(prComments.createdAt));
3013
3014 diffInlineComments = inlineRows
3015 .filter(r => r.filePath != null && r.lineNumber != null)
3016 .map(r => ({
3017 id: r.id,
3018 filePath: r.filePath!,
3019 lineNumber: r.lineNumber!,
3020 authorUsername: r.authorUsername,
3021 body: renderMarkdown(r.body),
3022 isAiReview: r.isAiReview,
3023 createdAt: r.createdAt.toISOString(),
3024 }));
0074234Claude3025 }
3026
b078860Claude3027 // ─── Derived visual state ───
3028 const stateKey =
3029 pr.state === "open"
3030 ? pr.isDraft
3031 ? "draft"
3032 : "open"
3033 : pr.state;
3034 const stateLabel =
3035 stateKey === "open"
3036 ? "Open"
3037 : stateKey === "draft"
3038 ? "Draft"
3039 : stateKey === "merged"
3040 ? "Merged"
3041 : "Closed";
3042 const stateIcon =
3043 stateKey === "open"
3044 ? "○"
3045 : stateKey === "draft"
3046 ? "◌"
3047 : stateKey === "merged"
3048 ? "⮌"
3049 : "✓";
3050 const commentCount = comments.length;
3051 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
3052 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
3053 const mergeBlocked =
3054 gateChecks.length > 0 &&
3055 gateChecks.some(
3056 (c) => !c.passed && c.name !== "Merge check"
3057 );
3058
0074234Claude3059 return c.html(
3060 <Layout
3061 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
3062 user={user}
3063 >
3064 <RepoHeader owner={ownerName} repo={repoName} />
3065 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude3066 <PendingCommentsBanner
3067 owner={ownerName}
3068 repo={repoName}
3069 count={prPendingCount}
3070 />
b078860Claude3071 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude3072 <div
3073 id="live-comment-banner"
3074 class="alert"
3075 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
3076 >
3077 <strong class="js-live-count">0</strong> new comment(s) —{" "}
3078 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
3079 reload to view
3080 </a>
3081 </div>
3082 <script
3083 dangerouslySetInnerHTML={{
3084 __html: liveCommentBannerScript({
3085 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
3086 bannerElementId: "live-comment-banner",
3087 }),
3088 }}
3089 />
b078860Claude3090
3091 <div class="prs-detail-hero">
3092 <h1 class="prs-detail-title">
0074234Claude3093 {pr.title}{" "}
b078860Claude3094 <span class="prs-detail-num">#{pr.number}</span>
3095 </h1>
3096 <div class="prs-detail-meta">
3097 <span class={`prs-state-pill state-${stateKey}`}>
3098 <span aria-hidden="true">{stateIcon}</span>
3099 <span>{stateLabel}</span>
3100 </span>
3101 <span>
3102 <strong>{author?.username}</strong> wants to merge
3103 </span>
3104 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
3105 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
3106 <span class="prs-branch-arrow-lg">{"→"}</span>
3107 <span class="prs-branch-pill">{pr.baseBranch}</span>
3108 </span>
3109 <span>opened {formatRelative(pr.createdAt)}</span>
3c03977Claude3110 <span
3111 id="live-pill"
3112 class="live-pill"
3113 title="People editing this PR right now"
3114 >
3115 <span class="live-pill-dot" aria-hidden="true"></span>
3116 <span>
3117 Live: <strong id="live-count">0</strong> editing
3118 </span>
3119 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
3120 </span>
4bbacbeClaude3121 {preview && (
3122 <a
3123 class={`preview-prpill is-${preview.status}`}
3124 href={
3125 preview.status === "ready"
3126 ? preview.previewUrl
3127 : `/${ownerName}/${repoName}/previews`
3128 }
3129 target={preview.status === "ready" ? "_blank" : undefined}
3130 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
3131 title={`Preview · ${previewStatusLabel(preview.status)}`}
3132 >
3133 <span class="preview-prpill-dot" aria-hidden="true"></span>
3134 <span>Preview: </span>
3135 <span>{previewStatusLabel(preview.status)}</span>
3136 </a>
3137 )}
b078860Claude3138 {canManage && pr.state === "open" && pr.isDraft && (
3139 <form
3140 method="post"
3141 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3142 class="prs-inline-form prs-detail-actions"
3143 >
3144 <button type="submit" class="prs-merge-ready-btn">
3145 Ready for review
3146 </button>
3147 </form>
3148 )}
3149 </div>
3150 </div>
3c03977Claude3151 <script
3152 dangerouslySetInnerHTML={{
3153 __html: LIVE_COEDIT_SCRIPT(pr.id),
3154 }}
3155 />
0074234Claude3156
b078860Claude3157 <nav class="prs-detail-tabs" aria-label="Pull request sections">
3158 <a
3159 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
3160 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
3161 >
3162 Conversation
3163 <span class="prs-detail-tab-count">{commentCount}</span>
3164 </a>
3165 <a
3166 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
3167 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3168 >
3169 Files changed
3170 {diffFiles.length > 0 && (
3171 <span class="prs-detail-tab-count">{diffFiles.length}</span>
3172 )}
3173 </a>
3174 </nav>
3175
3176 {tab === "files" ? (
ea9ed4cClaude3177 <DiffView
3178 raw={diffRaw}
3179 files={diffFiles}
3180 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
47a7a0aClaude3181 inlineComments={diffInlineComments}
3182 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
b5dd694Claude3183 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
ea9ed4cClaude3184 />
b078860Claude3185 ) : (
3186 <>
3187 {pr.body && (
3188 <CommentBox
3189 author={author?.username ?? "unknown"}
3190 date={pr.createdAt}
3191 body={renderMarkdown(pr.body)}
3192 />
3193 )}
3194
422a2d4Claude3195 {/* Block H — AI trio review (security/correctness/style). When
3196 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
3197 hoisted into a 3-column card grid above the normal comment
3198 stream so reviewers see verdicts at a glance. Disagreements
3199 are surfaced as a yellow callout. */}
3200 <TrioReviewGrid
3201 comments={comments.map(({ comment }) => comment)}
3202 />
3203
15db0e0Claude3204 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude3205 // Skip trio comments — already rendered in TrioReviewGrid above.
3206 if (isTrioComment(comment.body)) return null;
15db0e0Claude3207 const slashCmd = detectSlashCmdComment(comment.body);
3208 if (slashCmd) {
3209 const visible = stripSlashCmdMarker(comment.body);
3210 return (
3211 <div class={`slash-pill slash-cmd-${slashCmd}`}>
3212 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
3213 <span class="slash-pill-actor">
3214 <strong>{commentAuthor.username}</strong>
3215 {" ran "}
3216 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude3217 </span>
15db0e0Claude3218 <span class="slash-pill-time">
3219 {formatRelative(comment.createdAt)}
3220 </span>
3221 <div class="slash-pill-body">
3222 <MarkdownContent html={renderMarkdown(visible)} />
3223 </div>
3224 </div>
3225 );
3226 }
cb5a796Claude3227 const isPending = comment.moderationStatus === "pending";
15db0e0Claude3228 return (
cb5a796Claude3229 <div
3230 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
3231 >
15db0e0Claude3232 <div class="prs-comment-head">
3233 <strong>{commentAuthor.username}</strong>
3234 {comment.isAiReview && (
3235 <span class="prs-ai-badge">AI Review</span>
3236 )}
cb5a796Claude3237 {isPending && (
3238 <span
3239 class="modq-pending-badge"
3240 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
3241 >
3242 Awaiting approval
3243 </span>
3244 )}
15db0e0Claude3245 <span class="prs-comment-time">
3246 commented {formatRelative(comment.createdAt)}
3247 </span>
3248 {comment.filePath && (
3249 <span class="prs-comment-loc">
3250 {comment.filePath}
3251 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
3252 </span>
3253 )}
3254 </div>
3255 <div class="prs-comment-body">
3256 <MarkdownContent html={renderMarkdown(comment.body)} />
3257 </div>
0074234Claude3258 </div>
15db0e0Claude3259 );
3260 })}
0074234Claude3261
b078860Claude3262 {/* Quick link to the Files changed tab when there's a diff to look at. */}
3263 {pr.state !== "merged" && (
3264 <a
3265 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3266 class="prs-files-card"
3267 >
3268 <span class="prs-files-card-icon" aria-hidden="true">
3269 {"▤"}
3270 </span>
3271 <div class="prs-files-card-text">
3272 <p class="prs-files-card-title">Files changed</p>
3273 <p class="prs-files-card-sub">
3274 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
3275 </p>
e883329Claude3276 </div>
b078860Claude3277 <span class="prs-files-card-cta">View diff {"→"}</span>
3278 </a>
3279 )}
3280
3281 {error && (
3282 <div
3283 class="auth-error"
3284 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)"
3285 >
3286 {decodeURIComponent(error)}
3287 </div>
3288 )}
3289
3290 {info && (
3291 <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)">
3292 {decodeURIComponent(info)}
3293 </div>
3294 )}
e883329Claude3295
b078860Claude3296 {pr.state === "open" && (prRisk || prRiskCalculating) && (
3297 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
3298 )}
3299
0a67773Claude3300 {/* ─── Review summary ─────────────────────────────────── */}
3301 {(approvals.length > 0 || changesRequested.length > 0) && (
3302 <div class="prs-review-summary">
3303 {approvals.length > 0 && (
3304 <div class="prs-review-row prs-review-approved">
3305 <span class="prs-review-icon">✓</span>
3306 <span>
3307 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
3308 approved this pull request
3309 </span>
3310 </div>
3311 )}
3312 {changesRequested.length > 0 && (
3313 <div class="prs-review-row prs-review-changes">
3314 <span class="prs-review-icon">✗</span>
3315 <span>
3316 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
3317 requested changes
3318 </span>
3319 </div>
3320 )}
3321 </div>
3322 )}
3323
b078860Claude3324 {pr.state === "open" && gateChecks.length > 0 && (
3325 <div class="prs-gate-card">
3326 <div class="prs-gate-head">
3327 <h3>Gate checks</h3>
3328 <span class="prs-gate-summary">
3329 {gatesAllPassed
3330 ? `All ${gateChecks.length} checks passed`
3331 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
3332 </span>
c3e0c07Claude3333 </div>
b078860Claude3334 {gateChecks.map((check) => {
3335 const isAi = /ai.*review/i.test(check.name);
3336 const isSkip = check.skipped === true;
3337 const statusClass = isSkip
3338 ? "is-skip"
3339 : check.passed
3340 ? "is-pass"
3341 : "is-fail";
3342 const statusGlyph = isSkip
3343 ? "—"
3344 : check.passed
3345 ? "✓"
3346 : "✗";
3347 const statusLabel = isSkip
3348 ? "Skipped"
3349 : check.passed
3350 ? "Passed"
3351 : "Failing";
3352 return (
3353 <div
3354 class="prs-gate-row"
3355 style={
3356 isAi
3357 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
3358 : ""
3359 }
3360 >
3361 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
3362 {statusGlyph}
3363 </span>
3364 <span class="prs-gate-name">
3365 {check.name}
3366 {isAi && (
3367 <span
3368 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"
3369 >
3370 AI
3371 </span>
3372 )}
3373 </span>
3374 <span class="prs-gate-details">{check.details}</span>
3375 <span class={`prs-gate-pill ${statusClass}`}>
3376 {statusLabel}
e883329Claude3377 </span>
3378 </div>
b078860Claude3379 );
3380 })}
3381 <div class="prs-gate-footer">
3382 {gatesAllPassed
3383 ? "All checks passed — ready to merge."
3384 : gateChecks.some(
3385 (c) => !c.passed && c.name === "Merge check"
3386 )
3387 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
3388 : "Some checks failed — resolve issues before merging."}
3389 {aiReviewCount > 0 && (
3390 <>
3391 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
3392 </>
3393 )}
3394 </div>
3395 </div>
3396 )}
3397
3398 {/* ─── Merge area / state-aware action card ─────────────── */}
3399 {user && pr.state === "open" && (
3400 <div
3401 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
3402 >
3403 <div class="prs-merge-head">
3404 <strong>
3405 {pr.isDraft
3406 ? "Draft — ready for review?"
3407 : mergeBlocked
3408 ? "Merge blocked"
3409 : "Ready to merge"}
3410 </strong>
e883329Claude3411 </div>
b078860Claude3412 <p class="prs-merge-sub">
3413 {pr.isDraft
3414 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
3415 : mergeBlocked
3416 ? "Resolve the failing gate checks above before this PR can land."
3417 : gateChecks.length > 0
3418 ? gatesAllPassed
3419 ? "All gates green. Merge will fast-forward into the base branch."
3420 : "Conflicts will be auto-resolved by GlueCron AI on merge."
3421 : "Run gate checks by refreshing once your branch has a recent commit."}
3422 </p>
3423 <Form
3424 method="post"
3425 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
3426 >
3427 <FormGroup>
3c03977Claude3428 <div class="live-cursor-host" style="position:relative">
3429 <textarea
3430 name="body"
3431 id="pr-comment-body"
3432 data-live-field="comment_new"
3433 rows={5}
3434 required
3435 placeholder="Leave a comment... (Markdown supported)"
3436 style="font-family:var(--font-mono);font-size:13px;width:100%"
3437 ></textarea>
3438 </div>
15db0e0Claude3439 <span class="slash-hint" title="Type a slash-command as the first line">
3440 Type <code>/</code> for commands —{" "}
3441 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
3442 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
3443 </span>
b078860Claude3444 </FormGroup>
3445 <div class="prs-merge-actions">
3446 <Button type="submit" variant="primary">
3447 Comment
3448 </Button>
0a67773Claude3449 {user && user.id !== pr.authorId && pr.state === "open" && (
3450 <>
3451 <button
3452 type="submit"
3453 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
3454 name="review_state"
3455 value="approved"
3456 class="prs-review-approve-btn"
3457 title="Approve this pull request"
3458 >
3459 ✓ Approve
3460 </button>
3461 <button
3462 type="submit"
3463 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
3464 name="review_state"
3465 value="changes_requested"
3466 class="prs-review-changes-btn"
3467 title="Request changes before merging"
3468 >
3469 ✗ Request changes
3470 </button>
3471 </>
3472 )}
b078860Claude3473 {canManage && (
3474 <>
3475 {pr.isDraft ? (
3476 <button
3477 type="submit"
3478 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3479 formnovalidate
3480 class="prs-merge-ready-btn"
3481 >
3482 Ready for review
3483 </button>
3484 ) : (
0074234Claude3485 <button
3486 type="submit"
3487 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
b078860Claude3488 formnovalidate
3489 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
3490 title={
3491 mergeBlocked
3492 ? "Failing gate checks must be resolved before this PR can merge."
3493 : "Merge pull request"
3494 }
0074234Claude3495 >
b078860Claude3496 {"✔"} Merge pull request
0074234Claude3497 </button>
b078860Claude3498 )}
3499 {!pr.isDraft && (
3500 <button
0074234Claude3501 type="submit"
b078860Claude3502 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
3503 formnovalidate
3504 class="prs-merge-back-draft"
3505 title="Convert back to draft"
0074234Claude3506 >
b078860Claude3507 Convert to draft
3508 </button>
3509 )}
3510 {isAiReviewEnabled() && (
3511 <button
3512 type="submit"
3513 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
3514 formnovalidate
3515 class="btn"
3516 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
3517 >
3518 Re-run AI review
3519 </button>
3520 )}
1d4ff60Claude3521 {isAiReviewEnabled() && !hasAiTestsMarker && (
3522 <button
3523 type="submit"
3524 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
3525 formnovalidate
3526 class="btn"
3527 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."
3528 >
3529 Generate tests with AI
3530 </button>
3531 )}
b078860Claude3532 <Button
3533 type="submit"
3534 variant="danger"
3535 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
3536 >
3537 Close
3538 </Button>
3539 </>
3540 )}
3541 </div>
3542 </Form>
3543 </div>
3544 )}
3545
3546 {/* Read-only footers for non-open states. */}
3547 {pr.state === "merged" && (
3548 <div class="prs-merge-card is-merged">
3549 <div class="prs-merge-head">
3550 <strong>{"⮌"} Merged</strong>
0074234Claude3551 </div>
b078860Claude3552 <p class="prs-merge-sub">
3553 This pull request was merged into{" "}
3554 <code>{pr.baseBranch}</code>.
3555 </p>
3556 </div>
3557 )}
3558 {pr.state === "closed" && (
3559 <div class="prs-merge-card is-closed">
3560 <div class="prs-merge-head">
3561 <strong>{"✕"} Closed without merging</strong>
3562 </div>
3563 <p class="prs-merge-sub">
3564 This pull request was closed and not merged.
3565 </p>
3566 </div>
3567 )}
3568 </>
3569 )}
0074234Claude3570 </Layout>
3571 );
3572});
3573
cb5a796Claude3574// Add comment to PR.
3575//
3576// Permission model mirrors `issues.tsx`: any logged-in user with read
3577// access can submit; `decideInitialStatus` routes non-collaborators
3578// through the moderation queue. Slash commands only fire when the
3579// comment is auto-approved — we don't want a banned/pending comment to
3580// silently trigger AI work on the PR.
0074234Claude3581pulls.post(
3582 "/:owner/:repo/pulls/:number/comment",
3583 softAuth,
3584 requireAuth,
cb5a796Claude3585 requireRepoAccess("read"),
0074234Claude3586 async (c) => {
3587 const { owner: ownerName, repo: repoName } = c.req.param();
3588 const prNum = parseInt(c.req.param("number"), 10);
3589 const user = c.get("user")!;
3590 const body = await c.req.parseBody();
3591 const commentBody = String(body.body || "").trim();
47a7a0aClaude3592 const filePathRaw = String(body.file_path || "").trim();
3593 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
3594 const inlineFilePath = filePathRaw || undefined;
3595 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude3596
3597 if (!commentBody) {
3598 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3599 }
3600
3601 const resolved = await resolveRepo(ownerName, repoName);
3602 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3603
3604 const [pr] = await db
3605 .select()
3606 .from(pullRequests)
3607 .where(
3608 and(
3609 eq(pullRequests.repositoryId, resolved.repo.id),
3610 eq(pullRequests.number, prNum)
3611 )
3612 )
3613 .limit(1);
3614
3615 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3616
cb5a796Claude3617 const decision = await decideInitialStatus({
3618 commenterUserId: user.id,
3619 repositoryId: resolved.repo.id,
3620 kind: "pr",
3621 threadId: pr.id,
3622 });
3623
d4ac5c3Claude3624 const [inserted] = await db
3625 .insert(prComments)
3626 .values({
3627 pullRequestId: pr.id,
3628 authorId: user.id,
3629 body: commentBody,
cb5a796Claude3630 moderationStatus: decision.status,
47a7a0aClaude3631 filePath: inlineFilePath,
3632 lineNumber: inlineLineNumber,
d4ac5c3Claude3633 })
3634 .returning();
3635
cb5a796Claude3636 // Live update: only when the comment is actually visible.
3637 if (inserted && decision.status === "approved") {
d4ac5c3Claude3638 try {
3639 const { publish } = await import("../lib/sse");
3640 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
3641 event: "pr-comment",
3642 data: {
3643 pullRequestId: pr.id,
3644 commentId: inserted.id,
3645 authorId: user.id,
3646 authorUsername: user.username,
3647 },
3648 });
3649 } catch {
3650 /* SSE is best-effort */
3651 }
3652 }
0074234Claude3653
cb5a796Claude3654 if (decision.status === "pending") {
3655 void notifyOwnerOfPendingComment({
3656 repositoryId: resolved.repo.id,
3657 commenterUsername: user.username,
3658 kind: "pr",
3659 threadNumber: prNum,
3660 ownerUsername: ownerName,
3661 repoName,
3662 });
3663 return c.redirect(
3664 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
3665 );
3666 }
3667 if (decision.status === "rejected") {
3668 // Silent ban path — same UX as 'pending' so we don't leak the gate.
3669 return c.redirect(
3670 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
3671 );
3672 }
3673
15db0e0Claude3674 // Slash-command handoff. We always store the original comment above
3675 // first so free-form text that happens to start with `/` is preserved
3676 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude3677 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude3678 const parsed = parseSlashCommand(commentBody);
3679 if (parsed) {
3680 try {
3681 const result = await executeSlashCommand({
3682 command: parsed.command,
3683 args: parsed.args,
3684 prId: pr.id,
3685 userId: user.id,
3686 repositoryId: resolved.repo.id,
3687 });
3688 await db.insert(prComments).values({
3689 pullRequestId: pr.id,
3690 authorId: user.id,
3691 body: result.body,
3692 });
3693 } catch (err) {
3694 // Defence-in-depth — executeSlashCommand promises not to throw,
3695 // but if it ever does we want the PR thread to know.
3696 await db
3697 .insert(prComments)
3698 .values({
3699 pullRequestId: pr.id,
3700 authorId: user.id,
3701 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
3702 })
3703 .catch(() => {});
3704 }
3705 }
3706
47a7a0aClaude3707 // Inline comments go back to the files tab; conversation comments to the conversation tab
3708 const redirectTab = inlineFilePath ? "?tab=files" : "";
3709 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude3710 }
3711);
3712
b5dd694Claude3713// Apply a suggestion from a PR comment — commits the suggested code to the
3714// head branch on behalf of the logged-in user.
3715pulls.post(
3716 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
3717 softAuth,
3718 requireAuth,
3719 requireRepoAccess("read"),
3720 async (c) => {
3721 const { owner: ownerName, repo: repoName } = c.req.param();
3722 const prNum = parseInt(c.req.param("number"), 10);
3723 const commentId = c.req.param("commentId"); // UUID
3724 const user = c.get("user")!;
3725
3726 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
3727
3728 const resolved = await resolveRepo(ownerName, repoName);
3729 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3730
3731 const [pr] = await db
3732 .select()
3733 .from(pullRequests)
3734 .where(
3735 and(
3736 eq(pullRequests.repositoryId, resolved.repo.id),
3737 eq(pullRequests.number, prNum)
3738 )
3739 )
3740 .limit(1);
3741
3742 if (!pr || pr.state !== "open") {
3743 return c.redirect(`${backUrl}&error=pr_not_open`);
3744 }
3745
3746 // Only PR author or repo owner may apply suggestions.
3747 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
3748 return c.redirect(`${backUrl}&error=forbidden`);
3749 }
3750
3751 // Load the comment.
3752 const [comment] = await db
3753 .select()
3754 .from(prComments)
3755 .where(
3756 and(
3757 eq(prComments.id, commentId),
3758 eq(prComments.pullRequestId, pr.id)
3759 )
3760 )
3761 .limit(1);
3762
3763 if (!comment) {
3764 return c.redirect(`${backUrl}&error=comment_not_found`);
3765 }
3766
3767 // Parse suggestion block from comment body.
3768 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
3769 if (!m) {
3770 return c.redirect(`${backUrl}&error=no_suggestion`);
3771 }
3772 const suggestionCode = m[1];
3773
3774 // Get the commenter's details for the commit message co-author line.
3775 const [commenter] = await db
3776 .select()
3777 .from(users)
3778 .where(eq(users.id, comment.authorId))
3779 .limit(1);
3780
3781 // Fetch current file content from head branch.
3782 if (!comment.filePath) {
3783 return c.redirect(`${backUrl}&error=file_not_found`);
3784 }
3785 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
3786 if (!blob) {
3787 return c.redirect(`${backUrl}&error=file_not_found`);
3788 }
3789
3790 // Apply the patch — replace the target line(s) with suggestion lines.
3791 const lines = blob.content.split('\n');
3792 const lineIdx = (comment.lineNumber ?? 1) - 1;
3793 if (lineIdx < 0 || lineIdx >= lines.length) {
3794 return c.redirect(`${backUrl}&error=line_out_of_range`);
3795 }
3796 const suggestionLines = suggestionCode.split('\n');
3797 lines.splice(lineIdx, 1, ...suggestionLines);
3798 const newContent = lines.join('\n');
3799
3800 // Commit the change.
3801 const coAuthorLine = commenter
3802 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
3803 : "";
3804 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
3805
3806 const result = await createOrUpdateFileOnBranch({
3807 owner: ownerName,
3808 name: repoName,
3809 branch: pr.headBranch,
3810 filePath: comment.filePath,
3811 bytes: new TextEncoder().encode(newContent),
3812 message: commitMessage,
3813 authorName: user.username,
3814 authorEmail: `${user.username}@users.noreply.gluecron.com`,
3815 });
3816
3817 if ("error" in result) {
3818 return c.redirect(`${backUrl}&error=apply_failed`);
3819 }
3820
3821 // Post a follow-up comment noting the suggestion was applied.
3822 await db.insert(prComments).values({
3823 pullRequestId: pr.id,
3824 authorId: user.id,
3825 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
3826 });
3827
3828 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
3829 }
3830);
3831
0a67773Claude3832// Formal review — Approve / Request Changes / Comment
3833pulls.post(
3834 "/:owner/:repo/pulls/:number/review",
3835 softAuth,
3836 requireAuth,
3837 requireRepoAccess("read"),
3838 async (c) => {
3839 const { owner: ownerName, repo: repoName } = c.req.param();
3840 const prNum = parseInt(c.req.param("number"), 10);
3841 const user = c.get("user")!;
3842 const body = await c.req.parseBody();
3843 const reviewBody = String(body.body || "").trim();
3844 const reviewState = String(body.review_state || "commented");
3845
3846 const validStates = ["approved", "changes_requested", "commented"];
3847 if (!validStates.includes(reviewState)) {
3848 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3849 }
3850
3851 const resolved = await resolveRepo(ownerName, repoName);
3852 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3853
3854 const [pr] = await db
3855 .select()
3856 .from(pullRequests)
3857 .where(
3858 and(
3859 eq(pullRequests.repositoryId, resolved.repo.id),
3860 eq(pullRequests.number, prNum)
3861 )
3862 )
3863 .limit(1);
3864 if (!pr || pr.state !== "open") {
3865 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3866 }
3867 // Authors can't review their own PR
3868 if (pr.authorId === user.id) {
3869 return c.redirect(
3870 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
3871 );
3872 }
3873
3874 await db.insert(prReviews).values({
3875 pullRequestId: pr.id,
3876 reviewerId: user.id,
3877 state: reviewState,
3878 body: reviewBody || null,
3879 });
3880
3881 const stateLabel =
3882 reviewState === "approved" ? "Approved"
3883 : reviewState === "changes_requested" ? "Changes requested"
3884 : "Commented";
3885 return c.redirect(
3886 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
3887 );
3888 }
3889);
3890
e883329Claude3891// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude3892// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
3893// but we keep it at "write" for v1 so trusted collaborators can ship.
3894// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
3895// surface. Branch-protection rules (evaluated below) are the current mechanism
3896// for locking down merges further on specific branches.
0074234Claude3897pulls.post(
3898 "/:owner/:repo/pulls/:number/merge",
3899 softAuth,
3900 requireAuth,
04f6b7fClaude3901 requireRepoAccess("write"),
0074234Claude3902 async (c) => {
3903 const { owner: ownerName, repo: repoName } = c.req.param();
3904 const prNum = parseInt(c.req.param("number"), 10);
3905 const user = c.get("user")!;
3906
3907 const resolved = await resolveRepo(ownerName, repoName);
3908 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3909
3910 const [pr] = await db
3911 .select()
3912 .from(pullRequests)
3913 .where(
3914 and(
3915 eq(pullRequests.repositoryId, resolved.repo.id),
3916 eq(pullRequests.number, prNum)
3917 )
3918 )
3919 .limit(1);
3920
3921 if (!pr || pr.state !== "open") {
3922 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3923 }
3924
6fc53bdClaude3925 // Draft PRs cannot be merged — must be marked ready first.
3926 if (pr.isDraft) {
3927 return c.redirect(
3928 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3929 "This PR is a draft. Mark it as ready for review before merging."
3930 )}`
3931 );
3932 }
3933
e883329Claude3934 // Resolve head SHA
3935 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3936 if (!headSha) {
3937 return c.redirect(
3938 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
3939 );
3940 }
3941
3942 // Check if AI review approved this PR
3943 const aiComments = await db
3944 .select()
3945 .from(prComments)
3946 .where(
3947 and(
3948 eq(prComments.pullRequestId, pr.id),
3949 eq(prComments.isAiReview, true)
3950 )
3951 );
3952 const aiApproved = aiComments.length === 0 || aiComments.some(
3953 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude3954 );
e883329Claude3955
3956 // Run all green gate checks (GateTest + mergeability + AI review)
3957 const gateResult = await runAllGateChecks(
3958 ownerName,
3959 repoName,
3960 pr.baseBranch,
3961 pr.headBranch,
3962 headSha,
3963 aiApproved
0074234Claude3964 );
3965
e883329Claude3966 // If GateTest or AI review failed (hard blocks), reject the merge
3967 const hardFailures = gateResult.checks.filter(
3968 (check) => !check.passed && check.name !== "Merge check"
3969 );
3970 if (hardFailures.length > 0) {
3971 const errorMsg = hardFailures
3972 .map((f) => `${f.name}: ${f.details}`)
3973 .join("; ");
0074234Claude3974 return c.redirect(
e883329Claude3975 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude3976 );
3977 }
3978
1e162a8Claude3979 // D5 — Branch-protection enforcement. Looks up the matching rule for the
3980 // base branch and blocks the merge if requireAiApproval / requireGreenGates
3981 // / requireHumanReview / requiredApprovals are not satisfied. Independent
3982 // of repo-global settings, so owners can lock specific branches down
3983 // further than the repo default.
3984 const protectionRule = await matchProtection(
3985 resolved.repo.id,
3986 pr.baseBranch
3987 );
3988 if (protectionRule) {
3989 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude3990 const required = await listRequiredChecks(protectionRule.id);
3991 const passingNames = required.length > 0
3992 ? await passingCheckNames(resolved.repo.id, headSha)
3993 : [];
3994 const decision = evaluateProtection(
3995 protectionRule,
3996 {
3997 aiApproved,
3998 humanApprovalCount: humanApprovals,
3999 gateResultGreen: hardFailures.length === 0,
4000 hasFailedGates: hardFailures.length > 0,
4001 passingCheckNames: passingNames,
4002 },
4003 required.map((r) => r.checkName)
4004 );
1e162a8Claude4005 if (!decision.allowed) {
4006 return c.redirect(
4007 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4008 decision.reasons.join(" ")
4009 )}`
4010 );
4011 }
4012 }
4013
e883329Claude4014 // Attempt the merge — with auto conflict resolution if needed
4015 const repoDir = getRepoPath(ownerName, repoName);
4016 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
4017 const hasConflicts = mergeCheck && !mergeCheck.passed;
4018
4019 if (hasConflicts && isAiReviewEnabled()) {
4020 // Use Claude to auto-resolve conflicts
4021 const mergeResult = await mergeWithAutoResolve(
4022 ownerName,
4023 repoName,
4024 pr.baseBranch,
4025 pr.headBranch,
4026 `Merge pull request #${pr.number}: ${pr.title}`
4027 );
4028
4029 if (!mergeResult.success) {
4030 return c.redirect(
4031 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
4032 );
4033 }
4034
4035 // Post a comment about the auto-resolution
4036 if (mergeResult.resolvedFiles.length > 0) {
4037 await db.insert(prComments).values({
4038 pullRequestId: pr.id,
4039 authorId: user.id,
4040 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
4041 isAiReview: true,
4042 });
4043 }
4044 } else {
4045 // Standard merge — fast-forward or clean merge
4046 const ffProc = Bun.spawn(
4047 [
4048 "git",
4049 "update-ref",
4050 `refs/heads/${pr.baseBranch}`,
4051 `refs/heads/${pr.headBranch}`,
4052 ],
4053 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4054 );
4055 const ffExit = await ffProc.exited;
4056
4057 if (ffExit !== 0) {
4058 return c.redirect(
4059 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
4060 );
4061 }
4062 }
4063
0074234Claude4064 await db
4065 .update(pullRequests)
4066 .set({
4067 state: "merged",
4068 mergedAt: new Date(),
4069 mergedBy: user.id,
4070 updatedAt: new Date(),
4071 })
4072 .where(eq(pullRequests.id, pr.id));
4073
8809b87Claude4074 // Chat notifier — fan out merge event to Slack/Discord/Teams.
4075 import("../lib/chat-notifier")
4076 .then((m) =>
4077 m.notifyChatChannels({
4078 ownerUserId: resolved.repo.ownerId,
4079 repositoryId: resolved.repo.id,
4080 event: {
4081 event: "pr.merged",
4082 repo: `${ownerName}/${repoName}`,
4083 title: `#${pr.number} ${pr.title}`,
4084 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
4085 actor: user.username,
4086 },
4087 })
4088 )
4089 .catch((err) =>
4090 console.warn(`[chat-notifier] PR merge notify failed:`, err)
4091 );
4092
d62fb36Claude4093 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
4094 // and auto-close each matching open issue with a back-link comment. Bounded
4095 // to the same repo for v1 (cross-repo refs ignored). Failures never block
4096 // the merge redirect.
4097 try {
4098 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
4099 const refs = extractClosingRefsMulti([pr.title, pr.body]);
4100 for (const n of refs) {
4101 const [issue] = await db
4102 .select()
4103 .from(issues)
4104 .where(
4105 and(
4106 eq(issues.repositoryId, resolved.repo.id),
4107 eq(issues.number, n)
4108 )
4109 )
4110 .limit(1);
4111 if (!issue || issue.state !== "open") continue;
4112 await db
4113 .update(issues)
4114 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
4115 .where(eq(issues.id, issue.id));
4116 await db.insert(issueComments).values({
4117 issueId: issue.id,
4118 authorId: user.id,
4119 body: `Closed by pull request #${pr.number}.`,
4120 });
4121 }
4122 } catch {
4123 // Never block the merge on close-keyword failures.
4124 }
4125
0074234Claude4126 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4127 }
4128);
4129
6fc53bdClaude4130// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
4131// hasn't run yet on this PR.
4132pulls.post(
4133 "/:owner/:repo/pulls/:number/ready",
4134 softAuth,
4135 requireAuth,
04f6b7fClaude4136 requireRepoAccess("write"),
6fc53bdClaude4137 async (c) => {
4138 const { owner: ownerName, repo: repoName } = c.req.param();
4139 const prNum = parseInt(c.req.param("number"), 10);
4140 const user = c.get("user")!;
4141
4142 const resolved = await resolveRepo(ownerName, repoName);
4143 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4144
4145 const [pr] = await db
4146 .select()
4147 .from(pullRequests)
4148 .where(
4149 and(
4150 eq(pullRequests.repositoryId, resolved.repo.id),
4151 eq(pullRequests.number, prNum)
4152 )
4153 )
4154 .limit(1);
4155 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4156
4157 // Only the author or repo owner can toggle draft state.
4158 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
4159 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4160 }
4161
4162 if (pr.state === "open" && pr.isDraft) {
4163 await db
4164 .update(pullRequests)
4165 .set({ isDraft: false, updatedAt: new Date() })
4166 .where(eq(pullRequests.id, pr.id));
4167
4168 if (isAiReviewEnabled()) {
4169 triggerAiReview(
4170 ownerName,
4171 repoName,
4172 pr.id,
4173 pr.title,
0316dbbClaude4174 pr.body || "",
6fc53bdClaude4175 pr.baseBranch,
4176 pr.headBranch
4177 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
4178 }
4179 }
4180
4181 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4182 }
4183);
4184
4185// Convert a PR back to draft.
4186pulls.post(
4187 "/:owner/:repo/pulls/:number/draft",
4188 softAuth,
4189 requireAuth,
04f6b7fClaude4190 requireRepoAccess("write"),
6fc53bdClaude4191 async (c) => {
4192 const { owner: ownerName, repo: repoName } = c.req.param();
4193 const prNum = parseInt(c.req.param("number"), 10);
4194 const user = c.get("user")!;
4195
4196 const resolved = await resolveRepo(ownerName, repoName);
4197 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4198
4199 const [pr] = await db
4200 .select()
4201 .from(pullRequests)
4202 .where(
4203 and(
4204 eq(pullRequests.repositoryId, resolved.repo.id),
4205 eq(pullRequests.number, prNum)
4206 )
4207 )
4208 .limit(1);
4209 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4210
4211 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
4212 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4213 }
4214
4215 if (pr.state === "open" && !pr.isDraft) {
4216 await db
4217 .update(pullRequests)
4218 .set({ isDraft: true, updatedAt: new Date() })
4219 .where(eq(pullRequests.id, pr.id));
4220 }
4221
4222 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4223 }
4224);
4225
0074234Claude4226// Close PR
4227pulls.post(
4228 "/:owner/:repo/pulls/:number/close",
4229 softAuth,
4230 requireAuth,
04f6b7fClaude4231 requireRepoAccess("write"),
0074234Claude4232 async (c) => {
4233 const { owner: ownerName, repo: repoName } = c.req.param();
4234 const prNum = parseInt(c.req.param("number"), 10);
4235
4236 const resolved = await resolveRepo(ownerName, repoName);
4237 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4238
4239 await db
4240 .update(pullRequests)
4241 .set({
4242 state: "closed",
4243 closedAt: new Date(),
4244 updatedAt: new Date(),
4245 })
4246 .where(
4247 and(
4248 eq(pullRequests.repositoryId, resolved.repo.id),
4249 eq(pullRequests.number, prNum)
4250 )
4251 );
4252
4253 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4254 }
4255);
4256
c3e0c07Claude4257// Re-run AI review on demand (e.g. after a force-push). Bypasses the
4258// idempotency marker via { force: true }. Write-access only.
4259pulls.post(
4260 "/:owner/:repo/pulls/:number/ai-rereview",
4261 softAuth,
4262 requireAuth,
4263 requireRepoAccess("write"),
4264 async (c) => {
4265 const { owner: ownerName, repo: repoName } = c.req.param();
4266 const prNum = parseInt(c.req.param("number"), 10);
4267 const resolved = await resolveRepo(ownerName, repoName);
4268 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4269
4270 const [pr] = await db
4271 .select()
4272 .from(pullRequests)
4273 .where(
4274 and(
4275 eq(pullRequests.repositoryId, resolved.repo.id),
4276 eq(pullRequests.number, prNum)
4277 )
4278 )
4279 .limit(1);
4280 if (!pr) {
4281 return c.redirect(`/${ownerName}/${repoName}/pulls`);
4282 }
4283
4284 if (!isAiReviewEnabled()) {
4285 return c.redirect(
4286 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4287 "AI review is not configured (ANTHROPIC_API_KEY)."
4288 )}`
4289 );
4290 }
4291
4292 // Fire-and-forget but with { force: true } to bypass the
4293 // already-reviewed marker. The function still never throws.
4294 triggerAiReview(
4295 ownerName,
4296 repoName,
4297 pr.id,
4298 pr.title || "",
4299 pr.body || "",
4300 pr.baseBranch,
4301 pr.headBranch,
4302 { force: true }
a28cedeClaude4303 ).catch((err) => {
4304 console.warn(
4305 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
4306 err instanceof Error ? err.message : err
4307 );
4308 });
c3e0c07Claude4309
4310 return c.redirect(
4311 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
4312 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
4313 )}`
4314 );
4315 }
4316);
4317
1d4ff60Claude4318// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
4319// the PR's head branch carrying just the new test files. Write-access only.
4320// Idempotent — if `ai:added-tests` was previously applied we redirect with
4321// an `info` banner instead of re-firing.
4322pulls.post(
4323 "/:owner/:repo/pulls/:number/generate-tests",
4324 softAuth,
4325 requireAuth,
4326 requireRepoAccess("write"),
4327 async (c) => {
4328 const { owner: ownerName, repo: repoName } = c.req.param();
4329 const prNum = parseInt(c.req.param("number"), 10);
4330 const resolved = await resolveRepo(ownerName, repoName);
4331 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4332
4333 const [pr] = await db
4334 .select()
4335 .from(pullRequests)
4336 .where(
4337 and(
4338 eq(pullRequests.repositoryId, resolved.repo.id),
4339 eq(pullRequests.number, prNum)
4340 )
4341 )
4342 .limit(1);
4343 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4344
4345 if (!isAiReviewEnabled()) {
4346 return c.redirect(
4347 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4348 "AI test generation is not configured (ANTHROPIC_API_KEY)."
4349 )}`
4350 );
4351 }
4352
4353 // Fire-and-forget. The lib never throws.
4354 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
4355 .then((res) => {
4356 if (!res.ok) {
4357 console.warn(
4358 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
4359 );
4360 }
4361 })
4362 .catch((err) => {
4363 console.warn(
4364 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
4365 err instanceof Error ? err.message : err
4366 );
4367 });
4368
4369 return c.redirect(
4370 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
4371 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
4372 )}`
4373 );
4374 }
4375);
4376
0074234Claude4377export default pulls;