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.tsxBlame4525 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";
1aef949Claude17import { eq, and, desc, asc, sql, inArray } from "drizzle-orm";
0074234Claude18import { 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 }
1aef949Claude323 .prs-tag.is-approved {
324 color: #34d399;
325 border-color: rgba(52,211,153,0.40);
326 background: rgba(52,211,153,0.08);
327 }
328 .prs-tag.is-changes {
329 color: #f87171;
330 border-color: rgba(248,113,113,0.40);
331 background: rgba(248,113,113,0.08);
332 }
b078860Claude333
334 .prs-empty {
ea9ed4cClaude335 position: relative;
336 padding: 56px 32px;
b078860Claude337 text-align: center;
338 border: 1px dashed var(--border);
ea9ed4cClaude339 border-radius: 16px;
340 background: var(--bg-elevated);
b078860Claude341 color: var(--text-muted);
ea9ed4cClaude342 overflow: hidden;
b078860Claude343 }
ea9ed4cClaude344 .prs-empty::before {
345 content: '';
346 position: absolute;
347 inset: -40% -20% auto auto;
348 width: 320px; height: 320px;
349 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
350 filter: blur(70px);
351 opacity: 0.55;
352 pointer-events: none;
353 animation: prsEmptyOrb 16s ease-in-out infinite;
354 }
355 @keyframes prsEmptyOrb {
356 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
357 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
358 }
359 @media (prefers-reduced-motion: reduce) {
360 .prs-empty::before { animation: none; }
361 }
362 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude363 .prs-empty strong {
364 display: block;
365 color: var(--text-strong);
ea9ed4cClaude366 font-family: var(--font-display);
367 font-size: 22px;
368 font-weight: 700;
369 letter-spacing: -0.018em;
370 margin-bottom: 2px;
371 }
372 .prs-empty-sub {
373 font-size: 14.5px;
374 color: var(--text-muted);
375 line-height: 1.55;
376 max-width: 460px;
377 margin: 0 0 18px;
b078860Claude378 }
ea9ed4cClaude379 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude380
381 @media (max-width: 720px) {
382 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
383 .prs-hero-actions { width: 100%; }
384 .prs-row-tags { margin-left: 0; }
385 }
f1dc7c7Claude386
387 /* Additional mobile rules. Additive only. */
388 @media (max-width: 720px) {
389 .prs-hero { padding: 18px 18px 20px; }
390 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
391 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
392 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
393 .prs-row { padding: 12px 14px; gap: 10px; }
394 .prs-row-icon { width: 24px; height: 24px; }
395 }
b078860Claude396`;
397
398/* ──────────────────────────────────────────────────────────────────────
399 * Inline CSS for the detail page. Same `.prs-*` namespace.
400 * ──────────────────────────────────────────────────────────────────── */
401const PRS_DETAIL_STYLES = `
402 .prs-detail-hero {
403 position: relative;
404 margin: 0 0 var(--space-4);
405 padding: 24px 26px;
406 background: var(--bg-elevated);
407 border: 1px solid var(--border);
408 border-radius: 16px;
409 overflow: hidden;
410 }
411 .prs-detail-hero::before {
412 content: '';
413 position: absolute; top: 0; left: 0; right: 0;
414 height: 2px;
415 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
416 opacity: 0.7;
417 pointer-events: none;
418 }
419 .prs-detail-title {
420 font-family: var(--font-display);
421 font-size: clamp(22px, 2.6vw, 28px);
422 font-weight: 700;
423 letter-spacing: -0.022em;
424 line-height: 1.2;
425 color: var(--text-strong);
426 margin: 0 0 12px;
427 }
428 .prs-detail-num {
429 color: var(--text-muted);
430 font-weight: 400;
431 }
432 .prs-state-pill {
433 display: inline-flex; align-items: center; gap: 6px;
434 padding: 6px 12px;
435 border-radius: 9999px;
436 font-size: 12.5px;
437 font-weight: 600;
438 line-height: 1;
439 border: 1px solid transparent;
440 }
441 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
442 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
443 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
444 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
445
446 .prs-detail-meta {
447 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
448 font-size: 13px;
449 color: var(--text-muted);
450 }
451 .prs-detail-meta strong { color: var(--text); }
452 .prs-detail-branches {
453 display: inline-flex; align-items: center; gap: 6px;
454 font-family: var(--font-mono);
455 font-size: 12px;
456 }
457 .prs-branch-pill {
458 padding: 3px 9px;
459 border-radius: 9999px;
460 background: var(--bg-tertiary);
461 border: 1px solid var(--border);
462 color: var(--text);
463 }
464 .prs-branch-pill.is-head { color: var(--text-strong); }
465 .prs-branch-arrow-lg {
466 color: var(--accent);
467 font-size: 14px;
468 font-weight: 700;
469 }
470
471 .prs-detail-actions {
472 display: inline-flex; gap: 8px; margin-left: auto;
473 }
474
475 .prs-detail-tabs {
476 display: flex; gap: 4px;
477 margin: 0 0 16px;
478 border-bottom: 1px solid var(--border);
479 }
480 .prs-detail-tab {
481 padding: 10px 14px;
482 font-size: 13.5px;
483 font-weight: 500;
484 color: var(--text-muted);
485 text-decoration: none;
486 border-bottom: 2px solid transparent;
487 transition: color 120ms ease, border-color 120ms ease;
488 margin-bottom: -1px;
489 }
490 .prs-detail-tab:hover { color: var(--text); }
491 .prs-detail-tab.is-active {
492 color: var(--text-strong);
493 border-bottom-color: var(--accent);
494 }
495 .prs-detail-tab-count {
496 display: inline-flex; align-items: center; justify-content: center;
497 min-width: 20px; padding: 0 6px; margin-left: 6px;
498 height: 18px;
499 font-size: 11px;
500 font-weight: 600;
501 border-radius: 9999px;
502 background: var(--bg-tertiary);
503 color: var(--text-muted);
504 }
505
506 /* Gate / check status section */
507 .prs-gate-card {
508 margin-top: 20px;
509 background: var(--bg-elevated);
510 border: 1px solid var(--border);
511 border-radius: 14px;
512 overflow: hidden;
513 }
514 .prs-gate-head {
515 display: flex; align-items: center; gap: 10px;
516 padding: 14px 18px;
517 border-bottom: 1px solid var(--border);
518 }
519 .prs-gate-head h3 {
520 margin: 0;
521 font-size: 14px;
522 font-weight: 600;
523 color: var(--text-strong);
524 }
525 .prs-gate-summary {
526 margin-left: auto;
527 font-size: 12px;
528 color: var(--text-muted);
529 }
530 .prs-gate-row {
531 display: flex; align-items: center; gap: 12px;
532 padding: 12px 18px;
533 border-bottom: 1px solid var(--border-subtle);
534 }
535 .prs-gate-row:last-child { border-bottom: 0; }
536 .prs-gate-icon {
537 flex: 0 0 auto;
538 width: 22px; height: 22px;
539 display: inline-flex; align-items: center; justify-content: center;
540 border-radius: 9999px;
541 font-size: 12px;
542 font-weight: 700;
543 }
544 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
545 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
546 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
547 .prs-gate-name {
548 font-size: 13px;
549 font-weight: 600;
550 color: var(--text);
551 min-width: 140px;
552 }
553 .prs-gate-details {
554 flex: 1; min-width: 0;
555 font-size: 12.5px;
556 color: var(--text-muted);
557 }
558 .prs-gate-pill {
559 flex: 0 0 auto;
560 padding: 3px 10px;
561 border-radius: 9999px;
562 font-size: 11px;
563 font-weight: 600;
564 line-height: 1.5;
565 border: 1px solid transparent;
566 }
567 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
568 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
569 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
570 .prs-gate-footer {
571 padding: 12px 18px;
572 background: var(--bg-secondary);
573 font-size: 12px;
574 color: var(--text-muted);
575 }
576
577 /* Comment cards */
578 .prs-comment {
579 margin-top: 14px;
580 background: var(--bg-elevated);
581 border: 1px solid var(--border);
582 border-radius: 12px;
583 overflow: hidden;
584 }
585 .prs-comment-head {
586 display: flex; align-items: center; gap: 10px;
587 padding: 10px 14px;
588 background: var(--bg-secondary);
589 border-bottom: 1px solid var(--border);
590 font-size: 13px;
591 flex-wrap: wrap;
592 }
593 .prs-comment-head strong { color: var(--text-strong); }
594 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
595 .prs-comment-loc {
596 font-family: var(--font-mono);
597 font-size: 11.5px;
598 color: var(--text-muted);
599 background: var(--bg-tertiary);
600 padding: 2px 8px;
601 border-radius: 6px;
602 }
603 .prs-comment-body { padding: 14px 18px; }
604 .prs-comment.is-ai {
605 border-color: rgba(140,109,255,0.45);
606 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
607 }
608 .prs-comment.is-ai .prs-comment-head {
609 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
610 border-bottom-color: rgba(140,109,255,0.30);
611 }
612 .prs-ai-badge {
613 display: inline-flex; align-items: center; gap: 4px;
614 padding: 2px 9px;
615 font-size: 10.5px;
616 font-weight: 700;
617 letter-spacing: 0.04em;
618 text-transform: uppercase;
619 color: #fff;
620 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
621 border-radius: 9999px;
622 }
623
624 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
625 .prs-files-card {
626 margin-top: 18px;
627 padding: 14px 18px;
628 display: flex; align-items: center; gap: 14px;
629 background: var(--bg-elevated);
630 border: 1px solid var(--border);
631 border-radius: 12px;
632 text-decoration: none;
633 color: inherit;
634 transition: border-color 120ms ease, transform 140ms ease;
635 }
636 .prs-files-card:hover {
637 border-color: rgba(140,109,255,0.45);
638 transform: translateY(-1px);
639 }
640 .prs-files-card-icon {
641 width: 36px; height: 36px;
642 display: inline-flex; align-items: center; justify-content: center;
643 border-radius: 10px;
644 background: rgba(140,109,255,0.12);
645 color: var(--text-link);
646 font-size: 18px;
647 }
648 .prs-files-card-text { flex: 1; min-width: 0; }
649 .prs-files-card-title {
650 font-size: 14px;
651 font-weight: 600;
652 color: var(--text-strong);
653 margin: 0 0 2px;
654 }
655 .prs-files-card-sub {
656 font-size: 12.5px;
657 color: var(--text-muted);
658 margin: 0;
659 }
660 .prs-files-card-cta {
661 font-size: 12.5px;
662 color: var(--text-link);
663 font-weight: 600;
664 }
665
666 /* Merge area */
667 .prs-merge-card {
668 position: relative;
669 margin-top: 22px;
670 padding: 18px;
671 background: var(--bg-elevated);
672 border-radius: 14px;
673 overflow: hidden;
674 }
675 .prs-merge-card::before {
676 content: '';
677 position: absolute; inset: 0;
678 padding: 1px;
679 border-radius: 14px;
680 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
681 -webkit-mask:
682 linear-gradient(#000 0 0) content-box,
683 linear-gradient(#000 0 0);
684 -webkit-mask-composite: xor;
685 mask-composite: exclude;
686 pointer-events: none;
687 }
688 .prs-merge-card.is-closed::before { background: var(--border-strong); }
689 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
690 .prs-merge-head {
691 display: flex; align-items: center; gap: 12px;
692 margin-bottom: 12px;
693 }
694 .prs-merge-head strong {
695 font-family: var(--font-display);
696 font-size: 15px;
697 color: var(--text-strong);
698 font-weight: 700;
699 }
700 .prs-merge-sub {
701 font-size: 13px;
702 color: var(--text-muted);
703 margin: 0 0 12px;
704 }
705 .prs-merge-actions {
706 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
707 }
708 .prs-merge-btn {
709 display: inline-flex; align-items: center; gap: 6px;
710 padding: 9px 16px;
711 border-radius: 10px;
712 font-size: 13.5px;
713 font-weight: 600;
714 color: #fff;
715 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
716 border: 1px solid rgba(52,211,153,0.55);
717 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
718 cursor: pointer;
719 transition: transform 120ms ease, box-shadow 160ms ease;
720 }
721 .prs-merge-btn:hover {
722 transform: translateY(-1px);
723 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
724 }
725 .prs-merge-btn[disabled],
726 .prs-merge-btn.is-disabled {
727 opacity: 0.55;
728 cursor: not-allowed;
729 transform: none;
730 box-shadow: none;
731 }
732 .prs-merge-ready-btn {
733 display: inline-flex; align-items: center; gap: 6px;
734 padding: 9px 16px;
735 border-radius: 10px;
736 font-size: 13.5px;
737 font-weight: 600;
738 color: #fff;
739 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
740 border: 1px solid rgba(140,109,255,0.55);
741 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
742 cursor: pointer;
743 transition: transform 120ms ease, box-shadow 160ms ease;
744 }
745 .prs-merge-ready-btn:hover {
746 transform: translateY(-1px);
747 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
748 }
749 .prs-merge-back-draft {
750 background: none; border: 1px solid var(--border-strong);
751 color: var(--text-muted);
752 padding: 9px 14px; border-radius: 10px;
753 font-size: 13px; cursor: pointer;
754 }
755 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
756
a164a6dClaude757 /* Merge strategy selector */
758 .prs-merge-strategy-wrap {
759 display: inline-flex; align-items: center;
760 background: var(--bg-elevated);
761 border: 1px solid var(--border);
762 border-radius: 10px;
763 overflow: hidden;
764 }
765 .prs-merge-strategy-label {
766 font-size: 11.5px; font-weight: 600;
767 color: var(--text-muted);
768 padding: 0 10px 0 12px;
769 white-space: nowrap;
770 }
771 .prs-merge-strategy-select {
772 background: transparent;
773 border: none;
774 color: var(--text);
775 font-size: 13px;
776 padding: 7px 10px 7px 4px;
777 cursor: pointer;
778 outline: none;
779 appearance: auto;
780 }
781 .prs-merge-strategy-select:focus { outline: 2px solid rgba(140,109,255,0.45); }
782
0a67773Claude783 /* Review summary banner */
784 .prs-review-summary {
785 display: flex; flex-direction: column; gap: 6px;
786 padding: 12px 16px;
787 background: var(--bg-elevated);
788 border: 1px solid var(--border);
789 border-radius: var(--r-md, 8px);
790 margin-bottom: 12px;
791 }
792 .prs-review-row {
793 display: flex; align-items: center; gap: 10px;
794 font-size: 13px;
795 }
796 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
797 .prs-review-approved .prs-review-icon { color: #34d399; }
798 .prs-review-changes .prs-review-icon { color: #f87171; }
799
800 /* Review action buttons */
801 .prs-review-approve-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(52,211,153,0.12);
806 color: #34d399;
807 border: 1px solid rgba(52,211,153,0.35);
808 transition: background 120ms;
809 }
810 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
811 .prs-review-changes-btn {
812 display: inline-flex; align-items: center; gap: 5px;
813 padding: 8px 14px; border-radius: 8px; font-size: 13px;
814 font-weight: 600; cursor: pointer;
815 background: rgba(248,113,113,0.10);
816 color: #f87171;
817 border: 1px solid rgba(248,113,113,0.30);
818 transition: background 120ms;
819 }
820 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
821
b078860Claude822 /* Inline form helpers */
823 .prs-inline-form { display: inline-flex; }
824
825 /* Comment composer */
826 .prs-composer { margin-top: 22px; }
827 .prs-composer textarea {
828 border-radius: 12px;
829 }
830
831 @media (max-width: 720px) {
832 .prs-detail-actions { margin-left: 0; }
833 .prs-merge-actions { width: 100%; }
834 .prs-merge-actions > * { flex: 1; min-width: 0; }
835 }
f1dc7c7Claude836
837 /* Additional mobile rules. Additive only. */
838 @media (max-width: 720px) {
839 .prs-detail-hero { padding: 18px; }
840 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
841 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
842 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
843 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
844 .prs-gate-name { min-width: 0; }
845 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
846 .prs-gate-summary { margin-left: 0; }
847 .prs-merge-btn,
848 .prs-merge-ready-btn,
849 .prs-merge-back-draft { min-height: 44px; }
850 .prs-comment-body { padding: 12px 14px; }
851 .prs-comment-head { padding: 10px 12px; }
852 .prs-files-card { padding: 12px 14px; }
853 }
3c03977Claude854
855 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
856 .live-pill {
857 display: inline-flex;
858 align-items: center;
859 gap: 8px;
860 padding: 4px 10px 4px 8px;
861 margin-left: 6px;
862 background: var(--bg-elevated);
863 border: 1px solid var(--border);
864 border-radius: 9999px;
865 font-size: 12px;
866 color: var(--text-muted);
867 line-height: 1;
868 vertical-align: middle;
869 }
870 .live-pill.is-busy { color: var(--text); }
871 .live-pill-dot {
872 width: 8px; height: 8px;
873 border-radius: 9999px;
874 background: #34d399;
875 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
876 animation: live-pulse 1.6s ease-in-out infinite;
877 }
878 @keyframes live-pulse {
879 0%, 100% { opacity: 1; }
880 50% { opacity: 0.55; }
881 }
882 .live-avatars {
883 display: inline-flex;
884 margin-left: 2px;
885 }
886 .live-avatar {
887 display: inline-flex;
888 align-items: center;
889 justify-content: center;
890 width: 22px; height: 22px;
891 border-radius: 9999px;
892 font-size: 10px;
893 font-weight: 700;
894 color: #0b1020;
895 margin-left: -6px;
896 border: 2px solid var(--bg-elevated);
897 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
898 }
899 .live-avatar:first-child { margin-left: 0; }
900 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
901 .live-cursor-host {
902 position: relative;
903 }
904 .live-cursor-overlay {
905 position: absolute;
906 inset: 0;
907 pointer-events: none;
908 overflow: hidden;
909 border-radius: inherit;
910 }
911 .live-cursor {
912 position: absolute;
913 width: 2px;
914 height: 18px;
915 border-radius: 2px;
916 transform: translate(-1px, 0);
917 transition: transform 80ms linear, opacity 200ms ease;
918 }
919 .live-cursor::after {
920 content: attr(data-label);
921 position: absolute;
922 top: -16px;
923 left: -2px;
924 font-size: 10px;
925 line-height: 1;
926 color: #0b1020;
927 background: inherit;
928 padding: 2px 5px;
929 border-radius: 4px 4px 4px 0;
930 white-space: nowrap;
931 font-weight: 600;
932 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
933 }
934 .live-cursor.is-idle { opacity: 0.4; }
935 .live-edit-tag {
936 display: inline-block;
937 margin-left: 6px;
938 padding: 1px 6px;
939 font-size: 10px;
940 font-weight: 600;
941 letter-spacing: 0.02em;
942 color: #0b1020;
943 border-radius: 9999px;
944 }
15db0e0Claude945
946 /* ─── Slash-command pill + composer hint ─── */
947 .slash-hint {
948 display: inline-flex;
949 align-items: center;
950 gap: 6px;
951 margin-top: 6px;
952 padding: 3px 9px;
953 font-size: 11.5px;
954 color: var(--text-muted);
955 background: var(--bg-elevated);
956 border: 1px dashed var(--border);
957 border-radius: 9999px;
958 width: fit-content;
959 }
960 .slash-hint code {
961 background: rgba(110, 168, 255, 0.12);
962 color: var(--text-strong);
963 padding: 0 5px;
964 border-radius: 4px;
965 font-size: 11px;
966 }
967 .slash-pill {
968 display: grid;
969 grid-template-columns: auto 1fr auto;
970 align-items: center;
971 column-gap: 10px;
972 row-gap: 6px;
973 margin: 10px 0;
974 padding: 10px 14px;
975 background: linear-gradient(
976 135deg,
977 rgba(110, 168, 255, 0.08),
978 rgba(163, 113, 247, 0.06)
979 );
980 border: 1px solid rgba(110, 168, 255, 0.32);
981 border-left: 3px solid var(--accent, #6ea8ff);
982 border-radius: var(--radius);
983 font-size: 13px;
984 color: var(--text);
985 }
986 .slash-pill-icon {
987 font-size: 14px;
988 line-height: 1;
989 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
990 }
991 .slash-pill-actor { color: var(--text-muted); }
992 .slash-pill-actor strong { color: var(--text-strong); }
993 .slash-pill-cmd {
994 background: rgba(110, 168, 255, 0.16);
995 color: var(--text-strong);
996 padding: 1px 6px;
997 border-radius: 4px;
998 font-size: 12.5px;
999 }
1000 .slash-pill-time {
1001 color: var(--text-muted);
1002 font-size: 12px;
1003 justify-self: end;
1004 }
1005 .slash-pill-body {
1006 grid-column: 1 / -1;
1007 color: var(--text);
1008 font-size: 13px;
1009 line-height: 1.55;
1010 }
1011 .slash-pill-body p:first-child { margin-top: 0; }
1012 .slash-pill-body p:last-child { margin-bottom: 0; }
1013 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
1014 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1015 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
1016 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude1017
1018 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
1019 .preview-prpill {
1020 display: inline-flex; align-items: center; gap: 6px;
1021 padding: 3px 10px;
1022 border-radius: 9999px;
1023 font-family: var(--font-mono);
1024 font-size: 11.5px;
1025 font-weight: 600;
1026 background: rgba(255,255,255,0.04);
1027 color: var(--text-muted);
1028 text-decoration: none;
1029 border: 1px solid var(--border);
1030 }
1031 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
1032 .preview-prpill .preview-prpill-dot {
1033 width: 7px; height: 7px;
1034 border-radius: 9999px;
1035 background: currentColor;
1036 }
1037 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1038 .preview-prpill.is-building .preview-prpill-dot {
1039 animation: previewPrPulse 1.4s ease-in-out infinite;
1040 }
1041 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1042 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1043 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1044 @keyframes previewPrPulse {
1045 0%, 100% { opacity: 1; }
1046 50% { opacity: 0.4; }
1047 }
79ed944Claude1048
1049 /* ─── AI Trio Review — 3-column verdict cards ─── */
1050 .trio-wrap {
1051 margin-top: 18px;
1052 padding: 16px;
1053 background: var(--bg-elevated);
1054 border: 1px solid var(--border);
1055 border-radius: 14px;
1056 }
1057 .trio-header {
1058 display: flex; align-items: center; gap: 10px;
1059 margin: 0 0 12px;
1060 font-size: 13.5px;
1061 color: var(--text);
1062 }
1063 .trio-header strong { color: var(--text-strong); }
1064 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1065 .trio-header-dot {
1066 width: 8px; height: 8px; border-radius: 9999px;
1067 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1068 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1069 }
1070 .trio-grid {
1071 display: grid;
1072 grid-template-columns: repeat(3, minmax(0, 1fr));
1073 gap: 12px;
1074 }
1075 .trio-card {
1076 background: var(--bg-secondary);
1077 border: 1px solid var(--border);
1078 border-radius: 12px;
1079 overflow: hidden;
1080 display: flex; flex-direction: column;
1081 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1082 }
1083 .trio-card-head {
1084 display: flex; align-items: center; gap: 8px;
1085 padding: 10px 12px;
1086 border-bottom: 1px solid var(--border);
1087 background: rgba(255,255,255,0.02);
1088 font-size: 13px;
1089 }
1090 .trio-card-icon {
1091 display: inline-flex; align-items: center; justify-content: center;
1092 width: 22px; height: 22px;
1093 border-radius: 9999px;
1094 font-size: 12px;
1095 background: rgba(255,255,255,0.05);
1096 }
1097 .trio-card-title {
1098 color: var(--text-strong);
1099 font-weight: 600;
1100 letter-spacing: 0.01em;
1101 }
1102 .trio-card-verdict {
1103 margin-left: auto;
1104 font-size: 11px;
1105 font-weight: 700;
1106 letter-spacing: 0.06em;
1107 text-transform: uppercase;
1108 padding: 3px 9px;
1109 border-radius: 9999px;
1110 background: var(--bg-tertiary);
1111 color: var(--text-muted);
1112 border: 1px solid var(--border-strong);
1113 }
1114 .trio-card-body {
1115 padding: 12px 14px;
1116 font-size: 13px;
1117 color: var(--text);
1118 flex: 1;
1119 min-height: 64px;
1120 line-height: 1.55;
1121 }
1122 .trio-card-body p { margin: 0 0 8px; }
1123 .trio-card-body p:last-child { margin-bottom: 0; }
1124 .trio-card-body ul { margin: 0; padding-left: 18px; }
1125 .trio-card-body code {
1126 font-family: var(--font-mono);
1127 font-size: 12px;
1128 background: var(--bg-tertiary);
1129 padding: 1px 6px;
1130 border-radius: 5px;
1131 }
1132 .trio-card-empty {
1133 color: var(--text-muted);
1134 font-style: italic;
1135 font-size: 12.5px;
1136 }
1137
1138 /* Pass state — neutral, no accent. */
1139 .trio-card.is-pass .trio-card-verdict {
1140 color: var(--green);
1141 border-color: rgba(52,211,153,0.35);
1142 background: rgba(52,211,153,0.12);
1143 }
1144
1145 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1146 .trio-card.trio-security.is-fail {
1147 border-color: rgba(248,113,113,0.55);
1148 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1149 }
1150 .trio-card.trio-security.is-fail .trio-card-head {
1151 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1152 border-bottom-color: rgba(248,113,113,0.30);
1153 }
1154 .trio-card.trio-security.is-fail .trio-card-verdict {
1155 color: #fecaca;
1156 border-color: rgba(248,113,113,0.55);
1157 background: rgba(248,113,113,0.20);
1158 }
1159
1160 .trio-card.trio-correctness.is-fail {
1161 border-color: rgba(251,191,36,0.55);
1162 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1163 }
1164 .trio-card.trio-correctness.is-fail .trio-card-head {
1165 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1166 border-bottom-color: rgba(251,191,36,0.30);
1167 }
1168 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1169 color: #fde68a;
1170 border-color: rgba(251,191,36,0.55);
1171 background: rgba(251,191,36,0.20);
1172 }
1173
1174 .trio-card.trio-style.is-fail {
1175 border-color: rgba(96,165,250,0.55);
1176 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1177 }
1178 .trio-card.trio-style.is-fail .trio-card-head {
1179 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1180 border-bottom-color: rgba(96,165,250,0.30);
1181 }
1182 .trio-card.trio-style.is-fail .trio-card-verdict {
1183 color: #bfdbfe;
1184 border-color: rgba(96,165,250,0.55);
1185 background: rgba(96,165,250,0.20);
1186 }
1187
1188 /* Disagreement callout strip — yellow, prominent. */
1189 .trio-disagreement-strip {
1190 display: flex;
1191 gap: 12px;
1192 margin-top: 14px;
1193 padding: 12px 14px;
1194 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1195 border: 1px solid rgba(251,191,36,0.45);
1196 border-radius: 10px;
1197 color: var(--text);
1198 font-size: 13px;
1199 }
1200 .trio-disagreement-icon {
1201 flex: 0 0 auto;
1202 width: 26px; height: 26px;
1203 display: inline-flex; align-items: center; justify-content: center;
1204 border-radius: 9999px;
1205 background: rgba(251,191,36,0.25);
1206 color: #fde68a;
1207 font-size: 14px;
1208 }
1209 .trio-disagreement-body strong {
1210 display: block;
1211 color: #fde68a;
1212 margin: 0 0 4px;
1213 font-weight: 700;
1214 }
1215 .trio-disagreement-list {
1216 margin: 0;
1217 padding-left: 18px;
1218 color: var(--text);
1219 font-size: 12.5px;
1220 line-height: 1.55;
1221 }
1222 .trio-disagreement-list code {
1223 font-family: var(--font-mono);
1224 font-size: 11.5px;
1225 background: var(--bg-tertiary);
1226 padding: 1px 5px;
1227 border-radius: 4px;
1228 }
1229
1230 @media (max-width: 720px) {
1231 .trio-grid { grid-template-columns: 1fr; }
1232 .trio-wrap { padding: 12px; }
1233 }
b078860Claude1234`;
1235
81c73c1Claude1236/**
1237 * Tiny inline JS that drives the "Suggest description with AI" button.
1238 * On click, gathers form values, POSTs JSON to the given endpoint, and
1239 * pipes the response into the #pr-body textarea. All DOM lookups are
1240 * defensive — element absence is a silent no-op.
1241 *
1242 * Built as a string template so it lives next to its server-side caller
1243 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1244 * to avoid </script> breakouts.
1245 */
1246function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1247 const url = JSON.stringify(endpointUrl)
1248 .split("<").join("\\u003C")
1249 .split(">").join("\\u003E")
1250 .split("&").join("\\u0026");
1251 return (
1252 "(function(){try{" +
1253 "var btn=document.getElementById('ai-suggest-desc');" +
1254 "var status=document.getElementById('ai-suggest-status');" +
1255 "var body=document.getElementById('pr-body');" +
1256 "var form=btn&&btn.closest&&btn.closest('form');" +
1257 "if(!btn||!body||!form)return;" +
1258 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1259 "var fd=new FormData(form);" +
1260 "var title=String(fd.get('title')||'').trim();" +
1261 "var base=String(fd.get('base')||'').trim();" +
1262 "var head=String(fd.get('head')||'').trim();" +
1263 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1264 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1265 "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'})" +
1266 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1267 ".then(function(j){btn.disabled=false;" +
1268 "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;}}" +
1269 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1270 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1271 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1272 "});" +
1273 "}catch(e){}})();"
1274 );
1275}
1276
3c03977Claude1277/**
1278 * Live co-editing client. Connects to the per-PR SSE feed and:
1279 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1280 * status colour per user).
1281 * - Renders tinted cursor caret overlays inside #pr-body and every
1282 * `[data-live-field]` element.
1283 * - Broadcasts the local user's cursor position (selectionStart /
1284 * selectionEnd) debounced at 100ms.
1285 * - Broadcasts content patches (`replace` of the whole textarea —
1286 * last-write-wins v1) debounced at 250ms.
1287 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1288 * to the matching local field if untouched.
1289 *
1290 * All endpoint URLs are JSON-escaped via safe replacements so they
1291 * can't break out of the <script> tag.
1292 */
1293function LIVE_COEDIT_SCRIPT(prId: string): string {
1294 const idJson = JSON.stringify(prId)
1295 .split("<").join("\\u003C")
1296 .split(">").join("\\u003E")
1297 .split("&").join("\\u0026");
1298 return (
1299 "(function(){try{" +
1300 "if(typeof EventSource==='undefined')return;" +
1301 "var prId=" + idJson + ";" +
1302 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1303 "var pill=document.getElementById('live-pill');" +
1304 "var avEl=document.getElementById('live-avatars');" +
1305 "var countEl=document.getElementById('live-count');" +
1306 "var sessionId=null;var myColor=null;" +
1307 "var presence={};" + // sessionId -> {color,status,userId,initials}
1308 "var lastApplied={};" + // field -> last server value (for echo suppression)
1309 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1310 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1311 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1312 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1313 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1314 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1315 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1316 "avEl.innerHTML=html;}}" +
1317 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1318 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1319 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1320 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1321 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1322 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1323 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1324 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1325 "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);}" +
1326 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1327 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1328 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1329 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1330 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1331 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1332 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1333 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1334 "}catch(e){}" +
1335 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1336 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1337 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1338 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1339 "var es;var delay=1000;" +
1340 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1341 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1342 "(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){}});" +
1343 "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){}});" +
1344 "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){}});" +
1345 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1346 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1347 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1348 "var patch=d.patch;if(!patch||!patch.field)return;" +
1349 "var ta=fieldEl(patch.field);if(!ta)return;" +
1350 "if(document.activeElement===ta)return;" + // don't trample local typing
1351 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1352 "}catch(e){}});" +
1353 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1354 "}connect();" +
1355 "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){}}" +
1356 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1357 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1358 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1359 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1360 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1361 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1362 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1363 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1364 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1365 "}" +
1366 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1367 "var live=document.querySelectorAll('[data-live-field]');" +
1368 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1369 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1370 "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){}});" +
1371 "}catch(e){}})();"
1372 );
1373}
1374
0074234Claude1375async function resolveRepo(ownerName: string, repoName: string) {
1376 const [owner] = await db
1377 .select()
1378 .from(users)
1379 .where(eq(users.username, ownerName))
1380 .limit(1);
1381 if (!owner) return null;
1382 const [repo] = await db
1383 .select()
1384 .from(repositories)
1385 .where(
1386 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1387 )
1388 .limit(1);
1389 if (!repo) return null;
1390 return { owner, repo };
1391}
1392
1393// PR Nav helper
1394const PrNav = ({
1395 owner,
1396 repo,
1397 active,
1398}: {
1399 owner: string;
1400 repo: string;
1401 active: "code" | "issues" | "pulls" | "commits";
1402}) => (
bb0f894Claude1403 <TabNav
1404 tabs={[
1405 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1406 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1407 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1408 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1409 ]}
1410 />
0074234Claude1411);
1412
534f04aClaude1413/**
1414 * Block M3 — pre-merge risk score card. Pure presentational helper.
1415 * Rendered in the conversation tab above the gate checks block. Hidden
1416 * entirely when the PR is closed/merged or there is nothing cached and
1417 * nothing in-flight.
1418 */
1419function PrRiskCard({
1420 risk,
1421 calculating,
1422}: {
1423 risk: PrRiskScore | null;
1424 calculating: boolean;
1425}) {
1426 if (!risk) {
1427 return (
1428 <div
1429 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1430 >
1431 <strong style="font-size: 13px; color: var(--text)">
1432 Risk score: calculating…
1433 </strong>
1434 <div style="font-size: 12px; margin-top: 4px">
1435 Refresh in a moment to see the pre-merge risk score for this PR.
1436 </div>
1437 </div>
1438 );
1439 }
1440
1441 const palette = riskBandPalette(risk.band);
1442 const label = riskBandLabel(risk.band);
1443
1444 return (
1445 <div
1446 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1447 >
1448 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1449 <strong>Risk score:</strong>
1450 <span style={`color:${palette.border};font-weight:600`}>
1451 {palette.icon} {label} ({risk.score}/10)
1452 </span>
1453 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1454 {risk.commitSha.slice(0, 7)}
1455 </span>
1456 </div>
1457 {risk.aiSummary && (
1458 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1459 {risk.aiSummary}
1460 </div>
1461 )}
1462 <details style="margin-top:10px">
1463 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1464 See full signal breakdown
1465 </summary>
1466 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1467 <li>files changed: {risk.signals.filesChanged}</li>
1468 <li>
1469 lines added/removed: {risk.signals.linesAdded} /{" "}
1470 {risk.signals.linesRemoved}
1471 </li>
1472 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1473 <li>
1474 schema migration touched:{" "}
1475 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1476 </li>
1477 <li>
1478 locked / sensitive path touched:{" "}
1479 {risk.signals.lockedPathTouched ? "yes" : "no"}
1480 </li>
1481 <li>
1482 adds new dependency:{" "}
1483 {risk.signals.addsNewDependency ? "yes" : "no"}
1484 </li>
1485 <li>
1486 bumps major dependency:{" "}
1487 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1488 </li>
1489 <li>
1490 tests added for new code:{" "}
1491 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1492 </li>
1493 <li>
1494 diff-minus-test ratio:{" "}
1495 {risk.signals.diffMinusTestRatio.toFixed(2)}
1496 </li>
1497 </ul>
1498 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1499 How is this calculated? The score is a transparent sum of
1500 weighted signals — see <code>src/lib/pr-risk.ts</code>
1501 {" "}<code>computePrRiskScore</code>.
1502 </div>
1503 </details>
1504 {calculating && (
1505 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1506 (recomputing for the latest commit — refresh to update)
1507 </div>
1508 )}
1509 </div>
1510 );
1511}
1512
1513function riskBandPalette(band: PrRiskScore["band"]): {
1514 border: string;
1515 icon: string;
1516} {
1517 switch (band) {
1518 case "low":
1519 return { border: "var(--green)", icon: "" };
1520 case "medium":
1521 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1522 case "high":
1523 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1524 case "critical":
1525 return { border: "var(--red)", icon: "\u{1F6D1}" };
1526 }
1527}
1528
1529function riskBandLabel(band: PrRiskScore["band"]): string {
1530 switch (band) {
1531 case "low":
1532 return "LOW";
1533 case "medium":
1534 return "MEDIUM";
1535 case "high":
1536 return "HIGH";
1537 case "critical":
1538 return "CRITICAL";
1539 }
1540}
1541
422a2d4Claude1542// ---------------------------------------------------------------------------
1543// AI Trio Review — 3-column card grid + disagreement callout.
1544//
1545// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
1546// per run: one per persona (security/correctness/style) plus a top-level
1547// summary. We surface them here as a single grid above the normal
1548// comment stream so reviewers see the verdicts at a glance.
1549// ---------------------------------------------------------------------------
1550
1551const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
1552
1553interface TrioCommentLike {
1554 body: string;
1555}
1556
1557function isTrioComment(body: string | null | undefined): boolean {
1558 if (!body) return false;
1559 return (
1560 body.includes(TRIO_SUMMARY_MARKER) ||
1561 body.includes(TRIO_COMMENT_MARKER.security) ||
1562 body.includes(TRIO_COMMENT_MARKER.correctness) ||
1563 body.includes(TRIO_COMMENT_MARKER.style)
1564 );
1565}
1566
1567function trioPersonaOfComment(body: string): TrioPersona | null {
1568 for (const p of TRIO_PERSONAS) {
1569 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
1570 }
1571 return null;
1572}
1573
1574/**
1575 * Best-effort verdict parse from a persona comment body. The body shape
1576 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
1577 * we only need the "Pass" / "Fail" word from the H2 heading.
1578 */
1579function trioVerdictOfBody(body: string): "pass" | "fail" | null {
1580 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
1581 if (!m) return null;
1582 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
1583}
1584
1585/**
1586 * Parse the disagreement bullet list out of the summary comment so we
1587 * can render it as a polished callout strip. Returns [] when nothing
1588 * matches — the comment author may have edited the marker out.
1589 */
1590function parseDisagreements(summaryBody: string): Array<{
1591 file: string;
1592 failing: string;
1593 passing: string;
1594}> {
1595 const out: Array<{ file: string; failing: string; passing: string }> = [];
1596 // Each disagreement line looks like:
1597 // - `path:42` — security, style say ✗, correctness say ✓
1598 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
1599 let m: RegExpExecArray | null;
1600 while ((m = re.exec(summaryBody)) !== null) {
1601 out.push({
1602 file: m[1].trim(),
1603 failing: m[2].trim().replace(/[,\s]+$/g, ""),
1604 passing: m[3].trim().replace(/[,\s]+$/g, ""),
1605 });
1606 }
1607 return out;
1608}
1609
1610function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
1611 // Find the most recent persona comments + summary. We iterate from
1612 // the end so re-reviews (multiple runs on the same PR) display the
1613 // freshest verdict.
1614 const latest: Partial<Record<TrioPersona, string>> = {};
1615 let summaryBody: string | null = null;
1616 for (let i = comments.length - 1; i >= 0; i--) {
1617 const body = comments[i].body || "";
1618 if (!isTrioComment(body)) continue;
1619 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
1620 summaryBody = body;
1621 continue;
1622 }
1623 const persona = trioPersonaOfComment(body);
1624 if (persona && !latest[persona]) latest[persona] = body;
1625 }
1626 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1627 if (!anyPersona && !summaryBody) return null;
1628
1629 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
1630
1631 return (
1632 <div class="trio-wrap">
1633 <div class="trio-header">
1634 <span class="trio-header-dot" aria-hidden="true"></span>
1635 <strong>AI Trio Review</strong>
1636 <span class="trio-header-sub">
1637 Three independent reviewers ran in parallel.
1638 </span>
1639 </div>
1640 <div class="trio-grid">
1641 {TRIO_PERSONAS.map((persona) => {
1642 const body = latest[persona];
1643 const verdict = body ? trioVerdictOfBody(body) : null;
1644 const stateClass =
1645 verdict === "fail"
1646 ? "is-fail"
1647 : verdict === "pass"
1648 ? "is-pass"
1649 : "is-pending";
1650 return (
1651 <div class={`trio-card trio-${persona} ${stateClass}`}>
1652 <div class="trio-card-head">
1653 <span class="trio-card-icon" aria-hidden="true">
1654 {persona === "security"
1655 ? "🛡"
1656 : persona === "correctness"
1657 ? "✓"
1658 : "✎"}
1659 </span>
1660 <strong class="trio-card-title">
1661 {persona[0].toUpperCase() + persona.slice(1)}
1662 </strong>
1663 <span class="trio-card-verdict">
1664 {verdict === "pass"
1665 ? "Pass"
1666 : verdict === "fail"
1667 ? "Fail"
1668 : "Pending"}
1669 </span>
1670 </div>
1671 <div class="trio-card-body">
1672 {body ? (
1673 <MarkdownContent
1674 html={renderMarkdown(stripTrioHeading(body))}
1675 />
1676 ) : (
1677 <span class="trio-card-empty">
1678 Awaiting reviewer output.
1679 </span>
1680 )}
1681 </div>
1682 </div>
1683 );
1684 })}
1685 </div>
1686 {disagreements.length > 0 && (
1687 <div class="trio-disagreement-strip" role="note">
1688 <span class="trio-disagreement-icon" aria-hidden="true">
1689
1690 </span>
1691 <div class="trio-disagreement-body">
1692 <strong>Reviewers disagree — review carefully.</strong>
1693 <ul class="trio-disagreement-list">
1694 {disagreements.map((d) => (
1695 <li>
1696 <code>{d.file}</code> — {d.failing} says ✗,{" "}
1697 {d.passing} says ✓
1698 </li>
1699 ))}
1700 </ul>
1701 </div>
1702 </div>
1703 )}
1704 </div>
1705 );
1706}
1707
1708/**
1709 * Strip the marker comment + first H2 heading from a persona body so
1710 * the card body shows just the findings list (verdict is already in
1711 * the card head). Best-effort — malformed bodies render whole.
1712 */
1713function stripTrioHeading(body: string): string {
1714 return body
1715 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
1716 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
1717 .trim();
1718}
1719
0074234Claude1720// List PRs
04f6b7fClaude1721pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1722 const { owner: ownerName, repo: repoName } = c.req.param();
1723 const user = c.get("user");
1724 const state = c.req.query("state") || "open";
1725
ea9ed4cClaude1726 // ── Loading skeleton (flag-gated) ──
1727 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
1728 // the user see the page structure before counts + select resolve.
1729 // Behind a flag for now — we don't ship flashes.
1730 if (c.req.query("skeleton") === "1") {
1731 return c.html(
1732 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1733 <RepoHeader owner={ownerName} repo={repoName} />
1734 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1735 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1736 <style
1737 dangerouslySetInnerHTML={{
1738 __html: `
1739 .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; }
1740 @keyframes prsSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1741 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
1742 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
1743 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
1744 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
1745 .prs-skel-row { height: 76px; border-radius: 12px; }
1746 `,
1747 }}
1748 />
1749 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
1750 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
1751 <div class="prs-skel-list" aria-hidden="true">
1752 {Array.from({ length: 6 }).map(() => (
1753 <div class="prs-skel prs-skel-row" />
1754 ))}
1755 </div>
1756 <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">
1757 Loading pull requests for {ownerName}/{repoName}…
1758 </span>
1759 </Layout>
1760 );
1761 }
1762
0074234Claude1763 const resolved = await resolveRepo(ownerName, repoName);
1764 if (!resolved) return c.notFound();
1765
6fc53bdClaude1766 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
1767 const stateFilter =
1768 state === "draft"
1769 ? and(
1770 eq(pullRequests.state, "open"),
1771 eq(pullRequests.isDraft, true)
1772 )
1773 : eq(pullRequests.state, state);
1774
0074234Claude1775 const prList = await db
1776 .select({
1777 pr: pullRequests,
1778 author: { username: users.username },
1779 })
1780 .from(pullRequests)
1781 .innerJoin(users, eq(pullRequests.authorId, users.id))
1782 .where(
6fc53bdClaude1783 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude1784 )
1785 .orderBy(desc(pullRequests.createdAt));
1786
1aef949Claude1787 // Batch-load review states for all PRs in the list (approved / changes_requested badges)
1788 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
1789 if (prList.length > 0) {
1790 const prIds = prList.map(({ pr }) => pr.id);
1791 const reviewRows = await db
1792 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
1793 .from(prReviews)
1794 .where(inArray(prReviews.pullRequestId, prIds));
1795 for (const r of reviewRows) {
1796 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
1797 if (r.state === "approved") entry.approved = true;
1798 if (r.state === "changes_requested") entry.changesRequested = true;
1799 reviewMap.set(r.prId, entry);
1800 }
1801 }
1802
0074234Claude1803 const [counts] = await db
1804 .select({
1805 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude1806 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude1807 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
1808 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
1809 })
1810 .from(pullRequests)
1811 .where(eq(pullRequests.repositoryId, resolved.repo.id));
1812
b078860Claude1813 const openCount = counts?.open ?? 0;
1814 const mergedCount = counts?.merged ?? 0;
1815 const closedCount = counts?.closed ?? 0;
1816 const draftCount = counts?.draft ?? 0;
1817 const allCount = openCount + mergedCount + closedCount;
1818
1819 // "All" is presentational only — the DB query for state='all' matches
1820 // nothing, so we render a friendlier empty state when picked. We do NOT
1821 // change the query logic to keep this commit purely visual.
1822 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
1823 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
1824 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
1825 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
1826 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
1827 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
1828 ];
1829 const isAllState = state === "all";
cb5a796Claude1830 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
1831 const prListPendingCount = viewerIsOwnerOnPrList
1832 ? await countPendingForRepo(resolved.repo.id)
1833 : 0;
b078860Claude1834
0074234Claude1835 return c.html(
1836 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1837 <RepoHeader owner={ownerName} repo={repoName} />
1838 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude1839 <PendingCommentsBanner
1840 owner={ownerName}
1841 repo={repoName}
1842 count={prListPendingCount}
1843 />
b078860Claude1844 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1845
1846 <div class="prs-hero">
1847 <div class="prs-hero-inner">
1848 <div class="prs-hero-text">
1849 <div class="prs-hero-eyebrow">Pull requests</div>
1850 <h1 class="prs-hero-title">
1851 Review, <span class="gradient-text">merge with AI</span>.
1852 </h1>
1853 <p class="prs-hero-sub">
1854 {openCount === 0 && allCount === 0
1855 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
1856 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
1857 </p>
1858 </div>
7a28902Claude1859 <div class="prs-hero-actions">
1860 <a
1861 href={`/${ownerName}/${repoName}/pulls/insights`}
1862 class="prs-cta"
1863 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
1864 >
1865 Insights
1866 </a>
1867 {user && (
b078860Claude1868 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
1869 + New pull request
1870 </a>
7a28902Claude1871 )}
1872 </div>
b078860Claude1873 </div>
1874 </div>
1875
1876 <nav class="prs-tabs" aria-label="Pull request filters">
1877 {tabPills.map((t) => {
1878 const isActive =
1879 state === t.key ||
1880 (t.key === "open" &&
1881 state !== "merged" &&
1882 state !== "closed" &&
1883 state !== "all" &&
1884 state !== "draft");
1885 return (
1886 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
1887 <span>{t.label}</span>
1888 <span class="prs-tab-count">{t.count}</span>
1889 </a>
1890 );
1891 })}
1892 </nav>
1893
0074234Claude1894 {prList.length === 0 ? (
b078860Claude1895 <div class="prs-empty">
ea9ed4cClaude1896 <div class="prs-empty-inner">
1897 <strong>
1898 {isAllState
1899 ? "Pick a filter above to browse PRs."
1900 : `No ${state} pull requests.`}
1901 </strong>
1902 <p class="prs-empty-sub">
1903 {state === "open"
1904 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
1905 : isAllState
1906 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
1907 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
1908 </p>
1909 <div class="prs-empty-cta">
1910 {user && state === "open" && (
1911 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
1912 + New pull request
1913 </a>
1914 )}
1915 {state !== "open" && (
1916 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
1917 View open PRs
1918 </a>
1919 )}
1920 <a href={`/${ownerName}/${repoName}`} class="btn">
1921 Back to code
1922 </a>
1923 </div>
1924 </div>
b078860Claude1925 </div>
0074234Claude1926 ) : (
b078860Claude1927 <div class="prs-list">
1928 {prList.map(({ pr, author }) => {
1929 const stateClass =
1930 pr.state === "open"
1931 ? pr.isDraft
1932 ? "state-draft"
1933 : "state-open"
1934 : pr.state === "merged"
1935 ? "state-merged"
1936 : "state-closed";
1937 const icon =
1938 pr.state === "open"
1939 ? pr.isDraft
1940 ? "◌"
1941 : "○"
1942 : pr.state === "merged"
1943 ? "⮌"
1944 : "✓";
1aef949Claude1945 const rv = reviewMap.get(pr.id);
b078860Claude1946 return (
1947 <a
1948 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
1949 class="prs-row"
1950 style="text-decoration:none;color:inherit"
0074234Claude1951 >
b078860Claude1952 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
1953 {icon}
0074234Claude1954 </div>
b078860Claude1955 <div class="prs-row-body">
1956 <h3 class="prs-row-title">
1957 <span>{pr.title}</span>
1958 <span class="prs-row-number">#{pr.number}</span>
1959 </h3>
1960 <div class="prs-row-meta">
1961 <span
1962 class="prs-branch-chips"
1963 title={`${pr.headBranch} into ${pr.baseBranch}`}
1964 >
1965 <span class="prs-branch-chip">{pr.headBranch}</span>
1966 <span class="prs-branch-arrow">{"→"}</span>
1967 <span class="prs-branch-chip">{pr.baseBranch}</span>
1968 </span>
1969 <span>
1970 by{" "}
1971 <strong style="color:var(--text)">
1972 {author.username}
1973 </strong>{" "}
1974 {formatRelative(pr.createdAt)}
1975 </span>
1976 <span class="prs-row-tags">
1977 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
1978 {pr.state === "merged" && (
1979 <span class="prs-tag is-merged">Merged</span>
1980 )}
1aef949Claude1981 {rv?.approved && !rv.changesRequested && (
1982 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
1983 )}
1984 {rv?.changesRequested && (
1985 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
1986 )}
b078860Claude1987 </span>
1988 </div>
0074234Claude1989 </div>
b078860Claude1990 </a>
1991 );
1992 })}
1993 </div>
0074234Claude1994 )}
1995 </Layout>
1996 );
1997});
1998
7a28902Claude1999/* ─────────────────────────────────────────────────────────────────────────
2000 * PR Insights — 90-day analytics for the pull request activity of a repo.
2001 * Route: GET /:owner/:repo/pulls/insights
2002 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
2003 * "insights" is not swallowed by the :number param.
2004 * ───────────────────────────────────────────────────────────────────────── */
2005
2006/** Format a millisecond duration as human-readable string. */
2007function formatMsDuration(ms: number): string {
2008 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
2009 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
2010 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
2011 return `${Math.round(ms / 86_400_000)}d`;
2012}
2013
2014/** Format an ISO week string as "Jan 15". */
2015function formatWeekLabel(isoWeek: string): string {
2016 try {
2017 const d = new Date(isoWeek);
2018 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2019 } catch {
2020 return isoWeek.slice(5, 10);
2021 }
2022}
2023
2024const PR_INSIGHTS_STYLES = `
2025 .pri-page { padding-bottom: 48px; }
2026 .pri-hero {
2027 position: relative;
2028 margin: 0 0 var(--space-5);
2029 padding: 22px 26px 24px;
2030 background: var(--bg-elevated);
2031 border: 1px solid var(--border);
2032 border-radius: 16px;
2033 overflow: hidden;
2034 }
2035 .pri-hero::before {
2036 content: '';
2037 position: absolute; top: 0; left: 0; right: 0;
2038 height: 2px;
2039 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2040 opacity: 0.7;
2041 pointer-events: none;
2042 }
2043 .pri-hero-eyebrow {
2044 font-size: 12px;
2045 color: var(--text-muted);
2046 text-transform: uppercase;
2047 letter-spacing: 0.08em;
2048 font-weight: 600;
2049 margin-bottom: 8px;
2050 }
2051 .pri-hero-title {
2052 font-family: var(--font-display);
2053 font-size: clamp(26px, 3.4vw, 34px);
2054 font-weight: 800;
2055 letter-spacing: -0.025em;
2056 line-height: 1.06;
2057 margin: 0 0 8px;
2058 color: var(--text-strong);
2059 }
2060 .pri-hero-title .gradient-text {
2061 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2062 -webkit-background-clip: text;
2063 background-clip: text;
2064 -webkit-text-fill-color: transparent;
2065 color: transparent;
2066 }
2067 .pri-hero-sub {
2068 font-size: 14.5px;
2069 color: var(--text-muted);
2070 margin: 0;
2071 line-height: 1.5;
2072 }
2073 .pri-section { margin-bottom: 32px; }
2074 .pri-section-title {
2075 font-size: 13px;
2076 font-weight: 700;
2077 text-transform: uppercase;
2078 letter-spacing: 0.06em;
2079 color: var(--text-muted);
2080 margin: 0 0 14px;
2081 }
2082 .pri-cards {
2083 display: grid;
2084 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
2085 gap: 12px;
2086 }
2087 .pri-card {
2088 padding: 16px 18px;
2089 background: var(--bg-elevated);
2090 border: 1px solid var(--border);
2091 border-radius: 12px;
2092 }
2093 .pri-card-label {
2094 font-size: 12px;
2095 font-weight: 600;
2096 color: var(--text-muted);
2097 text-transform: uppercase;
2098 letter-spacing: 0.05em;
2099 margin-bottom: 6px;
2100 }
2101 .pri-card-value {
2102 font-size: 28px;
2103 font-weight: 800;
2104 letter-spacing: -0.04em;
2105 color: var(--text-strong);
2106 line-height: 1;
2107 }
2108 .pri-card-sub {
2109 font-size: 12px;
2110 color: var(--text-muted);
2111 margin-top: 4px;
2112 }
2113 .pri-chart {
2114 display: flex;
2115 align-items: flex-end;
2116 gap: 6px;
2117 height: 120px;
2118 background: var(--bg-elevated);
2119 border: 1px solid var(--border);
2120 border-radius: 12px;
2121 padding: 16px 16px 0;
2122 }
2123 .pri-bar-col {
2124 flex: 1;
2125 display: flex;
2126 flex-direction: column;
2127 align-items: center;
2128 justify-content: flex-end;
2129 height: 100%;
2130 gap: 4px;
2131 }
2132 .pri-bar {
2133 width: 100%;
2134 min-height: 4px;
2135 border-radius: 4px 4px 0 0;
2136 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 100%);
2137 transition: opacity 140ms;
2138 }
2139 .pri-bar:hover { opacity: 0.8; }
2140 .pri-bar-label {
2141 font-size: 10px;
2142 color: var(--text-muted);
2143 text-align: center;
2144 padding-bottom: 8px;
2145 white-space: nowrap;
2146 overflow: hidden;
2147 text-overflow: ellipsis;
2148 max-width: 100%;
2149 }
2150 .pri-table {
2151 width: 100%;
2152 border-collapse: collapse;
2153 font-size: 13.5px;
2154 }
2155 .pri-table th {
2156 text-align: left;
2157 font-size: 12px;
2158 font-weight: 600;
2159 text-transform: uppercase;
2160 letter-spacing: 0.05em;
2161 color: var(--text-muted);
2162 padding: 8px 12px;
2163 border-bottom: 1px solid var(--border);
2164 }
2165 .pri-table td {
2166 padding: 10px 12px;
2167 border-bottom: 1px solid var(--border);
2168 color: var(--text);
2169 }
2170 .pri-table tr:last-child td { border-bottom: none; }
2171 .pri-table-wrap {
2172 background: var(--bg-elevated);
2173 border: 1px solid var(--border);
2174 border-radius: 12px;
2175 overflow: hidden;
2176 }
2177 .pri-age-row {
2178 display: flex;
2179 align-items: center;
2180 gap: 12px;
2181 padding: 10px 0;
2182 border-bottom: 1px solid var(--border);
2183 font-size: 13.5px;
2184 }
2185 .pri-age-row:last-child { border-bottom: none; }
2186 .pri-age-label {
2187 flex: 0 0 80px;
2188 color: var(--text-muted);
2189 font-size: 12.5px;
2190 font-weight: 600;
2191 }
2192 .pri-age-bar-wrap {
2193 flex: 1;
2194 height: 8px;
2195 background: var(--bg-secondary);
2196 border-radius: 9999px;
2197 overflow: hidden;
2198 }
2199 .pri-age-bar {
2200 height: 100%;
2201 border-radius: 9999px;
2202 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
2203 min-width: 4px;
2204 }
2205 .pri-age-count {
2206 flex: 0 0 32px;
2207 text-align: right;
2208 font-weight: 600;
2209 color: var(--text-strong);
2210 font-size: 13px;
2211 }
2212 .pri-sparkline {
2213 display: flex;
2214 align-items: flex-end;
2215 gap: 3px;
2216 height: 40px;
2217 }
2218 .pri-spark-bar {
2219 flex: 1;
2220 min-height: 2px;
2221 border-radius: 2px 2px 0 0;
2222 background: var(--accent, #8c6dff);
2223 opacity: 0.7;
2224 }
2225 .pri-empty {
2226 color: var(--text-muted);
2227 font-size: 14px;
2228 padding: 24px 0;
2229 text-align: center;
2230 }
2231 @media (max-width: 600px) {
2232 .pri-cards { grid-template-columns: repeat(2, 1fr); }
2233 .pri-hero { padding: 18px 18px 20px; }
2234 }
2235`;
2236
2237pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
2238 const { owner: ownerName, repo: repoName } = c.req.param();
2239 const user = c.get("user");
2240
2241 const resolved = await resolveRepo(ownerName, repoName);
2242 if (!resolved) return c.notFound();
2243
2244 const repoId = resolved.repo.id;
2245 const now = Date.now();
2246
2247 // 1. Merged PRs in last 90 days (avg merge time)
2248 const mergedPRs = await db
2249 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
2250 .from(pullRequests)
2251 .where(and(
2252 eq(pullRequests.repositoryId, repoId),
2253 eq(pullRequests.state, "merged"),
2254 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2255 ));
2256
2257 const avgMergeMs = mergedPRs.length > 0
2258 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
2259 : null;
2260
2261 // 2. PR throughput (last 8 weeks)
2262 const weeklyPRs = await db
2263 .select({
2264 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
2265 count: sql<number>`count(*)::int`,
2266 })
2267 .from(pullRequests)
2268 .where(and(
2269 eq(pullRequests.repositoryId, repoId),
2270 sql`${pullRequests.createdAt} > now() - interval '56 days'`
2271 ))
2272 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
2273 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
2274
2275 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
2276
2277 // 3. PR merge rate (last 90 days)
2278 const [rateCounts] = await db
2279 .select({
2280 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
2281 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
2282 })
2283 .from(pullRequests)
2284 .where(and(
2285 eq(pullRequests.repositoryId, repoId),
2286 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2287 ));
2288
2289 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
2290 const mergeRate = totalResolved > 0
2291 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
2292 : null;
2293
2294 // 4. Top reviewers (last 90 days)
2295 const reviewerCounts = await db
2296 .select({
2297 userId: prReviews.reviewerId,
2298 username: users.username,
2299 count: sql<number>`count(*)::int`,
2300 })
2301 .from(prReviews)
2302 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2303 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
2304 .where(and(
2305 eq(pullRequests.repositoryId, repoId),
2306 sql`${prReviews.createdAt} > now() - interval '90 days'`
2307 ))
2308 .groupBy(prReviews.reviewerId, users.username)
2309 .orderBy(desc(sql`count(*)`))
2310 .limit(5);
2311
2312 // 5. Average reviews per merged PR
2313 const [avgReviewRow] = await db
2314 .select({
2315 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
2316 })
2317 .from(pullRequests)
2318 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2319 .where(and(
2320 eq(pullRequests.repositoryId, repoId),
2321 eq(pullRequests.state, "merged"),
2322 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2323 ));
2324
2325 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
2326 ? Math.round(avgReviewRow.avgReviews * 10) / 10
2327 : null;
2328
2329 // 6. Review turnaround — avg time from PR open to first review
2330 const prsWithReviews = await db
2331 .select({
2332 createdAt: pullRequests.createdAt,
2333 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
2334 })
2335 .from(pullRequests)
2336 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2337 .where(and(
2338 eq(pullRequests.repositoryId, repoId),
2339 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2340 ))
2341 .groupBy(pullRequests.id, pullRequests.createdAt);
2342
2343 const avgReviewTurnaroundMs = prsWithReviews.length > 0
2344 ? prsWithReviews.reduce((s, row) => {
2345 const firstMs = new Date(row.firstReview).getTime();
2346 return s + Math.max(0, firstMs - row.createdAt.getTime());
2347 }, 0) / prsWithReviews.length
2348 : null;
2349
2350 // 7. Open PRs by age bucket
2351 const openPRs = await db
2352 .select({ createdAt: pullRequests.createdAt })
2353 .from(pullRequests)
2354 .where(and(
2355 eq(pullRequests.repositoryId, repoId),
2356 eq(pullRequests.state, "open")
2357 ));
2358
2359 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
2360 for (const { createdAt } of openPRs) {
2361 const ageDays = (now - createdAt.getTime()) / 86_400_000;
2362 if (ageDays < 1) ageBuckets.lt1d++;
2363 else if (ageDays < 3) ageBuckets.d1to3++;
2364 else if (ageDays < 7) ageBuckets.d3to7++;
2365 else if (ageDays < 30) ageBuckets.d7to30++;
2366 else ageBuckets.gt30d++;
2367 }
2368 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
2369
2370 // 8. 7-day merge sparkline
2371 const sparklineRows = await db
2372 .select({
2373 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
2374 count: sql<number>`count(*)::int`,
2375 })
2376 .from(pullRequests)
2377 .where(and(
2378 eq(pullRequests.repositoryId, repoId),
2379 eq(pullRequests.state, "merged"),
2380 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
2381 ))
2382 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
2383 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
2384
2385 const sparkMap = new Map<string, number>();
2386 for (const row of sparklineRows) {
2387 sparkMap.set(row.day.slice(0, 10), row.count);
2388 }
2389 const sparkline: number[] = [];
2390 for (let i = 6; i >= 0; i--) {
2391 const d = new Date(now - i * 86_400_000);
2392 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
2393 }
2394 const maxSpark = Math.max(1, ...sparkline);
2395
2396 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
2397 { label: "< 1 day", key: "lt1d" },
2398 { label: "1–3 days", key: "d1to3" },
2399 { label: "3–7 days", key: "d3to7" },
2400 { label: "7–30 days", key: "d7to30" },
2401 { label: "> 30 days", key: "gt30d" },
2402 ];
2403
2404 return c.html(
2405 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
2406 <RepoHeader owner={ownerName} repo={repoName} />
2407 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2408 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
2409
2410 <div class="pri-page">
2411 {/* Hero */}
2412 <div class="pri-hero">
2413 <div class="pri-hero-eyebrow">Pull requests</div>
2414 <h1 class="pri-hero-title">
2415 PR <span class="gradient-text">Insights</span>
2416 </h1>
2417 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
2418 </div>
2419
2420 {/* Stat cards */}
2421 <div class="pri-section">
2422 <div class="pri-section-title">At a glance</div>
2423 <div class="pri-cards">
2424 <div class="pri-card">
2425 <div class="pri-card-label">Avg merge time</div>
2426 <div class="pri-card-value">
2427 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
2428 </div>
2429 <div class="pri-card-sub">last 90 days</div>
2430 </div>
2431 <div class="pri-card">
2432 <div class="pri-card-label">Total merged</div>
2433 <div class="pri-card-value">{mergedPRs.length}</div>
2434 <div class="pri-card-sub">last 90 days</div>
2435 </div>
2436 <div class="pri-card">
2437 <div class="pri-card-label">Open PRs</div>
2438 <div class="pri-card-value">{openPRs.length}</div>
2439 <div class="pri-card-sub">right now</div>
2440 </div>
2441 <div class="pri-card">
2442 <div class="pri-card-label">Merge rate</div>
2443 <div class="pri-card-value">
2444 {mergeRate != null ? `${mergeRate}%` : "—"}
2445 </div>
2446 <div class="pri-card-sub">merged vs closed</div>
2447 </div>
2448 <div class="pri-card">
2449 <div class="pri-card-label">Avg reviews / PR</div>
2450 <div class="pri-card-value">
2451 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
2452 </div>
2453 <div class="pri-card-sub">merged PRs, 90d</div>
2454 </div>
2455 <div class="pri-card">
2456 <div class="pri-card-label">Top reviewer</div>
2457 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
2458 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
2459 </div>
2460 <div class="pri-card-sub">
2461 {reviewerCounts.length > 0
2462 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
2463 : "no reviews yet"}
2464 </div>
2465 </div>
2466 </div>
2467 </div>
2468
2469 {/* Review turnaround */}
2470 <div class="pri-section">
2471 <div class="pri-section-title">Review turnaround</div>
2472 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
2473 <div class="pri-card">
2474 <div class="pri-card-label">Avg time to first review</div>
2475 <div class="pri-card-value">
2476 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
2477 </div>
2478 <div class="pri-card-sub">
2479 {prsWithReviews.length > 0
2480 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
2481 : "no reviewed PRs in 90d"}
2482 </div>
2483 </div>
2484 </div>
2485 </div>
2486
2487 {/* Weekly throughput bar chart */}
2488 <div class="pri-section">
2489 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
2490 {weeklyPRs.length === 0 ? (
2491 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
2492 ) : (
2493 <div class="pri-chart">
2494 {weeklyPRs.map((w) => (
2495 <div class="pri-bar-col">
2496 <div
2497 class="pri-bar"
2498 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
2499 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
2500 />
2501 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
2502 </div>
2503 ))}
2504 </div>
2505 )}
2506 </div>
2507
2508 {/* 7-day merge sparkline */}
2509 <div class="pri-section">
2510 <div class="pri-section-title">Merges this week (daily)</div>
2511 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
2512 <div class="pri-sparkline">
2513 {sparkline.map((v) => (
2514 <div
2515 class="pri-spark-bar"
2516 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
2517 title={`${v} merge${v === 1 ? "" : "s"}`}
2518 />
2519 ))}
2520 </div>
2521 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
2522 <span>7 days ago</span>
2523 <span>Today</span>
2524 </div>
2525 </div>
2526 </div>
2527
2528 {/* Top reviewers table */}
2529 <div class="pri-section">
2530 <div class="pri-section-title">Top reviewers (last 90 days)</div>
2531 {reviewerCounts.length === 0 ? (
2532 <div class="pri-empty">No reviews posted in the last 90 days.</div>
2533 ) : (
2534 <div class="pri-table-wrap">
2535 <table class="pri-table">
2536 <thead>
2537 <tr>
2538 <th>#</th>
2539 <th>Reviewer</th>
2540 <th>Reviews</th>
2541 </tr>
2542 </thead>
2543 <tbody>
2544 {reviewerCounts.map((r, i) => (
2545 <tr>
2546 <td style="color:var(--text-muted)">{i + 1}</td>
2547 <td>
2548 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
2549 {r.username}
2550 </a>
2551 </td>
2552 <td style="font-weight:600">{r.count}</td>
2553 </tr>
2554 ))}
2555 </tbody>
2556 </table>
2557 </div>
2558 )}
2559 </div>
2560
2561 {/* Open PRs by age */}
2562 <div class="pri-section">
2563 <div class="pri-section-title">Open PRs by age</div>
2564 {openPRs.length === 0 ? (
2565 <div class="pri-empty">No open pull requests.</div>
2566 ) : (
2567 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
2568 {ageBucketDefs.map(({ label, key }) => (
2569 <div class="pri-age-row">
2570 <span class="pri-age-label">{label}</span>
2571 <div class="pri-age-bar-wrap">
2572 <div
2573 class="pri-age-bar"
2574 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
2575 />
2576 </div>
2577 <span class="pri-age-count">{ageBuckets[key]}</span>
2578 </div>
2579 ))}
2580 </div>
2581 )}
2582 </div>
2583
2584 {/* Back link */}
2585 <div>
2586 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
2587 {"←"} Back to pull requests
2588 </a>
2589 </div>
2590 </div>
2591 </Layout>
2592 );
2593});
2594
0074234Claude2595// New PR form
2596pulls.get(
2597 "/:owner/:repo/pulls/new",
2598 softAuth,
2599 requireAuth,
04f6b7fClaude2600 requireRepoAccess("write"),
0074234Claude2601 async (c) => {
2602 const { owner: ownerName, repo: repoName } = c.req.param();
2603 const user = c.get("user")!;
2604 const branches = await listBranches(ownerName, repoName);
2605 const error = c.req.query("error");
2606 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude2607 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude2608
2609 return c.html(
2610 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
2611 <RepoHeader owner={ownerName} repo={repoName} />
2612 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude2613 <Container maxWidth={800}>
2614 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude2615 {error && (
bb0f894Claude2616 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude2617 )}
0316dbbClaude2618 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
2619 <Flex gap={12} align="center" style="margin-bottom: 16px">
2620 <Select name="base">
0074234Claude2621 {branches.map((b) => (
2622 <option value={b} selected={b === defaultBase}>
2623 {b}
2624 </option>
2625 ))}
bb0f894Claude2626 </Select>
2627 <Text muted>&larr;</Text>
2628 <Select name="head">
0074234Claude2629 {branches
2630 .filter((b) => b !== defaultBase)
2631 .concat(defaultBase === branches[0] ? [] : [branches[0]])
2632 .map((b) => (
2633 <option value={b}>{b}</option>
2634 ))}
bb0f894Claude2635 </Select>
2636 </Flex>
2637 <FormGroup>
2638 <Input
0074234Claude2639 name="title"
2640 required
2641 placeholder="Title"
bb0f894Claude2642 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]2643 aria-label="Pull request title"
0074234Claude2644 />
bb0f894Claude2645 </FormGroup>
2646 <FormGroup>
2647 <TextArea
0074234Claude2648 name="body"
81c73c1Claude2649 id="pr-body"
0074234Claude2650 rows={8}
2651 placeholder="Description (Markdown supported)"
bb0f894Claude2652 mono
0074234Claude2653 />
bb0f894Claude2654 </FormGroup>
81c73c1Claude2655 <Flex gap={8} align="center">
2656 <Button type="submit" variant="primary">
2657 Create pull request
2658 </Button>
2659 <button
2660 type="button"
2661 id="ai-suggest-desc"
2662 class="btn"
2663 style="font-weight:500"
2664 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
2665 >
2666 Suggest description with AI
2667 </button>
2668 <span
2669 id="ai-suggest-status"
2670 style="color:var(--text-muted);font-size:13px"
2671 />
2672 </Flex>
bb0f894Claude2673 </Form>
81c73c1Claude2674 <script
2675 dangerouslySetInnerHTML={{
2676 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
2677 }}
2678 />
bb0f894Claude2679 </Container>
0074234Claude2680 </Layout>
2681 );
2682 }
2683);
2684
81c73c1Claude2685// AI-suggested PR description — JSON endpoint driven by the form button.
2686// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
2687// 200; the inline script reads `ok` to decide what to do.
2688pulls.post(
2689 "/:owner/:repo/ai/pr-description",
2690 softAuth,
2691 requireAuth,
2692 requireRepoAccess("write"),
2693 async (c) => {
2694 const { owner: ownerName, repo: repoName } = c.req.param();
2695 if (!isAiAvailable()) {
2696 return c.json({
2697 ok: false,
2698 error: "AI is not available — set ANTHROPIC_API_KEY.",
2699 });
2700 }
2701 const body = await c.req.parseBody();
2702 const title = String(body.title || "").trim();
2703 const baseBranch = String(body.base || "").trim();
2704 const headBranch = String(body.head || "").trim();
2705 if (!baseBranch || !headBranch) {
2706 return c.json({ ok: false, error: "Pick base + head branches first." });
2707 }
2708 if (baseBranch === headBranch) {
2709 return c.json({ ok: false, error: "Base and head must differ." });
2710 }
2711
2712 let diff = "";
2713 try {
2714 const cwd = getRepoPath(ownerName, repoName);
2715 const proc = Bun.spawn(
2716 [
2717 "git",
2718 "diff",
2719 `${baseBranch}...${headBranch}`,
2720 "--",
2721 ],
2722 { cwd, stdout: "pipe", stderr: "pipe" }
2723 );
6ea2109Claude2724 // 30s ceiling — without this a pathological diff (huge binary or
2725 // a corrupt ref) hangs the request indefinitely.
2726 const killer = setTimeout(() => proc.kill(), 30_000);
2727 try {
2728 diff = await new Response(proc.stdout).text();
2729 await proc.exited;
2730 } finally {
2731 clearTimeout(killer);
2732 }
81c73c1Claude2733 } catch {
2734 diff = "";
2735 }
2736 if (!diff.trim()) {
2737 return c.json({
2738 ok: false,
2739 error: "No diff between branches — nothing to summarise.",
2740 });
2741 }
2742
2743 let summary = "";
2744 try {
2745 summary = await generatePrSummary(title || "(untitled)", diff);
2746 } catch (err) {
2747 const msg = err instanceof Error ? err.message : "AI request failed.";
2748 return c.json({ ok: false, error: msg });
2749 }
2750 if (!summary.trim()) {
2751 return c.json({ ok: false, error: "AI returned an empty draft." });
2752 }
2753 return c.json({ ok: true, body: summary });
2754 }
2755);
2756
0074234Claude2757// Create PR
2758pulls.post(
2759 "/:owner/:repo/pulls/new",
2760 softAuth,
2761 requireAuth,
04f6b7fClaude2762 requireRepoAccess("write"),
0074234Claude2763 async (c) => {
2764 const { owner: ownerName, repo: repoName } = c.req.param();
2765 const user = c.get("user")!;
2766 const body = await c.req.parseBody();
2767 const title = String(body.title || "").trim();
2768 const prBody = String(body.body || "").trim();
2769 const baseBranch = String(body.base || "main");
2770 const headBranch = String(body.head || "");
2771
2772 if (!title || !headBranch) {
2773 return c.redirect(
2774 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
2775 );
2776 }
2777
2778 if (baseBranch === headBranch) {
2779 return c.redirect(
2780 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
2781 );
2782 }
2783
2784 const resolved = await resolveRepo(ownerName, repoName);
2785 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2786
6fc53bdClaude2787 const isDraft = String(body.draft || "") === "1";
2788
0074234Claude2789 const [pr] = await db
2790 .insert(pullRequests)
2791 .values({
2792 repositoryId: resolved.repo.id,
2793 authorId: user.id,
2794 title,
2795 body: prBody || null,
2796 baseBranch,
2797 headBranch,
6fc53bdClaude2798 isDraft,
0074234Claude2799 })
2800 .returning();
2801
6fc53bdClaude2802 // Skip AI review on drafts — it runs again when the PR is marked ready.
2803 if (!isDraft && isAiReviewEnabled()) {
e883329Claude2804 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
2805 (err) => console.error("[ai-review] Failed:", err)
2806 );
2807 }
2808
3cbe3d6Claude2809 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
2810 triggerPrTriage({
2811 ownerName,
2812 repoName,
2813 repositoryId: resolved.repo.id,
2814 prId: pr.id,
2815 prAuthorId: user.id,
2816 title,
2817 body: prBody,
2818 baseBranch,
2819 headBranch,
2820 }).catch((err) => console.error("[pr-triage] Failed:", err));
2821
1d4ff60Claude2822 // Chat notifier — fan out to Slack/Discord/Teams.
2823 import("../lib/chat-notifier")
2824 .then((m) =>
2825 m.notifyChatChannels({
2826 ownerUserId: resolved.repo.ownerId,
2827 repositoryId: resolved.repo.id,
2828 event: {
2829 event: "pr.opened",
2830 repo: `${ownerName}/${repoName}`,
2831 title: `#${pr.number} ${title}`,
2832 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
2833 body: prBody || undefined,
2834 actor: user.username,
2835 },
2836 })
2837 )
2838 .catch((err) =>
2839 console.warn(`[chat-notifier] PR opened notify failed:`, err)
2840 );
2841
9dd96b9Test User2842 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude2843 import("../lib/auto-merge")
2844 .then((m) => m.tryAutoMergeNow(pr.id))
2845 .catch((err) => {
2846 console.warn(
2847 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
2848 err instanceof Error ? err.message : err
2849 );
2850 });
9dd96b9Test User2851
0074234Claude2852 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
2853 }
2854);
2855
2856// View single PR
04f6b7fClaude2857pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2858 const { owner: ownerName, repo: repoName } = c.req.param();
2859 const prNum = parseInt(c.req.param("number"), 10);
2860 const user = c.get("user");
2861 const tab = c.req.query("tab") || "conversation";
2862
2863 const resolved = await resolveRepo(ownerName, repoName);
2864 if (!resolved) return c.notFound();
2865
2866 const [pr] = await db
2867 .select()
2868 .from(pullRequests)
2869 .where(
2870 and(
2871 eq(pullRequests.repositoryId, resolved.repo.id),
2872 eq(pullRequests.number, prNum)
2873 )
2874 )
2875 .limit(1);
2876
2877 if (!pr) return c.notFound();
2878
2879 const [author] = await db
2880 .select()
2881 .from(users)
2882 .where(eq(users.id, pr.authorId))
2883 .limit(1);
2884
cb5a796Claude2885 const allCommentsRaw = await db
0074234Claude2886 .select({
2887 comment: prComments,
cb5a796Claude2888 author: { id: users.id, username: users.username },
0074234Claude2889 })
2890 .from(prComments)
2891 .innerJoin(users, eq(prComments.authorId, users.id))
2892 .where(eq(prComments.pullRequestId, pr.id))
2893 .orderBy(asc(prComments.createdAt));
2894
cb5a796Claude2895 // Filter pending/rejected/spam for non-owner, non-author viewers.
2896 // Owner always sees everything; comment author sees their own pending
2897 // with an "Awaiting approval" badge in the render below.
2898 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
2899 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
2900 if (viewerIsRepoOwner) return true;
2901 if (comment.moderationStatus === "approved") return true;
2902 if (
2903 user &&
2904 cAuthor.id === user.id &&
2905 comment.moderationStatus === "pending"
2906 ) {
2907 return true;
2908 }
2909 return false;
2910 });
2911 const prPendingCount = viewerIsRepoOwner
2912 ? await countPendingForRepo(resolved.repo.id)
2913 : 0;
2914
6fc53bdClaude2915 // Reactions for the PR body + each comment, in parallel.
2916 const [prReactions, ...prCommentReactions] = await Promise.all([
2917 summariseReactions("pr", pr.id, user?.id),
2918 ...comments.map((row) =>
2919 summariseReactions("pr_comment", row.comment.id, user?.id)
2920 ),
2921 ]);
2922
0a67773Claude2923 // Formal reviews (Approve / Request Changes)
2924 const reviewRows = await db
2925 .select({
2926 id: prReviews.id,
2927 state: prReviews.state,
2928 body: prReviews.body,
2929 isAi: prReviews.isAi,
2930 createdAt: prReviews.createdAt,
2931 reviewerUsername: users.username,
2932 reviewerId: prReviews.reviewerId,
2933 })
2934 .from(prReviews)
2935 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2936 .where(eq(prReviews.pullRequestId, pr.id))
2937 .orderBy(asc(prReviews.createdAt));
2938 // Most recent review per reviewer determines the current state
2939 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
2940 for (const r of reviewRows) {
2941 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
2942 }
2943 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
2944 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
2945 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
2946
0074234Claude2947 const canManage =
2948 user &&
2949 (user.id === resolved.owner.id || user.id === pr.authorId);
2950
1d4ff60Claude2951 // Has any previous AI-test-generator run already tagged this PR? Used
2952 // both to hide the "Generate tests with AI" button and to short-circuit
2953 // the explicit POST handler.
2954 const hasAiTestsMarker = comments.some(({ comment }) =>
2955 (comment.body || "").includes(AI_TESTS_MARKER)
2956 );
2957
e883329Claude2958 const error = c.req.query("error");
c3e0c07Claude2959 const info = c.req.query("info");
e883329Claude2960
2961 // Get gate check status for open PRs
2962 let gateChecks: GateCheckResult[] = [];
2963 if (pr.state === "open") {
2964 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
2965 if (headSha) {
2966 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
2967 const aiApproved = aiComments.length === 0 || aiComments.some(
2968 ({ comment }) => comment.body.includes("**Approved**")
2969 );
2970 const gateResult = await runAllGateChecks(
2971 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
2972 );
2973 gateChecks = gateResult.checks;
2974 }
2975 }
2976
534f04aClaude2977 // Block M3 — pre-merge risk score. Cache-only on the request path so
2978 // the page never waits on Haiku. On a cache miss for an open PR we
2979 // kick off the computation fire-and-forget; the next refresh shows it.
2980 let prRisk: PrRiskScore | null = null;
2981 let prRiskCalculating = false;
2982 if (pr.state === "open") {
2983 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
2984 if (!prRisk) {
2985 prRiskCalculating = true;
a28cedeClaude2986 void computePrRiskForPullRequest(pr.id).catch((err) => {
2987 console.warn(
2988 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
2989 err instanceof Error ? err.message : err
2990 );
2991 });
534f04aClaude2992 }
2993 }
2994
4bbacbeClaude2995 // Migration 0062 — per-branch preview URL. The head branch always
2996 // has a preview row (unless it's the default branch, which never
2997 // happens for an open PR) once it has been pushed at least once.
2998 const preview = await getPreviewForBranch(
2999 (resolved.repo as { id: string }).id,
3000 pr.headBranch
3001 );
3002
47a7a0aClaude3003 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude3004 let diffRaw = "";
3005 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude3006 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude3007 if (tab === "files") {
3008 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude3009 // Run the two git diffs in parallel — they're independent reads of
3010 // the same range. Previously sequential, doubling the wall time on
3011 // big PRs (100+ files = 10-30s for no reason).
0074234Claude3012 const proc = Bun.spawn(
3013 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
3014 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3015 );
3016 const statProc = Bun.spawn(
3017 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
3018 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3019 );
6ea2109Claude3020 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
3021 // would otherwise hang the whole request.
3022 const killer = setTimeout(() => {
3023 proc.kill();
3024 statProc.kill();
3025 }, 30_000);
3026 let stat = "";
3027 try {
3028 [diffRaw, stat] = await Promise.all([
3029 new Response(proc.stdout).text(),
3030 new Response(statProc.stdout).text(),
3031 ]);
3032 await Promise.all([proc.exited, statProc.exited]);
3033 } finally {
3034 clearTimeout(killer);
3035 }
0074234Claude3036
3037 diffFiles = stat
3038 .trim()
3039 .split("\n")
3040 .filter(Boolean)
3041 .map((line) => {
3042 const [add, del, filePath] = line.split("\t");
3043 return {
3044 path: filePath,
3045 status: "modified",
3046 additions: add === "-" ? 0 : parseInt(add, 10),
3047 deletions: del === "-" ? 0 : parseInt(del, 10),
3048 patch: "",
3049 };
3050 });
47a7a0aClaude3051
3052 // Fetch inline comments (file+line anchored) for the files tab
3053 const inlineRows = await db
3054 .select({
3055 id: prComments.id,
3056 filePath: prComments.filePath,
3057 lineNumber: prComments.lineNumber,
3058 body: prComments.body,
3059 isAiReview: prComments.isAiReview,
3060 createdAt: prComments.createdAt,
3061 authorUsername: users.username,
3062 })
3063 .from(prComments)
3064 .innerJoin(users, eq(prComments.authorId, users.id))
3065 .where(
3066 and(
3067 eq(prComments.pullRequestId, pr.id),
3068 eq(prComments.moderationStatus, "approved"),
3069 )
3070 )
3071 .orderBy(asc(prComments.createdAt));
3072
3073 diffInlineComments = inlineRows
3074 .filter(r => r.filePath != null && r.lineNumber != null)
3075 .map(r => ({
3076 id: r.id,
3077 filePath: r.filePath!,
3078 lineNumber: r.lineNumber!,
3079 authorUsername: r.authorUsername,
3080 body: renderMarkdown(r.body),
3081 isAiReview: r.isAiReview,
3082 createdAt: r.createdAt.toISOString(),
3083 }));
0074234Claude3084 }
3085
b078860Claude3086 // ─── Derived visual state ───
3087 const stateKey =
3088 pr.state === "open"
3089 ? pr.isDraft
3090 ? "draft"
3091 : "open"
3092 : pr.state;
3093 const stateLabel =
3094 stateKey === "open"
3095 ? "Open"
3096 : stateKey === "draft"
3097 ? "Draft"
3098 : stateKey === "merged"
3099 ? "Merged"
3100 : "Closed";
3101 const stateIcon =
3102 stateKey === "open"
3103 ? "○"
3104 : stateKey === "draft"
3105 ? "◌"
3106 : stateKey === "merged"
3107 ? "⮌"
3108 : "✓";
3109 const commentCount = comments.length;
3110 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
3111 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
3112 const mergeBlocked =
3113 gateChecks.length > 0 &&
3114 gateChecks.some(
3115 (c) => !c.passed && c.name !== "Merge check"
3116 );
3117
0074234Claude3118 return c.html(
3119 <Layout
3120 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
3121 user={user}
3122 >
3123 <RepoHeader owner={ownerName} repo={repoName} />
3124 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude3125 <PendingCommentsBanner
3126 owner={ownerName}
3127 repo={repoName}
3128 count={prPendingCount}
3129 />
b078860Claude3130 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude3131 <div
3132 id="live-comment-banner"
3133 class="alert"
3134 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
3135 >
3136 <strong class="js-live-count">0</strong> new comment(s) —{" "}
3137 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
3138 reload to view
3139 </a>
3140 </div>
3141 <script
3142 dangerouslySetInnerHTML={{
3143 __html: liveCommentBannerScript({
3144 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
3145 bannerElementId: "live-comment-banner",
3146 }),
3147 }}
3148 />
b078860Claude3149
3150 <div class="prs-detail-hero">
3151 <h1 class="prs-detail-title">
0074234Claude3152 {pr.title}{" "}
b078860Claude3153 <span class="prs-detail-num">#{pr.number}</span>
3154 </h1>
3155 <div class="prs-detail-meta">
3156 <span class={`prs-state-pill state-${stateKey}`}>
3157 <span aria-hidden="true">{stateIcon}</span>
3158 <span>{stateLabel}</span>
3159 </span>
3160 <span>
3161 <strong>{author?.username}</strong> wants to merge
3162 </span>
3163 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
3164 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
3165 <span class="prs-branch-arrow-lg">{"→"}</span>
3166 <span class="prs-branch-pill">{pr.baseBranch}</span>
3167 </span>
3168 <span>opened {formatRelative(pr.createdAt)}</span>
3c03977Claude3169 <span
3170 id="live-pill"
3171 class="live-pill"
3172 title="People editing this PR right now"
3173 >
3174 <span class="live-pill-dot" aria-hidden="true"></span>
3175 <span>
3176 Live: <strong id="live-count">0</strong> editing
3177 </span>
3178 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
3179 </span>
4bbacbeClaude3180 {preview && (
3181 <a
3182 class={`preview-prpill is-${preview.status}`}
3183 href={
3184 preview.status === "ready"
3185 ? preview.previewUrl
3186 : `/${ownerName}/${repoName}/previews`
3187 }
3188 target={preview.status === "ready" ? "_blank" : undefined}
3189 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
3190 title={`Preview · ${previewStatusLabel(preview.status)}`}
3191 >
3192 <span class="preview-prpill-dot" aria-hidden="true"></span>
3193 <span>Preview: </span>
3194 <span>{previewStatusLabel(preview.status)}</span>
3195 </a>
3196 )}
b078860Claude3197 {canManage && pr.state === "open" && pr.isDraft && (
3198 <form
3199 method="post"
3200 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3201 class="prs-inline-form prs-detail-actions"
3202 >
3203 <button type="submit" class="prs-merge-ready-btn">
3204 Ready for review
3205 </button>
3206 </form>
3207 )}
3208 </div>
3209 </div>
3c03977Claude3210 <script
3211 dangerouslySetInnerHTML={{
3212 __html: LIVE_COEDIT_SCRIPT(pr.id),
3213 }}
3214 />
0074234Claude3215
b078860Claude3216 <nav class="prs-detail-tabs" aria-label="Pull request sections">
3217 <a
3218 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
3219 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
3220 >
3221 Conversation
3222 <span class="prs-detail-tab-count">{commentCount}</span>
3223 </a>
3224 <a
3225 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
3226 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3227 >
3228 Files changed
3229 {diffFiles.length > 0 && (
3230 <span class="prs-detail-tab-count">{diffFiles.length}</span>
3231 )}
3232 </a>
3233 </nav>
3234
3235 {tab === "files" ? (
ea9ed4cClaude3236 <DiffView
3237 raw={diffRaw}
3238 files={diffFiles}
3239 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
47a7a0aClaude3240 inlineComments={diffInlineComments}
3241 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
b5dd694Claude3242 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
ea9ed4cClaude3243 />
b078860Claude3244 ) : (
3245 <>
3246 {pr.body && (
3247 <CommentBox
3248 author={author?.username ?? "unknown"}
3249 date={pr.createdAt}
3250 body={renderMarkdown(pr.body)}
3251 />
3252 )}
3253
422a2d4Claude3254 {/* Block H — AI trio review (security/correctness/style). When
3255 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
3256 hoisted into a 3-column card grid above the normal comment
3257 stream so reviewers see verdicts at a glance. Disagreements
3258 are surfaced as a yellow callout. */}
3259 <TrioReviewGrid
3260 comments={comments.map(({ comment }) => comment)}
3261 />
3262
15db0e0Claude3263 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude3264 // Skip trio comments — already rendered in TrioReviewGrid above.
3265 if (isTrioComment(comment.body)) return null;
15db0e0Claude3266 const slashCmd = detectSlashCmdComment(comment.body);
3267 if (slashCmd) {
3268 const visible = stripSlashCmdMarker(comment.body);
3269 return (
3270 <div class={`slash-pill slash-cmd-${slashCmd}`}>
3271 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
3272 <span class="slash-pill-actor">
3273 <strong>{commentAuthor.username}</strong>
3274 {" ran "}
3275 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude3276 </span>
15db0e0Claude3277 <span class="slash-pill-time">
3278 {formatRelative(comment.createdAt)}
3279 </span>
3280 <div class="slash-pill-body">
3281 <MarkdownContent html={renderMarkdown(visible)} />
3282 </div>
3283 </div>
3284 );
3285 }
cb5a796Claude3286 const isPending = comment.moderationStatus === "pending";
15db0e0Claude3287 return (
cb5a796Claude3288 <div
3289 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
3290 >
15db0e0Claude3291 <div class="prs-comment-head">
3292 <strong>{commentAuthor.username}</strong>
3293 {comment.isAiReview && (
3294 <span class="prs-ai-badge">AI Review</span>
3295 )}
cb5a796Claude3296 {isPending && (
3297 <span
3298 class="modq-pending-badge"
3299 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
3300 >
3301 Awaiting approval
3302 </span>
3303 )}
15db0e0Claude3304 <span class="prs-comment-time">
3305 commented {formatRelative(comment.createdAt)}
3306 </span>
3307 {comment.filePath && (
3308 <span class="prs-comment-loc">
3309 {comment.filePath}
3310 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
3311 </span>
3312 )}
3313 </div>
3314 <div class="prs-comment-body">
3315 <MarkdownContent html={renderMarkdown(comment.body)} />
3316 </div>
0074234Claude3317 </div>
15db0e0Claude3318 );
3319 })}
0074234Claude3320
b078860Claude3321 {/* Quick link to the Files changed tab when there's a diff to look at. */}
3322 {pr.state !== "merged" && (
3323 <a
3324 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3325 class="prs-files-card"
3326 >
3327 <span class="prs-files-card-icon" aria-hidden="true">
3328 {"▤"}
3329 </span>
3330 <div class="prs-files-card-text">
3331 <p class="prs-files-card-title">Files changed</p>
3332 <p class="prs-files-card-sub">
3333 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
3334 </p>
e883329Claude3335 </div>
b078860Claude3336 <span class="prs-files-card-cta">View diff {"→"}</span>
3337 </a>
3338 )}
3339
3340 {error && (
3341 <div
3342 class="auth-error"
3343 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)"
3344 >
3345 {decodeURIComponent(error)}
3346 </div>
3347 )}
3348
3349 {info && (
3350 <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)">
3351 {decodeURIComponent(info)}
3352 </div>
3353 )}
e883329Claude3354
b078860Claude3355 {pr.state === "open" && (prRisk || prRiskCalculating) && (
3356 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
3357 )}
3358
0a67773Claude3359 {/* ─── Review summary ─────────────────────────────────── */}
3360 {(approvals.length > 0 || changesRequested.length > 0) && (
3361 <div class="prs-review-summary">
3362 {approvals.length > 0 && (
3363 <div class="prs-review-row prs-review-approved">
3364 <span class="prs-review-icon">✓</span>
3365 <span>
3366 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
3367 approved this pull request
3368 </span>
3369 </div>
3370 )}
3371 {changesRequested.length > 0 && (
3372 <div class="prs-review-row prs-review-changes">
3373 <span class="prs-review-icon">✗</span>
3374 <span>
3375 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
3376 requested changes
3377 </span>
3378 </div>
3379 )}
3380 </div>
3381 )}
3382
b078860Claude3383 {pr.state === "open" && gateChecks.length > 0 && (
3384 <div class="prs-gate-card">
3385 <div class="prs-gate-head">
3386 <h3>Gate checks</h3>
3387 <span class="prs-gate-summary">
3388 {gatesAllPassed
3389 ? `All ${gateChecks.length} checks passed`
3390 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
3391 </span>
c3e0c07Claude3392 </div>
b078860Claude3393 {gateChecks.map((check) => {
3394 const isAi = /ai.*review/i.test(check.name);
3395 const isSkip = check.skipped === true;
3396 const statusClass = isSkip
3397 ? "is-skip"
3398 : check.passed
3399 ? "is-pass"
3400 : "is-fail";
3401 const statusGlyph = isSkip
3402 ? "—"
3403 : check.passed
3404 ? "✓"
3405 : "✗";
3406 const statusLabel = isSkip
3407 ? "Skipped"
3408 : check.passed
3409 ? "Passed"
3410 : "Failing";
3411 return (
3412 <div
3413 class="prs-gate-row"
3414 style={
3415 isAi
3416 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
3417 : ""
3418 }
3419 >
3420 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
3421 {statusGlyph}
3422 </span>
3423 <span class="prs-gate-name">
3424 {check.name}
3425 {isAi && (
3426 <span
3427 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"
3428 >
3429 AI
3430 </span>
3431 )}
3432 </span>
3433 <span class="prs-gate-details">{check.details}</span>
3434 <span class={`prs-gate-pill ${statusClass}`}>
3435 {statusLabel}
e883329Claude3436 </span>
3437 </div>
b078860Claude3438 );
3439 })}
3440 <div class="prs-gate-footer">
3441 {gatesAllPassed
3442 ? "All checks passed — ready to merge."
3443 : gateChecks.some(
3444 (c) => !c.passed && c.name === "Merge check"
3445 )
3446 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
3447 : "Some checks failed — resolve issues before merging."}
3448 {aiReviewCount > 0 && (
3449 <>
3450 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
3451 </>
3452 )}
3453 </div>
3454 </div>
3455 )}
3456
3457 {/* ─── Merge area / state-aware action card ─────────────── */}
3458 {user && pr.state === "open" && (
3459 <div
3460 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
3461 >
3462 <div class="prs-merge-head">
3463 <strong>
3464 {pr.isDraft
3465 ? "Draft — ready for review?"
3466 : mergeBlocked
3467 ? "Merge blocked"
3468 : "Ready to merge"}
3469 </strong>
e883329Claude3470 </div>
b078860Claude3471 <p class="prs-merge-sub">
3472 {pr.isDraft
3473 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
3474 : mergeBlocked
3475 ? "Resolve the failing gate checks above before this PR can land."
3476 : gateChecks.length > 0
3477 ? gatesAllPassed
3478 ? "All gates green. Merge will fast-forward into the base branch."
3479 : "Conflicts will be auto-resolved by GlueCron AI on merge."
3480 : "Run gate checks by refreshing once your branch has a recent commit."}
3481 </p>
3482 <Form
3483 method="post"
3484 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
3485 >
3486 <FormGroup>
3c03977Claude3487 <div class="live-cursor-host" style="position:relative">
3488 <textarea
3489 name="body"
3490 id="pr-comment-body"
3491 data-live-field="comment_new"
3492 rows={5}
3493 required
3494 placeholder="Leave a comment... (Markdown supported)"
3495 style="font-family:var(--font-mono);font-size:13px;width:100%"
3496 ></textarea>
3497 </div>
15db0e0Claude3498 <span class="slash-hint" title="Type a slash-command as the first line">
3499 Type <code>/</code> for commands —{" "}
3500 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
3501 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
3502 </span>
b078860Claude3503 </FormGroup>
3504 <div class="prs-merge-actions">
3505 <Button type="submit" variant="primary">
3506 Comment
3507 </Button>
0a67773Claude3508 {user && user.id !== pr.authorId && pr.state === "open" && (
3509 <>
3510 <button
3511 type="submit"
3512 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
3513 name="review_state"
3514 value="approved"
3515 class="prs-review-approve-btn"
3516 title="Approve this pull request"
3517 >
3518 ✓ Approve
3519 </button>
3520 <button
3521 type="submit"
3522 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
3523 name="review_state"
3524 value="changes_requested"
3525 class="prs-review-changes-btn"
3526 title="Request changes before merging"
3527 >
3528 ✗ Request changes
3529 </button>
3530 </>
3531 )}
b078860Claude3532 {canManage && (
3533 <>
3534 {pr.isDraft ? (
3535 <button
3536 type="submit"
3537 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3538 formnovalidate
3539 class="prs-merge-ready-btn"
3540 >
3541 Ready for review
3542 </button>
3543 ) : (
a164a6dClaude3544 <>
3545 <div class="prs-merge-strategy-wrap">
3546 <span class="prs-merge-strategy-label">Strategy</span>
3547 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
3548 <option value="merge">Merge commit</option>
3549 <option value="squash">Squash and merge</option>
3550 <option value="ff">Fast-forward</option>
3551 </select>
3552 </div>
3553 <button
3554 type="submit"
3555 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
3556 formnovalidate
3557 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
3558 title={
3559 mergeBlocked
3560 ? "Failing gate checks must be resolved before this PR can merge."
3561 : "Merge pull request"
3562 }
3563 >
3564 {"✔"} Merge pull request
3565 </button>
3566 </>
b078860Claude3567 )}
3568 {!pr.isDraft && (
3569 <button
0074234Claude3570 type="submit"
b078860Claude3571 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
3572 formnovalidate
3573 class="prs-merge-back-draft"
3574 title="Convert back to draft"
0074234Claude3575 >
b078860Claude3576 Convert to draft
3577 </button>
3578 )}
3579 {isAiReviewEnabled() && (
3580 <button
3581 type="submit"
3582 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
3583 formnovalidate
3584 class="btn"
3585 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
3586 >
3587 Re-run AI review
3588 </button>
3589 )}
1d4ff60Claude3590 {isAiReviewEnabled() && !hasAiTestsMarker && (
3591 <button
3592 type="submit"
3593 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
3594 formnovalidate
3595 class="btn"
3596 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."
3597 >
3598 Generate tests with AI
3599 </button>
3600 )}
b078860Claude3601 <Button
3602 type="submit"
3603 variant="danger"
3604 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
3605 >
3606 Close
3607 </Button>
3608 </>
3609 )}
3610 </div>
3611 </Form>
3612 </div>
3613 )}
3614
3615 {/* Read-only footers for non-open states. */}
3616 {pr.state === "merged" && (
3617 <div class="prs-merge-card is-merged">
3618 <div class="prs-merge-head">
3619 <strong>{"⮌"} Merged</strong>
0074234Claude3620 </div>
b078860Claude3621 <p class="prs-merge-sub">
3622 This pull request was merged into{" "}
3623 <code>{pr.baseBranch}</code>.
3624 </p>
3625 </div>
3626 )}
3627 {pr.state === "closed" && (
3628 <div class="prs-merge-card is-closed">
3629 <div class="prs-merge-head">
3630 <strong>{"✕"} Closed without merging</strong>
3631 </div>
3632 <p class="prs-merge-sub">
3633 This pull request was closed and not merged.
3634 </p>
3635 </div>
3636 )}
3637 </>
3638 )}
0074234Claude3639 </Layout>
3640 );
3641});
3642
cb5a796Claude3643// Add comment to PR.
3644//
3645// Permission model mirrors `issues.tsx`: any logged-in user with read
3646// access can submit; `decideInitialStatus` routes non-collaborators
3647// through the moderation queue. Slash commands only fire when the
3648// comment is auto-approved — we don't want a banned/pending comment to
3649// silently trigger AI work on the PR.
0074234Claude3650pulls.post(
3651 "/:owner/:repo/pulls/:number/comment",
3652 softAuth,
3653 requireAuth,
cb5a796Claude3654 requireRepoAccess("read"),
0074234Claude3655 async (c) => {
3656 const { owner: ownerName, repo: repoName } = c.req.param();
3657 const prNum = parseInt(c.req.param("number"), 10);
3658 const user = c.get("user")!;
3659 const body = await c.req.parseBody();
3660 const commentBody = String(body.body || "").trim();
47a7a0aClaude3661 const filePathRaw = String(body.file_path || "").trim();
3662 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
3663 const inlineFilePath = filePathRaw || undefined;
3664 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude3665
3666 if (!commentBody) {
3667 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3668 }
3669
3670 const resolved = await resolveRepo(ownerName, repoName);
3671 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3672
3673 const [pr] = await db
3674 .select()
3675 .from(pullRequests)
3676 .where(
3677 and(
3678 eq(pullRequests.repositoryId, resolved.repo.id),
3679 eq(pullRequests.number, prNum)
3680 )
3681 )
3682 .limit(1);
3683
3684 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3685
cb5a796Claude3686 const decision = await decideInitialStatus({
3687 commenterUserId: user.id,
3688 repositoryId: resolved.repo.id,
3689 kind: "pr",
3690 threadId: pr.id,
3691 });
3692
d4ac5c3Claude3693 const [inserted] = await db
3694 .insert(prComments)
3695 .values({
3696 pullRequestId: pr.id,
3697 authorId: user.id,
3698 body: commentBody,
cb5a796Claude3699 moderationStatus: decision.status,
47a7a0aClaude3700 filePath: inlineFilePath,
3701 lineNumber: inlineLineNumber,
d4ac5c3Claude3702 })
3703 .returning();
3704
cb5a796Claude3705 // Live update: only when the comment is actually visible.
3706 if (inserted && decision.status === "approved") {
d4ac5c3Claude3707 try {
3708 const { publish } = await import("../lib/sse");
3709 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
3710 event: "pr-comment",
3711 data: {
3712 pullRequestId: pr.id,
3713 commentId: inserted.id,
3714 authorId: user.id,
3715 authorUsername: user.username,
3716 },
3717 });
3718 } catch {
3719 /* SSE is best-effort */
3720 }
3721 }
0074234Claude3722
cb5a796Claude3723 if (decision.status === "pending") {
3724 void notifyOwnerOfPendingComment({
3725 repositoryId: resolved.repo.id,
3726 commenterUsername: user.username,
3727 kind: "pr",
3728 threadNumber: prNum,
3729 ownerUsername: ownerName,
3730 repoName,
3731 });
3732 return c.redirect(
3733 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
3734 );
3735 }
3736 if (decision.status === "rejected") {
3737 // Silent ban path — same UX as 'pending' so we don't leak the gate.
3738 return c.redirect(
3739 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
3740 );
3741 }
3742
15db0e0Claude3743 // Slash-command handoff. We always store the original comment above
3744 // first so free-form text that happens to start with `/` is preserved
3745 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude3746 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude3747 const parsed = parseSlashCommand(commentBody);
3748 if (parsed) {
3749 try {
3750 const result = await executeSlashCommand({
3751 command: parsed.command,
3752 args: parsed.args,
3753 prId: pr.id,
3754 userId: user.id,
3755 repositoryId: resolved.repo.id,
3756 });
3757 await db.insert(prComments).values({
3758 pullRequestId: pr.id,
3759 authorId: user.id,
3760 body: result.body,
3761 });
3762 } catch (err) {
3763 // Defence-in-depth — executeSlashCommand promises not to throw,
3764 // but if it ever does we want the PR thread to know.
3765 await db
3766 .insert(prComments)
3767 .values({
3768 pullRequestId: pr.id,
3769 authorId: user.id,
3770 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
3771 })
3772 .catch(() => {});
3773 }
3774 }
3775
47a7a0aClaude3776 // Inline comments go back to the files tab; conversation comments to the conversation tab
3777 const redirectTab = inlineFilePath ? "?tab=files" : "";
3778 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude3779 }
3780);
3781
b5dd694Claude3782// Apply a suggestion from a PR comment — commits the suggested code to the
3783// head branch on behalf of the logged-in user.
3784pulls.post(
3785 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
3786 softAuth,
3787 requireAuth,
3788 requireRepoAccess("read"),
3789 async (c) => {
3790 const { owner: ownerName, repo: repoName } = c.req.param();
3791 const prNum = parseInt(c.req.param("number"), 10);
3792 const commentId = c.req.param("commentId"); // UUID
3793 const user = c.get("user")!;
3794
3795 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
3796
3797 const resolved = await resolveRepo(ownerName, repoName);
3798 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3799
3800 const [pr] = await db
3801 .select()
3802 .from(pullRequests)
3803 .where(
3804 and(
3805 eq(pullRequests.repositoryId, resolved.repo.id),
3806 eq(pullRequests.number, prNum)
3807 )
3808 )
3809 .limit(1);
3810
3811 if (!pr || pr.state !== "open") {
3812 return c.redirect(`${backUrl}&error=pr_not_open`);
3813 }
3814
3815 // Only PR author or repo owner may apply suggestions.
3816 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
3817 return c.redirect(`${backUrl}&error=forbidden`);
3818 }
3819
3820 // Load the comment.
3821 const [comment] = await db
3822 .select()
3823 .from(prComments)
3824 .where(
3825 and(
3826 eq(prComments.id, commentId),
3827 eq(prComments.pullRequestId, pr.id)
3828 )
3829 )
3830 .limit(1);
3831
3832 if (!comment) {
3833 return c.redirect(`${backUrl}&error=comment_not_found`);
3834 }
3835
3836 // Parse suggestion block from comment body.
3837 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
3838 if (!m) {
3839 return c.redirect(`${backUrl}&error=no_suggestion`);
3840 }
3841 const suggestionCode = m[1];
3842
3843 // Get the commenter's details for the commit message co-author line.
3844 const [commenter] = await db
3845 .select()
3846 .from(users)
3847 .where(eq(users.id, comment.authorId))
3848 .limit(1);
3849
3850 // Fetch current file content from head branch.
3851 if (!comment.filePath) {
3852 return c.redirect(`${backUrl}&error=file_not_found`);
3853 }
3854 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
3855 if (!blob) {
3856 return c.redirect(`${backUrl}&error=file_not_found`);
3857 }
3858
3859 // Apply the patch — replace the target line(s) with suggestion lines.
3860 const lines = blob.content.split('\n');
3861 const lineIdx = (comment.lineNumber ?? 1) - 1;
3862 if (lineIdx < 0 || lineIdx >= lines.length) {
3863 return c.redirect(`${backUrl}&error=line_out_of_range`);
3864 }
3865 const suggestionLines = suggestionCode.split('\n');
3866 lines.splice(lineIdx, 1, ...suggestionLines);
3867 const newContent = lines.join('\n');
3868
3869 // Commit the change.
3870 const coAuthorLine = commenter
3871 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
3872 : "";
3873 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
3874
3875 const result = await createOrUpdateFileOnBranch({
3876 owner: ownerName,
3877 name: repoName,
3878 branch: pr.headBranch,
3879 filePath: comment.filePath,
3880 bytes: new TextEncoder().encode(newContent),
3881 message: commitMessage,
3882 authorName: user.username,
3883 authorEmail: `${user.username}@users.noreply.gluecron.com`,
3884 });
3885
3886 if ("error" in result) {
3887 return c.redirect(`${backUrl}&error=apply_failed`);
3888 }
3889
3890 // Post a follow-up comment noting the suggestion was applied.
3891 await db.insert(prComments).values({
3892 pullRequestId: pr.id,
3893 authorId: user.id,
3894 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
3895 });
3896
3897 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
3898 }
3899);
3900
0a67773Claude3901// Formal review — Approve / Request Changes / Comment
3902pulls.post(
3903 "/:owner/:repo/pulls/:number/review",
3904 softAuth,
3905 requireAuth,
3906 requireRepoAccess("read"),
3907 async (c) => {
3908 const { owner: ownerName, repo: repoName } = c.req.param();
3909 const prNum = parseInt(c.req.param("number"), 10);
3910 const user = c.get("user")!;
3911 const body = await c.req.parseBody();
3912 const reviewBody = String(body.body || "").trim();
3913 const reviewState = String(body.review_state || "commented");
3914
3915 const validStates = ["approved", "changes_requested", "commented"];
3916 if (!validStates.includes(reviewState)) {
3917 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3918 }
3919
3920 const resolved = await resolveRepo(ownerName, repoName);
3921 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3922
3923 const [pr] = await db
3924 .select()
3925 .from(pullRequests)
3926 .where(
3927 and(
3928 eq(pullRequests.repositoryId, resolved.repo.id),
3929 eq(pullRequests.number, prNum)
3930 )
3931 )
3932 .limit(1);
3933 if (!pr || pr.state !== "open") {
3934 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3935 }
3936 // Authors can't review their own PR
3937 if (pr.authorId === user.id) {
3938 return c.redirect(
3939 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
3940 );
3941 }
3942
3943 await db.insert(prReviews).values({
3944 pullRequestId: pr.id,
3945 reviewerId: user.id,
3946 state: reviewState,
3947 body: reviewBody || null,
3948 });
3949
3950 const stateLabel =
3951 reviewState === "approved" ? "Approved"
3952 : reviewState === "changes_requested" ? "Changes requested"
3953 : "Commented";
3954 return c.redirect(
3955 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
3956 );
3957 }
3958);
3959
e883329Claude3960// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude3961// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
3962// but we keep it at "write" for v1 so trusted collaborators can ship.
3963// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
3964// surface. Branch-protection rules (evaluated below) are the current mechanism
3965// for locking down merges further on specific branches.
0074234Claude3966pulls.post(
3967 "/:owner/:repo/pulls/:number/merge",
3968 softAuth,
3969 requireAuth,
04f6b7fClaude3970 requireRepoAccess("write"),
0074234Claude3971 async (c) => {
3972 const { owner: ownerName, repo: repoName } = c.req.param();
3973 const prNum = parseInt(c.req.param("number"), 10);
3974 const user = c.get("user")!;
3975
a164a6dClaude3976 // Read merge strategy from form (default: merge commit)
3977 let mergeStrategy = "merge";
3978 try {
3979 const body = await c.req.parseBody();
3980 const s = body.merge_strategy;
3981 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
3982 } catch { /* ignore parse errors — default to merge commit */ }
3983
0074234Claude3984 const resolved = await resolveRepo(ownerName, repoName);
3985 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3986
3987 const [pr] = await db
3988 .select()
3989 .from(pullRequests)
3990 .where(
3991 and(
3992 eq(pullRequests.repositoryId, resolved.repo.id),
3993 eq(pullRequests.number, prNum)
3994 )
3995 )
3996 .limit(1);
3997
3998 if (!pr || pr.state !== "open") {
3999 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4000 }
4001
6fc53bdClaude4002 // Draft PRs cannot be merged — must be marked ready first.
4003 if (pr.isDraft) {
4004 return c.redirect(
4005 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4006 "This PR is a draft. Mark it as ready for review before merging."
4007 )}`
4008 );
4009 }
4010
e883329Claude4011 // Resolve head SHA
4012 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4013 if (!headSha) {
4014 return c.redirect(
4015 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
4016 );
4017 }
4018
4019 // Check if AI review approved this PR
4020 const aiComments = await db
4021 .select()
4022 .from(prComments)
4023 .where(
4024 and(
4025 eq(prComments.pullRequestId, pr.id),
4026 eq(prComments.isAiReview, true)
4027 )
4028 );
4029 const aiApproved = aiComments.length === 0 || aiComments.some(
4030 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude4031 );
e883329Claude4032
4033 // Run all green gate checks (GateTest + mergeability + AI review)
4034 const gateResult = await runAllGateChecks(
4035 ownerName,
4036 repoName,
4037 pr.baseBranch,
4038 pr.headBranch,
4039 headSha,
4040 aiApproved
0074234Claude4041 );
4042
e883329Claude4043 // If GateTest or AI review failed (hard blocks), reject the merge
4044 const hardFailures = gateResult.checks.filter(
4045 (check) => !check.passed && check.name !== "Merge check"
4046 );
4047 if (hardFailures.length > 0) {
4048 const errorMsg = hardFailures
4049 .map((f) => `${f.name}: ${f.details}`)
4050 .join("; ");
0074234Claude4051 return c.redirect(
e883329Claude4052 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude4053 );
4054 }
4055
1e162a8Claude4056 // D5 — Branch-protection enforcement. Looks up the matching rule for the
4057 // base branch and blocks the merge if requireAiApproval / requireGreenGates
4058 // / requireHumanReview / requiredApprovals are not satisfied. Independent
4059 // of repo-global settings, so owners can lock specific branches down
4060 // further than the repo default.
4061 const protectionRule = await matchProtection(
4062 resolved.repo.id,
4063 pr.baseBranch
4064 );
4065 if (protectionRule) {
4066 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude4067 const required = await listRequiredChecks(protectionRule.id);
4068 const passingNames = required.length > 0
4069 ? await passingCheckNames(resolved.repo.id, headSha)
4070 : [];
4071 const decision = evaluateProtection(
4072 protectionRule,
4073 {
4074 aiApproved,
4075 humanApprovalCount: humanApprovals,
4076 gateResultGreen: hardFailures.length === 0,
4077 hasFailedGates: hardFailures.length > 0,
4078 passingCheckNames: passingNames,
4079 },
4080 required.map((r) => r.checkName)
4081 );
1e162a8Claude4082 if (!decision.allowed) {
4083 return c.redirect(
4084 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4085 decision.reasons.join(" ")
4086 )}`
4087 );
4088 }
4089 }
4090
e883329Claude4091 // Attempt the merge — with auto conflict resolution if needed
4092 const repoDir = getRepoPath(ownerName, repoName);
4093 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
4094 const hasConflicts = mergeCheck && !mergeCheck.passed;
4095
4096 if (hasConflicts && isAiReviewEnabled()) {
4097 // Use Claude to auto-resolve conflicts
4098 const mergeResult = await mergeWithAutoResolve(
4099 ownerName,
4100 repoName,
4101 pr.baseBranch,
4102 pr.headBranch,
4103 `Merge pull request #${pr.number}: ${pr.title}`
4104 );
4105
4106 if (!mergeResult.success) {
4107 return c.redirect(
4108 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
4109 );
4110 }
4111
4112 // Post a comment about the auto-resolution
4113 if (mergeResult.resolvedFiles.length > 0) {
4114 await db.insert(prComments).values({
4115 pullRequestId: pr.id,
4116 authorId: user.id,
4117 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
4118 isAiReview: true,
4119 });
4120 }
4121 } else {
a164a6dClaude4122 // Worktree-based merge: supports merge-commit, squash, and fast-forward
4123 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
4124 const gitEnv = {
4125 ...process.env,
4126 GIT_AUTHOR_NAME: user.displayName || user.username,
4127 GIT_AUTHOR_EMAIL: user.email,
4128 GIT_COMMITTER_NAME: user.displayName || user.username,
4129 GIT_COMMITTER_EMAIL: user.email,
4130 };
4131
4132 // Create linked worktree on the base branch
4133 const addWt = Bun.spawn(
4134 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude4135 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4136 );
a164a6dClaude4137 if (await addWt.exited !== 0) {
4138 return c.redirect(
4139 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
4140 );
4141 }
4142
4143 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
4144 let mergeOk = false;
4145
4146 try {
4147 if (mergeStrategy === "squash") {
4148 // Squash: stage all changes without committing
4149 const squashProc = Bun.spawn(
4150 ["git", "merge", "--squash", headSha],
4151 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4152 );
4153 if (await squashProc.exited !== 0) {
4154 const errTxt = await new Response(squashProc.stderr).text();
4155 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
4156 }
4157 // Commit the squashed changes
4158 const commitProc = Bun.spawn(
4159 ["git", "commit", "-m", commitMsg],
4160 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4161 );
4162 if (await commitProc.exited !== 0) {
4163 const errTxt = await new Response(commitProc.stderr).text();
4164 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
4165 }
4166 mergeOk = true;
4167 } else if (mergeStrategy === "ff") {
4168 // Fast-forward only — fail if FF is not possible
4169 const ffProc = Bun.spawn(
4170 ["git", "merge", "--ff-only", headSha],
4171 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4172 );
4173 if (await ffProc.exited !== 0) {
4174 const errTxt = await new Response(ffProc.stderr).text();
4175 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
4176 }
4177 mergeOk = true;
4178 } else {
4179 // Default: merge commit (--no-ff always creates a merge commit)
4180 const mergeProc = Bun.spawn(
4181 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
4182 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4183 );
4184 if (await mergeProc.exited !== 0) {
4185 const errTxt = await new Response(mergeProc.stderr).text();
4186 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
4187 }
4188 mergeOk = true;
4189 }
4190 } catch (err) {
4191 // Always clean up the worktree before redirecting
4192 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
4193 const msg = err instanceof Error ? err.message : "Merge failed";
4194 return c.redirect(
4195 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
4196 );
4197 }
4198
4199 // Clean up worktree (changes are now in the bare repo via linked worktree)
4200 await Bun.spawn(
4201 ["git", "worktree", "remove", "--force", wt],
4202 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4203 ).exited.catch(() => {});
e883329Claude4204
a164a6dClaude4205 if (!mergeOk) {
e883329Claude4206 return c.redirect(
a164a6dClaude4207 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude4208 );
4209 }
4210 }
4211
0074234Claude4212 await db
4213 .update(pullRequests)
4214 .set({
4215 state: "merged",
4216 mergedAt: new Date(),
4217 mergedBy: user.id,
4218 updatedAt: new Date(),
4219 })
4220 .where(eq(pullRequests.id, pr.id));
4221
8809b87Claude4222 // Chat notifier — fan out merge event to Slack/Discord/Teams.
4223 import("../lib/chat-notifier")
4224 .then((m) =>
4225 m.notifyChatChannels({
4226 ownerUserId: resolved.repo.ownerId,
4227 repositoryId: resolved.repo.id,
4228 event: {
4229 event: "pr.merged",
4230 repo: `${ownerName}/${repoName}`,
4231 title: `#${pr.number} ${pr.title}`,
4232 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
4233 actor: user.username,
4234 },
4235 })
4236 )
4237 .catch((err) =>
4238 console.warn(`[chat-notifier] PR merge notify failed:`, err)
4239 );
4240
d62fb36Claude4241 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
4242 // and auto-close each matching open issue with a back-link comment. Bounded
4243 // to the same repo for v1 (cross-repo refs ignored). Failures never block
4244 // the merge redirect.
4245 try {
4246 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
4247 const refs = extractClosingRefsMulti([pr.title, pr.body]);
4248 for (const n of refs) {
4249 const [issue] = await db
4250 .select()
4251 .from(issues)
4252 .where(
4253 and(
4254 eq(issues.repositoryId, resolved.repo.id),
4255 eq(issues.number, n)
4256 )
4257 )
4258 .limit(1);
4259 if (!issue || issue.state !== "open") continue;
4260 await db
4261 .update(issues)
4262 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
4263 .where(eq(issues.id, issue.id));
4264 await db.insert(issueComments).values({
4265 issueId: issue.id,
4266 authorId: user.id,
4267 body: `Closed by pull request #${pr.number}.`,
4268 });
4269 }
4270 } catch {
4271 // Never block the merge on close-keyword failures.
4272 }
4273
0074234Claude4274 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4275 }
4276);
4277
6fc53bdClaude4278// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
4279// hasn't run yet on this PR.
4280pulls.post(
4281 "/:owner/:repo/pulls/:number/ready",
4282 softAuth,
4283 requireAuth,
04f6b7fClaude4284 requireRepoAccess("write"),
6fc53bdClaude4285 async (c) => {
4286 const { owner: ownerName, repo: repoName } = c.req.param();
4287 const prNum = parseInt(c.req.param("number"), 10);
4288 const user = c.get("user")!;
4289
4290 const resolved = await resolveRepo(ownerName, repoName);
4291 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4292
4293 const [pr] = await db
4294 .select()
4295 .from(pullRequests)
4296 .where(
4297 and(
4298 eq(pullRequests.repositoryId, resolved.repo.id),
4299 eq(pullRequests.number, prNum)
4300 )
4301 )
4302 .limit(1);
4303 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4304
4305 // Only the author or repo owner can toggle draft state.
4306 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
4307 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4308 }
4309
4310 if (pr.state === "open" && pr.isDraft) {
4311 await db
4312 .update(pullRequests)
4313 .set({ isDraft: false, updatedAt: new Date() })
4314 .where(eq(pullRequests.id, pr.id));
4315
4316 if (isAiReviewEnabled()) {
4317 triggerAiReview(
4318 ownerName,
4319 repoName,
4320 pr.id,
4321 pr.title,
0316dbbClaude4322 pr.body || "",
6fc53bdClaude4323 pr.baseBranch,
4324 pr.headBranch
4325 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
4326 }
4327 }
4328
4329 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4330 }
4331);
4332
4333// Convert a PR back to draft.
4334pulls.post(
4335 "/:owner/:repo/pulls/:number/draft",
4336 softAuth,
4337 requireAuth,
04f6b7fClaude4338 requireRepoAccess("write"),
6fc53bdClaude4339 async (c) => {
4340 const { owner: ownerName, repo: repoName } = c.req.param();
4341 const prNum = parseInt(c.req.param("number"), 10);
4342 const user = c.get("user")!;
4343
4344 const resolved = await resolveRepo(ownerName, repoName);
4345 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4346
4347 const [pr] = await db
4348 .select()
4349 .from(pullRequests)
4350 .where(
4351 and(
4352 eq(pullRequests.repositoryId, resolved.repo.id),
4353 eq(pullRequests.number, prNum)
4354 )
4355 )
4356 .limit(1);
4357 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4358
4359 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
4360 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4361 }
4362
4363 if (pr.state === "open" && !pr.isDraft) {
4364 await db
4365 .update(pullRequests)
4366 .set({ isDraft: true, updatedAt: new Date() })
4367 .where(eq(pullRequests.id, pr.id));
4368 }
4369
4370 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4371 }
4372);
4373
0074234Claude4374// Close PR
4375pulls.post(
4376 "/:owner/:repo/pulls/:number/close",
4377 softAuth,
4378 requireAuth,
04f6b7fClaude4379 requireRepoAccess("write"),
0074234Claude4380 async (c) => {
4381 const { owner: ownerName, repo: repoName } = c.req.param();
4382 const prNum = parseInt(c.req.param("number"), 10);
4383
4384 const resolved = await resolveRepo(ownerName, repoName);
4385 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4386
4387 await db
4388 .update(pullRequests)
4389 .set({
4390 state: "closed",
4391 closedAt: new Date(),
4392 updatedAt: new Date(),
4393 })
4394 .where(
4395 and(
4396 eq(pullRequests.repositoryId, resolved.repo.id),
4397 eq(pullRequests.number, prNum)
4398 )
4399 );
4400
4401 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4402 }
4403);
4404
c3e0c07Claude4405// Re-run AI review on demand (e.g. after a force-push). Bypasses the
4406// idempotency marker via { force: true }. Write-access only.
4407pulls.post(
4408 "/:owner/:repo/pulls/:number/ai-rereview",
4409 softAuth,
4410 requireAuth,
4411 requireRepoAccess("write"),
4412 async (c) => {
4413 const { owner: ownerName, repo: repoName } = c.req.param();
4414 const prNum = parseInt(c.req.param("number"), 10);
4415 const resolved = await resolveRepo(ownerName, repoName);
4416 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4417
4418 const [pr] = await db
4419 .select()
4420 .from(pullRequests)
4421 .where(
4422 and(
4423 eq(pullRequests.repositoryId, resolved.repo.id),
4424 eq(pullRequests.number, prNum)
4425 )
4426 )
4427 .limit(1);
4428 if (!pr) {
4429 return c.redirect(`/${ownerName}/${repoName}/pulls`);
4430 }
4431
4432 if (!isAiReviewEnabled()) {
4433 return c.redirect(
4434 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4435 "AI review is not configured (ANTHROPIC_API_KEY)."
4436 )}`
4437 );
4438 }
4439
4440 // Fire-and-forget but with { force: true } to bypass the
4441 // already-reviewed marker. The function still never throws.
4442 triggerAiReview(
4443 ownerName,
4444 repoName,
4445 pr.id,
4446 pr.title || "",
4447 pr.body || "",
4448 pr.baseBranch,
4449 pr.headBranch,
4450 { force: true }
a28cedeClaude4451 ).catch((err) => {
4452 console.warn(
4453 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
4454 err instanceof Error ? err.message : err
4455 );
4456 });
c3e0c07Claude4457
4458 return c.redirect(
4459 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
4460 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
4461 )}`
4462 );
4463 }
4464);
4465
1d4ff60Claude4466// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
4467// the PR's head branch carrying just the new test files. Write-access only.
4468// Idempotent — if `ai:added-tests` was previously applied we redirect with
4469// an `info` banner instead of re-firing.
4470pulls.post(
4471 "/:owner/:repo/pulls/:number/generate-tests",
4472 softAuth,
4473 requireAuth,
4474 requireRepoAccess("write"),
4475 async (c) => {
4476 const { owner: ownerName, repo: repoName } = c.req.param();
4477 const prNum = parseInt(c.req.param("number"), 10);
4478 const resolved = await resolveRepo(ownerName, repoName);
4479 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4480
4481 const [pr] = await db
4482 .select()
4483 .from(pullRequests)
4484 .where(
4485 and(
4486 eq(pullRequests.repositoryId, resolved.repo.id),
4487 eq(pullRequests.number, prNum)
4488 )
4489 )
4490 .limit(1);
4491 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4492
4493 if (!isAiReviewEnabled()) {
4494 return c.redirect(
4495 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4496 "AI test generation is not configured (ANTHROPIC_API_KEY)."
4497 )}`
4498 );
4499 }
4500
4501 // Fire-and-forget. The lib never throws.
4502 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
4503 .then((res) => {
4504 if (!res.ok) {
4505 console.warn(
4506 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
4507 );
4508 }
4509 })
4510 .catch((err) => {
4511 console.warn(
4512 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
4513 err instanceof Error ? err.message : err
4514 );
4515 });
4516
4517 return c.redirect(
4518 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
4519 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
4520 )}`
4521 );
4522 }
4523);
4524
0074234Claude4525export default pulls;