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.tsxBlame4492 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
a164a6dClaude747 /* Merge strategy selector */
748 .prs-merge-strategy-wrap {
749 display: inline-flex; align-items: center;
750 background: var(--bg-elevated);
751 border: 1px solid var(--border);
752 border-radius: 10px;
753 overflow: hidden;
754 }
755 .prs-merge-strategy-label {
756 font-size: 11.5px; font-weight: 600;
757 color: var(--text-muted);
758 padding: 0 10px 0 12px;
759 white-space: nowrap;
760 }
761 .prs-merge-strategy-select {
762 background: transparent;
763 border: none;
764 color: var(--text);
765 font-size: 13px;
766 padding: 7px 10px 7px 4px;
767 cursor: pointer;
768 outline: none;
769 appearance: auto;
770 }
771 .prs-merge-strategy-select:focus { outline: 2px solid rgba(140,109,255,0.45); }
772
0a67773Claude773 /* Review summary banner */
774 .prs-review-summary {
775 display: flex; flex-direction: column; gap: 6px;
776 padding: 12px 16px;
777 background: var(--bg-elevated);
778 border: 1px solid var(--border);
779 border-radius: var(--r-md, 8px);
780 margin-bottom: 12px;
781 }
782 .prs-review-row {
783 display: flex; align-items: center; gap: 10px;
784 font-size: 13px;
785 }
786 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
787 .prs-review-approved .prs-review-icon { color: #34d399; }
788 .prs-review-changes .prs-review-icon { color: #f87171; }
789
790 /* Review action buttons */
791 .prs-review-approve-btn {
792 display: inline-flex; align-items: center; gap: 5px;
793 padding: 8px 14px; border-radius: 8px; font-size: 13px;
794 font-weight: 600; cursor: pointer;
795 background: rgba(52,211,153,0.12);
796 color: #34d399;
797 border: 1px solid rgba(52,211,153,0.35);
798 transition: background 120ms;
799 }
800 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
801 .prs-review-changes-btn {
802 display: inline-flex; align-items: center; gap: 5px;
803 padding: 8px 14px; border-radius: 8px; font-size: 13px;
804 font-weight: 600; cursor: pointer;
805 background: rgba(248,113,113,0.10);
806 color: #f87171;
807 border: 1px solid rgba(248,113,113,0.30);
808 transition: background 120ms;
809 }
810 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
811
b078860Claude812 /* Inline form helpers */
813 .prs-inline-form { display: inline-flex; }
814
815 /* Comment composer */
816 .prs-composer { margin-top: 22px; }
817 .prs-composer textarea {
818 border-radius: 12px;
819 }
820
821 @media (max-width: 720px) {
822 .prs-detail-actions { margin-left: 0; }
823 .prs-merge-actions { width: 100%; }
824 .prs-merge-actions > * { flex: 1; min-width: 0; }
825 }
f1dc7c7Claude826
827 /* Additional mobile rules. Additive only. */
828 @media (max-width: 720px) {
829 .prs-detail-hero { padding: 18px; }
830 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
831 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
832 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
833 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
834 .prs-gate-name { min-width: 0; }
835 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
836 .prs-gate-summary { margin-left: 0; }
837 .prs-merge-btn,
838 .prs-merge-ready-btn,
839 .prs-merge-back-draft { min-height: 44px; }
840 .prs-comment-body { padding: 12px 14px; }
841 .prs-comment-head { padding: 10px 12px; }
842 .prs-files-card { padding: 12px 14px; }
843 }
3c03977Claude844
845 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
846 .live-pill {
847 display: inline-flex;
848 align-items: center;
849 gap: 8px;
850 padding: 4px 10px 4px 8px;
851 margin-left: 6px;
852 background: var(--bg-elevated);
853 border: 1px solid var(--border);
854 border-radius: 9999px;
855 font-size: 12px;
856 color: var(--text-muted);
857 line-height: 1;
858 vertical-align: middle;
859 }
860 .live-pill.is-busy { color: var(--text); }
861 .live-pill-dot {
862 width: 8px; height: 8px;
863 border-radius: 9999px;
864 background: #34d399;
865 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
866 animation: live-pulse 1.6s ease-in-out infinite;
867 }
868 @keyframes live-pulse {
869 0%, 100% { opacity: 1; }
870 50% { opacity: 0.55; }
871 }
872 .live-avatars {
873 display: inline-flex;
874 margin-left: 2px;
875 }
876 .live-avatar {
877 display: inline-flex;
878 align-items: center;
879 justify-content: center;
880 width: 22px; height: 22px;
881 border-radius: 9999px;
882 font-size: 10px;
883 font-weight: 700;
884 color: #0b1020;
885 margin-left: -6px;
886 border: 2px solid var(--bg-elevated);
887 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
888 }
889 .live-avatar:first-child { margin-left: 0; }
890 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
891 .live-cursor-host {
892 position: relative;
893 }
894 .live-cursor-overlay {
895 position: absolute;
896 inset: 0;
897 pointer-events: none;
898 overflow: hidden;
899 border-radius: inherit;
900 }
901 .live-cursor {
902 position: absolute;
903 width: 2px;
904 height: 18px;
905 border-radius: 2px;
906 transform: translate(-1px, 0);
907 transition: transform 80ms linear, opacity 200ms ease;
908 }
909 .live-cursor::after {
910 content: attr(data-label);
911 position: absolute;
912 top: -16px;
913 left: -2px;
914 font-size: 10px;
915 line-height: 1;
916 color: #0b1020;
917 background: inherit;
918 padding: 2px 5px;
919 border-radius: 4px 4px 4px 0;
920 white-space: nowrap;
921 font-weight: 600;
922 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
923 }
924 .live-cursor.is-idle { opacity: 0.4; }
925 .live-edit-tag {
926 display: inline-block;
927 margin-left: 6px;
928 padding: 1px 6px;
929 font-size: 10px;
930 font-weight: 600;
931 letter-spacing: 0.02em;
932 color: #0b1020;
933 border-radius: 9999px;
934 }
15db0e0Claude935
936 /* ─── Slash-command pill + composer hint ─── */
937 .slash-hint {
938 display: inline-flex;
939 align-items: center;
940 gap: 6px;
941 margin-top: 6px;
942 padding: 3px 9px;
943 font-size: 11.5px;
944 color: var(--text-muted);
945 background: var(--bg-elevated);
946 border: 1px dashed var(--border);
947 border-radius: 9999px;
948 width: fit-content;
949 }
950 .slash-hint code {
951 background: rgba(110, 168, 255, 0.12);
952 color: var(--text-strong);
953 padding: 0 5px;
954 border-radius: 4px;
955 font-size: 11px;
956 }
957 .slash-pill {
958 display: grid;
959 grid-template-columns: auto 1fr auto;
960 align-items: center;
961 column-gap: 10px;
962 row-gap: 6px;
963 margin: 10px 0;
964 padding: 10px 14px;
965 background: linear-gradient(
966 135deg,
967 rgba(110, 168, 255, 0.08),
968 rgba(163, 113, 247, 0.06)
969 );
970 border: 1px solid rgba(110, 168, 255, 0.32);
971 border-left: 3px solid var(--accent, #6ea8ff);
972 border-radius: var(--radius);
973 font-size: 13px;
974 color: var(--text);
975 }
976 .slash-pill-icon {
977 font-size: 14px;
978 line-height: 1;
979 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
980 }
981 .slash-pill-actor { color: var(--text-muted); }
982 .slash-pill-actor strong { color: var(--text-strong); }
983 .slash-pill-cmd {
984 background: rgba(110, 168, 255, 0.16);
985 color: var(--text-strong);
986 padding: 1px 6px;
987 border-radius: 4px;
988 font-size: 12.5px;
989 }
990 .slash-pill-time {
991 color: var(--text-muted);
992 font-size: 12px;
993 justify-self: end;
994 }
995 .slash-pill-body {
996 grid-column: 1 / -1;
997 color: var(--text);
998 font-size: 13px;
999 line-height: 1.55;
1000 }
1001 .slash-pill-body p:first-child { margin-top: 0; }
1002 .slash-pill-body p:last-child { margin-bottom: 0; }
1003 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
1004 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1005 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
1006 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude1007
1008 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
1009 .preview-prpill {
1010 display: inline-flex; align-items: center; gap: 6px;
1011 padding: 3px 10px;
1012 border-radius: 9999px;
1013 font-family: var(--font-mono);
1014 font-size: 11.5px;
1015 font-weight: 600;
1016 background: rgba(255,255,255,0.04);
1017 color: var(--text-muted);
1018 text-decoration: none;
1019 border: 1px solid var(--border);
1020 }
1021 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
1022 .preview-prpill .preview-prpill-dot {
1023 width: 7px; height: 7px;
1024 border-radius: 9999px;
1025 background: currentColor;
1026 }
1027 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1028 .preview-prpill.is-building .preview-prpill-dot {
1029 animation: previewPrPulse 1.4s ease-in-out infinite;
1030 }
1031 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1032 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1033 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1034 @keyframes previewPrPulse {
1035 0%, 100% { opacity: 1; }
1036 50% { opacity: 0.4; }
1037 }
79ed944Claude1038
1039 /* ─── AI Trio Review — 3-column verdict cards ─── */
1040 .trio-wrap {
1041 margin-top: 18px;
1042 padding: 16px;
1043 background: var(--bg-elevated);
1044 border: 1px solid var(--border);
1045 border-radius: 14px;
1046 }
1047 .trio-header {
1048 display: flex; align-items: center; gap: 10px;
1049 margin: 0 0 12px;
1050 font-size: 13.5px;
1051 color: var(--text);
1052 }
1053 .trio-header strong { color: var(--text-strong); }
1054 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1055 .trio-header-dot {
1056 width: 8px; height: 8px; border-radius: 9999px;
1057 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1058 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1059 }
1060 .trio-grid {
1061 display: grid;
1062 grid-template-columns: repeat(3, minmax(0, 1fr));
1063 gap: 12px;
1064 }
1065 .trio-card {
1066 background: var(--bg-secondary);
1067 border: 1px solid var(--border);
1068 border-radius: 12px;
1069 overflow: hidden;
1070 display: flex; flex-direction: column;
1071 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1072 }
1073 .trio-card-head {
1074 display: flex; align-items: center; gap: 8px;
1075 padding: 10px 12px;
1076 border-bottom: 1px solid var(--border);
1077 background: rgba(255,255,255,0.02);
1078 font-size: 13px;
1079 }
1080 .trio-card-icon {
1081 display: inline-flex; align-items: center; justify-content: center;
1082 width: 22px; height: 22px;
1083 border-radius: 9999px;
1084 font-size: 12px;
1085 background: rgba(255,255,255,0.05);
1086 }
1087 .trio-card-title {
1088 color: var(--text-strong);
1089 font-weight: 600;
1090 letter-spacing: 0.01em;
1091 }
1092 .trio-card-verdict {
1093 margin-left: auto;
1094 font-size: 11px;
1095 font-weight: 700;
1096 letter-spacing: 0.06em;
1097 text-transform: uppercase;
1098 padding: 3px 9px;
1099 border-radius: 9999px;
1100 background: var(--bg-tertiary);
1101 color: var(--text-muted);
1102 border: 1px solid var(--border-strong);
1103 }
1104 .trio-card-body {
1105 padding: 12px 14px;
1106 font-size: 13px;
1107 color: var(--text);
1108 flex: 1;
1109 min-height: 64px;
1110 line-height: 1.55;
1111 }
1112 .trio-card-body p { margin: 0 0 8px; }
1113 .trio-card-body p:last-child { margin-bottom: 0; }
1114 .trio-card-body ul { margin: 0; padding-left: 18px; }
1115 .trio-card-body code {
1116 font-family: var(--font-mono);
1117 font-size: 12px;
1118 background: var(--bg-tertiary);
1119 padding: 1px 6px;
1120 border-radius: 5px;
1121 }
1122 .trio-card-empty {
1123 color: var(--text-muted);
1124 font-style: italic;
1125 font-size: 12.5px;
1126 }
1127
1128 /* Pass state — neutral, no accent. */
1129 .trio-card.is-pass .trio-card-verdict {
1130 color: var(--green);
1131 border-color: rgba(52,211,153,0.35);
1132 background: rgba(52,211,153,0.12);
1133 }
1134
1135 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1136 .trio-card.trio-security.is-fail {
1137 border-color: rgba(248,113,113,0.55);
1138 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1139 }
1140 .trio-card.trio-security.is-fail .trio-card-head {
1141 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1142 border-bottom-color: rgba(248,113,113,0.30);
1143 }
1144 .trio-card.trio-security.is-fail .trio-card-verdict {
1145 color: #fecaca;
1146 border-color: rgba(248,113,113,0.55);
1147 background: rgba(248,113,113,0.20);
1148 }
1149
1150 .trio-card.trio-correctness.is-fail {
1151 border-color: rgba(251,191,36,0.55);
1152 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1153 }
1154 .trio-card.trio-correctness.is-fail .trio-card-head {
1155 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1156 border-bottom-color: rgba(251,191,36,0.30);
1157 }
1158 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1159 color: #fde68a;
1160 border-color: rgba(251,191,36,0.55);
1161 background: rgba(251,191,36,0.20);
1162 }
1163
1164 .trio-card.trio-style.is-fail {
1165 border-color: rgba(96,165,250,0.55);
1166 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1167 }
1168 .trio-card.trio-style.is-fail .trio-card-head {
1169 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1170 border-bottom-color: rgba(96,165,250,0.30);
1171 }
1172 .trio-card.trio-style.is-fail .trio-card-verdict {
1173 color: #bfdbfe;
1174 border-color: rgba(96,165,250,0.55);
1175 background: rgba(96,165,250,0.20);
1176 }
1177
1178 /* Disagreement callout strip — yellow, prominent. */
1179 .trio-disagreement-strip {
1180 display: flex;
1181 gap: 12px;
1182 margin-top: 14px;
1183 padding: 12px 14px;
1184 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1185 border: 1px solid rgba(251,191,36,0.45);
1186 border-radius: 10px;
1187 color: var(--text);
1188 font-size: 13px;
1189 }
1190 .trio-disagreement-icon {
1191 flex: 0 0 auto;
1192 width: 26px; height: 26px;
1193 display: inline-flex; align-items: center; justify-content: center;
1194 border-radius: 9999px;
1195 background: rgba(251,191,36,0.25);
1196 color: #fde68a;
1197 font-size: 14px;
1198 }
1199 .trio-disagreement-body strong {
1200 display: block;
1201 color: #fde68a;
1202 margin: 0 0 4px;
1203 font-weight: 700;
1204 }
1205 .trio-disagreement-list {
1206 margin: 0;
1207 padding-left: 18px;
1208 color: var(--text);
1209 font-size: 12.5px;
1210 line-height: 1.55;
1211 }
1212 .trio-disagreement-list code {
1213 font-family: var(--font-mono);
1214 font-size: 11.5px;
1215 background: var(--bg-tertiary);
1216 padding: 1px 5px;
1217 border-radius: 4px;
1218 }
1219
1220 @media (max-width: 720px) {
1221 .trio-grid { grid-template-columns: 1fr; }
1222 .trio-wrap { padding: 12px; }
1223 }
b078860Claude1224`;
1225
81c73c1Claude1226/**
1227 * Tiny inline JS that drives the "Suggest description with AI" button.
1228 * On click, gathers form values, POSTs JSON to the given endpoint, and
1229 * pipes the response into the #pr-body textarea. All DOM lookups are
1230 * defensive — element absence is a silent no-op.
1231 *
1232 * Built as a string template so it lives next to its server-side caller
1233 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1234 * to avoid </script> breakouts.
1235 */
1236function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1237 const url = JSON.stringify(endpointUrl)
1238 .split("<").join("\\u003C")
1239 .split(">").join("\\u003E")
1240 .split("&").join("\\u0026");
1241 return (
1242 "(function(){try{" +
1243 "var btn=document.getElementById('ai-suggest-desc');" +
1244 "var status=document.getElementById('ai-suggest-status');" +
1245 "var body=document.getElementById('pr-body');" +
1246 "var form=btn&&btn.closest&&btn.closest('form');" +
1247 "if(!btn||!body||!form)return;" +
1248 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1249 "var fd=new FormData(form);" +
1250 "var title=String(fd.get('title')||'').trim();" +
1251 "var base=String(fd.get('base')||'').trim();" +
1252 "var head=String(fd.get('head')||'').trim();" +
1253 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1254 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1255 "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'})" +
1256 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1257 ".then(function(j){btn.disabled=false;" +
1258 "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;}}" +
1259 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1260 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1261 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1262 "});" +
1263 "}catch(e){}})();"
1264 );
1265}
1266
3c03977Claude1267/**
1268 * Live co-editing client. Connects to the per-PR SSE feed and:
1269 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1270 * status colour per user).
1271 * - Renders tinted cursor caret overlays inside #pr-body and every
1272 * `[data-live-field]` element.
1273 * - Broadcasts the local user's cursor position (selectionStart /
1274 * selectionEnd) debounced at 100ms.
1275 * - Broadcasts content patches (`replace` of the whole textarea —
1276 * last-write-wins v1) debounced at 250ms.
1277 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1278 * to the matching local field if untouched.
1279 *
1280 * All endpoint URLs are JSON-escaped via safe replacements so they
1281 * can't break out of the <script> tag.
1282 */
1283function LIVE_COEDIT_SCRIPT(prId: string): string {
1284 const idJson = JSON.stringify(prId)
1285 .split("<").join("\\u003C")
1286 .split(">").join("\\u003E")
1287 .split("&").join("\\u0026");
1288 return (
1289 "(function(){try{" +
1290 "if(typeof EventSource==='undefined')return;" +
1291 "var prId=" + idJson + ";" +
1292 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1293 "var pill=document.getElementById('live-pill');" +
1294 "var avEl=document.getElementById('live-avatars');" +
1295 "var countEl=document.getElementById('live-count');" +
1296 "var sessionId=null;var myColor=null;" +
1297 "var presence={};" + // sessionId -> {color,status,userId,initials}
1298 "var lastApplied={};" + // field -> last server value (for echo suppression)
1299 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1300 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1301 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1302 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1303 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1304 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1305 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1306 "avEl.innerHTML=html;}}" +
1307 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1308 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1309 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1310 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1311 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1312 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1313 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1314 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1315 "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);}" +
1316 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1317 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1318 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1319 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1320 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1321 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1322 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1323 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1324 "}catch(e){}" +
1325 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1326 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1327 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1328 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1329 "var es;var delay=1000;" +
1330 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1331 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1332 "(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){}});" +
1333 "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){}});" +
1334 "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){}});" +
1335 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1336 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1337 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1338 "var patch=d.patch;if(!patch||!patch.field)return;" +
1339 "var ta=fieldEl(patch.field);if(!ta)return;" +
1340 "if(document.activeElement===ta)return;" + // don't trample local typing
1341 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1342 "}catch(e){}});" +
1343 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1344 "}connect();" +
1345 "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){}}" +
1346 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1347 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1348 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1349 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1350 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1351 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1352 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1353 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1354 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1355 "}" +
1356 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1357 "var live=document.querySelectorAll('[data-live-field]');" +
1358 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1359 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1360 "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){}});" +
1361 "}catch(e){}})();"
1362 );
1363}
1364
0074234Claude1365async function resolveRepo(ownerName: string, repoName: string) {
1366 const [owner] = await db
1367 .select()
1368 .from(users)
1369 .where(eq(users.username, ownerName))
1370 .limit(1);
1371 if (!owner) return null;
1372 const [repo] = await db
1373 .select()
1374 .from(repositories)
1375 .where(
1376 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1377 )
1378 .limit(1);
1379 if (!repo) return null;
1380 return { owner, repo };
1381}
1382
1383// PR Nav helper
1384const PrNav = ({
1385 owner,
1386 repo,
1387 active,
1388}: {
1389 owner: string;
1390 repo: string;
1391 active: "code" | "issues" | "pulls" | "commits";
1392}) => (
bb0f894Claude1393 <TabNav
1394 tabs={[
1395 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1396 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1397 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1398 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1399 ]}
1400 />
0074234Claude1401);
1402
534f04aClaude1403/**
1404 * Block M3 — pre-merge risk score card. Pure presentational helper.
1405 * Rendered in the conversation tab above the gate checks block. Hidden
1406 * entirely when the PR is closed/merged or there is nothing cached and
1407 * nothing in-flight.
1408 */
1409function PrRiskCard({
1410 risk,
1411 calculating,
1412}: {
1413 risk: PrRiskScore | null;
1414 calculating: boolean;
1415}) {
1416 if (!risk) {
1417 return (
1418 <div
1419 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1420 >
1421 <strong style="font-size: 13px; color: var(--text)">
1422 Risk score: calculating…
1423 </strong>
1424 <div style="font-size: 12px; margin-top: 4px">
1425 Refresh in a moment to see the pre-merge risk score for this PR.
1426 </div>
1427 </div>
1428 );
1429 }
1430
1431 const palette = riskBandPalette(risk.band);
1432 const label = riskBandLabel(risk.band);
1433
1434 return (
1435 <div
1436 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1437 >
1438 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1439 <strong>Risk score:</strong>
1440 <span style={`color:${palette.border};font-weight:600`}>
1441 {palette.icon} {label} ({risk.score}/10)
1442 </span>
1443 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1444 {risk.commitSha.slice(0, 7)}
1445 </span>
1446 </div>
1447 {risk.aiSummary && (
1448 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1449 {risk.aiSummary}
1450 </div>
1451 )}
1452 <details style="margin-top:10px">
1453 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1454 See full signal breakdown
1455 </summary>
1456 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1457 <li>files changed: {risk.signals.filesChanged}</li>
1458 <li>
1459 lines added/removed: {risk.signals.linesAdded} /{" "}
1460 {risk.signals.linesRemoved}
1461 </li>
1462 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1463 <li>
1464 schema migration touched:{" "}
1465 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1466 </li>
1467 <li>
1468 locked / sensitive path touched:{" "}
1469 {risk.signals.lockedPathTouched ? "yes" : "no"}
1470 </li>
1471 <li>
1472 adds new dependency:{" "}
1473 {risk.signals.addsNewDependency ? "yes" : "no"}
1474 </li>
1475 <li>
1476 bumps major dependency:{" "}
1477 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1478 </li>
1479 <li>
1480 tests added for new code:{" "}
1481 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1482 </li>
1483 <li>
1484 diff-minus-test ratio:{" "}
1485 {risk.signals.diffMinusTestRatio.toFixed(2)}
1486 </li>
1487 </ul>
1488 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1489 How is this calculated? The score is a transparent sum of
1490 weighted signals — see <code>src/lib/pr-risk.ts</code>
1491 {" "}<code>computePrRiskScore</code>.
1492 </div>
1493 </details>
1494 {calculating && (
1495 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1496 (recomputing for the latest commit — refresh to update)
1497 </div>
1498 )}
1499 </div>
1500 );
1501}
1502
1503function riskBandPalette(band: PrRiskScore["band"]): {
1504 border: string;
1505 icon: string;
1506} {
1507 switch (band) {
1508 case "low":
1509 return { border: "var(--green)", icon: "" };
1510 case "medium":
1511 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1512 case "high":
1513 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1514 case "critical":
1515 return { border: "var(--red)", icon: "\u{1F6D1}" };
1516 }
1517}
1518
1519function riskBandLabel(band: PrRiskScore["band"]): string {
1520 switch (band) {
1521 case "low":
1522 return "LOW";
1523 case "medium":
1524 return "MEDIUM";
1525 case "high":
1526 return "HIGH";
1527 case "critical":
1528 return "CRITICAL";
1529 }
1530}
1531
422a2d4Claude1532// ---------------------------------------------------------------------------
1533// AI Trio Review — 3-column card grid + disagreement callout.
1534//
1535// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
1536// per run: one per persona (security/correctness/style) plus a top-level
1537// summary. We surface them here as a single grid above the normal
1538// comment stream so reviewers see the verdicts at a glance.
1539// ---------------------------------------------------------------------------
1540
1541const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
1542
1543interface TrioCommentLike {
1544 body: string;
1545}
1546
1547function isTrioComment(body: string | null | undefined): boolean {
1548 if (!body) return false;
1549 return (
1550 body.includes(TRIO_SUMMARY_MARKER) ||
1551 body.includes(TRIO_COMMENT_MARKER.security) ||
1552 body.includes(TRIO_COMMENT_MARKER.correctness) ||
1553 body.includes(TRIO_COMMENT_MARKER.style)
1554 );
1555}
1556
1557function trioPersonaOfComment(body: string): TrioPersona | null {
1558 for (const p of TRIO_PERSONAS) {
1559 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
1560 }
1561 return null;
1562}
1563
1564/**
1565 * Best-effort verdict parse from a persona comment body. The body shape
1566 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
1567 * we only need the "Pass" / "Fail" word from the H2 heading.
1568 */
1569function trioVerdictOfBody(body: string): "pass" | "fail" | null {
1570 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
1571 if (!m) return null;
1572 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
1573}
1574
1575/**
1576 * Parse the disagreement bullet list out of the summary comment so we
1577 * can render it as a polished callout strip. Returns [] when nothing
1578 * matches — the comment author may have edited the marker out.
1579 */
1580function parseDisagreements(summaryBody: string): Array<{
1581 file: string;
1582 failing: string;
1583 passing: string;
1584}> {
1585 const out: Array<{ file: string; failing: string; passing: string }> = [];
1586 // Each disagreement line looks like:
1587 // - `path:42` — security, style say ✗, correctness say ✓
1588 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
1589 let m: RegExpExecArray | null;
1590 while ((m = re.exec(summaryBody)) !== null) {
1591 out.push({
1592 file: m[1].trim(),
1593 failing: m[2].trim().replace(/[,\s]+$/g, ""),
1594 passing: m[3].trim().replace(/[,\s]+$/g, ""),
1595 });
1596 }
1597 return out;
1598}
1599
1600function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
1601 // Find the most recent persona comments + summary. We iterate from
1602 // the end so re-reviews (multiple runs on the same PR) display the
1603 // freshest verdict.
1604 const latest: Partial<Record<TrioPersona, string>> = {};
1605 let summaryBody: string | null = null;
1606 for (let i = comments.length - 1; i >= 0; i--) {
1607 const body = comments[i].body || "";
1608 if (!isTrioComment(body)) continue;
1609 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
1610 summaryBody = body;
1611 continue;
1612 }
1613 const persona = trioPersonaOfComment(body);
1614 if (persona && !latest[persona]) latest[persona] = body;
1615 }
1616 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1617 if (!anyPersona && !summaryBody) return null;
1618
1619 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
1620
1621 return (
1622 <div class="trio-wrap">
1623 <div class="trio-header">
1624 <span class="trio-header-dot" aria-hidden="true"></span>
1625 <strong>AI Trio Review</strong>
1626 <span class="trio-header-sub">
1627 Three independent reviewers ran in parallel.
1628 </span>
1629 </div>
1630 <div class="trio-grid">
1631 {TRIO_PERSONAS.map((persona) => {
1632 const body = latest[persona];
1633 const verdict = body ? trioVerdictOfBody(body) : null;
1634 const stateClass =
1635 verdict === "fail"
1636 ? "is-fail"
1637 : verdict === "pass"
1638 ? "is-pass"
1639 : "is-pending";
1640 return (
1641 <div class={`trio-card trio-${persona} ${stateClass}`}>
1642 <div class="trio-card-head">
1643 <span class="trio-card-icon" aria-hidden="true">
1644 {persona === "security"
1645 ? "🛡"
1646 : persona === "correctness"
1647 ? "✓"
1648 : "✎"}
1649 </span>
1650 <strong class="trio-card-title">
1651 {persona[0].toUpperCase() + persona.slice(1)}
1652 </strong>
1653 <span class="trio-card-verdict">
1654 {verdict === "pass"
1655 ? "Pass"
1656 : verdict === "fail"
1657 ? "Fail"
1658 : "Pending"}
1659 </span>
1660 </div>
1661 <div class="trio-card-body">
1662 {body ? (
1663 <MarkdownContent
1664 html={renderMarkdown(stripTrioHeading(body))}
1665 />
1666 ) : (
1667 <span class="trio-card-empty">
1668 Awaiting reviewer output.
1669 </span>
1670 )}
1671 </div>
1672 </div>
1673 );
1674 })}
1675 </div>
1676 {disagreements.length > 0 && (
1677 <div class="trio-disagreement-strip" role="note">
1678 <span class="trio-disagreement-icon" aria-hidden="true">
1679
1680 </span>
1681 <div class="trio-disagreement-body">
1682 <strong>Reviewers disagree — review carefully.</strong>
1683 <ul class="trio-disagreement-list">
1684 {disagreements.map((d) => (
1685 <li>
1686 <code>{d.file}</code> — {d.failing} says ✗,{" "}
1687 {d.passing} says ✓
1688 </li>
1689 ))}
1690 </ul>
1691 </div>
1692 </div>
1693 )}
1694 </div>
1695 );
1696}
1697
1698/**
1699 * Strip the marker comment + first H2 heading from a persona body so
1700 * the card body shows just the findings list (verdict is already in
1701 * the card head). Best-effort — malformed bodies render whole.
1702 */
1703function stripTrioHeading(body: string): string {
1704 return body
1705 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
1706 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
1707 .trim();
1708}
1709
0074234Claude1710// List PRs
04f6b7fClaude1711pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1712 const { owner: ownerName, repo: repoName } = c.req.param();
1713 const user = c.get("user");
1714 const state = c.req.query("state") || "open";
1715
ea9ed4cClaude1716 // ── Loading skeleton (flag-gated) ──
1717 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
1718 // the user see the page structure before counts + select resolve.
1719 // Behind a flag for now — we don't ship flashes.
1720 if (c.req.query("skeleton") === "1") {
1721 return c.html(
1722 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1723 <RepoHeader owner={ownerName} repo={repoName} />
1724 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1725 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1726 <style
1727 dangerouslySetInnerHTML={{
1728 __html: `
1729 .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; }
1730 @keyframes prsSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1731 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
1732 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
1733 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
1734 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
1735 .prs-skel-row { height: 76px; border-radius: 12px; }
1736 `,
1737 }}
1738 />
1739 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
1740 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
1741 <div class="prs-skel-list" aria-hidden="true">
1742 {Array.from({ length: 6 }).map(() => (
1743 <div class="prs-skel prs-skel-row" />
1744 ))}
1745 </div>
1746 <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">
1747 Loading pull requests for {ownerName}/{repoName}…
1748 </span>
1749 </Layout>
1750 );
1751 }
1752
0074234Claude1753 const resolved = await resolveRepo(ownerName, repoName);
1754 if (!resolved) return c.notFound();
1755
6fc53bdClaude1756 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
1757 const stateFilter =
1758 state === "draft"
1759 ? and(
1760 eq(pullRequests.state, "open"),
1761 eq(pullRequests.isDraft, true)
1762 )
1763 : eq(pullRequests.state, state);
1764
0074234Claude1765 const prList = await db
1766 .select({
1767 pr: pullRequests,
1768 author: { username: users.username },
1769 })
1770 .from(pullRequests)
1771 .innerJoin(users, eq(pullRequests.authorId, users.id))
1772 .where(
6fc53bdClaude1773 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude1774 )
1775 .orderBy(desc(pullRequests.createdAt));
1776
1777 const [counts] = await db
1778 .select({
1779 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude1780 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude1781 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
1782 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
1783 })
1784 .from(pullRequests)
1785 .where(eq(pullRequests.repositoryId, resolved.repo.id));
1786
b078860Claude1787 const openCount = counts?.open ?? 0;
1788 const mergedCount = counts?.merged ?? 0;
1789 const closedCount = counts?.closed ?? 0;
1790 const draftCount = counts?.draft ?? 0;
1791 const allCount = openCount + mergedCount + closedCount;
1792
1793 // "All" is presentational only — the DB query for state='all' matches
1794 // nothing, so we render a friendlier empty state when picked. We do NOT
1795 // change the query logic to keep this commit purely visual.
1796 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
1797 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
1798 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
1799 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
1800 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
1801 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
1802 ];
1803 const isAllState = state === "all";
cb5a796Claude1804 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
1805 const prListPendingCount = viewerIsOwnerOnPrList
1806 ? await countPendingForRepo(resolved.repo.id)
1807 : 0;
b078860Claude1808
0074234Claude1809 return c.html(
1810 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1811 <RepoHeader owner={ownerName} repo={repoName} />
1812 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude1813 <PendingCommentsBanner
1814 owner={ownerName}
1815 repo={repoName}
1816 count={prListPendingCount}
1817 />
b078860Claude1818 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1819
1820 <div class="prs-hero">
1821 <div class="prs-hero-inner">
1822 <div class="prs-hero-text">
1823 <div class="prs-hero-eyebrow">Pull requests</div>
1824 <h1 class="prs-hero-title">
1825 Review, <span class="gradient-text">merge with AI</span>.
1826 </h1>
1827 <p class="prs-hero-sub">
1828 {openCount === 0 && allCount === 0
1829 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
1830 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
1831 </p>
1832 </div>
7a28902Claude1833 <div class="prs-hero-actions">
1834 <a
1835 href={`/${ownerName}/${repoName}/pulls/insights`}
1836 class="prs-cta"
1837 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
1838 >
1839 Insights
1840 </a>
1841 {user && (
b078860Claude1842 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
1843 + New pull request
1844 </a>
7a28902Claude1845 )}
1846 </div>
b078860Claude1847 </div>
1848 </div>
1849
1850 <nav class="prs-tabs" aria-label="Pull request filters">
1851 {tabPills.map((t) => {
1852 const isActive =
1853 state === t.key ||
1854 (t.key === "open" &&
1855 state !== "merged" &&
1856 state !== "closed" &&
1857 state !== "all" &&
1858 state !== "draft");
1859 return (
1860 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
1861 <span>{t.label}</span>
1862 <span class="prs-tab-count">{t.count}</span>
1863 </a>
1864 );
1865 })}
1866 </nav>
1867
0074234Claude1868 {prList.length === 0 ? (
b078860Claude1869 <div class="prs-empty">
ea9ed4cClaude1870 <div class="prs-empty-inner">
1871 <strong>
1872 {isAllState
1873 ? "Pick a filter above to browse PRs."
1874 : `No ${state} pull requests.`}
1875 </strong>
1876 <p class="prs-empty-sub">
1877 {state === "open"
1878 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
1879 : isAllState
1880 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1881 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
1882 </p>
1883 <div class="prs-empty-cta">
1884 {user && state === "open" && (
1885 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
1886 + New pull request
1887 </a>
1888 )}
1889 {state !== "open" && (
1890 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
1891 View open PRs
1892 </a>
1893 )}
1894 <a href={`/${ownerName}/${repoName}`} class="btn">
1895 Back to code
1896 </a>
1897 </div>
1898 </div>
b078860Claude1899 </div>
0074234Claude1900 ) : (
b078860Claude1901 <div class="prs-list">
1902 {prList.map(({ pr, author }) => {
1903 const stateClass =
1904 pr.state === "open"
1905 ? pr.isDraft
1906 ? "state-draft"
1907 : "state-open"
1908 : pr.state === "merged"
1909 ? "state-merged"
1910 : "state-closed";
1911 const icon =
1912 pr.state === "open"
1913 ? pr.isDraft
1914 ? "◌"
1915 : "○"
1916 : pr.state === "merged"
1917 ? "⮌"
1918 : "✓";
1919 return (
1920 <a
1921 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1922 class="prs-row"
1923 style="text-decoration:none;color:inherit"
0074234Claude1924 >
b078860Claude1925 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
1926 {icon}
0074234Claude1927 </div>
b078860Claude1928 <div class="prs-row-body">
1929 <h3 class="prs-row-title">
1930 <span>{pr.title}</span>
1931 <span class="prs-row-number">#{pr.number}</span>
1932 </h3>
1933 <div class="prs-row-meta">
1934 <span
1935 class="prs-branch-chips"
1936 title={`${pr.headBranch} into ${pr.baseBranch}`}
1937 >
1938 <span class="prs-branch-chip">{pr.headBranch}</span>
1939 <span class="prs-branch-arrow">{"→"}</span>
1940 <span class="prs-branch-chip">{pr.baseBranch}</span>
1941 </span>
1942 <span>
1943 by{" "}
1944 <strong style="color:var(--text)">
1945 {author.username}
1946 </strong>{" "}
1947 {formatRelative(pr.createdAt)}
1948 </span>
1949 <span class="prs-row-tags">
1950 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
1951 {pr.state === "merged" && (
1952 <span class="prs-tag is-merged">Merged</span>
1953 )}
1954 </span>
1955 </div>
0074234Claude1956 </div>
b078860Claude1957 </a>
1958 );
1959 })}
1960 </div>
0074234Claude1961 )}
1962 </Layout>
1963 );
1964});
1965
7a28902Claude1966/* ─────────────────────────────────────────────────────────────────────────
1967 * PR Insights — 90-day analytics for the pull request activity of a repo.
1968 * Route: GET /:owner/:repo/pulls/insights
1969 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
1970 * "insights" is not swallowed by the :number param.
1971 * ───────────────────────────────────────────────────────────────────────── */
1972
1973/** Format a millisecond duration as human-readable string. */
1974function formatMsDuration(ms: number): string {
1975 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
1976 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
1977 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
1978 return `${Math.round(ms / 86_400_000)}d`;
1979}
1980
1981/** Format an ISO week string as "Jan 15". */
1982function formatWeekLabel(isoWeek: string): string {
1983 try {
1984 const d = new Date(isoWeek);
1985 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
1986 } catch {
1987 return isoWeek.slice(5, 10);
1988 }
1989}
1990
1991const PR_INSIGHTS_STYLES = `
1992 .pri-page { padding-bottom: 48px; }
1993 .pri-hero {
1994 position: relative;
1995 margin: 0 0 var(--space-5);
1996 padding: 22px 26px 24px;
1997 background: var(--bg-elevated);
1998 border: 1px solid var(--border);
1999 border-radius: 16px;
2000 overflow: hidden;
2001 }
2002 .pri-hero::before {
2003 content: '';
2004 position: absolute; top: 0; left: 0; right: 0;
2005 height: 2px;
2006 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2007 opacity: 0.7;
2008 pointer-events: none;
2009 }
2010 .pri-hero-eyebrow {
2011 font-size: 12px;
2012 color: var(--text-muted);
2013 text-transform: uppercase;
2014 letter-spacing: 0.08em;
2015 font-weight: 600;
2016 margin-bottom: 8px;
2017 }
2018 .pri-hero-title {
2019 font-family: var(--font-display);
2020 font-size: clamp(26px, 3.4vw, 34px);
2021 font-weight: 800;
2022 letter-spacing: -0.025em;
2023 line-height: 1.06;
2024 margin: 0 0 8px;
2025 color: var(--text-strong);
2026 }
2027 .pri-hero-title .gradient-text {
2028 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2029 -webkit-background-clip: text;
2030 background-clip: text;
2031 -webkit-text-fill-color: transparent;
2032 color: transparent;
2033 }
2034 .pri-hero-sub {
2035 font-size: 14.5px;
2036 color: var(--text-muted);
2037 margin: 0;
2038 line-height: 1.5;
2039 }
2040 .pri-section { margin-bottom: 32px; }
2041 .pri-section-title {
2042 font-size: 13px;
2043 font-weight: 700;
2044 text-transform: uppercase;
2045 letter-spacing: 0.06em;
2046 color: var(--text-muted);
2047 margin: 0 0 14px;
2048 }
2049 .pri-cards {
2050 display: grid;
2051 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
2052 gap: 12px;
2053 }
2054 .pri-card {
2055 padding: 16px 18px;
2056 background: var(--bg-elevated);
2057 border: 1px solid var(--border);
2058 border-radius: 12px;
2059 }
2060 .pri-card-label {
2061 font-size: 12px;
2062 font-weight: 600;
2063 color: var(--text-muted);
2064 text-transform: uppercase;
2065 letter-spacing: 0.05em;
2066 margin-bottom: 6px;
2067 }
2068 .pri-card-value {
2069 font-size: 28px;
2070 font-weight: 800;
2071 letter-spacing: -0.04em;
2072 color: var(--text-strong);
2073 line-height: 1;
2074 }
2075 .pri-card-sub {
2076 font-size: 12px;
2077 color: var(--text-muted);
2078 margin-top: 4px;
2079 }
2080 .pri-chart {
2081 display: flex;
2082 align-items: flex-end;
2083 gap: 6px;
2084 height: 120px;
2085 background: var(--bg-elevated);
2086 border: 1px solid var(--border);
2087 border-radius: 12px;
2088 padding: 16px 16px 0;
2089 }
2090 .pri-bar-col {
2091 flex: 1;
2092 display: flex;
2093 flex-direction: column;
2094 align-items: center;
2095 justify-content: flex-end;
2096 height: 100%;
2097 gap: 4px;
2098 }
2099 .pri-bar {
2100 width: 100%;
2101 min-height: 4px;
2102 border-radius: 4px 4px 0 0;
2103 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 100%);
2104 transition: opacity 140ms;
2105 }
2106 .pri-bar:hover { opacity: 0.8; }
2107 .pri-bar-label {
2108 font-size: 10px;
2109 color: var(--text-muted);
2110 text-align: center;
2111 padding-bottom: 8px;
2112 white-space: nowrap;
2113 overflow: hidden;
2114 text-overflow: ellipsis;
2115 max-width: 100%;
2116 }
2117 .pri-table {
2118 width: 100%;
2119 border-collapse: collapse;
2120 font-size: 13.5px;
2121 }
2122 .pri-table th {
2123 text-align: left;
2124 font-size: 12px;
2125 font-weight: 600;
2126 text-transform: uppercase;
2127 letter-spacing: 0.05em;
2128 color: var(--text-muted);
2129 padding: 8px 12px;
2130 border-bottom: 1px solid var(--border);
2131 }
2132 .pri-table td {
2133 padding: 10px 12px;
2134 border-bottom: 1px solid var(--border);
2135 color: var(--text);
2136 }
2137 .pri-table tr:last-child td { border-bottom: none; }
2138 .pri-table-wrap {
2139 background: var(--bg-elevated);
2140 border: 1px solid var(--border);
2141 border-radius: 12px;
2142 overflow: hidden;
2143 }
2144 .pri-age-row {
2145 display: flex;
2146 align-items: center;
2147 gap: 12px;
2148 padding: 10px 0;
2149 border-bottom: 1px solid var(--border);
2150 font-size: 13.5px;
2151 }
2152 .pri-age-row:last-child { border-bottom: none; }
2153 .pri-age-label {
2154 flex: 0 0 80px;
2155 color: var(--text-muted);
2156 font-size: 12.5px;
2157 font-weight: 600;
2158 }
2159 .pri-age-bar-wrap {
2160 flex: 1;
2161 height: 8px;
2162 background: var(--bg-secondary);
2163 border-radius: 9999px;
2164 overflow: hidden;
2165 }
2166 .pri-age-bar {
2167 height: 100%;
2168 border-radius: 9999px;
2169 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
2170 min-width: 4px;
2171 }
2172 .pri-age-count {
2173 flex: 0 0 32px;
2174 text-align: right;
2175 font-weight: 600;
2176 color: var(--text-strong);
2177 font-size: 13px;
2178 }
2179 .pri-sparkline {
2180 display: flex;
2181 align-items: flex-end;
2182 gap: 3px;
2183 height: 40px;
2184 }
2185 .pri-spark-bar {
2186 flex: 1;
2187 min-height: 2px;
2188 border-radius: 2px 2px 0 0;
2189 background: var(--accent, #8c6dff);
2190 opacity: 0.7;
2191 }
2192 .pri-empty {
2193 color: var(--text-muted);
2194 font-size: 14px;
2195 padding: 24px 0;
2196 text-align: center;
2197 }
2198 @media (max-width: 600px) {
2199 .pri-cards { grid-template-columns: repeat(2, 1fr); }
2200 .pri-hero { padding: 18px 18px 20px; }
2201 }
2202`;
2203
2204pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
2205 const { owner: ownerName, repo: repoName } = c.req.param();
2206 const user = c.get("user");
2207
2208 const resolved = await resolveRepo(ownerName, repoName);
2209 if (!resolved) return c.notFound();
2210
2211 const repoId = resolved.repo.id;
2212 const now = Date.now();
2213
2214 // 1. Merged PRs in last 90 days (avg merge time)
2215 const mergedPRs = await db
2216 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
2217 .from(pullRequests)
2218 .where(and(
2219 eq(pullRequests.repositoryId, repoId),
2220 eq(pullRequests.state, "merged"),
2221 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2222 ));
2223
2224 const avgMergeMs = mergedPRs.length > 0
2225 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
2226 : null;
2227
2228 // 2. PR throughput (last 8 weeks)
2229 const weeklyPRs = await db
2230 .select({
2231 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
2232 count: sql<number>`count(*)::int`,
2233 })
2234 .from(pullRequests)
2235 .where(and(
2236 eq(pullRequests.repositoryId, repoId),
2237 sql`${pullRequests.createdAt} > now() - interval '56 days'`
2238 ))
2239 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
2240 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
2241
2242 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
2243
2244 // 3. PR merge rate (last 90 days)
2245 const [rateCounts] = await db
2246 .select({
2247 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
2248 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
2249 })
2250 .from(pullRequests)
2251 .where(and(
2252 eq(pullRequests.repositoryId, repoId),
2253 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2254 ));
2255
2256 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
2257 const mergeRate = totalResolved > 0
2258 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
2259 : null;
2260
2261 // 4. Top reviewers (last 90 days)
2262 const reviewerCounts = await db
2263 .select({
2264 userId: prReviews.reviewerId,
2265 username: users.username,
2266 count: sql<number>`count(*)::int`,
2267 })
2268 .from(prReviews)
2269 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2270 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
2271 .where(and(
2272 eq(pullRequests.repositoryId, repoId),
2273 sql`${prReviews.createdAt} > now() - interval '90 days'`
2274 ))
2275 .groupBy(prReviews.reviewerId, users.username)
2276 .orderBy(desc(sql`count(*)`))
2277 .limit(5);
2278
2279 // 5. Average reviews per merged PR
2280 const [avgReviewRow] = await db
2281 .select({
2282 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
2283 })
2284 .from(pullRequests)
2285 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2286 .where(and(
2287 eq(pullRequests.repositoryId, repoId),
2288 eq(pullRequests.state, "merged"),
2289 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2290 ));
2291
2292 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
2293 ? Math.round(avgReviewRow.avgReviews * 10) / 10
2294 : null;
2295
2296 // 6. Review turnaround — avg time from PR open to first review
2297 const prsWithReviews = await db
2298 .select({
2299 createdAt: pullRequests.createdAt,
2300 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
2301 })
2302 .from(pullRequests)
2303 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2304 .where(and(
2305 eq(pullRequests.repositoryId, repoId),
2306 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2307 ))
2308 .groupBy(pullRequests.id, pullRequests.createdAt);
2309
2310 const avgReviewTurnaroundMs = prsWithReviews.length > 0
2311 ? prsWithReviews.reduce((s, row) => {
2312 const firstMs = new Date(row.firstReview).getTime();
2313 return s + Math.max(0, firstMs - row.createdAt.getTime());
2314 }, 0) / prsWithReviews.length
2315 : null;
2316
2317 // 7. Open PRs by age bucket
2318 const openPRs = await db
2319 .select({ createdAt: pullRequests.createdAt })
2320 .from(pullRequests)
2321 .where(and(
2322 eq(pullRequests.repositoryId, repoId),
2323 eq(pullRequests.state, "open")
2324 ));
2325
2326 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
2327 for (const { createdAt } of openPRs) {
2328 const ageDays = (now - createdAt.getTime()) / 86_400_000;
2329 if (ageDays < 1) ageBuckets.lt1d++;
2330 else if (ageDays < 3) ageBuckets.d1to3++;
2331 else if (ageDays < 7) ageBuckets.d3to7++;
2332 else if (ageDays < 30) ageBuckets.d7to30++;
2333 else ageBuckets.gt30d++;
2334 }
2335 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
2336
2337 // 8. 7-day merge sparkline
2338 const sparklineRows = await db
2339 .select({
2340 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
2341 count: sql<number>`count(*)::int`,
2342 })
2343 .from(pullRequests)
2344 .where(and(
2345 eq(pullRequests.repositoryId, repoId),
2346 eq(pullRequests.state, "merged"),
2347 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
2348 ))
2349 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
2350 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
2351
2352 const sparkMap = new Map<string, number>();
2353 for (const row of sparklineRows) {
2354 sparkMap.set(row.day.slice(0, 10), row.count);
2355 }
2356 const sparkline: number[] = [];
2357 for (let i = 6; i >= 0; i--) {
2358 const d = new Date(now - i * 86_400_000);
2359 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
2360 }
2361 const maxSpark = Math.max(1, ...sparkline);
2362
2363 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
2364 { label: "< 1 day", key: "lt1d" },
2365 { label: "1–3 days", key: "d1to3" },
2366 { label: "3–7 days", key: "d3to7" },
2367 { label: "7–30 days", key: "d7to30" },
2368 { label: "> 30 days", key: "gt30d" },
2369 ];
2370
2371 return c.html(
2372 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
2373 <RepoHeader owner={ownerName} repo={repoName} />
2374 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2375 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
2376
2377 <div class="pri-page">
2378 {/* Hero */}
2379 <div class="pri-hero">
2380 <div class="pri-hero-eyebrow">Pull requests</div>
2381 <h1 class="pri-hero-title">
2382 PR <span class="gradient-text">Insights</span>
2383 </h1>
2384 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
2385 </div>
2386
2387 {/* Stat cards */}
2388 <div class="pri-section">
2389 <div class="pri-section-title">At a glance</div>
2390 <div class="pri-cards">
2391 <div class="pri-card">
2392 <div class="pri-card-label">Avg merge time</div>
2393 <div class="pri-card-value">
2394 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
2395 </div>
2396 <div class="pri-card-sub">last 90 days</div>
2397 </div>
2398 <div class="pri-card">
2399 <div class="pri-card-label">Total merged</div>
2400 <div class="pri-card-value">{mergedPRs.length}</div>
2401 <div class="pri-card-sub">last 90 days</div>
2402 </div>
2403 <div class="pri-card">
2404 <div class="pri-card-label">Open PRs</div>
2405 <div class="pri-card-value">{openPRs.length}</div>
2406 <div class="pri-card-sub">right now</div>
2407 </div>
2408 <div class="pri-card">
2409 <div class="pri-card-label">Merge rate</div>
2410 <div class="pri-card-value">
2411 {mergeRate != null ? `${mergeRate}%` : "—"}
2412 </div>
2413 <div class="pri-card-sub">merged vs closed</div>
2414 </div>
2415 <div class="pri-card">
2416 <div class="pri-card-label">Avg reviews / PR</div>
2417 <div class="pri-card-value">
2418 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
2419 </div>
2420 <div class="pri-card-sub">merged PRs, 90d</div>
2421 </div>
2422 <div class="pri-card">
2423 <div class="pri-card-label">Top reviewer</div>
2424 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
2425 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
2426 </div>
2427 <div class="pri-card-sub">
2428 {reviewerCounts.length > 0
2429 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
2430 : "no reviews yet"}
2431 </div>
2432 </div>
2433 </div>
2434 </div>
2435
2436 {/* Review turnaround */}
2437 <div class="pri-section">
2438 <div class="pri-section-title">Review turnaround</div>
2439 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
2440 <div class="pri-card">
2441 <div class="pri-card-label">Avg time to first review</div>
2442 <div class="pri-card-value">
2443 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
2444 </div>
2445 <div class="pri-card-sub">
2446 {prsWithReviews.length > 0
2447 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
2448 : "no reviewed PRs in 90d"}
2449 </div>
2450 </div>
2451 </div>
2452 </div>
2453
2454 {/* Weekly throughput bar chart */}
2455 <div class="pri-section">
2456 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
2457 {weeklyPRs.length === 0 ? (
2458 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
2459 ) : (
2460 <div class="pri-chart">
2461 {weeklyPRs.map((w) => (
2462 <div class="pri-bar-col">
2463 <div
2464 class="pri-bar"
2465 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
2466 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
2467 />
2468 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
2469 </div>
2470 ))}
2471 </div>
2472 )}
2473 </div>
2474
2475 {/* 7-day merge sparkline */}
2476 <div class="pri-section">
2477 <div class="pri-section-title">Merges this week (daily)</div>
2478 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
2479 <div class="pri-sparkline">
2480 {sparkline.map((v) => (
2481 <div
2482 class="pri-spark-bar"
2483 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
2484 title={`${v} merge${v === 1 ? "" : "s"}`}
2485 />
2486 ))}
2487 </div>
2488 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
2489 <span>7 days ago</span>
2490 <span>Today</span>
2491 </div>
2492 </div>
2493 </div>
2494
2495 {/* Top reviewers table */}
2496 <div class="pri-section">
2497 <div class="pri-section-title">Top reviewers (last 90 days)</div>
2498 {reviewerCounts.length === 0 ? (
2499 <div class="pri-empty">No reviews posted in the last 90 days.</div>
2500 ) : (
2501 <div class="pri-table-wrap">
2502 <table class="pri-table">
2503 <thead>
2504 <tr>
2505 <th>#</th>
2506 <th>Reviewer</th>
2507 <th>Reviews</th>
2508 </tr>
2509 </thead>
2510 <tbody>
2511 {reviewerCounts.map((r, i) => (
2512 <tr>
2513 <td style="color:var(--text-muted)">{i + 1}</td>
2514 <td>
2515 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
2516 {r.username}
2517 </a>
2518 </td>
2519 <td style="font-weight:600">{r.count}</td>
2520 </tr>
2521 ))}
2522 </tbody>
2523 </table>
2524 </div>
2525 )}
2526 </div>
2527
2528 {/* Open PRs by age */}
2529 <div class="pri-section">
2530 <div class="pri-section-title">Open PRs by age</div>
2531 {openPRs.length === 0 ? (
2532 <div class="pri-empty">No open pull requests.</div>
2533 ) : (
2534 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
2535 {ageBucketDefs.map(({ label, key }) => (
2536 <div class="pri-age-row">
2537 <span class="pri-age-label">{label}</span>
2538 <div class="pri-age-bar-wrap">
2539 <div
2540 class="pri-age-bar"
2541 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
2542 />
2543 </div>
2544 <span class="pri-age-count">{ageBuckets[key]}</span>
2545 </div>
2546 ))}
2547 </div>
2548 )}
2549 </div>
2550
2551 {/* Back link */}
2552 <div>
2553 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
2554 {"←"} Back to pull requests
2555 </a>
2556 </div>
2557 </div>
2558 </Layout>
2559 );
2560});
2561
0074234Claude2562// New PR form
2563pulls.get(
2564 "/:owner/:repo/pulls/new",
2565 softAuth,
2566 requireAuth,
04f6b7fClaude2567 requireRepoAccess("write"),
0074234Claude2568 async (c) => {
2569 const { owner: ownerName, repo: repoName } = c.req.param();
2570 const user = c.get("user")!;
2571 const branches = await listBranches(ownerName, repoName);
2572 const error = c.req.query("error");
2573 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude2574 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude2575
2576 return c.html(
2577 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
2578 <RepoHeader owner={ownerName} repo={repoName} />
2579 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude2580 <Container maxWidth={800}>
2581 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude2582 {error && (
bb0f894Claude2583 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude2584 )}
0316dbbClaude2585 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
2586 <Flex gap={12} align="center" style="margin-bottom: 16px">
2587 <Select name="base">
0074234Claude2588 {branches.map((b) => (
2589 <option value={b} selected={b === defaultBase}>
2590 {b}
2591 </option>
2592 ))}
bb0f894Claude2593 </Select>
2594 <Text muted>&larr;</Text>
2595 <Select name="head">
0074234Claude2596 {branches
2597 .filter((b) => b !== defaultBase)
2598 .concat(defaultBase === branches[0] ? [] : [branches[0]])
2599 .map((b) => (
2600 <option value={b}>{b}</option>
2601 ))}
bb0f894Claude2602 </Select>
2603 </Flex>
2604 <FormGroup>
2605 <Input
0074234Claude2606 name="title"
2607 required
2608 placeholder="Title"
bb0f894Claude2609 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]2610 aria-label="Pull request title"
0074234Claude2611 />
bb0f894Claude2612 </FormGroup>
2613 <FormGroup>
2614 <TextArea
0074234Claude2615 name="body"
81c73c1Claude2616 id="pr-body"
0074234Claude2617 rows={8}
2618 placeholder="Description (Markdown supported)"
bb0f894Claude2619 mono
0074234Claude2620 />
bb0f894Claude2621 </FormGroup>
81c73c1Claude2622 <Flex gap={8} align="center">
2623 <Button type="submit" variant="primary">
2624 Create pull request
2625 </Button>
2626 <button
2627 type="button"
2628 id="ai-suggest-desc"
2629 class="btn"
2630 style="font-weight:500"
2631 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
2632 >
2633 Suggest description with AI
2634 </button>
2635 <span
2636 id="ai-suggest-status"
2637 style="color:var(--text-muted);font-size:13px"
2638 />
2639 </Flex>
bb0f894Claude2640 </Form>
81c73c1Claude2641 <script
2642 dangerouslySetInnerHTML={{
2643 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
2644 }}
2645 />
bb0f894Claude2646 </Container>
0074234Claude2647 </Layout>
2648 );
2649 }
2650);
2651
81c73c1Claude2652// AI-suggested PR description — JSON endpoint driven by the form button.
2653// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
2654// 200; the inline script reads `ok` to decide what to do.
2655pulls.post(
2656 "/:owner/:repo/ai/pr-description",
2657 softAuth,
2658 requireAuth,
2659 requireRepoAccess("write"),
2660 async (c) => {
2661 const { owner: ownerName, repo: repoName } = c.req.param();
2662 if (!isAiAvailable()) {
2663 return c.json({
2664 ok: false,
2665 error: "AI is not available — set ANTHROPIC_API_KEY.",
2666 });
2667 }
2668 const body = await c.req.parseBody();
2669 const title = String(body.title || "").trim();
2670 const baseBranch = String(body.base || "").trim();
2671 const headBranch = String(body.head || "").trim();
2672 if (!baseBranch || !headBranch) {
2673 return c.json({ ok: false, error: "Pick base + head branches first." });
2674 }
2675 if (baseBranch === headBranch) {
2676 return c.json({ ok: false, error: "Base and head must differ." });
2677 }
2678
2679 let diff = "";
2680 try {
2681 const cwd = getRepoPath(ownerName, repoName);
2682 const proc = Bun.spawn(
2683 [
2684 "git",
2685 "diff",
2686 `${baseBranch}...${headBranch}`,
2687 "--",
2688 ],
2689 { cwd, stdout: "pipe", stderr: "pipe" }
2690 );
6ea2109Claude2691 // 30s ceiling — without this a pathological diff (huge binary or
2692 // a corrupt ref) hangs the request indefinitely.
2693 const killer = setTimeout(() => proc.kill(), 30_000);
2694 try {
2695 diff = await new Response(proc.stdout).text();
2696 await proc.exited;
2697 } finally {
2698 clearTimeout(killer);
2699 }
81c73c1Claude2700 } catch {
2701 diff = "";
2702 }
2703 if (!diff.trim()) {
2704 return c.json({
2705 ok: false,
2706 error: "No diff between branches — nothing to summarise.",
2707 });
2708 }
2709
2710 let summary = "";
2711 try {
2712 summary = await generatePrSummary(title || "(untitled)", diff);
2713 } catch (err) {
2714 const msg = err instanceof Error ? err.message : "AI request failed.";
2715 return c.json({ ok: false, error: msg });
2716 }
2717 if (!summary.trim()) {
2718 return c.json({ ok: false, error: "AI returned an empty draft." });
2719 }
2720 return c.json({ ok: true, body: summary });
2721 }
2722);
2723
0074234Claude2724// Create PR
2725pulls.post(
2726 "/:owner/:repo/pulls/new",
2727 softAuth,
2728 requireAuth,
04f6b7fClaude2729 requireRepoAccess("write"),
0074234Claude2730 async (c) => {
2731 const { owner: ownerName, repo: repoName } = c.req.param();
2732 const user = c.get("user")!;
2733 const body = await c.req.parseBody();
2734 const title = String(body.title || "").trim();
2735 const prBody = String(body.body || "").trim();
2736 const baseBranch = String(body.base || "main");
2737 const headBranch = String(body.head || "");
2738
2739 if (!title || !headBranch) {
2740 return c.redirect(
2741 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
2742 );
2743 }
2744
2745 if (baseBranch === headBranch) {
2746 return c.redirect(
2747 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
2748 );
2749 }
2750
2751 const resolved = await resolveRepo(ownerName, repoName);
2752 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2753
6fc53bdClaude2754 const isDraft = String(body.draft || "") === "1";
2755
0074234Claude2756 const [pr] = await db
2757 .insert(pullRequests)
2758 .values({
2759 repositoryId: resolved.repo.id,
2760 authorId: user.id,
2761 title,
2762 body: prBody || null,
2763 baseBranch,
2764 headBranch,
6fc53bdClaude2765 isDraft,
0074234Claude2766 })
2767 .returning();
2768
6fc53bdClaude2769 // Skip AI review on drafts — it runs again when the PR is marked ready.
2770 if (!isDraft && isAiReviewEnabled()) {
e883329Claude2771 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
2772 (err) => console.error("[ai-review] Failed:", err)
2773 );
2774 }
2775
3cbe3d6Claude2776 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
2777 triggerPrTriage({
2778 ownerName,
2779 repoName,
2780 repositoryId: resolved.repo.id,
2781 prId: pr.id,
2782 prAuthorId: user.id,
2783 title,
2784 body: prBody,
2785 baseBranch,
2786 headBranch,
2787 }).catch((err) => console.error("[pr-triage] Failed:", err));
2788
1d4ff60Claude2789 // Chat notifier — fan out to Slack/Discord/Teams.
2790 import("../lib/chat-notifier")
2791 .then((m) =>
2792 m.notifyChatChannels({
2793 ownerUserId: resolved.repo.ownerId,
2794 repositoryId: resolved.repo.id,
2795 event: {
2796 event: "pr.opened",
2797 repo: `${ownerName}/${repoName}`,
2798 title: `#${pr.number} ${title}`,
2799 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
2800 body: prBody || undefined,
2801 actor: user.username,
2802 },
2803 })
2804 )
2805 .catch((err) =>
2806 console.warn(`[chat-notifier] PR opened notify failed:`, err)
2807 );
2808
9dd96b9Test User2809 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude2810 import("../lib/auto-merge")
2811 .then((m) => m.tryAutoMergeNow(pr.id))
2812 .catch((err) => {
2813 console.warn(
2814 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
2815 err instanceof Error ? err.message : err
2816 );
2817 });
9dd96b9Test User2818
0074234Claude2819 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
2820 }
2821);
2822
2823// View single PR
04f6b7fClaude2824pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2825 const { owner: ownerName, repo: repoName } = c.req.param();
2826 const prNum = parseInt(c.req.param("number"), 10);
2827 const user = c.get("user");
2828 const tab = c.req.query("tab") || "conversation";
2829
2830 const resolved = await resolveRepo(ownerName, repoName);
2831 if (!resolved) return c.notFound();
2832
2833 const [pr] = await db
2834 .select()
2835 .from(pullRequests)
2836 .where(
2837 and(
2838 eq(pullRequests.repositoryId, resolved.repo.id),
2839 eq(pullRequests.number, prNum)
2840 )
2841 )
2842 .limit(1);
2843
2844 if (!pr) return c.notFound();
2845
2846 const [author] = await db
2847 .select()
2848 .from(users)
2849 .where(eq(users.id, pr.authorId))
2850 .limit(1);
2851
cb5a796Claude2852 const allCommentsRaw = await db
0074234Claude2853 .select({
2854 comment: prComments,
cb5a796Claude2855 author: { id: users.id, username: users.username },
0074234Claude2856 })
2857 .from(prComments)
2858 .innerJoin(users, eq(prComments.authorId, users.id))
2859 .where(eq(prComments.pullRequestId, pr.id))
2860 .orderBy(asc(prComments.createdAt));
2861
cb5a796Claude2862 // Filter pending/rejected/spam for non-owner, non-author viewers.
2863 // Owner always sees everything; comment author sees their own pending
2864 // with an "Awaiting approval" badge in the render below.
2865 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
2866 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
2867 if (viewerIsRepoOwner) return true;
2868 if (comment.moderationStatus === "approved") return true;
2869 if (
2870 user &&
2871 cAuthor.id === user.id &&
2872 comment.moderationStatus === "pending"
2873 ) {
2874 return true;
2875 }
2876 return false;
2877 });
2878 const prPendingCount = viewerIsRepoOwner
2879 ? await countPendingForRepo(resolved.repo.id)
2880 : 0;
2881
6fc53bdClaude2882 // Reactions for the PR body + each comment, in parallel.
2883 const [prReactions, ...prCommentReactions] = await Promise.all([
2884 summariseReactions("pr", pr.id, user?.id),
2885 ...comments.map((row) =>
2886 summariseReactions("pr_comment", row.comment.id, user?.id)
2887 ),
2888 ]);
2889
0a67773Claude2890 // Formal reviews (Approve / Request Changes)
2891 const reviewRows = await db
2892 .select({
2893 id: prReviews.id,
2894 state: prReviews.state,
2895 body: prReviews.body,
2896 isAi: prReviews.isAi,
2897 createdAt: prReviews.createdAt,
2898 reviewerUsername: users.username,
2899 reviewerId: prReviews.reviewerId,
2900 })
2901 .from(prReviews)
2902 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2903 .where(eq(prReviews.pullRequestId, pr.id))
2904 .orderBy(asc(prReviews.createdAt));
2905 // Most recent review per reviewer determines the current state
2906 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
2907 for (const r of reviewRows) {
2908 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
2909 }
2910 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
2911 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
2912 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
2913
0074234Claude2914 const canManage =
2915 user &&
2916 (user.id === resolved.owner.id || user.id === pr.authorId);
2917
1d4ff60Claude2918 // Has any previous AI-test-generator run already tagged this PR? Used
2919 // both to hide the "Generate tests with AI" button and to short-circuit
2920 // the explicit POST handler.
2921 const hasAiTestsMarker = comments.some(({ comment }) =>
2922 (comment.body || "").includes(AI_TESTS_MARKER)
2923 );
2924
e883329Claude2925 const error = c.req.query("error");
c3e0c07Claude2926 const info = c.req.query("info");
e883329Claude2927
2928 // Get gate check status for open PRs
2929 let gateChecks: GateCheckResult[] = [];
2930 if (pr.state === "open") {
2931 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2932 if (headSha) {
2933 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
2934 const aiApproved = aiComments.length === 0 || aiComments.some(
2935 ({ comment }) => comment.body.includes("**Approved**")
2936 );
2937 const gateResult = await runAllGateChecks(
2938 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
2939 );
2940 gateChecks = gateResult.checks;
2941 }
2942 }
2943
534f04aClaude2944 // Block M3 — pre-merge risk score. Cache-only on the request path so
2945 // the page never waits on Haiku. On a cache miss for an open PR we
2946 // kick off the computation fire-and-forget; the next refresh shows it.
2947 let prRisk: PrRiskScore | null = null;
2948 let prRiskCalculating = false;
2949 if (pr.state === "open") {
2950 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
2951 if (!prRisk) {
2952 prRiskCalculating = true;
a28cedeClaude2953 void computePrRiskForPullRequest(pr.id).catch((err) => {
2954 console.warn(
2955 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
2956 err instanceof Error ? err.message : err
2957 );
2958 });
534f04aClaude2959 }
2960 }
2961
4bbacbeClaude2962 // Migration 0062 — per-branch preview URL. The head branch always
2963 // has a preview row (unless it's the default branch, which never
2964 // happens for an open PR) once it has been pushed at least once.
2965 const preview = await getPreviewForBranch(
2966 (resolved.repo as { id: string }).id,
2967 pr.headBranch
2968 );
2969
47a7a0aClaude2970 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude2971 let diffRaw = "";
2972 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude2973 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude2974 if (tab === "files") {
2975 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude2976 // Run the two git diffs in parallel — they're independent reads of
2977 // the same range. Previously sequential, doubling the wall time on
2978 // big PRs (100+ files = 10-30s for no reason).
0074234Claude2979 const proc = Bun.spawn(
2980 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
2981 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2982 );
2983 const statProc = Bun.spawn(
2984 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
2985 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
2986 );
6ea2109Claude2987 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
2988 // would otherwise hang the whole request.
2989 const killer = setTimeout(() => {
2990 proc.kill();
2991 statProc.kill();
2992 }, 30_000);
2993 let stat = "";
2994 try {
2995 [diffRaw, stat] = await Promise.all([
2996 new Response(proc.stdout).text(),
2997 new Response(statProc.stdout).text(),
2998 ]);
2999 await Promise.all([proc.exited, statProc.exited]);
3000 } finally {
3001 clearTimeout(killer);
3002 }
0074234Claude3003
3004 diffFiles = stat
3005 .trim()
3006 .split("\n")
3007 .filter(Boolean)
3008 .map((line) => {
3009 const [add, del, filePath] = line.split("\t");
3010 return {
3011 path: filePath,
3012 status: "modified",
3013 additions: add === "-" ? 0 : parseInt(add, 10),
3014 deletions: del === "-" ? 0 : parseInt(del, 10),
3015 patch: "",
3016 };
3017 });
47a7a0aClaude3018
3019 // Fetch inline comments (file+line anchored) for the files tab
3020 const inlineRows = await db
3021 .select({
3022 id: prComments.id,
3023 filePath: prComments.filePath,
3024 lineNumber: prComments.lineNumber,
3025 body: prComments.body,
3026 isAiReview: prComments.isAiReview,
3027 createdAt: prComments.createdAt,
3028 authorUsername: users.username,
3029 })
3030 .from(prComments)
3031 .innerJoin(users, eq(prComments.authorId, users.id))
3032 .where(
3033 and(
3034 eq(prComments.pullRequestId, pr.id),
3035 eq(prComments.moderationStatus, "approved"),
3036 )
3037 )
3038 .orderBy(asc(prComments.createdAt));
3039
3040 diffInlineComments = inlineRows
3041 .filter(r => r.filePath != null && r.lineNumber != null)
3042 .map(r => ({
3043 id: r.id,
3044 filePath: r.filePath!,
3045 lineNumber: r.lineNumber!,
3046 authorUsername: r.authorUsername,
3047 body: renderMarkdown(r.body),
3048 isAiReview: r.isAiReview,
3049 createdAt: r.createdAt.toISOString(),
3050 }));
0074234Claude3051 }
3052
b078860Claude3053 // ─── Derived visual state ───
3054 const stateKey =
3055 pr.state === "open"
3056 ? pr.isDraft
3057 ? "draft"
3058 : "open"
3059 : pr.state;
3060 const stateLabel =
3061 stateKey === "open"
3062 ? "Open"
3063 : stateKey === "draft"
3064 ? "Draft"
3065 : stateKey === "merged"
3066 ? "Merged"
3067 : "Closed";
3068 const stateIcon =
3069 stateKey === "open"
3070 ? "○"
3071 : stateKey === "draft"
3072 ? "◌"
3073 : stateKey === "merged"
3074 ? "⮌"
3075 : "✓";
3076 const commentCount = comments.length;
3077 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
3078 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
3079 const mergeBlocked =
3080 gateChecks.length > 0 &&
3081 gateChecks.some(
3082 (c) => !c.passed && c.name !== "Merge check"
3083 );
3084
0074234Claude3085 return c.html(
3086 <Layout
3087 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
3088 user={user}
3089 >
3090 <RepoHeader owner={ownerName} repo={repoName} />
3091 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude3092 <PendingCommentsBanner
3093 owner={ownerName}
3094 repo={repoName}
3095 count={prPendingCount}
3096 />
b078860Claude3097 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude3098 <div
3099 id="live-comment-banner"
3100 class="alert"
3101 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
3102 >
3103 <strong class="js-live-count">0</strong> new comment(s) —{" "}
3104 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
3105 reload to view
3106 </a>
3107 </div>
3108 <script
3109 dangerouslySetInnerHTML={{
3110 __html: liveCommentBannerScript({
3111 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
3112 bannerElementId: "live-comment-banner",
3113 }),
3114 }}
3115 />
b078860Claude3116
3117 <div class="prs-detail-hero">
3118 <h1 class="prs-detail-title">
0074234Claude3119 {pr.title}{" "}
b078860Claude3120 <span class="prs-detail-num">#{pr.number}</span>
3121 </h1>
3122 <div class="prs-detail-meta">
3123 <span class={`prs-state-pill state-${stateKey}`}>
3124 <span aria-hidden="true">{stateIcon}</span>
3125 <span>{stateLabel}</span>
3126 </span>
3127 <span>
3128 <strong>{author?.username}</strong> wants to merge
3129 </span>
3130 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
3131 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
3132 <span class="prs-branch-arrow-lg">{"→"}</span>
3133 <span class="prs-branch-pill">{pr.baseBranch}</span>
3134 </span>
3135 <span>opened {formatRelative(pr.createdAt)}</span>
3c03977Claude3136 <span
3137 id="live-pill"
3138 class="live-pill"
3139 title="People editing this PR right now"
3140 >
3141 <span class="live-pill-dot" aria-hidden="true"></span>
3142 <span>
3143 Live: <strong id="live-count">0</strong> editing
3144 </span>
3145 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
3146 </span>
4bbacbeClaude3147 {preview && (
3148 <a
3149 class={`preview-prpill is-${preview.status}`}
3150 href={
3151 preview.status === "ready"
3152 ? preview.previewUrl
3153 : `/${ownerName}/${repoName}/previews`
3154 }
3155 target={preview.status === "ready" ? "_blank" : undefined}
3156 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
3157 title={`Preview · ${previewStatusLabel(preview.status)}`}
3158 >
3159 <span class="preview-prpill-dot" aria-hidden="true"></span>
3160 <span>Preview: </span>
3161 <span>{previewStatusLabel(preview.status)}</span>
3162 </a>
3163 )}
b078860Claude3164 {canManage && pr.state === "open" && pr.isDraft && (
3165 <form
3166 method="post"
3167 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3168 class="prs-inline-form prs-detail-actions"
3169 >
3170 <button type="submit" class="prs-merge-ready-btn">
3171 Ready for review
3172 </button>
3173 </form>
3174 )}
3175 </div>
3176 </div>
3c03977Claude3177 <script
3178 dangerouslySetInnerHTML={{
3179 __html: LIVE_COEDIT_SCRIPT(pr.id),
3180 }}
3181 />
0074234Claude3182
b078860Claude3183 <nav class="prs-detail-tabs" aria-label="Pull request sections">
3184 <a
3185 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
3186 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
3187 >
3188 Conversation
3189 <span class="prs-detail-tab-count">{commentCount}</span>
3190 </a>
3191 <a
3192 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
3193 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3194 >
3195 Files changed
3196 {diffFiles.length > 0 && (
3197 <span class="prs-detail-tab-count">{diffFiles.length}</span>
3198 )}
3199 </a>
3200 </nav>
3201
3202 {tab === "files" ? (
ea9ed4cClaude3203 <DiffView
3204 raw={diffRaw}
3205 files={diffFiles}
3206 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
47a7a0aClaude3207 inlineComments={diffInlineComments}
3208 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
b5dd694Claude3209 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
ea9ed4cClaude3210 />
b078860Claude3211 ) : (
3212 <>
3213 {pr.body && (
3214 <CommentBox
3215 author={author?.username ?? "unknown"}
3216 date={pr.createdAt}
3217 body={renderMarkdown(pr.body)}
3218 />
3219 )}
3220
422a2d4Claude3221 {/* Block H — AI trio review (security/correctness/style). When
3222 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
3223 hoisted into a 3-column card grid above the normal comment
3224 stream so reviewers see verdicts at a glance. Disagreements
3225 are surfaced as a yellow callout. */}
3226 <TrioReviewGrid
3227 comments={comments.map(({ comment }) => comment)}
3228 />
3229
15db0e0Claude3230 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude3231 // Skip trio comments — already rendered in TrioReviewGrid above.
3232 if (isTrioComment(comment.body)) return null;
15db0e0Claude3233 const slashCmd = detectSlashCmdComment(comment.body);
3234 if (slashCmd) {
3235 const visible = stripSlashCmdMarker(comment.body);
3236 return (
3237 <div class={`slash-pill slash-cmd-${slashCmd}`}>
3238 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
3239 <span class="slash-pill-actor">
3240 <strong>{commentAuthor.username}</strong>
3241 {" ran "}
3242 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude3243 </span>
15db0e0Claude3244 <span class="slash-pill-time">
3245 {formatRelative(comment.createdAt)}
3246 </span>
3247 <div class="slash-pill-body">
3248 <MarkdownContent html={renderMarkdown(visible)} />
3249 </div>
3250 </div>
3251 );
3252 }
cb5a796Claude3253 const isPending = comment.moderationStatus === "pending";
15db0e0Claude3254 return (
cb5a796Claude3255 <div
3256 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
3257 >
15db0e0Claude3258 <div class="prs-comment-head">
3259 <strong>{commentAuthor.username}</strong>
3260 {comment.isAiReview && (
3261 <span class="prs-ai-badge">AI Review</span>
3262 )}
cb5a796Claude3263 {isPending && (
3264 <span
3265 class="modq-pending-badge"
3266 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
3267 >
3268 Awaiting approval
3269 </span>
3270 )}
15db0e0Claude3271 <span class="prs-comment-time">
3272 commented {formatRelative(comment.createdAt)}
3273 </span>
3274 {comment.filePath && (
3275 <span class="prs-comment-loc">
3276 {comment.filePath}
3277 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
3278 </span>
3279 )}
3280 </div>
3281 <div class="prs-comment-body">
3282 <MarkdownContent html={renderMarkdown(comment.body)} />
3283 </div>
0074234Claude3284 </div>
15db0e0Claude3285 );
3286 })}
0074234Claude3287
b078860Claude3288 {/* Quick link to the Files changed tab when there's a diff to look at. */}
3289 {pr.state !== "merged" && (
3290 <a
3291 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3292 class="prs-files-card"
3293 >
3294 <span class="prs-files-card-icon" aria-hidden="true">
3295 {"▤"}
3296 </span>
3297 <div class="prs-files-card-text">
3298 <p class="prs-files-card-title">Files changed</p>
3299 <p class="prs-files-card-sub">
3300 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
3301 </p>
e883329Claude3302 </div>
b078860Claude3303 <span class="prs-files-card-cta">View diff {"→"}</span>
3304 </a>
3305 )}
3306
3307 {error && (
3308 <div
3309 class="auth-error"
3310 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)"
3311 >
3312 {decodeURIComponent(error)}
3313 </div>
3314 )}
3315
3316 {info && (
3317 <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)">
3318 {decodeURIComponent(info)}
3319 </div>
3320 )}
e883329Claude3321
b078860Claude3322 {pr.state === "open" && (prRisk || prRiskCalculating) && (
3323 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
3324 )}
3325
0a67773Claude3326 {/* ─── Review summary ─────────────────────────────────── */}
3327 {(approvals.length > 0 || changesRequested.length > 0) && (
3328 <div class="prs-review-summary">
3329 {approvals.length > 0 && (
3330 <div class="prs-review-row prs-review-approved">
3331 <span class="prs-review-icon">✓</span>
3332 <span>
3333 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
3334 approved this pull request
3335 </span>
3336 </div>
3337 )}
3338 {changesRequested.length > 0 && (
3339 <div class="prs-review-row prs-review-changes">
3340 <span class="prs-review-icon">✗</span>
3341 <span>
3342 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
3343 requested changes
3344 </span>
3345 </div>
3346 )}
3347 </div>
3348 )}
3349
b078860Claude3350 {pr.state === "open" && gateChecks.length > 0 && (
3351 <div class="prs-gate-card">
3352 <div class="prs-gate-head">
3353 <h3>Gate checks</h3>
3354 <span class="prs-gate-summary">
3355 {gatesAllPassed
3356 ? `All ${gateChecks.length} checks passed`
3357 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
3358 </span>
c3e0c07Claude3359 </div>
b078860Claude3360 {gateChecks.map((check) => {
3361 const isAi = /ai.*review/i.test(check.name);
3362 const isSkip = check.skipped === true;
3363 const statusClass = isSkip
3364 ? "is-skip"
3365 : check.passed
3366 ? "is-pass"
3367 : "is-fail";
3368 const statusGlyph = isSkip
3369 ? "—"
3370 : check.passed
3371 ? "✓"
3372 : "✗";
3373 const statusLabel = isSkip
3374 ? "Skipped"
3375 : check.passed
3376 ? "Passed"
3377 : "Failing";
3378 return (
3379 <div
3380 class="prs-gate-row"
3381 style={
3382 isAi
3383 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
3384 : ""
3385 }
3386 >
3387 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
3388 {statusGlyph}
3389 </span>
3390 <span class="prs-gate-name">
3391 {check.name}
3392 {isAi && (
3393 <span
3394 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"
3395 >
3396 AI
3397 </span>
3398 )}
3399 </span>
3400 <span class="prs-gate-details">{check.details}</span>
3401 <span class={`prs-gate-pill ${statusClass}`}>
3402 {statusLabel}
e883329Claude3403 </span>
3404 </div>
b078860Claude3405 );
3406 })}
3407 <div class="prs-gate-footer">
3408 {gatesAllPassed
3409 ? "All checks passed — ready to merge."
3410 : gateChecks.some(
3411 (c) => !c.passed && c.name === "Merge check"
3412 )
3413 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
3414 : "Some checks failed — resolve issues before merging."}
3415 {aiReviewCount > 0 && (
3416 <>
3417 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
3418 </>
3419 )}
3420 </div>
3421 </div>
3422 )}
3423
3424 {/* ─── Merge area / state-aware action card ─────────────── */}
3425 {user && pr.state === "open" && (
3426 <div
3427 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
3428 >
3429 <div class="prs-merge-head">
3430 <strong>
3431 {pr.isDraft
3432 ? "Draft — ready for review?"
3433 : mergeBlocked
3434 ? "Merge blocked"
3435 : "Ready to merge"}
3436 </strong>
e883329Claude3437 </div>
b078860Claude3438 <p class="prs-merge-sub">
3439 {pr.isDraft
3440 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
3441 : mergeBlocked
3442 ? "Resolve the failing gate checks above before this PR can land."
3443 : gateChecks.length > 0
3444 ? gatesAllPassed
3445 ? "All gates green. Merge will fast-forward into the base branch."
3446 : "Conflicts will be auto-resolved by GlueCron AI on merge."
3447 : "Run gate checks by refreshing once your branch has a recent commit."}
3448 </p>
3449 <Form
3450 method="post"
3451 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
3452 >
3453 <FormGroup>
3c03977Claude3454 <div class="live-cursor-host" style="position:relative">
3455 <textarea
3456 name="body"
3457 id="pr-comment-body"
3458 data-live-field="comment_new"
3459 rows={5}
3460 required
3461 placeholder="Leave a comment... (Markdown supported)"
3462 style="font-family:var(--font-mono);font-size:13px;width:100%"
3463 ></textarea>
3464 </div>
15db0e0Claude3465 <span class="slash-hint" title="Type a slash-command as the first line">
3466 Type <code>/</code> for commands —{" "}
3467 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
3468 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
3469 </span>
b078860Claude3470 </FormGroup>
3471 <div class="prs-merge-actions">
3472 <Button type="submit" variant="primary">
3473 Comment
3474 </Button>
0a67773Claude3475 {user && user.id !== pr.authorId && pr.state === "open" && (
3476 <>
3477 <button
3478 type="submit"
3479 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
3480 name="review_state"
3481 value="approved"
3482 class="prs-review-approve-btn"
3483 title="Approve this pull request"
3484 >
3485 ✓ Approve
3486 </button>
3487 <button
3488 type="submit"
3489 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
3490 name="review_state"
3491 value="changes_requested"
3492 class="prs-review-changes-btn"
3493 title="Request changes before merging"
3494 >
3495 ✗ Request changes
3496 </button>
3497 </>
3498 )}
b078860Claude3499 {canManage && (
3500 <>
3501 {pr.isDraft ? (
3502 <button
3503 type="submit"
3504 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3505 formnovalidate
3506 class="prs-merge-ready-btn"
3507 >
3508 Ready for review
3509 </button>
3510 ) : (
a164a6dClaude3511 <>
3512 <div class="prs-merge-strategy-wrap">
3513 <span class="prs-merge-strategy-label">Strategy</span>
3514 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
3515 <option value="merge">Merge commit</option>
3516 <option value="squash">Squash and merge</option>
3517 <option value="ff">Fast-forward</option>
3518 </select>
3519 </div>
3520 <button
3521 type="submit"
3522 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
3523 formnovalidate
3524 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
3525 title={
3526 mergeBlocked
3527 ? "Failing gate checks must be resolved before this PR can merge."
3528 : "Merge pull request"
3529 }
3530 >
3531 {"✔"} Merge pull request
3532 </button>
3533 </>
b078860Claude3534 )}
3535 {!pr.isDraft && (
3536 <button
0074234Claude3537 type="submit"
b078860Claude3538 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
3539 formnovalidate
3540 class="prs-merge-back-draft"
3541 title="Convert back to draft"
0074234Claude3542 >
b078860Claude3543 Convert to draft
3544 </button>
3545 )}
3546 {isAiReviewEnabled() && (
3547 <button
3548 type="submit"
3549 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
3550 formnovalidate
3551 class="btn"
3552 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
3553 >
3554 Re-run AI review
3555 </button>
3556 )}
1d4ff60Claude3557 {isAiReviewEnabled() && !hasAiTestsMarker && (
3558 <button
3559 type="submit"
3560 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
3561 formnovalidate
3562 class="btn"
3563 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."
3564 >
3565 Generate tests with AI
3566 </button>
3567 )}
b078860Claude3568 <Button
3569 type="submit"
3570 variant="danger"
3571 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
3572 >
3573 Close
3574 </Button>
3575 </>
3576 )}
3577 </div>
3578 </Form>
3579 </div>
3580 )}
3581
3582 {/* Read-only footers for non-open states. */}
3583 {pr.state === "merged" && (
3584 <div class="prs-merge-card is-merged">
3585 <div class="prs-merge-head">
3586 <strong>{"⮌"} Merged</strong>
0074234Claude3587 </div>
b078860Claude3588 <p class="prs-merge-sub">
3589 This pull request was merged into{" "}
3590 <code>{pr.baseBranch}</code>.
3591 </p>
3592 </div>
3593 )}
3594 {pr.state === "closed" && (
3595 <div class="prs-merge-card is-closed">
3596 <div class="prs-merge-head">
3597 <strong>{"✕"} Closed without merging</strong>
3598 </div>
3599 <p class="prs-merge-sub">
3600 This pull request was closed and not merged.
3601 </p>
3602 </div>
3603 )}
3604 </>
3605 )}
0074234Claude3606 </Layout>
3607 );
3608});
3609
cb5a796Claude3610// Add comment to PR.
3611//
3612// Permission model mirrors `issues.tsx`: any logged-in user with read
3613// access can submit; `decideInitialStatus` routes non-collaborators
3614// through the moderation queue. Slash commands only fire when the
3615// comment is auto-approved — we don't want a banned/pending comment to
3616// silently trigger AI work on the PR.
0074234Claude3617pulls.post(
3618 "/:owner/:repo/pulls/:number/comment",
3619 softAuth,
3620 requireAuth,
cb5a796Claude3621 requireRepoAccess("read"),
0074234Claude3622 async (c) => {
3623 const { owner: ownerName, repo: repoName } = c.req.param();
3624 const prNum = parseInt(c.req.param("number"), 10);
3625 const user = c.get("user")!;
3626 const body = await c.req.parseBody();
3627 const commentBody = String(body.body || "").trim();
47a7a0aClaude3628 const filePathRaw = String(body.file_path || "").trim();
3629 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
3630 const inlineFilePath = filePathRaw || undefined;
3631 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude3632
3633 if (!commentBody) {
3634 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3635 }
3636
3637 const resolved = await resolveRepo(ownerName, repoName);
3638 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3639
3640 const [pr] = await db
3641 .select()
3642 .from(pullRequests)
3643 .where(
3644 and(
3645 eq(pullRequests.repositoryId, resolved.repo.id),
3646 eq(pullRequests.number, prNum)
3647 )
3648 )
3649 .limit(1);
3650
3651 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3652
cb5a796Claude3653 const decision = await decideInitialStatus({
3654 commenterUserId: user.id,
3655 repositoryId: resolved.repo.id,
3656 kind: "pr",
3657 threadId: pr.id,
3658 });
3659
d4ac5c3Claude3660 const [inserted] = await db
3661 .insert(prComments)
3662 .values({
3663 pullRequestId: pr.id,
3664 authorId: user.id,
3665 body: commentBody,
cb5a796Claude3666 moderationStatus: decision.status,
47a7a0aClaude3667 filePath: inlineFilePath,
3668 lineNumber: inlineLineNumber,
d4ac5c3Claude3669 })
3670 .returning();
3671
cb5a796Claude3672 // Live update: only when the comment is actually visible.
3673 if (inserted && decision.status === "approved") {
d4ac5c3Claude3674 try {
3675 const { publish } = await import("../lib/sse");
3676 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
3677 event: "pr-comment",
3678 data: {
3679 pullRequestId: pr.id,
3680 commentId: inserted.id,
3681 authorId: user.id,
3682 authorUsername: user.username,
3683 },
3684 });
3685 } catch {
3686 /* SSE is best-effort */
3687 }
3688 }
0074234Claude3689
cb5a796Claude3690 if (decision.status === "pending") {
3691 void notifyOwnerOfPendingComment({
3692 repositoryId: resolved.repo.id,
3693 commenterUsername: user.username,
3694 kind: "pr",
3695 threadNumber: prNum,
3696 ownerUsername: ownerName,
3697 repoName,
3698 });
3699 return c.redirect(
3700 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
3701 );
3702 }
3703 if (decision.status === "rejected") {
3704 // Silent ban path — same UX as 'pending' so we don't leak the gate.
3705 return c.redirect(
3706 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
3707 );
3708 }
3709
15db0e0Claude3710 // Slash-command handoff. We always store the original comment above
3711 // first so free-form text that happens to start with `/` is preserved
3712 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude3713 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude3714 const parsed = parseSlashCommand(commentBody);
3715 if (parsed) {
3716 try {
3717 const result = await executeSlashCommand({
3718 command: parsed.command,
3719 args: parsed.args,
3720 prId: pr.id,
3721 userId: user.id,
3722 repositoryId: resolved.repo.id,
3723 });
3724 await db.insert(prComments).values({
3725 pullRequestId: pr.id,
3726 authorId: user.id,
3727 body: result.body,
3728 });
3729 } catch (err) {
3730 // Defence-in-depth — executeSlashCommand promises not to throw,
3731 // but if it ever does we want the PR thread to know.
3732 await db
3733 .insert(prComments)
3734 .values({
3735 pullRequestId: pr.id,
3736 authorId: user.id,
3737 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
3738 })
3739 .catch(() => {});
3740 }
3741 }
3742
47a7a0aClaude3743 // Inline comments go back to the files tab; conversation comments to the conversation tab
3744 const redirectTab = inlineFilePath ? "?tab=files" : "";
3745 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude3746 }
3747);
3748
b5dd694Claude3749// Apply a suggestion from a PR comment — commits the suggested code to the
3750// head branch on behalf of the logged-in user.
3751pulls.post(
3752 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
3753 softAuth,
3754 requireAuth,
3755 requireRepoAccess("read"),
3756 async (c) => {
3757 const { owner: ownerName, repo: repoName } = c.req.param();
3758 const prNum = parseInt(c.req.param("number"), 10);
3759 const commentId = c.req.param("commentId"); // UUID
3760 const user = c.get("user")!;
3761
3762 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
3763
3764 const resolved = await resolveRepo(ownerName, repoName);
3765 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3766
3767 const [pr] = await db
3768 .select()
3769 .from(pullRequests)
3770 .where(
3771 and(
3772 eq(pullRequests.repositoryId, resolved.repo.id),
3773 eq(pullRequests.number, prNum)
3774 )
3775 )
3776 .limit(1);
3777
3778 if (!pr || pr.state !== "open") {
3779 return c.redirect(`${backUrl}&error=pr_not_open`);
3780 }
3781
3782 // Only PR author or repo owner may apply suggestions.
3783 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
3784 return c.redirect(`${backUrl}&error=forbidden`);
3785 }
3786
3787 // Load the comment.
3788 const [comment] = await db
3789 .select()
3790 .from(prComments)
3791 .where(
3792 and(
3793 eq(prComments.id, commentId),
3794 eq(prComments.pullRequestId, pr.id)
3795 )
3796 )
3797 .limit(1);
3798
3799 if (!comment) {
3800 return c.redirect(`${backUrl}&error=comment_not_found`);
3801 }
3802
3803 // Parse suggestion block from comment body.
3804 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
3805 if (!m) {
3806 return c.redirect(`${backUrl}&error=no_suggestion`);
3807 }
3808 const suggestionCode = m[1];
3809
3810 // Get the commenter's details for the commit message co-author line.
3811 const [commenter] = await db
3812 .select()
3813 .from(users)
3814 .where(eq(users.id, comment.authorId))
3815 .limit(1);
3816
3817 // Fetch current file content from head branch.
3818 if (!comment.filePath) {
3819 return c.redirect(`${backUrl}&error=file_not_found`);
3820 }
3821 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
3822 if (!blob) {
3823 return c.redirect(`${backUrl}&error=file_not_found`);
3824 }
3825
3826 // Apply the patch — replace the target line(s) with suggestion lines.
3827 const lines = blob.content.split('\n');
3828 const lineIdx = (comment.lineNumber ?? 1) - 1;
3829 if (lineIdx < 0 || lineIdx >= lines.length) {
3830 return c.redirect(`${backUrl}&error=line_out_of_range`);
3831 }
3832 const suggestionLines = suggestionCode.split('\n');
3833 lines.splice(lineIdx, 1, ...suggestionLines);
3834 const newContent = lines.join('\n');
3835
3836 // Commit the change.
3837 const coAuthorLine = commenter
3838 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
3839 : "";
3840 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
3841
3842 const result = await createOrUpdateFileOnBranch({
3843 owner: ownerName,
3844 name: repoName,
3845 branch: pr.headBranch,
3846 filePath: comment.filePath,
3847 bytes: new TextEncoder().encode(newContent),
3848 message: commitMessage,
3849 authorName: user.username,
3850 authorEmail: `${user.username}@users.noreply.gluecron.com`,
3851 });
3852
3853 if ("error" in result) {
3854 return c.redirect(`${backUrl}&error=apply_failed`);
3855 }
3856
3857 // Post a follow-up comment noting the suggestion was applied.
3858 await db.insert(prComments).values({
3859 pullRequestId: pr.id,
3860 authorId: user.id,
3861 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
3862 });
3863
3864 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
3865 }
3866);
3867
0a67773Claude3868// Formal review — Approve / Request Changes / Comment
3869pulls.post(
3870 "/:owner/:repo/pulls/:number/review",
3871 softAuth,
3872 requireAuth,
3873 requireRepoAccess("read"),
3874 async (c) => {
3875 const { owner: ownerName, repo: repoName } = c.req.param();
3876 const prNum = parseInt(c.req.param("number"), 10);
3877 const user = c.get("user")!;
3878 const body = await c.req.parseBody();
3879 const reviewBody = String(body.body || "").trim();
3880 const reviewState = String(body.review_state || "commented");
3881
3882 const validStates = ["approved", "changes_requested", "commented"];
3883 if (!validStates.includes(reviewState)) {
3884 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3885 }
3886
3887 const resolved = await resolveRepo(ownerName, repoName);
3888 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3889
3890 const [pr] = await db
3891 .select()
3892 .from(pullRequests)
3893 .where(
3894 and(
3895 eq(pullRequests.repositoryId, resolved.repo.id),
3896 eq(pullRequests.number, prNum)
3897 )
3898 )
3899 .limit(1);
3900 if (!pr || pr.state !== "open") {
3901 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3902 }
3903 // Authors can't review their own PR
3904 if (pr.authorId === user.id) {
3905 return c.redirect(
3906 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
3907 );
3908 }
3909
3910 await db.insert(prReviews).values({
3911 pullRequestId: pr.id,
3912 reviewerId: user.id,
3913 state: reviewState,
3914 body: reviewBody || null,
3915 });
3916
3917 const stateLabel =
3918 reviewState === "approved" ? "Approved"
3919 : reviewState === "changes_requested" ? "Changes requested"
3920 : "Commented";
3921 return c.redirect(
3922 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
3923 );
3924 }
3925);
3926
e883329Claude3927// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude3928// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
3929// but we keep it at "write" for v1 so trusted collaborators can ship.
3930// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
3931// surface. Branch-protection rules (evaluated below) are the current mechanism
3932// for locking down merges further on specific branches.
0074234Claude3933pulls.post(
3934 "/:owner/:repo/pulls/:number/merge",
3935 softAuth,
3936 requireAuth,
04f6b7fClaude3937 requireRepoAccess("write"),
0074234Claude3938 async (c) => {
3939 const { owner: ownerName, repo: repoName } = c.req.param();
3940 const prNum = parseInt(c.req.param("number"), 10);
3941 const user = c.get("user")!;
3942
a164a6dClaude3943 // Read merge strategy from form (default: merge commit)
3944 let mergeStrategy = "merge";
3945 try {
3946 const body = await c.req.parseBody();
3947 const s = body.merge_strategy;
3948 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
3949 } catch { /* ignore parse errors — default to merge commit */ }
3950
0074234Claude3951 const resolved = await resolveRepo(ownerName, repoName);
3952 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3953
3954 const [pr] = await db
3955 .select()
3956 .from(pullRequests)
3957 .where(
3958 and(
3959 eq(pullRequests.repositoryId, resolved.repo.id),
3960 eq(pullRequests.number, prNum)
3961 )
3962 )
3963 .limit(1);
3964
3965 if (!pr || pr.state !== "open") {
3966 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3967 }
3968
6fc53bdClaude3969 // Draft PRs cannot be merged — must be marked ready first.
3970 if (pr.isDraft) {
3971 return c.redirect(
3972 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
3973 "This PR is a draft. Mark it as ready for review before merging."
3974 )}`
3975 );
3976 }
3977
e883329Claude3978 // Resolve head SHA
3979 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3980 if (!headSha) {
3981 return c.redirect(
3982 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
3983 );
3984 }
3985
3986 // Check if AI review approved this PR
3987 const aiComments = await db
3988 .select()
3989 .from(prComments)
3990 .where(
3991 and(
3992 eq(prComments.pullRequestId, pr.id),
3993 eq(prComments.isAiReview, true)
3994 )
3995 );
3996 const aiApproved = aiComments.length === 0 || aiComments.some(
3997 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude3998 );
e883329Claude3999
4000 // Run all green gate checks (GateTest + mergeability + AI review)
4001 const gateResult = await runAllGateChecks(
4002 ownerName,
4003 repoName,
4004 pr.baseBranch,
4005 pr.headBranch,
4006 headSha,
4007 aiApproved
0074234Claude4008 );
4009
e883329Claude4010 // If GateTest or AI review failed (hard blocks), reject the merge
4011 const hardFailures = gateResult.checks.filter(
4012 (check) => !check.passed && check.name !== "Merge check"
4013 );
4014 if (hardFailures.length > 0) {
4015 const errorMsg = hardFailures
4016 .map((f) => `${f.name}: ${f.details}`)
4017 .join("; ");
0074234Claude4018 return c.redirect(
e883329Claude4019 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude4020 );
4021 }
4022
1e162a8Claude4023 // D5 — Branch-protection enforcement. Looks up the matching rule for the
4024 // base branch and blocks the merge if requireAiApproval / requireGreenGates
4025 // / requireHumanReview / requiredApprovals are not satisfied. Independent
4026 // of repo-global settings, so owners can lock specific branches down
4027 // further than the repo default.
4028 const protectionRule = await matchProtection(
4029 resolved.repo.id,
4030 pr.baseBranch
4031 );
4032 if (protectionRule) {
4033 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude4034 const required = await listRequiredChecks(protectionRule.id);
4035 const passingNames = required.length > 0
4036 ? await passingCheckNames(resolved.repo.id, headSha)
4037 : [];
4038 const decision = evaluateProtection(
4039 protectionRule,
4040 {
4041 aiApproved,
4042 humanApprovalCount: humanApprovals,
4043 gateResultGreen: hardFailures.length === 0,
4044 hasFailedGates: hardFailures.length > 0,
4045 passingCheckNames: passingNames,
4046 },
4047 required.map((r) => r.checkName)
4048 );
1e162a8Claude4049 if (!decision.allowed) {
4050 return c.redirect(
4051 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4052 decision.reasons.join(" ")
4053 )}`
4054 );
4055 }
4056 }
4057
e883329Claude4058 // Attempt the merge — with auto conflict resolution if needed
4059 const repoDir = getRepoPath(ownerName, repoName);
4060 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
4061 const hasConflicts = mergeCheck && !mergeCheck.passed;
4062
4063 if (hasConflicts && isAiReviewEnabled()) {
4064 // Use Claude to auto-resolve conflicts
4065 const mergeResult = await mergeWithAutoResolve(
4066 ownerName,
4067 repoName,
4068 pr.baseBranch,
4069 pr.headBranch,
4070 `Merge pull request #${pr.number}: ${pr.title}`
4071 );
4072
4073 if (!mergeResult.success) {
4074 return c.redirect(
4075 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
4076 );
4077 }
4078
4079 // Post a comment about the auto-resolution
4080 if (mergeResult.resolvedFiles.length > 0) {
4081 await db.insert(prComments).values({
4082 pullRequestId: pr.id,
4083 authorId: user.id,
4084 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
4085 isAiReview: true,
4086 });
4087 }
4088 } else {
a164a6dClaude4089 // Worktree-based merge: supports merge-commit, squash, and fast-forward
4090 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
4091 const gitEnv = {
4092 ...process.env,
4093 GIT_AUTHOR_NAME: user.displayName || user.username,
4094 GIT_AUTHOR_EMAIL: user.email,
4095 GIT_COMMITTER_NAME: user.displayName || user.username,
4096 GIT_COMMITTER_EMAIL: user.email,
4097 };
4098
4099 // Create linked worktree on the base branch
4100 const addWt = Bun.spawn(
4101 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude4102 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4103 );
a164a6dClaude4104 if (await addWt.exited !== 0) {
4105 return c.redirect(
4106 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
4107 );
4108 }
4109
4110 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
4111 let mergeOk = false;
4112
4113 try {
4114 if (mergeStrategy === "squash") {
4115 // Squash: stage all changes without committing
4116 const squashProc = Bun.spawn(
4117 ["git", "merge", "--squash", headSha],
4118 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4119 );
4120 if (await squashProc.exited !== 0) {
4121 const errTxt = await new Response(squashProc.stderr).text();
4122 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
4123 }
4124 // Commit the squashed changes
4125 const commitProc = Bun.spawn(
4126 ["git", "commit", "-m", commitMsg],
4127 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4128 );
4129 if (await commitProc.exited !== 0) {
4130 const errTxt = await new Response(commitProc.stderr).text();
4131 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
4132 }
4133 mergeOk = true;
4134 } else if (mergeStrategy === "ff") {
4135 // Fast-forward only — fail if FF is not possible
4136 const ffProc = Bun.spawn(
4137 ["git", "merge", "--ff-only", headSha],
4138 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4139 );
4140 if (await ffProc.exited !== 0) {
4141 const errTxt = await new Response(ffProc.stderr).text();
4142 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
4143 }
4144 mergeOk = true;
4145 } else {
4146 // Default: merge commit (--no-ff always creates a merge commit)
4147 const mergeProc = Bun.spawn(
4148 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
4149 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4150 );
4151 if (await mergeProc.exited !== 0) {
4152 const errTxt = await new Response(mergeProc.stderr).text();
4153 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
4154 }
4155 mergeOk = true;
4156 }
4157 } catch (err) {
4158 // Always clean up the worktree before redirecting
4159 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
4160 const msg = err instanceof Error ? err.message : "Merge failed";
4161 return c.redirect(
4162 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
4163 );
4164 }
4165
4166 // Clean up worktree (changes are now in the bare repo via linked worktree)
4167 await Bun.spawn(
4168 ["git", "worktree", "remove", "--force", wt],
4169 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4170 ).exited.catch(() => {});
e883329Claude4171
a164a6dClaude4172 if (!mergeOk) {
e883329Claude4173 return c.redirect(
a164a6dClaude4174 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude4175 );
4176 }
4177 }
4178
0074234Claude4179 await db
4180 .update(pullRequests)
4181 .set({
4182 state: "merged",
4183 mergedAt: new Date(),
4184 mergedBy: user.id,
4185 updatedAt: new Date(),
4186 })
4187 .where(eq(pullRequests.id, pr.id));
4188
8809b87Claude4189 // Chat notifier — fan out merge event to Slack/Discord/Teams.
4190 import("../lib/chat-notifier")
4191 .then((m) =>
4192 m.notifyChatChannels({
4193 ownerUserId: resolved.repo.ownerId,
4194 repositoryId: resolved.repo.id,
4195 event: {
4196 event: "pr.merged",
4197 repo: `${ownerName}/${repoName}`,
4198 title: `#${pr.number} ${pr.title}`,
4199 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
4200 actor: user.username,
4201 },
4202 })
4203 )
4204 .catch((err) =>
4205 console.warn(`[chat-notifier] PR merge notify failed:`, err)
4206 );
4207
d62fb36Claude4208 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
4209 // and auto-close each matching open issue with a back-link comment. Bounded
4210 // to the same repo for v1 (cross-repo refs ignored). Failures never block
4211 // the merge redirect.
4212 try {
4213 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
4214 const refs = extractClosingRefsMulti([pr.title, pr.body]);
4215 for (const n of refs) {
4216 const [issue] = await db
4217 .select()
4218 .from(issues)
4219 .where(
4220 and(
4221 eq(issues.repositoryId, resolved.repo.id),
4222 eq(issues.number, n)
4223 )
4224 )
4225 .limit(1);
4226 if (!issue || issue.state !== "open") continue;
4227 await db
4228 .update(issues)
4229 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
4230 .where(eq(issues.id, issue.id));
4231 await db.insert(issueComments).values({
4232 issueId: issue.id,
4233 authorId: user.id,
4234 body: `Closed by pull request #${pr.number}.`,
4235 });
4236 }
4237 } catch {
4238 // Never block the merge on close-keyword failures.
4239 }
4240
0074234Claude4241 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4242 }
4243);
4244
6fc53bdClaude4245// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
4246// hasn't run yet on this PR.
4247pulls.post(
4248 "/:owner/:repo/pulls/:number/ready",
4249 softAuth,
4250 requireAuth,
04f6b7fClaude4251 requireRepoAccess("write"),
6fc53bdClaude4252 async (c) => {
4253 const { owner: ownerName, repo: repoName } = c.req.param();
4254 const prNum = parseInt(c.req.param("number"), 10);
4255 const user = c.get("user")!;
4256
4257 const resolved = await resolveRepo(ownerName, repoName);
4258 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4259
4260 const [pr] = await db
4261 .select()
4262 .from(pullRequests)
4263 .where(
4264 and(
4265 eq(pullRequests.repositoryId, resolved.repo.id),
4266 eq(pullRequests.number, prNum)
4267 )
4268 )
4269 .limit(1);
4270 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4271
4272 // Only the author or repo owner can toggle draft state.
4273 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
4274 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4275 }
4276
4277 if (pr.state === "open" && pr.isDraft) {
4278 await db
4279 .update(pullRequests)
4280 .set({ isDraft: false, updatedAt: new Date() })
4281 .where(eq(pullRequests.id, pr.id));
4282
4283 if (isAiReviewEnabled()) {
4284 triggerAiReview(
4285 ownerName,
4286 repoName,
4287 pr.id,
4288 pr.title,
0316dbbClaude4289 pr.body || "",
6fc53bdClaude4290 pr.baseBranch,
4291 pr.headBranch
4292 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
4293 }
4294 }
4295
4296 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4297 }
4298);
4299
4300// Convert a PR back to draft.
4301pulls.post(
4302 "/:owner/:repo/pulls/:number/draft",
4303 softAuth,
4304 requireAuth,
04f6b7fClaude4305 requireRepoAccess("write"),
6fc53bdClaude4306 async (c) => {
4307 const { owner: ownerName, repo: repoName } = c.req.param();
4308 const prNum = parseInt(c.req.param("number"), 10);
4309 const user = c.get("user")!;
4310
4311 const resolved = await resolveRepo(ownerName, repoName);
4312 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4313
4314 const [pr] = await db
4315 .select()
4316 .from(pullRequests)
4317 .where(
4318 and(
4319 eq(pullRequests.repositoryId, resolved.repo.id),
4320 eq(pullRequests.number, prNum)
4321 )
4322 )
4323 .limit(1);
4324 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4325
4326 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
4327 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4328 }
4329
4330 if (pr.state === "open" && !pr.isDraft) {
4331 await db
4332 .update(pullRequests)
4333 .set({ isDraft: true, updatedAt: new Date() })
4334 .where(eq(pullRequests.id, pr.id));
4335 }
4336
4337 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4338 }
4339);
4340
0074234Claude4341// Close PR
4342pulls.post(
4343 "/:owner/:repo/pulls/:number/close",
4344 softAuth,
4345 requireAuth,
04f6b7fClaude4346 requireRepoAccess("write"),
0074234Claude4347 async (c) => {
4348 const { owner: ownerName, repo: repoName } = c.req.param();
4349 const prNum = parseInt(c.req.param("number"), 10);
4350
4351 const resolved = await resolveRepo(ownerName, repoName);
4352 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4353
4354 await db
4355 .update(pullRequests)
4356 .set({
4357 state: "closed",
4358 closedAt: new Date(),
4359 updatedAt: new Date(),
4360 })
4361 .where(
4362 and(
4363 eq(pullRequests.repositoryId, resolved.repo.id),
4364 eq(pullRequests.number, prNum)
4365 )
4366 );
4367
4368 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4369 }
4370);
4371
c3e0c07Claude4372// Re-run AI review on demand (e.g. after a force-push). Bypasses the
4373// idempotency marker via { force: true }. Write-access only.
4374pulls.post(
4375 "/:owner/:repo/pulls/:number/ai-rereview",
4376 softAuth,
4377 requireAuth,
4378 requireRepoAccess("write"),
4379 async (c) => {
4380 const { owner: ownerName, repo: repoName } = c.req.param();
4381 const prNum = parseInt(c.req.param("number"), 10);
4382 const resolved = await resolveRepo(ownerName, repoName);
4383 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4384
4385 const [pr] = await db
4386 .select()
4387 .from(pullRequests)
4388 .where(
4389 and(
4390 eq(pullRequests.repositoryId, resolved.repo.id),
4391 eq(pullRequests.number, prNum)
4392 )
4393 )
4394 .limit(1);
4395 if (!pr) {
4396 return c.redirect(`/${ownerName}/${repoName}/pulls`);
4397 }
4398
4399 if (!isAiReviewEnabled()) {
4400 return c.redirect(
4401 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4402 "AI review is not configured (ANTHROPIC_API_KEY)."
4403 )}`
4404 );
4405 }
4406
4407 // Fire-and-forget but with { force: true } to bypass the
4408 // already-reviewed marker. The function still never throws.
4409 triggerAiReview(
4410 ownerName,
4411 repoName,
4412 pr.id,
4413 pr.title || "",
4414 pr.body || "",
4415 pr.baseBranch,
4416 pr.headBranch,
4417 { force: true }
a28cedeClaude4418 ).catch((err) => {
4419 console.warn(
4420 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
4421 err instanceof Error ? err.message : err
4422 );
4423 });
c3e0c07Claude4424
4425 return c.redirect(
4426 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
4427 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
4428 )}`
4429 );
4430 }
4431);
4432
1d4ff60Claude4433// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
4434// the PR's head branch carrying just the new test files. Write-access only.
4435// Idempotent — if `ai:added-tests` was previously applied we redirect with
4436// an `info` banner instead of re-firing.
4437pulls.post(
4438 "/:owner/:repo/pulls/:number/generate-tests",
4439 softAuth,
4440 requireAuth,
4441 requireRepoAccess("write"),
4442 async (c) => {
4443 const { owner: ownerName, repo: repoName } = c.req.param();
4444 const prNum = parseInt(c.req.param("number"), 10);
4445 const resolved = await resolveRepo(ownerName, repoName);
4446 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4447
4448 const [pr] = await db
4449 .select()
4450 .from(pullRequests)
4451 .where(
4452 and(
4453 eq(pullRequests.repositoryId, resolved.repo.id),
4454 eq(pullRequests.number, prNum)
4455 )
4456 )
4457 .limit(1);
4458 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4459
4460 if (!isAiReviewEnabled()) {
4461 return c.redirect(
4462 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4463 "AI test generation is not configured (ANTHROPIC_API_KEY)."
4464 )}`
4465 );
4466 }
4467
4468 // Fire-and-forget. The lib never throws.
4469 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
4470 .then((res) => {
4471 if (!res.ok) {
4472 console.warn(
4473 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
4474 );
4475 }
4476 })
4477 .catch((err) => {
4478 console.warn(
4479 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
4480 err instanceof Error ? err.message : err
4481 );
4482 });
4483
4484 return c.redirect(
4485 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
4486 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
4487 )}`
4488 );
4489 }
4490);
4491
0074234Claude4492export default pulls;