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