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.tsxBlame4826 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 }
6d1bbc2Claude1254
1255 /* ─── Task list progress pill ─── */
1256 .prs-tasks-pill {
1257 display: inline-flex; align-items: center; gap: 5px;
1258 font-size: 11.5px; font-weight: 600;
1259 padding: 2px 9px; border-radius: 9999px;
1260 border: 1px solid var(--border);
1261 background: var(--bg-elevated);
1262 color: var(--text-muted);
1263 }
1264 .prs-tasks-pill.is-complete {
1265 color: #34d399;
1266 border-color: rgba(52,211,153,0.40);
1267 background: rgba(52,211,153,0.08);
1268 }
1269 .prs-tasks-progress { display: inline-block; width: 36px; height: 4px; border-radius: 9999px; background: var(--border); overflow: hidden; vertical-align: middle; }
1270 .prs-tasks-progress-bar { height: 100%; border-radius: 9999px; background: #34d399; }
1271
1272 /* ─── Update branch button ─── */
1273 .prs-update-branch-btn {
1274 display: inline-flex; align-items: center; gap: 5px;
1275 padding: 4px 12px; border-radius: 8px; font-size: 12.5px;
1276 font-weight: 600; cursor: pointer;
1277 background: rgba(96,165,250,0.10);
1278 color: #60a5fa;
1279 border: 1px solid rgba(96,165,250,0.30);
1280 transition: background 120ms;
1281 }
1282 .prs-update-branch-btn:hover { background: rgba(96,165,250,0.20); }
1283
1284 /* ─── Linked issues panel ─── */
1285 .prs-linked-issues {
1286 margin-top: 16px;
1287 border: 1px solid var(--border);
1288 border-radius: 12px;
1289 overflow: hidden;
1290 }
1291 .prs-linked-issues-head {
1292 display: flex; align-items: center; justify-content: space-between;
1293 padding: 10px 16px;
1294 background: var(--bg-elevated);
1295 border-bottom: 1px solid var(--border);
1296 font-size: 13px; font-weight: 600; color: var(--text);
1297 }
1298 .prs-linked-issues-count {
1299 font-size: 11px; font-weight: 700;
1300 padding: 1px 7px; border-radius: 9999px;
1301 background: var(--bg-tertiary);
1302 color: var(--text-muted);
1303 }
1304 .prs-linked-issue-row {
1305 display: flex; align-items: center; gap: 10px;
1306 padding: 9px 16px;
1307 border-bottom: 1px solid var(--border);
1308 font-size: 13px;
1309 text-decoration: none; color: inherit;
1310 }
1311 .prs-linked-issue-row:last-child { border-bottom: none; }
1312 .prs-linked-issue-row:hover { background: var(--bg-hover); }
1313 .prs-linked-issue-icon { flex: 0 0 auto; font-size: 14px; }
1314 .prs-linked-issue-icon.is-open { color: #34d399; }
1315 .prs-linked-issue-icon.is-closed { color: #8b949e; }
1316 .prs-linked-issue-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1317 .prs-linked-issue-num { color: var(--text-muted); font-size: 12px; }
1318 .prs-linked-issue-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
1319 .prs-linked-issue-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
1320 .prs-linked-issue-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
b078860Claude1321`;
1322
81c73c1Claude1323/**
1324 * Tiny inline JS that drives the "Suggest description with AI" button.
1325 * On click, gathers form values, POSTs JSON to the given endpoint, and
1326 * pipes the response into the #pr-body textarea. All DOM lookups are
1327 * defensive — element absence is a silent no-op.
1328 *
1329 * Built as a string template so it lives next to its server-side caller
1330 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1331 * to avoid </script> breakouts.
1332 */
1333function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1334 const url = JSON.stringify(endpointUrl)
1335 .split("<").join("\\u003C")
1336 .split(">").join("\\u003E")
1337 .split("&").join("\\u0026");
1338 return (
1339 "(function(){try{" +
1340 "var btn=document.getElementById('ai-suggest-desc');" +
1341 "var status=document.getElementById('ai-suggest-status');" +
1342 "var body=document.getElementById('pr-body');" +
1343 "var form=btn&&btn.closest&&btn.closest('form');" +
1344 "if(!btn||!body||!form)return;" +
1345 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1346 "var fd=new FormData(form);" +
1347 "var title=String(fd.get('title')||'').trim();" +
1348 "var base=String(fd.get('base')||'').trim();" +
1349 "var head=String(fd.get('head')||'').trim();" +
1350 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1351 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1352 "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'})" +
1353 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1354 ".then(function(j){btn.disabled=false;" +
1355 "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;}}" +
1356 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1357 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1358 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1359 "});" +
1360 "}catch(e){}})();"
1361 );
1362}
1363
3c03977Claude1364/**
1365 * Live co-editing client. Connects to the per-PR SSE feed and:
1366 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1367 * status colour per user).
1368 * - Renders tinted cursor caret overlays inside #pr-body and every
1369 * `[data-live-field]` element.
1370 * - Broadcasts the local user's cursor position (selectionStart /
1371 * selectionEnd) debounced at 100ms.
1372 * - Broadcasts content patches (`replace` of the whole textarea —
1373 * last-write-wins v1) debounced at 250ms.
1374 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1375 * to the matching local field if untouched.
1376 *
1377 * All endpoint URLs are JSON-escaped via safe replacements so they
1378 * can't break out of the <script> tag.
1379 */
1380function LIVE_COEDIT_SCRIPT(prId: string): string {
1381 const idJson = JSON.stringify(prId)
1382 .split("<").join("\\u003C")
1383 .split(">").join("\\u003E")
1384 .split("&").join("\\u0026");
1385 return (
1386 "(function(){try{" +
1387 "if(typeof EventSource==='undefined')return;" +
1388 "var prId=" + idJson + ";" +
1389 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1390 "var pill=document.getElementById('live-pill');" +
1391 "var avEl=document.getElementById('live-avatars');" +
1392 "var countEl=document.getElementById('live-count');" +
1393 "var sessionId=null;var myColor=null;" +
1394 "var presence={};" + // sessionId -> {color,status,userId,initials}
1395 "var lastApplied={};" + // field -> last server value (for echo suppression)
1396 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1397 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1398 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1399 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1400 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1401 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1402 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1403 "avEl.innerHTML=html;}}" +
1404 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1405 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1406 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1407 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1408 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1409 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1410 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1411 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1412 "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);}" +
1413 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1414 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1415 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1416 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1417 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1418 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1419 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1420 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1421 "}catch(e){}" +
1422 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1423 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1424 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1425 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1426 "var es;var delay=1000;" +
1427 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1428 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1429 "(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){}});" +
1430 "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){}});" +
1431 "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){}});" +
1432 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1433 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1434 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1435 "var patch=d.patch;if(!patch||!patch.field)return;" +
1436 "var ta=fieldEl(patch.field);if(!ta)return;" +
1437 "if(document.activeElement===ta)return;" + // don't trample local typing
1438 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1439 "}catch(e){}});" +
1440 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1441 "}connect();" +
1442 "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){}}" +
1443 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1444 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1445 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1446 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1447 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1448 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1449 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1450 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1451 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1452 "}" +
1453 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1454 "var live=document.querySelectorAll('[data-live-field]');" +
1455 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1456 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1457 "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){}});" +
1458 "}catch(e){}})();"
1459 );
1460}
1461
0074234Claude1462async function resolveRepo(ownerName: string, repoName: string) {
1463 const [owner] = await db
1464 .select()
1465 .from(users)
1466 .where(eq(users.username, ownerName))
1467 .limit(1);
1468 if (!owner) return null;
1469 const [repo] = await db
1470 .select()
1471 .from(repositories)
1472 .where(
1473 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1474 )
1475 .limit(1);
1476 if (!repo) return null;
1477 return { owner, repo };
1478}
1479
1480// PR Nav helper
1481const PrNav = ({
1482 owner,
1483 repo,
1484 active,
1485}: {
1486 owner: string;
1487 repo: string;
1488 active: "code" | "issues" | "pulls" | "commits";
1489}) => (
bb0f894Claude1490 <TabNav
1491 tabs={[
1492 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1493 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1494 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1495 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1496 ]}
1497 />
0074234Claude1498);
1499
534f04aClaude1500/**
1501 * Block M3 — pre-merge risk score card. Pure presentational helper.
1502 * Rendered in the conversation tab above the gate checks block. Hidden
1503 * entirely when the PR is closed/merged or there is nothing cached and
1504 * nothing in-flight.
1505 */
1506function PrRiskCard({
1507 risk,
1508 calculating,
1509}: {
1510 risk: PrRiskScore | null;
1511 calculating: boolean;
1512}) {
1513 if (!risk) {
1514 return (
1515 <div
1516 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1517 >
1518 <strong style="font-size: 13px; color: var(--text)">
1519 Risk score: calculating…
1520 </strong>
1521 <div style="font-size: 12px; margin-top: 4px">
1522 Refresh in a moment to see the pre-merge risk score for this PR.
1523 </div>
1524 </div>
1525 );
1526 }
1527
1528 const palette = riskBandPalette(risk.band);
1529 const label = riskBandLabel(risk.band);
1530
1531 return (
1532 <div
1533 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1534 >
1535 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1536 <strong>Risk score:</strong>
1537 <span style={`color:${palette.border};font-weight:600`}>
1538 {palette.icon} {label} ({risk.score}/10)
1539 </span>
1540 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1541 {risk.commitSha.slice(0, 7)}
1542 </span>
1543 </div>
1544 {risk.aiSummary && (
1545 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1546 {risk.aiSummary}
1547 </div>
1548 )}
1549 <details style="margin-top:10px">
1550 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1551 See full signal breakdown
1552 </summary>
1553 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1554 <li>files changed: {risk.signals.filesChanged}</li>
1555 <li>
1556 lines added/removed: {risk.signals.linesAdded} /{" "}
1557 {risk.signals.linesRemoved}
1558 </li>
1559 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1560 <li>
1561 schema migration touched:{" "}
1562 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1563 </li>
1564 <li>
1565 locked / sensitive path touched:{" "}
1566 {risk.signals.lockedPathTouched ? "yes" : "no"}
1567 </li>
1568 <li>
1569 adds new dependency:{" "}
1570 {risk.signals.addsNewDependency ? "yes" : "no"}
1571 </li>
1572 <li>
1573 bumps major dependency:{" "}
1574 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1575 </li>
1576 <li>
1577 tests added for new code:{" "}
1578 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1579 </li>
1580 <li>
1581 diff-minus-test ratio:{" "}
1582 {risk.signals.diffMinusTestRatio.toFixed(2)}
1583 </li>
1584 </ul>
1585 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1586 How is this calculated? The score is a transparent sum of
1587 weighted signals — see <code>src/lib/pr-risk.ts</code>
1588 {" "}<code>computePrRiskScore</code>.
1589 </div>
1590 </details>
1591 {calculating && (
1592 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1593 (recomputing for the latest commit — refresh to update)
1594 </div>
1595 )}
1596 </div>
1597 );
1598}
1599
1600function riskBandPalette(band: PrRiskScore["band"]): {
1601 border: string;
1602 icon: string;
1603} {
1604 switch (band) {
1605 case "low":
1606 return { border: "var(--green)", icon: "" };
1607 case "medium":
1608 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1609 case "high":
1610 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1611 case "critical":
1612 return { border: "var(--red)", icon: "\u{1F6D1}" };
1613 }
1614}
1615
1616function riskBandLabel(band: PrRiskScore["band"]): string {
1617 switch (band) {
1618 case "low":
1619 return "LOW";
1620 case "medium":
1621 return "MEDIUM";
1622 case "high":
1623 return "HIGH";
1624 case "critical":
1625 return "CRITICAL";
1626 }
1627}
1628
422a2d4Claude1629// ---------------------------------------------------------------------------
1630// AI Trio Review — 3-column card grid + disagreement callout.
1631//
1632// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
1633// per run: one per persona (security/correctness/style) plus a top-level
1634// summary. We surface them here as a single grid above the normal
1635// comment stream so reviewers see the verdicts at a glance.
1636// ---------------------------------------------------------------------------
1637
1638const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
1639
1640interface TrioCommentLike {
1641 body: string;
1642}
1643
1644function isTrioComment(body: string | null | undefined): boolean {
1645 if (!body) return false;
1646 return (
1647 body.includes(TRIO_SUMMARY_MARKER) ||
1648 body.includes(TRIO_COMMENT_MARKER.security) ||
1649 body.includes(TRIO_COMMENT_MARKER.correctness) ||
1650 body.includes(TRIO_COMMENT_MARKER.style)
1651 );
1652}
1653
1654function trioPersonaOfComment(body: string): TrioPersona | null {
1655 for (const p of TRIO_PERSONAS) {
1656 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
1657 }
1658 return null;
1659}
1660
1661/**
1662 * Best-effort verdict parse from a persona comment body. The body shape
1663 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
1664 * we only need the "Pass" / "Fail" word from the H2 heading.
1665 */
1666function trioVerdictOfBody(body: string): "pass" | "fail" | null {
1667 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
1668 if (!m) return null;
1669 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
1670}
1671
1672/**
1673 * Parse the disagreement bullet list out of the summary comment so we
1674 * can render it as a polished callout strip. Returns [] when nothing
1675 * matches — the comment author may have edited the marker out.
1676 */
1677function parseDisagreements(summaryBody: string): Array<{
1678 file: string;
1679 failing: string;
1680 passing: string;
1681}> {
1682 const out: Array<{ file: string; failing: string; passing: string }> = [];
1683 // Each disagreement line looks like:
1684 // - `path:42` — security, style say ✗, correctness say ✓
1685 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
1686 let m: RegExpExecArray | null;
1687 while ((m = re.exec(summaryBody)) !== null) {
1688 out.push({
1689 file: m[1].trim(),
1690 failing: m[2].trim().replace(/[,\s]+$/g, ""),
1691 passing: m[3].trim().replace(/[,\s]+$/g, ""),
1692 });
1693 }
1694 return out;
1695}
1696
1697function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
1698 // Find the most recent persona comments + summary. We iterate from
1699 // the end so re-reviews (multiple runs on the same PR) display the
1700 // freshest verdict.
1701 const latest: Partial<Record<TrioPersona, string>> = {};
1702 let summaryBody: string | null = null;
1703 for (let i = comments.length - 1; i >= 0; i--) {
1704 const body = comments[i].body || "";
1705 if (!isTrioComment(body)) continue;
1706 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
1707 summaryBody = body;
1708 continue;
1709 }
1710 const persona = trioPersonaOfComment(body);
1711 if (persona && !latest[persona]) latest[persona] = body;
1712 }
1713 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1714 if (!anyPersona && !summaryBody) return null;
1715
1716 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
1717
1718 return (
1719 <div class="trio-wrap">
1720 <div class="trio-header">
1721 <span class="trio-header-dot" aria-hidden="true"></span>
1722 <strong>AI Trio Review</strong>
1723 <span class="trio-header-sub">
1724 Three independent reviewers ran in parallel.
1725 </span>
1726 </div>
1727 <div class="trio-grid">
1728 {TRIO_PERSONAS.map((persona) => {
1729 const body = latest[persona];
1730 const verdict = body ? trioVerdictOfBody(body) : null;
1731 const stateClass =
1732 verdict === "fail"
1733 ? "is-fail"
1734 : verdict === "pass"
1735 ? "is-pass"
1736 : "is-pending";
1737 return (
1738 <div class={`trio-card trio-${persona} ${stateClass}`}>
1739 <div class="trio-card-head">
1740 <span class="trio-card-icon" aria-hidden="true">
1741 {persona === "security"
1742 ? "🛡"
1743 : persona === "correctness"
1744 ? "✓"
1745 : "✎"}
1746 </span>
1747 <strong class="trio-card-title">
1748 {persona[0].toUpperCase() + persona.slice(1)}
1749 </strong>
1750 <span class="trio-card-verdict">
1751 {verdict === "pass"
1752 ? "Pass"
1753 : verdict === "fail"
1754 ? "Fail"
1755 : "Pending"}
1756 </span>
1757 </div>
1758 <div class="trio-card-body">
1759 {body ? (
1760 <MarkdownContent
1761 html={renderMarkdown(stripTrioHeading(body))}
1762 />
1763 ) : (
1764 <span class="trio-card-empty">
1765 Awaiting reviewer output.
1766 </span>
1767 )}
1768 </div>
1769 </div>
1770 );
1771 })}
1772 </div>
1773 {disagreements.length > 0 && (
1774 <div class="trio-disagreement-strip" role="note">
1775 <span class="trio-disagreement-icon" aria-hidden="true">
1776
1777 </span>
1778 <div class="trio-disagreement-body">
1779 <strong>Reviewers disagree — review carefully.</strong>
1780 <ul class="trio-disagreement-list">
1781 {disagreements.map((d) => (
1782 <li>
1783 <code>{d.file}</code> — {d.failing} says ✗,{" "}
1784 {d.passing} says ✓
1785 </li>
1786 ))}
1787 </ul>
1788 </div>
1789 </div>
1790 )}
1791 </div>
1792 );
1793}
1794
1795/**
1796 * Strip the marker comment + first H2 heading from a persona body so
1797 * the card body shows just the findings list (verdict is already in
1798 * the card head). Best-effort — malformed bodies render whole.
1799 */
1800function stripTrioHeading(body: string): string {
1801 return body
1802 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
1803 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
1804 .trim();
1805}
1806
0074234Claude1807// List PRs
04f6b7fClaude1808pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1809 const { owner: ownerName, repo: repoName } = c.req.param();
1810 const user = c.get("user");
1811 const state = c.req.query("state") || "open";
1812
ea9ed4cClaude1813 // ── Loading skeleton (flag-gated) ──
1814 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
1815 // the user see the page structure before counts + select resolve.
1816 // Behind a flag for now — we don't ship flashes.
1817 if (c.req.query("skeleton") === "1") {
1818 return c.html(
1819 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1820 <RepoHeader owner={ownerName} repo={repoName} />
1821 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1822 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1823 <style
1824 dangerouslySetInnerHTML={{
1825 __html: `
404b398Claude1826 .prs-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: prs-skel-shimmer 1.4s infinite; border-radius: 6px; display: block; }
1827 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude1828 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
1829 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
1830 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
1831 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
1832 .prs-skel-row { height: 76px; border-radius: 12px; }
1833 `,
1834 }}
1835 />
1836 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
1837 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
1838 <div class="prs-skel-list" aria-hidden="true">
1839 {Array.from({ length: 6 }).map(() => (
1840 <div class="prs-skel prs-skel-row" />
1841 ))}
1842 </div>
1843 <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">
1844 Loading pull requests for {ownerName}/{repoName}…
1845 </span>
1846 </Layout>
1847 );
1848 }
1849
0074234Claude1850 const resolved = await resolveRepo(ownerName, repoName);
1851 if (!resolved) return c.notFound();
1852
6fc53bdClaude1853 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
1854 const stateFilter =
1855 state === "draft"
1856 ? and(
1857 eq(pullRequests.state, "open"),
1858 eq(pullRequests.isDraft, true)
1859 )
1860 : eq(pullRequests.state, state);
1861
0074234Claude1862 const prList = await db
1863 .select({
1864 pr: pullRequests,
1865 author: { username: users.username },
1866 })
1867 .from(pullRequests)
1868 .innerJoin(users, eq(pullRequests.authorId, users.id))
1869 .where(
6fc53bdClaude1870 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude1871 )
1872 .orderBy(desc(pullRequests.createdAt));
1873
0369e77Claude1874 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude1875 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude1876 const commentCountMap = new Map<string, number>();
1aef949Claude1877 if (prList.length > 0) {
1878 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude1879 const [reviewRows, commentRows] = await Promise.all([
1880 db
1881 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
1882 .from(prReviews)
1883 .where(inArray(prReviews.pullRequestId, prIds)),
1884 db
1885 .select({
1886 prId: prComments.pullRequestId,
1887 cnt: sql<number>`count(*)::int`,
1888 })
1889 .from(prComments)
1890 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
1891 .groupBy(prComments.pullRequestId),
1892 ]);
1aef949Claude1893 for (const r of reviewRows) {
1894 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
1895 if (r.state === "approved") entry.approved = true;
1896 if (r.state === "changes_requested") entry.changesRequested = true;
1897 reviewMap.set(r.prId, entry);
1898 }
0369e77Claude1899 for (const r of commentRows) {
1900 commentCountMap.set(r.prId, Number(r.cnt));
1901 }
1aef949Claude1902 }
1903
0074234Claude1904 const [counts] = await db
1905 .select({
1906 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude1907 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude1908 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
1909 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
1910 })
1911 .from(pullRequests)
1912 .where(eq(pullRequests.repositoryId, resolved.repo.id));
1913
b078860Claude1914 const openCount = counts?.open ?? 0;
1915 const mergedCount = counts?.merged ?? 0;
1916 const closedCount = counts?.closed ?? 0;
1917 const draftCount = counts?.draft ?? 0;
1918 const allCount = openCount + mergedCount + closedCount;
1919
1920 // "All" is presentational only — the DB query for state='all' matches
1921 // nothing, so we render a friendlier empty state when picked. We do NOT
1922 // change the query logic to keep this commit purely visual.
1923 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
1924 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
1925 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
1926 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
1927 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
1928 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
1929 ];
1930 const isAllState = state === "all";
cb5a796Claude1931 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
1932 const prListPendingCount = viewerIsOwnerOnPrList
1933 ? await countPendingForRepo(resolved.repo.id)
1934 : 0;
b078860Claude1935
0074234Claude1936 return c.html(
1937 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1938 <RepoHeader owner={ownerName} repo={repoName} />
1939 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude1940 <PendingCommentsBanner
1941 owner={ownerName}
1942 repo={repoName}
1943 count={prListPendingCount}
1944 />
b078860Claude1945 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1946
1947 <div class="prs-hero">
1948 <div class="prs-hero-inner">
1949 <div class="prs-hero-text">
1950 <div class="prs-hero-eyebrow">Pull requests</div>
1951 <h1 class="prs-hero-title">
1952 Review, <span class="gradient-text">merge with AI</span>.
1953 </h1>
1954 <p class="prs-hero-sub">
1955 {openCount === 0 && allCount === 0
1956 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
1957 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
1958 </p>
1959 </div>
7a28902Claude1960 <div class="prs-hero-actions">
1961 <a
1962 href={`/${ownerName}/${repoName}/pulls/insights`}
1963 class="prs-cta"
1964 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
1965 >
1966 Insights
1967 </a>
1968 {user && (
b078860Claude1969 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
1970 + New pull request
1971 </a>
7a28902Claude1972 )}
1973 </div>
b078860Claude1974 </div>
1975 </div>
1976
1977 <nav class="prs-tabs" aria-label="Pull request filters">
1978 {tabPills.map((t) => {
1979 const isActive =
1980 state === t.key ||
1981 (t.key === "open" &&
1982 state !== "merged" &&
1983 state !== "closed" &&
1984 state !== "all" &&
1985 state !== "draft");
1986 return (
1987 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
1988 <span>{t.label}</span>
1989 <span class="prs-tab-count">{t.count}</span>
1990 </a>
1991 );
1992 })}
1993 </nav>
1994
0074234Claude1995 {prList.length === 0 ? (
b078860Claude1996 <div class="prs-empty">
ea9ed4cClaude1997 <div class="prs-empty-inner">
1998 <strong>
1999 {isAllState
2000 ? "Pick a filter above to browse PRs."
2001 : `No ${state} pull requests.`}
2002 </strong>
2003 <p class="prs-empty-sub">
2004 {state === "open"
2005 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2006 : isAllState
2007 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2008 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
2009 </p>
2010 <div class="prs-empty-cta">
2011 {user && state === "open" && (
2012 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2013 + New pull request
2014 </a>
2015 )}
2016 {state !== "open" && (
2017 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2018 View open PRs
2019 </a>
2020 )}
2021 <a href={`/${ownerName}/${repoName}`} class="btn">
2022 Back to code
2023 </a>
2024 </div>
2025 </div>
b078860Claude2026 </div>
0074234Claude2027 ) : (
b078860Claude2028 <div class="prs-list">
2029 {prList.map(({ pr, author }) => {
2030 const stateClass =
2031 pr.state === "open"
2032 ? pr.isDraft
2033 ? "state-draft"
2034 : "state-open"
2035 : pr.state === "merged"
2036 ? "state-merged"
2037 : "state-closed";
2038 const icon =
2039 pr.state === "open"
2040 ? pr.isDraft
2041 ? "◌"
2042 : "○"
2043 : pr.state === "merged"
2044 ? "⮌"
2045 : "✓";
1aef949Claude2046 const rv = reviewMap.get(pr.id);
b078860Claude2047 return (
2048 <a
2049 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2050 class="prs-row"
2051 style="text-decoration:none;color:inherit"
0074234Claude2052 >
b078860Claude2053 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2054 {icon}
0074234Claude2055 </div>
b078860Claude2056 <div class="prs-row-body">
2057 <h3 class="prs-row-title">
2058 <span>{pr.title}</span>
2059 <span class="prs-row-number">#{pr.number}</span>
2060 </h3>
2061 <div class="prs-row-meta">
2062 <span
2063 class="prs-branch-chips"
2064 title={`${pr.headBranch} into ${pr.baseBranch}`}
2065 >
2066 <span class="prs-branch-chip">{pr.headBranch}</span>
2067 <span class="prs-branch-arrow">{"→"}</span>
2068 <span class="prs-branch-chip">{pr.baseBranch}</span>
2069 </span>
2070 <span>
2071 by{" "}
2072 <strong style="color:var(--text)">
2073 {author.username}
2074 </strong>{" "}
2075 {formatRelative(pr.createdAt)}
2076 </span>
2077 <span class="prs-row-tags">
2078 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2079 {pr.state === "merged" && (
2080 <span class="prs-tag is-merged">Merged</span>
2081 )}
1aef949Claude2082 {rv?.approved && !rv.changesRequested && (
2083 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
2084 )}
2085 {rv?.changesRequested && (
2086 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
2087 )}
0369e77Claude2088 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
2089 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
2090 💬 {commentCountMap.get(pr.id)}
2091 </span>
2092 )}
b078860Claude2093 </span>
2094 </div>
0074234Claude2095 </div>
b078860Claude2096 </a>
2097 );
2098 })}
2099 </div>
0074234Claude2100 )}
2101 </Layout>
2102 );
2103});
2104
7a28902Claude2105/* ─────────────────────────────────────────────────────────────────────────
2106 * PR Insights — 90-day analytics for the pull request activity of a repo.
2107 * Route: GET /:owner/:repo/pulls/insights
2108 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
2109 * "insights" is not swallowed by the :number param.
2110 * ───────────────────────────────────────────────────────────────────────── */
2111
2112/** Format a millisecond duration as human-readable string. */
2113function formatMsDuration(ms: number): string {
2114 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
2115 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
2116 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
2117 return `${Math.round(ms / 86_400_000)}d`;
2118}
2119
2120/** Format an ISO week string as "Jan 15". */
2121function formatWeekLabel(isoWeek: string): string {
2122 try {
2123 const d = new Date(isoWeek);
2124 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2125 } catch {
2126 return isoWeek.slice(5, 10);
2127 }
2128}
2129
2130const PR_INSIGHTS_STYLES = `
2131 .pri-page { padding-bottom: 48px; }
2132 .pri-hero {
2133 position: relative;
2134 margin: 0 0 var(--space-5);
2135 padding: 22px 26px 24px;
2136 background: var(--bg-elevated);
2137 border: 1px solid var(--border);
2138 border-radius: 16px;
2139 overflow: hidden;
2140 }
2141 .pri-hero::before {
2142 content: '';
2143 position: absolute; top: 0; left: 0; right: 0;
2144 height: 2px;
2145 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2146 opacity: 0.7;
2147 pointer-events: none;
2148 }
2149 .pri-hero-eyebrow {
2150 font-size: 12px;
2151 color: var(--text-muted);
2152 text-transform: uppercase;
2153 letter-spacing: 0.08em;
2154 font-weight: 600;
2155 margin-bottom: 8px;
2156 }
2157 .pri-hero-title {
2158 font-family: var(--font-display);
2159 font-size: clamp(26px, 3.4vw, 34px);
2160 font-weight: 800;
2161 letter-spacing: -0.025em;
2162 line-height: 1.06;
2163 margin: 0 0 8px;
2164 color: var(--text-strong);
2165 }
2166 .pri-hero-title .gradient-text {
2167 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2168 -webkit-background-clip: text;
2169 background-clip: text;
2170 -webkit-text-fill-color: transparent;
2171 color: transparent;
2172 }
2173 .pri-hero-sub {
2174 font-size: 14.5px;
2175 color: var(--text-muted);
2176 margin: 0;
2177 line-height: 1.5;
2178 }
2179 .pri-section { margin-bottom: 32px; }
2180 .pri-section-title {
2181 font-size: 13px;
2182 font-weight: 700;
2183 text-transform: uppercase;
2184 letter-spacing: 0.06em;
2185 color: var(--text-muted);
2186 margin: 0 0 14px;
2187 }
2188 .pri-cards {
2189 display: grid;
2190 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
2191 gap: 12px;
2192 }
2193 .pri-card {
2194 padding: 16px 18px;
2195 background: var(--bg-elevated);
2196 border: 1px solid var(--border);
2197 border-radius: 12px;
2198 }
2199 .pri-card-label {
2200 font-size: 12px;
2201 font-weight: 600;
2202 color: var(--text-muted);
2203 text-transform: uppercase;
2204 letter-spacing: 0.05em;
2205 margin-bottom: 6px;
2206 }
2207 .pri-card-value {
2208 font-size: 28px;
2209 font-weight: 800;
2210 letter-spacing: -0.04em;
2211 color: var(--text-strong);
2212 line-height: 1;
2213 }
2214 .pri-card-sub {
2215 font-size: 12px;
2216 color: var(--text-muted);
2217 margin-top: 4px;
2218 }
2219 .pri-chart {
2220 display: flex;
2221 align-items: flex-end;
2222 gap: 6px;
2223 height: 120px;
2224 background: var(--bg-elevated);
2225 border: 1px solid var(--border);
2226 border-radius: 12px;
2227 padding: 16px 16px 0;
2228 }
2229 .pri-bar-col {
2230 flex: 1;
2231 display: flex;
2232 flex-direction: column;
2233 align-items: center;
2234 justify-content: flex-end;
2235 height: 100%;
2236 gap: 4px;
2237 }
2238 .pri-bar {
2239 width: 100%;
2240 min-height: 4px;
2241 border-radius: 4px 4px 0 0;
2242 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 100%);
2243 transition: opacity 140ms;
2244 }
2245 .pri-bar:hover { opacity: 0.8; }
2246 .pri-bar-label {
2247 font-size: 10px;
2248 color: var(--text-muted);
2249 text-align: center;
2250 padding-bottom: 8px;
2251 white-space: nowrap;
2252 overflow: hidden;
2253 text-overflow: ellipsis;
2254 max-width: 100%;
2255 }
2256 .pri-table {
2257 width: 100%;
2258 border-collapse: collapse;
2259 font-size: 13.5px;
2260 }
2261 .pri-table th {
2262 text-align: left;
2263 font-size: 12px;
2264 font-weight: 600;
2265 text-transform: uppercase;
2266 letter-spacing: 0.05em;
2267 color: var(--text-muted);
2268 padding: 8px 12px;
2269 border-bottom: 1px solid var(--border);
2270 }
2271 .pri-table td {
2272 padding: 10px 12px;
2273 border-bottom: 1px solid var(--border);
2274 color: var(--text);
2275 }
2276 .pri-table tr:last-child td { border-bottom: none; }
2277 .pri-table-wrap {
2278 background: var(--bg-elevated);
2279 border: 1px solid var(--border);
2280 border-radius: 12px;
2281 overflow: hidden;
2282 }
2283 .pri-age-row {
2284 display: flex;
2285 align-items: center;
2286 gap: 12px;
2287 padding: 10px 0;
2288 border-bottom: 1px solid var(--border);
2289 font-size: 13.5px;
2290 }
2291 .pri-age-row:last-child { border-bottom: none; }
2292 .pri-age-label {
2293 flex: 0 0 80px;
2294 color: var(--text-muted);
2295 font-size: 12.5px;
2296 font-weight: 600;
2297 }
2298 .pri-age-bar-wrap {
2299 flex: 1;
2300 height: 8px;
2301 background: var(--bg-secondary);
2302 border-radius: 9999px;
2303 overflow: hidden;
2304 }
2305 .pri-age-bar {
2306 height: 100%;
2307 border-radius: 9999px;
2308 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
2309 min-width: 4px;
2310 }
2311 .pri-age-count {
2312 flex: 0 0 32px;
2313 text-align: right;
2314 font-weight: 600;
2315 color: var(--text-strong);
2316 font-size: 13px;
2317 }
2318 .pri-sparkline {
2319 display: flex;
2320 align-items: flex-end;
2321 gap: 3px;
2322 height: 40px;
2323 }
2324 .pri-spark-bar {
2325 flex: 1;
2326 min-height: 2px;
2327 border-radius: 2px 2px 0 0;
2328 background: var(--accent, #8c6dff);
2329 opacity: 0.7;
2330 }
2331 .pri-empty {
2332 color: var(--text-muted);
2333 font-size: 14px;
2334 padding: 24px 0;
2335 text-align: center;
2336 }
2337 @media (max-width: 600px) {
2338 .pri-cards { grid-template-columns: repeat(2, 1fr); }
2339 .pri-hero { padding: 18px 18px 20px; }
2340 }
2341`;
2342
2343pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
2344 const { owner: ownerName, repo: repoName } = c.req.param();
2345 const user = c.get("user");
2346
2347 const resolved = await resolveRepo(ownerName, repoName);
2348 if (!resolved) return c.notFound();
2349
2350 const repoId = resolved.repo.id;
2351 const now = Date.now();
2352
2353 // 1. Merged PRs in last 90 days (avg merge time)
2354 const mergedPRs = await db
2355 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
2356 .from(pullRequests)
2357 .where(and(
2358 eq(pullRequests.repositoryId, repoId),
2359 eq(pullRequests.state, "merged"),
2360 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2361 ));
2362
2363 const avgMergeMs = mergedPRs.length > 0
2364 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
2365 : null;
2366
2367 // 2. PR throughput (last 8 weeks)
2368 const weeklyPRs = await db
2369 .select({
2370 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
2371 count: sql<number>`count(*)::int`,
2372 })
2373 .from(pullRequests)
2374 .where(and(
2375 eq(pullRequests.repositoryId, repoId),
2376 sql`${pullRequests.createdAt} > now() - interval '56 days'`
2377 ))
2378 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
2379 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
2380
2381 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
2382
2383 // 3. PR merge rate (last 90 days)
2384 const [rateCounts] = await db
2385 .select({
2386 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
2387 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
2388 })
2389 .from(pullRequests)
2390 .where(and(
2391 eq(pullRequests.repositoryId, repoId),
2392 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2393 ));
2394
2395 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
2396 const mergeRate = totalResolved > 0
2397 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
2398 : null;
2399
2400 // 4. Top reviewers (last 90 days)
2401 const reviewerCounts = await db
2402 .select({
2403 userId: prReviews.reviewerId,
2404 username: users.username,
2405 count: sql<number>`count(*)::int`,
2406 })
2407 .from(prReviews)
2408 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2409 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
2410 .where(and(
2411 eq(pullRequests.repositoryId, repoId),
2412 sql`${prReviews.createdAt} > now() - interval '90 days'`
2413 ))
2414 .groupBy(prReviews.reviewerId, users.username)
2415 .orderBy(desc(sql`count(*)`))
2416 .limit(5);
2417
2418 // 5. Average reviews per merged PR
2419 const [avgReviewRow] = await db
2420 .select({
2421 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
2422 })
2423 .from(pullRequests)
2424 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2425 .where(and(
2426 eq(pullRequests.repositoryId, repoId),
2427 eq(pullRequests.state, "merged"),
2428 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2429 ));
2430
2431 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
2432 ? Math.round(avgReviewRow.avgReviews * 10) / 10
2433 : null;
2434
2435 // 6. Review turnaround — avg time from PR open to first review
2436 const prsWithReviews = await db
2437 .select({
2438 createdAt: pullRequests.createdAt,
2439 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
2440 })
2441 .from(pullRequests)
2442 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2443 .where(and(
2444 eq(pullRequests.repositoryId, repoId),
2445 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2446 ))
2447 .groupBy(pullRequests.id, pullRequests.createdAt);
2448
2449 const avgReviewTurnaroundMs = prsWithReviews.length > 0
2450 ? prsWithReviews.reduce((s, row) => {
2451 const firstMs = new Date(row.firstReview).getTime();
2452 return s + Math.max(0, firstMs - row.createdAt.getTime());
2453 }, 0) / prsWithReviews.length
2454 : null;
2455
2456 // 7. Open PRs by age bucket
2457 const openPRs = await db
2458 .select({ createdAt: pullRequests.createdAt })
2459 .from(pullRequests)
2460 .where(and(
2461 eq(pullRequests.repositoryId, repoId),
2462 eq(pullRequests.state, "open")
2463 ));
2464
2465 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
2466 for (const { createdAt } of openPRs) {
2467 const ageDays = (now - createdAt.getTime()) / 86_400_000;
2468 if (ageDays < 1) ageBuckets.lt1d++;
2469 else if (ageDays < 3) ageBuckets.d1to3++;
2470 else if (ageDays < 7) ageBuckets.d3to7++;
2471 else if (ageDays < 30) ageBuckets.d7to30++;
2472 else ageBuckets.gt30d++;
2473 }
2474 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
2475
2476 // 8. 7-day merge sparkline
2477 const sparklineRows = await db
2478 .select({
2479 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
2480 count: sql<number>`count(*)::int`,
2481 })
2482 .from(pullRequests)
2483 .where(and(
2484 eq(pullRequests.repositoryId, repoId),
2485 eq(pullRequests.state, "merged"),
2486 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
2487 ))
2488 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
2489 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
2490
2491 const sparkMap = new Map<string, number>();
2492 for (const row of sparklineRows) {
2493 sparkMap.set(row.day.slice(0, 10), row.count);
2494 }
2495 const sparkline: number[] = [];
2496 for (let i = 6; i >= 0; i--) {
2497 const d = new Date(now - i * 86_400_000);
2498 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
2499 }
2500 const maxSpark = Math.max(1, ...sparkline);
2501
2502 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
2503 { label: "< 1 day", key: "lt1d" },
2504 { label: "1–3 days", key: "d1to3" },
2505 { label: "3–7 days", key: "d3to7" },
2506 { label: "7–30 days", key: "d7to30" },
2507 { label: "> 30 days", key: "gt30d" },
2508 ];
2509
2510 return c.html(
2511 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
2512 <RepoHeader owner={ownerName} repo={repoName} />
2513 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2514 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
2515
2516 <div class="pri-page">
2517 {/* Hero */}
2518 <div class="pri-hero">
2519 <div class="pri-hero-eyebrow">Pull requests</div>
2520 <h1 class="pri-hero-title">
2521 PR <span class="gradient-text">Insights</span>
2522 </h1>
2523 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
2524 </div>
2525
2526 {/* Stat cards */}
2527 <div class="pri-section">
2528 <div class="pri-section-title">At a glance</div>
2529 <div class="pri-cards">
2530 <div class="pri-card">
2531 <div class="pri-card-label">Avg merge time</div>
2532 <div class="pri-card-value">
2533 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
2534 </div>
2535 <div class="pri-card-sub">last 90 days</div>
2536 </div>
2537 <div class="pri-card">
2538 <div class="pri-card-label">Total merged</div>
2539 <div class="pri-card-value">{mergedPRs.length}</div>
2540 <div class="pri-card-sub">last 90 days</div>
2541 </div>
2542 <div class="pri-card">
2543 <div class="pri-card-label">Open PRs</div>
2544 <div class="pri-card-value">{openPRs.length}</div>
2545 <div class="pri-card-sub">right now</div>
2546 </div>
2547 <div class="pri-card">
2548 <div class="pri-card-label">Merge rate</div>
2549 <div class="pri-card-value">
2550 {mergeRate != null ? `${mergeRate}%` : "—"}
2551 </div>
2552 <div class="pri-card-sub">merged vs closed</div>
2553 </div>
2554 <div class="pri-card">
2555 <div class="pri-card-label">Avg reviews / PR</div>
2556 <div class="pri-card-value">
2557 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
2558 </div>
2559 <div class="pri-card-sub">merged PRs, 90d</div>
2560 </div>
2561 <div class="pri-card">
2562 <div class="pri-card-label">Top reviewer</div>
2563 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
2564 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
2565 </div>
2566 <div class="pri-card-sub">
2567 {reviewerCounts.length > 0
2568 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
2569 : "no reviews yet"}
2570 </div>
2571 </div>
2572 </div>
2573 </div>
2574
2575 {/* Review turnaround */}
2576 <div class="pri-section">
2577 <div class="pri-section-title">Review turnaround</div>
2578 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
2579 <div class="pri-card">
2580 <div class="pri-card-label">Avg time to first review</div>
2581 <div class="pri-card-value">
2582 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
2583 </div>
2584 <div class="pri-card-sub">
2585 {prsWithReviews.length > 0
2586 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
2587 : "no reviewed PRs in 90d"}
2588 </div>
2589 </div>
2590 </div>
2591 </div>
2592
2593 {/* Weekly throughput bar chart */}
2594 <div class="pri-section">
2595 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
2596 {weeklyPRs.length === 0 ? (
2597 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
2598 ) : (
2599 <div class="pri-chart">
2600 {weeklyPRs.map((w) => (
2601 <div class="pri-bar-col">
2602 <div
2603 class="pri-bar"
2604 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
2605 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
2606 />
2607 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
2608 </div>
2609 ))}
2610 </div>
2611 )}
2612 </div>
2613
2614 {/* 7-day merge sparkline */}
2615 <div class="pri-section">
2616 <div class="pri-section-title">Merges this week (daily)</div>
2617 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
2618 <div class="pri-sparkline">
2619 {sparkline.map((v) => (
2620 <div
2621 class="pri-spark-bar"
2622 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
2623 title={`${v} merge${v === 1 ? "" : "s"}`}
2624 />
2625 ))}
2626 </div>
2627 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
2628 <span>7 days ago</span>
2629 <span>Today</span>
2630 </div>
2631 </div>
2632 </div>
2633
2634 {/* Top reviewers table */}
2635 <div class="pri-section">
2636 <div class="pri-section-title">Top reviewers (last 90 days)</div>
2637 {reviewerCounts.length === 0 ? (
2638 <div class="pri-empty">No reviews posted in the last 90 days.</div>
2639 ) : (
2640 <div class="pri-table-wrap">
2641 <table class="pri-table">
2642 <thead>
2643 <tr>
2644 <th>#</th>
2645 <th>Reviewer</th>
2646 <th>Reviews</th>
2647 </tr>
2648 </thead>
2649 <tbody>
2650 {reviewerCounts.map((r, i) => (
2651 <tr>
2652 <td style="color:var(--text-muted)">{i + 1}</td>
2653 <td>
2654 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
2655 {r.username}
2656 </a>
2657 </td>
2658 <td style="font-weight:600">{r.count}</td>
2659 </tr>
2660 ))}
2661 </tbody>
2662 </table>
2663 </div>
2664 )}
2665 </div>
2666
2667 {/* Open PRs by age */}
2668 <div class="pri-section">
2669 <div class="pri-section-title">Open PRs by age</div>
2670 {openPRs.length === 0 ? (
2671 <div class="pri-empty">No open pull requests.</div>
2672 ) : (
2673 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
2674 {ageBucketDefs.map(({ label, key }) => (
2675 <div class="pri-age-row">
2676 <span class="pri-age-label">{label}</span>
2677 <div class="pri-age-bar-wrap">
2678 <div
2679 class="pri-age-bar"
2680 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
2681 />
2682 </div>
2683 <span class="pri-age-count">{ageBuckets[key]}</span>
2684 </div>
2685 ))}
2686 </div>
2687 )}
2688 </div>
2689
2690 {/* Back link */}
2691 <div>
2692 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
2693 {"←"} Back to pull requests
2694 </a>
2695 </div>
2696 </div>
2697 </Layout>
2698 );
2699});
2700
0074234Claude2701// New PR form
2702pulls.get(
2703 "/:owner/:repo/pulls/new",
2704 softAuth,
2705 requireAuth,
04f6b7fClaude2706 requireRepoAccess("write"),
0074234Claude2707 async (c) => {
2708 const { owner: ownerName, repo: repoName } = c.req.param();
2709 const user = c.get("user")!;
2710 const branches = await listBranches(ownerName, repoName);
2711 const error = c.req.query("error");
2712 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude2713 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude2714
2715 return c.html(
2716 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
2717 <RepoHeader owner={ownerName} repo={repoName} />
2718 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude2719 <Container maxWidth={800}>
2720 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude2721 {error && (
bb0f894Claude2722 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude2723 )}
0316dbbClaude2724 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
2725 <Flex gap={12} align="center" style="margin-bottom: 16px">
2726 <Select name="base">
0074234Claude2727 {branches.map((b) => (
2728 <option value={b} selected={b === defaultBase}>
2729 {b}
2730 </option>
2731 ))}
bb0f894Claude2732 </Select>
2733 <Text muted>&larr;</Text>
2734 <Select name="head">
0074234Claude2735 {branches
2736 .filter((b) => b !== defaultBase)
2737 .concat(defaultBase === branches[0] ? [] : [branches[0]])
2738 .map((b) => (
2739 <option value={b}>{b}</option>
2740 ))}
bb0f894Claude2741 </Select>
2742 </Flex>
2743 <FormGroup>
2744 <Input
0074234Claude2745 name="title"
2746 required
2747 placeholder="Title"
bb0f894Claude2748 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]2749 aria-label="Pull request title"
0074234Claude2750 />
bb0f894Claude2751 </FormGroup>
2752 <FormGroup>
2753 <TextArea
0074234Claude2754 name="body"
81c73c1Claude2755 id="pr-body"
0074234Claude2756 rows={8}
2757 placeholder="Description (Markdown supported)"
bb0f894Claude2758 mono
0074234Claude2759 />
bb0f894Claude2760 </FormGroup>
81c73c1Claude2761 <Flex gap={8} align="center">
2762 <Button type="submit" variant="primary">
2763 Create pull request
2764 </Button>
2765 <button
2766 type="button"
2767 id="ai-suggest-desc"
2768 class="btn"
2769 style="font-weight:500"
2770 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
2771 >
2772 Suggest description with AI
2773 </button>
2774 <span
2775 id="ai-suggest-status"
2776 style="color:var(--text-muted);font-size:13px"
2777 />
2778 </Flex>
bb0f894Claude2779 </Form>
81c73c1Claude2780 <script
2781 dangerouslySetInnerHTML={{
2782 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
2783 }}
2784 />
bb0f894Claude2785 </Container>
0074234Claude2786 </Layout>
2787 );
2788 }
2789);
2790
81c73c1Claude2791// AI-suggested PR description — JSON endpoint driven by the form button.
2792// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
2793// 200; the inline script reads `ok` to decide what to do.
2794pulls.post(
2795 "/:owner/:repo/ai/pr-description",
2796 softAuth,
2797 requireAuth,
2798 requireRepoAccess("write"),
2799 async (c) => {
2800 const { owner: ownerName, repo: repoName } = c.req.param();
2801 if (!isAiAvailable()) {
2802 return c.json({
2803 ok: false,
2804 error: "AI is not available — set ANTHROPIC_API_KEY.",
2805 });
2806 }
2807 const body = await c.req.parseBody();
2808 const title = String(body.title || "").trim();
2809 const baseBranch = String(body.base || "").trim();
2810 const headBranch = String(body.head || "").trim();
2811 if (!baseBranch || !headBranch) {
2812 return c.json({ ok: false, error: "Pick base + head branches first." });
2813 }
2814 if (baseBranch === headBranch) {
2815 return c.json({ ok: false, error: "Base and head must differ." });
2816 }
2817
2818 let diff = "";
2819 try {
2820 const cwd = getRepoPath(ownerName, repoName);
2821 const proc = Bun.spawn(
2822 [
2823 "git",
2824 "diff",
2825 `${baseBranch}...${headBranch}`,
2826 "--",
2827 ],
2828 { cwd, stdout: "pipe", stderr: "pipe" }
2829 );
6ea2109Claude2830 // 30s ceiling — without this a pathological diff (huge binary or
2831 // a corrupt ref) hangs the request indefinitely.
2832 const killer = setTimeout(() => proc.kill(), 30_000);
2833 try {
2834 diff = await new Response(proc.stdout).text();
2835 await proc.exited;
2836 } finally {
2837 clearTimeout(killer);
2838 }
81c73c1Claude2839 } catch {
2840 diff = "";
2841 }
2842 if (!diff.trim()) {
2843 return c.json({
2844 ok: false,
2845 error: "No diff between branches — nothing to summarise.",
2846 });
2847 }
2848
2849 let summary = "";
2850 try {
2851 summary = await generatePrSummary(title || "(untitled)", diff);
2852 } catch (err) {
2853 const msg = err instanceof Error ? err.message : "AI request failed.";
2854 return c.json({ ok: false, error: msg });
2855 }
2856 if (!summary.trim()) {
2857 return c.json({ ok: false, error: "AI returned an empty draft." });
2858 }
2859 return c.json({ ok: true, body: summary });
2860 }
2861);
2862
0074234Claude2863// Create PR
2864pulls.post(
2865 "/:owner/:repo/pulls/new",
2866 softAuth,
2867 requireAuth,
04f6b7fClaude2868 requireRepoAccess("write"),
0074234Claude2869 async (c) => {
2870 const { owner: ownerName, repo: repoName } = c.req.param();
2871 const user = c.get("user")!;
2872 const body = await c.req.parseBody();
2873 const title = String(body.title || "").trim();
2874 const prBody = String(body.body || "").trim();
2875 const baseBranch = String(body.base || "main");
2876 const headBranch = String(body.head || "");
2877
2878 if (!title || !headBranch) {
2879 return c.redirect(
2880 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
2881 );
2882 }
2883
2884 if (baseBranch === headBranch) {
2885 return c.redirect(
2886 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
2887 );
2888 }
2889
2890 const resolved = await resolveRepo(ownerName, repoName);
2891 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2892
6fc53bdClaude2893 const isDraft = String(body.draft || "") === "1";
2894
0074234Claude2895 const [pr] = await db
2896 .insert(pullRequests)
2897 .values({
2898 repositoryId: resolved.repo.id,
2899 authorId: user.id,
2900 title,
2901 body: prBody || null,
2902 baseBranch,
2903 headBranch,
6fc53bdClaude2904 isDraft,
0074234Claude2905 })
2906 .returning();
2907
6fc53bdClaude2908 // Skip AI review on drafts — it runs again when the PR is marked ready.
2909 if (!isDraft && isAiReviewEnabled()) {
e883329Claude2910 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
2911 (err) => console.error("[ai-review] Failed:", err)
2912 );
2913 }
2914
3cbe3d6Claude2915 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
2916 triggerPrTriage({
2917 ownerName,
2918 repoName,
2919 repositoryId: resolved.repo.id,
2920 prId: pr.id,
2921 prAuthorId: user.id,
2922 title,
2923 body: prBody,
2924 baseBranch,
2925 headBranch,
2926 }).catch((err) => console.error("[pr-triage] Failed:", err));
2927
1d4ff60Claude2928 // Chat notifier — fan out to Slack/Discord/Teams.
2929 import("../lib/chat-notifier")
2930 .then((m) =>
2931 m.notifyChatChannels({
2932 ownerUserId: resolved.repo.ownerId,
2933 repositoryId: resolved.repo.id,
2934 event: {
2935 event: "pr.opened",
2936 repo: `${ownerName}/${repoName}`,
2937 title: `#${pr.number} ${title}`,
2938 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
2939 body: prBody || undefined,
2940 actor: user.username,
2941 },
2942 })
2943 )
2944 .catch((err) =>
2945 console.warn(`[chat-notifier] PR opened notify failed:`, err)
2946 );
2947
9dd96b9Test User2948 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude2949 import("../lib/auto-merge")
2950 .then((m) => m.tryAutoMergeNow(pr.id))
2951 .catch((err) => {
2952 console.warn(
2953 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
2954 err instanceof Error ? err.message : err
2955 );
2956 });
9dd96b9Test User2957
0074234Claude2958 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
2959 }
2960);
2961
2962// View single PR
04f6b7fClaude2963pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2964 const { owner: ownerName, repo: repoName } = c.req.param();
2965 const prNum = parseInt(c.req.param("number"), 10);
2966 const user = c.get("user");
2967 const tab = c.req.query("tab") || "conversation";
2968
2969 const resolved = await resolveRepo(ownerName, repoName);
2970 if (!resolved) return c.notFound();
2971
2972 const [pr] = await db
2973 .select()
2974 .from(pullRequests)
2975 .where(
2976 and(
2977 eq(pullRequests.repositoryId, resolved.repo.id),
2978 eq(pullRequests.number, prNum)
2979 )
2980 )
2981 .limit(1);
2982
2983 if (!pr) return c.notFound();
2984
2985 const [author] = await db
2986 .select()
2987 .from(users)
2988 .where(eq(users.id, pr.authorId))
2989 .limit(1);
2990
cb5a796Claude2991 const allCommentsRaw = await db
0074234Claude2992 .select({
2993 comment: prComments,
cb5a796Claude2994 author: { id: users.id, username: users.username },
0074234Claude2995 })
2996 .from(prComments)
2997 .innerJoin(users, eq(prComments.authorId, users.id))
2998 .where(eq(prComments.pullRequestId, pr.id))
2999 .orderBy(asc(prComments.createdAt));
3000
cb5a796Claude3001 // Filter pending/rejected/spam for non-owner, non-author viewers.
3002 // Owner always sees everything; comment author sees their own pending
3003 // with an "Awaiting approval" badge in the render below.
3004 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
3005 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
3006 if (viewerIsRepoOwner) return true;
3007 if (comment.moderationStatus === "approved") return true;
3008 if (
3009 user &&
3010 cAuthor.id === user.id &&
3011 comment.moderationStatus === "pending"
3012 ) {
3013 return true;
3014 }
3015 return false;
3016 });
3017 const prPendingCount = viewerIsRepoOwner
3018 ? await countPendingForRepo(resolved.repo.id)
3019 : 0;
3020
6fc53bdClaude3021 // Reactions for the PR body + each comment, in parallel.
3022 const [prReactions, ...prCommentReactions] = await Promise.all([
3023 summariseReactions("pr", pr.id, user?.id),
3024 ...comments.map((row) =>
3025 summariseReactions("pr_comment", row.comment.id, user?.id)
3026 ),
3027 ]);
3028
0a67773Claude3029 // Formal reviews (Approve / Request Changes)
3030 const reviewRows = await db
3031 .select({
3032 id: prReviews.id,
3033 state: prReviews.state,
3034 body: prReviews.body,
3035 isAi: prReviews.isAi,
3036 createdAt: prReviews.createdAt,
3037 reviewerUsername: users.username,
3038 reviewerId: prReviews.reviewerId,
3039 })
3040 .from(prReviews)
3041 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3042 .where(eq(prReviews.pullRequestId, pr.id))
3043 .orderBy(asc(prReviews.createdAt));
3044 // Most recent review per reviewer determines the current state
3045 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
3046 for (const r of reviewRows) {
3047 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
3048 }
3049 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
3050 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
3051 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
3052
0074234Claude3053 const canManage =
3054 user &&
3055 (user.id === resolved.owner.id || user.id === pr.authorId);
3056
1d4ff60Claude3057 // Has any previous AI-test-generator run already tagged this PR? Used
3058 // both to hide the "Generate tests with AI" button and to short-circuit
3059 // the explicit POST handler.
3060 const hasAiTestsMarker = comments.some(({ comment }) =>
3061 (comment.body || "").includes(AI_TESTS_MARKER)
3062 );
3063
e883329Claude3064 const error = c.req.query("error");
c3e0c07Claude3065 const info = c.req.query("info");
e883329Claude3066
3067 // Get gate check status for open PRs
3068 let gateChecks: GateCheckResult[] = [];
3069 if (pr.state === "open") {
3070 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3071 if (headSha) {
3072 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
3073 const aiApproved = aiComments.length === 0 || aiComments.some(
3074 ({ comment }) => comment.body.includes("**Approved**")
3075 );
3076 const gateResult = await runAllGateChecks(
3077 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
3078 );
3079 gateChecks = gateResult.checks;
3080 }
3081 }
3082
534f04aClaude3083 // Block M3 — pre-merge risk score. Cache-only on the request path so
3084 // the page never waits on Haiku. On a cache miss for an open PR we
3085 // kick off the computation fire-and-forget; the next refresh shows it.
3086 let prRisk: PrRiskScore | null = null;
3087 let prRiskCalculating = false;
3088 if (pr.state === "open") {
3089 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
3090 if (!prRisk) {
3091 prRiskCalculating = true;
a28cedeClaude3092 void computePrRiskForPullRequest(pr.id).catch((err) => {
3093 console.warn(
3094 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
3095 err instanceof Error ? err.message : err
3096 );
3097 });
534f04aClaude3098 }
3099 }
3100
4bbacbeClaude3101 // Migration 0062 — per-branch preview URL. The head branch always
3102 // has a preview row (unless it's the default branch, which never
3103 // happens for an open PR) once it has been pushed at least once.
3104 const preview = await getPreviewForBranch(
3105 (resolved.repo as { id: string }).id,
3106 pr.headBranch
3107 );
3108
0369e77Claude3109 // Branch ahead/behind counts — how many commits head is ahead of base and
3110 // how many commits base has advanced since head branched off.
3111 let branchAhead = 0;
3112 let branchBehind = 0;
3113 if (pr.state === "open") {
3114 try {
3115 const repoDir = getRepoPath(ownerName, repoName);
3116 const [aheadProc, behindProc] = [
3117 Bun.spawn(
3118 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
3119 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3120 ),
3121 Bun.spawn(
3122 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
3123 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3124 ),
3125 ];
3126 const [aheadTxt, behindTxt] = await Promise.all([
3127 new Response(aheadProc.stdout).text(),
3128 new Response(behindProc.stdout).text(),
3129 ]);
3130 await Promise.all([aheadProc.exited, behindProc.exited]);
3131 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
3132 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
3133 } catch { /* non-blocking */ }
3134 }
3135
6d1bbc2Claude3136 // Linked issues — parse closing keywords from PR title+body, look up issues
3137 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
3138 try {
3139 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3140 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3141 if (refs.length > 0) {
3142 linkedIssues = await db
3143 .select({ number: issues.number, title: issues.title, state: issues.state })
3144 .from(issues)
3145 .where(and(
3146 eq(issues.repositoryId, resolved.repo.id),
3147 inArray(issues.number, refs),
3148 ));
3149 }
3150 } catch { /* non-blocking */ }
3151
3152 // Task list progress — count markdown checkboxes in PR body
3153 let taskTotal = 0;
3154 let taskChecked = 0;
3155 if (pr.body) {
3156 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
3157 taskTotal++;
3158 if (m[1].trim() !== "") taskChecked++;
3159 }
3160 }
3161
47a7a0aClaude3162 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude3163 let diffRaw = "";
3164 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude3165 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude3166 if (tab === "files") {
3167 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude3168 // Run the two git diffs in parallel — they're independent reads of
3169 // the same range. Previously sequential, doubling the wall time on
3170 // big PRs (100+ files = 10-30s for no reason).
0074234Claude3171 const proc = Bun.spawn(
3172 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
3173 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3174 );
3175 const statProc = Bun.spawn(
3176 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
3177 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3178 );
6ea2109Claude3179 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
3180 // would otherwise hang the whole request.
3181 const killer = setTimeout(() => {
3182 proc.kill();
3183 statProc.kill();
3184 }, 30_000);
3185 let stat = "";
3186 try {
3187 [diffRaw, stat] = await Promise.all([
3188 new Response(proc.stdout).text(),
3189 new Response(statProc.stdout).text(),
3190 ]);
3191 await Promise.all([proc.exited, statProc.exited]);
3192 } finally {
3193 clearTimeout(killer);
3194 }
0074234Claude3195
3196 diffFiles = stat
3197 .trim()
3198 .split("\n")
3199 .filter(Boolean)
3200 .map((line) => {
3201 const [add, del, filePath] = line.split("\t");
3202 return {
3203 path: filePath,
3204 status: "modified",
3205 additions: add === "-" ? 0 : parseInt(add, 10),
3206 deletions: del === "-" ? 0 : parseInt(del, 10),
3207 patch: "",
3208 };
3209 });
47a7a0aClaude3210
3211 // Fetch inline comments (file+line anchored) for the files tab
3212 const inlineRows = await db
3213 .select({
3214 id: prComments.id,
3215 filePath: prComments.filePath,
3216 lineNumber: prComments.lineNumber,
3217 body: prComments.body,
3218 isAiReview: prComments.isAiReview,
3219 createdAt: prComments.createdAt,
3220 authorUsername: users.username,
3221 })
3222 .from(prComments)
3223 .innerJoin(users, eq(prComments.authorId, users.id))
3224 .where(
3225 and(
3226 eq(prComments.pullRequestId, pr.id),
3227 eq(prComments.moderationStatus, "approved"),
3228 )
3229 )
3230 .orderBy(asc(prComments.createdAt));
3231
3232 diffInlineComments = inlineRows
3233 .filter(r => r.filePath != null && r.lineNumber != null)
3234 .map(r => ({
3235 id: r.id,
3236 filePath: r.filePath!,
3237 lineNumber: r.lineNumber!,
3238 authorUsername: r.authorUsername,
3239 body: renderMarkdown(r.body),
3240 isAiReview: r.isAiReview,
3241 createdAt: r.createdAt.toISOString(),
3242 }));
0074234Claude3243 }
3244
b078860Claude3245 // ─── Derived visual state ───
3246 const stateKey =
3247 pr.state === "open"
3248 ? pr.isDraft
3249 ? "draft"
3250 : "open"
3251 : pr.state;
3252 const stateLabel =
3253 stateKey === "open"
3254 ? "Open"
3255 : stateKey === "draft"
3256 ? "Draft"
3257 : stateKey === "merged"
3258 ? "Merged"
3259 : "Closed";
3260 const stateIcon =
3261 stateKey === "open"
3262 ? "○"
3263 : stateKey === "draft"
3264 ? "◌"
3265 : stateKey === "merged"
3266 ? "⮌"
3267 : "✓";
3268 const commentCount = comments.length;
3269 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
3270 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
3271 const mergeBlocked =
3272 gateChecks.length > 0 &&
3273 gateChecks.some(
3274 (c) => !c.passed && c.name !== "Merge check"
3275 );
3276
0074234Claude3277 return c.html(
3278 <Layout
3279 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
3280 user={user}
3281 >
3282 <RepoHeader owner={ownerName} repo={repoName} />
3283 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude3284 <PendingCommentsBanner
3285 owner={ownerName}
3286 repo={repoName}
3287 count={prPendingCount}
3288 />
b078860Claude3289 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude3290 <div
3291 id="live-comment-banner"
3292 class="alert"
3293 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
3294 >
3295 <strong class="js-live-count">0</strong> new comment(s) —{" "}
3296 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
3297 reload to view
3298 </a>
3299 </div>
3300 <script
3301 dangerouslySetInnerHTML={{
3302 __html: liveCommentBannerScript({
3303 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
3304 bannerElementId: "live-comment-banner",
3305 }),
3306 }}
3307 />
b078860Claude3308
3309 <div class="prs-detail-hero">
3310 <h1 class="prs-detail-title">
0074234Claude3311 {pr.title}{" "}
b078860Claude3312 <span class="prs-detail-num">#{pr.number}</span>
3313 </h1>
3314 <div class="prs-detail-meta">
3315 <span class={`prs-state-pill state-${stateKey}`}>
3316 <span aria-hidden="true">{stateIcon}</span>
3317 <span>{stateLabel}</span>
3318 </span>
3319 <span>
3320 <strong>{author?.username}</strong> wants to merge
3321 </span>
3322 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
3323 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
3324 <span class="prs-branch-arrow-lg">{"→"}</span>
3325 <span class="prs-branch-pill">{pr.baseBranch}</span>
3326 </span>
0369e77Claude3327 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
3328 <span
3329 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
3330 title={branchBehind > 0
3331 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
3332 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
3333 >
3334 {branchAhead > 0 ? `↑${branchAhead}` : ""}
3335 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
3336 {branchBehind > 0 ? `↓${branchBehind}` : ""}
3337 </span>
3338 )}
b078860Claude3339 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude3340 {taskTotal > 0 && (
3341 <span
3342 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
3343 title={`${taskChecked} of ${taskTotal} tasks completed`}
3344 >
3345 <span class="prs-tasks-progress" aria-hidden="true">
3346 <span
3347 class="prs-tasks-progress-bar"
3348 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
3349 ></span>
3350 </span>
3351 {taskChecked}/{taskTotal} tasks
3352 </span>
3353 )}
3354 {canManage && pr.state === "open" && branchBehind > 0 && (
3355 <form
3356 method="post"
3357 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
3358 class="prs-inline-form"
3359 >
3360 <button
3361 type="submit"
3362 class="prs-update-branch-btn"
3363 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
3364 >
3365 ↑ Update branch
3366 </button>
3367 </form>
3368 )}
3c03977Claude3369 <span
3370 id="live-pill"
3371 class="live-pill"
3372 title="People editing this PR right now"
3373 >
3374 <span class="live-pill-dot" aria-hidden="true"></span>
3375 <span>
3376 Live: <strong id="live-count">0</strong> editing
3377 </span>
3378 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
3379 </span>
4bbacbeClaude3380 {preview && (
3381 <a
3382 class={`preview-prpill is-${preview.status}`}
3383 href={
3384 preview.status === "ready"
3385 ? preview.previewUrl
3386 : `/${ownerName}/${repoName}/previews`
3387 }
3388 target={preview.status === "ready" ? "_blank" : undefined}
3389 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
3390 title={`Preview · ${previewStatusLabel(preview.status)}`}
3391 >
3392 <span class="preview-prpill-dot" aria-hidden="true"></span>
3393 <span>Preview: </span>
3394 <span>{previewStatusLabel(preview.status)}</span>
3395 </a>
3396 )}
b078860Claude3397 {canManage && pr.state === "open" && pr.isDraft && (
3398 <form
3399 method="post"
3400 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3401 class="prs-inline-form prs-detail-actions"
3402 >
3403 <button type="submit" class="prs-merge-ready-btn">
3404 Ready for review
3405 </button>
3406 </form>
3407 )}
3408 </div>
3409 </div>
3c03977Claude3410 <script
3411 dangerouslySetInnerHTML={{
3412 __html: LIVE_COEDIT_SCRIPT(pr.id),
3413 }}
3414 />
0074234Claude3415
b078860Claude3416 <nav class="prs-detail-tabs" aria-label="Pull request sections">
3417 <a
3418 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
3419 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
3420 >
3421 Conversation
3422 <span class="prs-detail-tab-count">{commentCount}</span>
3423 </a>
3424 <a
3425 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
3426 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3427 >
3428 Files changed
3429 {diffFiles.length > 0 && (
3430 <span class="prs-detail-tab-count">{diffFiles.length}</span>
3431 )}
3432 </a>
3433 </nav>
3434
3435 {tab === "files" ? (
ea9ed4cClaude3436 <DiffView
3437 raw={diffRaw}
3438 files={diffFiles}
3439 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
47a7a0aClaude3440 inlineComments={diffInlineComments}
3441 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
b5dd694Claude3442 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
ea9ed4cClaude3443 />
b078860Claude3444 ) : (
3445 <>
3446 {pr.body && (
3447 <CommentBox
3448 author={author?.username ?? "unknown"}
3449 date={pr.createdAt}
3450 body={renderMarkdown(pr.body)}
3451 />
3452 )}
3453
422a2d4Claude3454 {/* Block H — AI trio review (security/correctness/style). When
3455 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
3456 hoisted into a 3-column card grid above the normal comment
3457 stream so reviewers see verdicts at a glance. Disagreements
3458 are surfaced as a yellow callout. */}
3459 <TrioReviewGrid
3460 comments={comments.map(({ comment }) => comment)}
3461 />
3462
15db0e0Claude3463 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude3464 // Skip trio comments — already rendered in TrioReviewGrid above.
3465 if (isTrioComment(comment.body)) return null;
15db0e0Claude3466 const slashCmd = detectSlashCmdComment(comment.body);
3467 if (slashCmd) {
3468 const visible = stripSlashCmdMarker(comment.body);
3469 return (
3470 <div class={`slash-pill slash-cmd-${slashCmd}`}>
3471 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
3472 <span class="slash-pill-actor">
3473 <strong>{commentAuthor.username}</strong>
3474 {" ran "}
3475 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude3476 </span>
15db0e0Claude3477 <span class="slash-pill-time">
3478 {formatRelative(comment.createdAt)}
3479 </span>
3480 <div class="slash-pill-body">
3481 <MarkdownContent html={renderMarkdown(visible)} />
3482 </div>
3483 </div>
3484 );
3485 }
cb5a796Claude3486 const isPending = comment.moderationStatus === "pending";
15db0e0Claude3487 return (
cb5a796Claude3488 <div
3489 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
3490 >
15db0e0Claude3491 <div class="prs-comment-head">
3492 <strong>{commentAuthor.username}</strong>
3493 {comment.isAiReview && (
3494 <span class="prs-ai-badge">AI Review</span>
3495 )}
cb5a796Claude3496 {isPending && (
3497 <span
3498 class="modq-pending-badge"
3499 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
3500 >
3501 Awaiting approval
3502 </span>
3503 )}
15db0e0Claude3504 <span class="prs-comment-time">
3505 commented {formatRelative(comment.createdAt)}
3506 </span>
3507 {comment.filePath && (
3508 <span class="prs-comment-loc">
3509 {comment.filePath}
3510 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
3511 </span>
3512 )}
3513 </div>
3514 <div class="prs-comment-body">
3515 <MarkdownContent html={renderMarkdown(comment.body)} />
3516 </div>
0074234Claude3517 </div>
15db0e0Claude3518 );
3519 })}
0074234Claude3520
b078860Claude3521 {/* Quick link to the Files changed tab when there's a diff to look at. */}
3522 {pr.state !== "merged" && (
3523 <a
3524 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3525 class="prs-files-card"
3526 >
3527 <span class="prs-files-card-icon" aria-hidden="true">
3528 {"▤"}
3529 </span>
3530 <div class="prs-files-card-text">
3531 <p class="prs-files-card-title">Files changed</p>
3532 <p class="prs-files-card-sub">
3533 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
3534 </p>
e883329Claude3535 </div>
b078860Claude3536 <span class="prs-files-card-cta">View diff {"→"}</span>
3537 </a>
3538 )}
3539
6d1bbc2Claude3540 {linkedIssues.length > 0 && (
3541 <div class="prs-linked-issues">
3542 <div class="prs-linked-issues-head">
3543 <span>Closing issues</span>
3544 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
3545 </div>
3546 {linkedIssues.map((issue) => (
3547 <a
3548 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
3549 class="prs-linked-issue-row"
3550 >
3551 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
3552 {issue.state === "open" ? "○" : "✓"}
3553 </span>
3554 <span class="prs-linked-issue-title">{issue.title}</span>
3555 <span class="prs-linked-issue-num">#{issue.number}</span>
3556 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
3557 {issue.state}
3558 </span>
3559 </a>
3560 ))}
3561 </div>
3562 )}
3563
b078860Claude3564 {error && (
3565 <div
3566 class="auth-error"
3567 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)"
3568 >
3569 {decodeURIComponent(error)}
3570 </div>
3571 )}
3572
3573 {info && (
3574 <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)">
3575 {decodeURIComponent(info)}
3576 </div>
3577 )}
e883329Claude3578
b078860Claude3579 {pr.state === "open" && (prRisk || prRiskCalculating) && (
3580 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
3581 )}
3582
0a67773Claude3583 {/* ─── Review summary ─────────────────────────────────── */}
3584 {(approvals.length > 0 || changesRequested.length > 0) && (
3585 <div class="prs-review-summary">
3586 {approvals.length > 0 && (
3587 <div class="prs-review-row prs-review-approved">
3588 <span class="prs-review-icon">✓</span>
3589 <span>
3590 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
3591 approved this pull request
3592 </span>
3593 </div>
3594 )}
3595 {changesRequested.length > 0 && (
3596 <div class="prs-review-row prs-review-changes">
3597 <span class="prs-review-icon">✗</span>
3598 <span>
3599 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
3600 requested changes
3601 </span>
3602 </div>
3603 )}
3604 </div>
3605 )}
3606
b078860Claude3607 {pr.state === "open" && gateChecks.length > 0 && (
3608 <div class="prs-gate-card">
3609 <div class="prs-gate-head">
3610 <h3>Gate checks</h3>
3611 <span class="prs-gate-summary">
3612 {gatesAllPassed
3613 ? `All ${gateChecks.length} checks passed`
3614 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
3615 </span>
c3e0c07Claude3616 </div>
b078860Claude3617 {gateChecks.map((check) => {
3618 const isAi = /ai.*review/i.test(check.name);
3619 const isSkip = check.skipped === true;
3620 const statusClass = isSkip
3621 ? "is-skip"
3622 : check.passed
3623 ? "is-pass"
3624 : "is-fail";
3625 const statusGlyph = isSkip
3626 ? "—"
3627 : check.passed
3628 ? "✓"
3629 : "✗";
3630 const statusLabel = isSkip
3631 ? "Skipped"
3632 : check.passed
3633 ? "Passed"
3634 : "Failing";
3635 return (
3636 <div
3637 class="prs-gate-row"
3638 style={
3639 isAi
3640 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
3641 : ""
3642 }
3643 >
3644 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
3645 {statusGlyph}
3646 </span>
3647 <span class="prs-gate-name">
3648 {check.name}
3649 {isAi && (
3650 <span
3651 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"
3652 >
3653 AI
3654 </span>
3655 )}
3656 </span>
3657 <span class="prs-gate-details">{check.details}</span>
3658 <span class={`prs-gate-pill ${statusClass}`}>
3659 {statusLabel}
e883329Claude3660 </span>
3661 </div>
b078860Claude3662 );
3663 })}
3664 <div class="prs-gate-footer">
3665 {gatesAllPassed
3666 ? "All checks passed — ready to merge."
3667 : gateChecks.some(
3668 (c) => !c.passed && c.name === "Merge check"
3669 )
3670 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
3671 : "Some checks failed — resolve issues before merging."}
3672 {aiReviewCount > 0 && (
3673 <>
3674 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
3675 </>
3676 )}
3677 </div>
3678 </div>
3679 )}
3680
3681 {/* ─── Merge area / state-aware action card ─────────────── */}
3682 {user && pr.state === "open" && (
3683 <div
3684 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
3685 >
3686 <div class="prs-merge-head">
3687 <strong>
3688 {pr.isDraft
3689 ? "Draft — ready for review?"
3690 : mergeBlocked
3691 ? "Merge blocked"
3692 : "Ready to merge"}
3693 </strong>
e883329Claude3694 </div>
b078860Claude3695 <p class="prs-merge-sub">
3696 {pr.isDraft
3697 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
3698 : mergeBlocked
3699 ? "Resolve the failing gate checks above before this PR can land."
3700 : gateChecks.length > 0
3701 ? gatesAllPassed
3702 ? "All gates green. Merge will fast-forward into the base branch."
3703 : "Conflicts will be auto-resolved by GlueCron AI on merge."
3704 : "Run gate checks by refreshing once your branch has a recent commit."}
3705 </p>
3706 <Form
3707 method="post"
3708 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
3709 >
3710 <FormGroup>
3c03977Claude3711 <div class="live-cursor-host" style="position:relative">
3712 <textarea
3713 name="body"
3714 id="pr-comment-body"
3715 data-live-field="comment_new"
3716 rows={5}
3717 required
3718 placeholder="Leave a comment... (Markdown supported)"
3719 style="font-family:var(--font-mono);font-size:13px;width:100%"
3720 ></textarea>
3721 </div>
15db0e0Claude3722 <span class="slash-hint" title="Type a slash-command as the first line">
3723 Type <code>/</code> for commands —{" "}
3724 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
3725 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
3726 </span>
b078860Claude3727 </FormGroup>
3728 <div class="prs-merge-actions">
3729 <Button type="submit" variant="primary">
3730 Comment
3731 </Button>
0a67773Claude3732 {user && user.id !== pr.authorId && pr.state === "open" && (
3733 <>
3734 <button
3735 type="submit"
3736 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
3737 name="review_state"
3738 value="approved"
3739 class="prs-review-approve-btn"
3740 title="Approve this pull request"
3741 >
3742 ✓ Approve
3743 </button>
3744 <button
3745 type="submit"
3746 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
3747 name="review_state"
3748 value="changes_requested"
3749 class="prs-review-changes-btn"
3750 title="Request changes before merging"
3751 >
3752 ✗ Request changes
3753 </button>
3754 </>
3755 )}
b078860Claude3756 {canManage && (
3757 <>
3758 {pr.isDraft ? (
3759 <button
3760 type="submit"
3761 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3762 formnovalidate
3763 class="prs-merge-ready-btn"
3764 >
3765 Ready for review
3766 </button>
3767 ) : (
a164a6dClaude3768 <>
3769 <div class="prs-merge-strategy-wrap">
3770 <span class="prs-merge-strategy-label">Strategy</span>
3771 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
3772 <option value="merge">Merge commit</option>
3773 <option value="squash">Squash and merge</option>
3774 <option value="ff">Fast-forward</option>
3775 </select>
3776 </div>
3777 <button
3778 type="submit"
3779 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
3780 formnovalidate
3781 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
3782 title={
3783 mergeBlocked
3784 ? "Failing gate checks must be resolved before this PR can merge."
3785 : "Merge pull request"
3786 }
3787 >
3788 {"✔"} Merge pull request
3789 </button>
3790 </>
b078860Claude3791 )}
3792 {!pr.isDraft && (
3793 <button
0074234Claude3794 type="submit"
b078860Claude3795 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
3796 formnovalidate
3797 class="prs-merge-back-draft"
3798 title="Convert back to draft"
0074234Claude3799 >
b078860Claude3800 Convert to draft
3801 </button>
3802 )}
3803 {isAiReviewEnabled() && (
3804 <button
3805 type="submit"
3806 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
3807 formnovalidate
3808 class="btn"
3809 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
3810 >
3811 Re-run AI review
3812 </button>
3813 )}
1d4ff60Claude3814 {isAiReviewEnabled() && !hasAiTestsMarker && (
3815 <button
3816 type="submit"
3817 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
3818 formnovalidate
3819 class="btn"
3820 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."
3821 >
3822 Generate tests with AI
3823 </button>
3824 )}
b078860Claude3825 <Button
3826 type="submit"
3827 variant="danger"
3828 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
3829 >
3830 Close
3831 </Button>
3832 </>
3833 )}
3834 </div>
3835 </Form>
3836 </div>
3837 )}
3838
3839 {/* Read-only footers for non-open states. */}
3840 {pr.state === "merged" && (
3841 <div class="prs-merge-card is-merged">
3842 <div class="prs-merge-head">
3843 <strong>{"⮌"} Merged</strong>
0074234Claude3844 </div>
b078860Claude3845 <p class="prs-merge-sub">
3846 This pull request was merged into{" "}
3847 <code>{pr.baseBranch}</code>.
3848 </p>
3849 </div>
3850 )}
3851 {pr.state === "closed" && (
3852 <div class="prs-merge-card is-closed">
3853 <div class="prs-merge-head">
3854 <strong>{"✕"} Closed without merging</strong>
3855 </div>
3856 <p class="prs-merge-sub">
3857 This pull request was closed and not merged.
3858 </p>
3859 </div>
3860 )}
3861 </>
3862 )}
0074234Claude3863 </Layout>
3864 );
3865});
3866
6d1bbc2Claude3867// Update branch — merge base into head so the PR branch is up to date.
3868// Uses a git worktree so the bare repo stays clean. Write access required.
3869pulls.post(
3870 "/:owner/:repo/pulls/:number/update-branch",
3871 softAuth,
3872 requireAuth,
3873 requireRepoAccess("write"),
3874 async (c) => {
3875 const { owner: ownerName, repo: repoName } = c.req.param();
3876 const prNum = parseInt(c.req.param("number"), 10);
3877 const user = c.get("user")!;
3878 const resolved = await resolveRepo(ownerName, repoName);
3879 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3880
3881 const [pr] = await db
3882 .select()
3883 .from(pullRequests)
3884 .where(and(
3885 eq(pullRequests.repositoryId, resolved.repo.id),
3886 eq(pullRequests.number, prNum),
3887 ))
3888 .limit(1);
3889 if (!pr || pr.state !== "open") {
3890 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3891 }
3892
3893 const repoDir = getRepoPath(ownerName, repoName);
3894 const wt = `${repoDir}/_update_wt_${Date.now()}`;
3895 const gitEnv = {
3896 ...process.env,
3897 GIT_AUTHOR_NAME: user.displayName || user.username,
3898 GIT_AUTHOR_EMAIL: user.email,
3899 GIT_COMMITTER_NAME: user.displayName || user.username,
3900 GIT_COMMITTER_EMAIL: user.email,
3901 };
3902
3903 const addWt = Bun.spawn(
3904 ["git", "worktree", "add", wt, pr.headBranch],
3905 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3906 );
3907 if (await addWt.exited !== 0) {
3908 return c.redirect(
3909 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
3910 );
3911 }
3912
3913 let ok = false;
3914 try {
3915 const mergeProc = Bun.spawn(
3916 ["git", "merge", "--no-edit", pr.baseBranch],
3917 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
3918 );
3919 if (await mergeProc.exited === 0) {
3920 ok = true;
3921 } else {
3922 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
3923 }
3924 } catch {
3925 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
3926 }
3927
3928 await Bun.spawn(
3929 ["git", "worktree", "remove", "--force", wt],
3930 { cwd: repoDir }
3931 ).exited.catch(() => {});
3932
3933 if (ok) {
3934 return c.redirect(
3935 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
3936 );
3937 }
3938 return c.redirect(
3939 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
3940 );
3941 }
3942);
3943
cb5a796Claude3944// Add comment to PR.
3945//
3946// Permission model mirrors `issues.tsx`: any logged-in user with read
3947// access can submit; `decideInitialStatus` routes non-collaborators
3948// through the moderation queue. Slash commands only fire when the
3949// comment is auto-approved — we don't want a banned/pending comment to
3950// silently trigger AI work on the PR.
0074234Claude3951pulls.post(
3952 "/:owner/:repo/pulls/:number/comment",
3953 softAuth,
3954 requireAuth,
cb5a796Claude3955 requireRepoAccess("read"),
0074234Claude3956 async (c) => {
3957 const { owner: ownerName, repo: repoName } = c.req.param();
3958 const prNum = parseInt(c.req.param("number"), 10);
3959 const user = c.get("user")!;
3960 const body = await c.req.parseBody();
3961 const commentBody = String(body.body || "").trim();
47a7a0aClaude3962 const filePathRaw = String(body.file_path || "").trim();
3963 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
3964 const inlineFilePath = filePathRaw || undefined;
3965 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude3966
3967 if (!commentBody) {
3968 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3969 }
3970
3971 const resolved = await resolveRepo(ownerName, repoName);
3972 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3973
3974 const [pr] = await db
3975 .select()
3976 .from(pullRequests)
3977 .where(
3978 and(
3979 eq(pullRequests.repositoryId, resolved.repo.id),
3980 eq(pullRequests.number, prNum)
3981 )
3982 )
3983 .limit(1);
3984
3985 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
3986
cb5a796Claude3987 const decision = await decideInitialStatus({
3988 commenterUserId: user.id,
3989 repositoryId: resolved.repo.id,
3990 kind: "pr",
3991 threadId: pr.id,
3992 });
3993
d4ac5c3Claude3994 const [inserted] = await db
3995 .insert(prComments)
3996 .values({
3997 pullRequestId: pr.id,
3998 authorId: user.id,
3999 body: commentBody,
cb5a796Claude4000 moderationStatus: decision.status,
47a7a0aClaude4001 filePath: inlineFilePath,
4002 lineNumber: inlineLineNumber,
d4ac5c3Claude4003 })
4004 .returning();
4005
cb5a796Claude4006 // Live update: only when the comment is actually visible.
4007 if (inserted && decision.status === "approved") {
d4ac5c3Claude4008 try {
4009 const { publish } = await import("../lib/sse");
4010 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
4011 event: "pr-comment",
4012 data: {
4013 pullRequestId: pr.id,
4014 commentId: inserted.id,
4015 authorId: user.id,
4016 authorUsername: user.username,
4017 },
4018 });
4019 } catch {
4020 /* SSE is best-effort */
4021 }
4022 }
0074234Claude4023
cb5a796Claude4024 if (decision.status === "pending") {
4025 void notifyOwnerOfPendingComment({
4026 repositoryId: resolved.repo.id,
4027 commenterUsername: user.username,
4028 kind: "pr",
4029 threadNumber: prNum,
4030 ownerUsername: ownerName,
4031 repoName,
4032 });
4033 return c.redirect(
4034 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4035 );
4036 }
4037 if (decision.status === "rejected") {
4038 // Silent ban path — same UX as 'pending' so we don't leak the gate.
4039 return c.redirect(
4040 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4041 );
4042 }
4043
15db0e0Claude4044 // Slash-command handoff. We always store the original comment above
4045 // first so free-form text that happens to start with `/` is preserved
4046 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude4047 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude4048 const parsed = parseSlashCommand(commentBody);
4049 if (parsed) {
4050 try {
4051 const result = await executeSlashCommand({
4052 command: parsed.command,
4053 args: parsed.args,
4054 prId: pr.id,
4055 userId: user.id,
4056 repositoryId: resolved.repo.id,
4057 });
4058 await db.insert(prComments).values({
4059 pullRequestId: pr.id,
4060 authorId: user.id,
4061 body: result.body,
4062 });
4063 } catch (err) {
4064 // Defence-in-depth — executeSlashCommand promises not to throw,
4065 // but if it ever does we want the PR thread to know.
4066 await db
4067 .insert(prComments)
4068 .values({
4069 pullRequestId: pr.id,
4070 authorId: user.id,
4071 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
4072 })
4073 .catch(() => {});
4074 }
4075 }
4076
47a7a0aClaude4077 // Inline comments go back to the files tab; conversation comments to the conversation tab
4078 const redirectTab = inlineFilePath ? "?tab=files" : "";
4079 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude4080 }
4081);
4082
b5dd694Claude4083// Apply a suggestion from a PR comment — commits the suggested code to the
4084// head branch on behalf of the logged-in user.
4085pulls.post(
4086 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
4087 softAuth,
4088 requireAuth,
4089 requireRepoAccess("read"),
4090 async (c) => {
4091 const { owner: ownerName, repo: repoName } = c.req.param();
4092 const prNum = parseInt(c.req.param("number"), 10);
4093 const commentId = c.req.param("commentId"); // UUID
4094 const user = c.get("user")!;
4095
4096 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
4097
4098 const resolved = await resolveRepo(ownerName, repoName);
4099 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4100
4101 const [pr] = await db
4102 .select()
4103 .from(pullRequests)
4104 .where(
4105 and(
4106 eq(pullRequests.repositoryId, resolved.repo.id),
4107 eq(pullRequests.number, prNum)
4108 )
4109 )
4110 .limit(1);
4111
4112 if (!pr || pr.state !== "open") {
4113 return c.redirect(`${backUrl}&error=pr_not_open`);
4114 }
4115
4116 // Only PR author or repo owner may apply suggestions.
4117 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
4118 return c.redirect(`${backUrl}&error=forbidden`);
4119 }
4120
4121 // Load the comment.
4122 const [comment] = await db
4123 .select()
4124 .from(prComments)
4125 .where(
4126 and(
4127 eq(prComments.id, commentId),
4128 eq(prComments.pullRequestId, pr.id)
4129 )
4130 )
4131 .limit(1);
4132
4133 if (!comment) {
4134 return c.redirect(`${backUrl}&error=comment_not_found`);
4135 }
4136
4137 // Parse suggestion block from comment body.
4138 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
4139 if (!m) {
4140 return c.redirect(`${backUrl}&error=no_suggestion`);
4141 }
4142 const suggestionCode = m[1];
4143
4144 // Get the commenter's details for the commit message co-author line.
4145 const [commenter] = await db
4146 .select()
4147 .from(users)
4148 .where(eq(users.id, comment.authorId))
4149 .limit(1);
4150
4151 // Fetch current file content from head branch.
4152 if (!comment.filePath) {
4153 return c.redirect(`${backUrl}&error=file_not_found`);
4154 }
4155 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
4156 if (!blob) {
4157 return c.redirect(`${backUrl}&error=file_not_found`);
4158 }
4159
4160 // Apply the patch — replace the target line(s) with suggestion lines.
4161 const lines = blob.content.split('\n');
4162 const lineIdx = (comment.lineNumber ?? 1) - 1;
4163 if (lineIdx < 0 || lineIdx >= lines.length) {
4164 return c.redirect(`${backUrl}&error=line_out_of_range`);
4165 }
4166 const suggestionLines = suggestionCode.split('\n');
4167 lines.splice(lineIdx, 1, ...suggestionLines);
4168 const newContent = lines.join('\n');
4169
4170 // Commit the change.
4171 const coAuthorLine = commenter
4172 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
4173 : "";
4174 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
4175
4176 const result = await createOrUpdateFileOnBranch({
4177 owner: ownerName,
4178 name: repoName,
4179 branch: pr.headBranch,
4180 filePath: comment.filePath,
4181 bytes: new TextEncoder().encode(newContent),
4182 message: commitMessage,
4183 authorName: user.username,
4184 authorEmail: `${user.username}@users.noreply.gluecron.com`,
4185 });
4186
4187 if ("error" in result) {
4188 return c.redirect(`${backUrl}&error=apply_failed`);
4189 }
4190
4191 // Post a follow-up comment noting the suggestion was applied.
4192 await db.insert(prComments).values({
4193 pullRequestId: pr.id,
4194 authorId: user.id,
4195 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
4196 });
4197
4198 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
4199 }
4200);
4201
0a67773Claude4202// Formal review — Approve / Request Changes / Comment
4203pulls.post(
4204 "/:owner/:repo/pulls/:number/review",
4205 softAuth,
4206 requireAuth,
4207 requireRepoAccess("read"),
4208 async (c) => {
4209 const { owner: ownerName, repo: repoName } = c.req.param();
4210 const prNum = parseInt(c.req.param("number"), 10);
4211 const user = c.get("user")!;
4212 const body = await c.req.parseBody();
4213 const reviewBody = String(body.body || "").trim();
4214 const reviewState = String(body.review_state || "commented");
4215
4216 const validStates = ["approved", "changes_requested", "commented"];
4217 if (!validStates.includes(reviewState)) {
4218 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4219 }
4220
4221 const resolved = await resolveRepo(ownerName, repoName);
4222 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4223
4224 const [pr] = await db
4225 .select()
4226 .from(pullRequests)
4227 .where(
4228 and(
4229 eq(pullRequests.repositoryId, resolved.repo.id),
4230 eq(pullRequests.number, prNum)
4231 )
4232 )
4233 .limit(1);
4234 if (!pr || pr.state !== "open") {
4235 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4236 }
4237 // Authors can't review their own PR
4238 if (pr.authorId === user.id) {
4239 return c.redirect(
4240 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
4241 );
4242 }
4243
4244 await db.insert(prReviews).values({
4245 pullRequestId: pr.id,
4246 reviewerId: user.id,
4247 state: reviewState,
4248 body: reviewBody || null,
4249 });
4250
4251 const stateLabel =
4252 reviewState === "approved" ? "Approved"
4253 : reviewState === "changes_requested" ? "Changes requested"
4254 : "Commented";
4255 return c.redirect(
4256 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
4257 );
4258 }
4259);
4260
e883329Claude4261// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude4262// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
4263// but we keep it at "write" for v1 so trusted collaborators can ship.
4264// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
4265// surface. Branch-protection rules (evaluated below) are the current mechanism
4266// for locking down merges further on specific branches.
0074234Claude4267pulls.post(
4268 "/:owner/:repo/pulls/:number/merge",
4269 softAuth,
4270 requireAuth,
04f6b7fClaude4271 requireRepoAccess("write"),
0074234Claude4272 async (c) => {
4273 const { owner: ownerName, repo: repoName } = c.req.param();
4274 const prNum = parseInt(c.req.param("number"), 10);
4275 const user = c.get("user")!;
4276
a164a6dClaude4277 // Read merge strategy from form (default: merge commit)
4278 let mergeStrategy = "merge";
4279 try {
4280 const body = await c.req.parseBody();
4281 const s = body.merge_strategy;
4282 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
4283 } catch { /* ignore parse errors — default to merge commit */ }
4284
0074234Claude4285 const resolved = await resolveRepo(ownerName, repoName);
4286 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4287
4288 const [pr] = await db
4289 .select()
4290 .from(pullRequests)
4291 .where(
4292 and(
4293 eq(pullRequests.repositoryId, resolved.repo.id),
4294 eq(pullRequests.number, prNum)
4295 )
4296 )
4297 .limit(1);
4298
4299 if (!pr || pr.state !== "open") {
4300 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4301 }
4302
6fc53bdClaude4303 // Draft PRs cannot be merged — must be marked ready first.
4304 if (pr.isDraft) {
4305 return c.redirect(
4306 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4307 "This PR is a draft. Mark it as ready for review before merging."
4308 )}`
4309 );
4310 }
4311
e883329Claude4312 // Resolve head SHA
4313 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4314 if (!headSha) {
4315 return c.redirect(
4316 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
4317 );
4318 }
4319
4320 // Check if AI review approved this PR
4321 const aiComments = await db
4322 .select()
4323 .from(prComments)
4324 .where(
4325 and(
4326 eq(prComments.pullRequestId, pr.id),
4327 eq(prComments.isAiReview, true)
4328 )
4329 );
4330 const aiApproved = aiComments.length === 0 || aiComments.some(
4331 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude4332 );
e883329Claude4333
4334 // Run all green gate checks (GateTest + mergeability + AI review)
4335 const gateResult = await runAllGateChecks(
4336 ownerName,
4337 repoName,
4338 pr.baseBranch,
4339 pr.headBranch,
4340 headSha,
4341 aiApproved
0074234Claude4342 );
4343
e883329Claude4344 // If GateTest or AI review failed (hard blocks), reject the merge
4345 const hardFailures = gateResult.checks.filter(
4346 (check) => !check.passed && check.name !== "Merge check"
4347 );
4348 if (hardFailures.length > 0) {
4349 const errorMsg = hardFailures
4350 .map((f) => `${f.name}: ${f.details}`)
4351 .join("; ");
0074234Claude4352 return c.redirect(
e883329Claude4353 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude4354 );
4355 }
4356
1e162a8Claude4357 // D5 — Branch-protection enforcement. Looks up the matching rule for the
4358 // base branch and blocks the merge if requireAiApproval / requireGreenGates
4359 // / requireHumanReview / requiredApprovals are not satisfied. Independent
4360 // of repo-global settings, so owners can lock specific branches down
4361 // further than the repo default.
4362 const protectionRule = await matchProtection(
4363 resolved.repo.id,
4364 pr.baseBranch
4365 );
4366 if (protectionRule) {
4367 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude4368 const required = await listRequiredChecks(protectionRule.id);
4369 const passingNames = required.length > 0
4370 ? await passingCheckNames(resolved.repo.id, headSha)
4371 : [];
4372 const decision = evaluateProtection(
4373 protectionRule,
4374 {
4375 aiApproved,
4376 humanApprovalCount: humanApprovals,
4377 gateResultGreen: hardFailures.length === 0,
4378 hasFailedGates: hardFailures.length > 0,
4379 passingCheckNames: passingNames,
4380 },
4381 required.map((r) => r.checkName)
4382 );
1e162a8Claude4383 if (!decision.allowed) {
4384 return c.redirect(
4385 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4386 decision.reasons.join(" ")
4387 )}`
4388 );
4389 }
4390 }
4391
e883329Claude4392 // Attempt the merge — with auto conflict resolution if needed
4393 const repoDir = getRepoPath(ownerName, repoName);
4394 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
4395 const hasConflicts = mergeCheck && !mergeCheck.passed;
4396
4397 if (hasConflicts && isAiReviewEnabled()) {
4398 // Use Claude to auto-resolve conflicts
4399 const mergeResult = await mergeWithAutoResolve(
4400 ownerName,
4401 repoName,
4402 pr.baseBranch,
4403 pr.headBranch,
4404 `Merge pull request #${pr.number}: ${pr.title}`
4405 );
4406
4407 if (!mergeResult.success) {
4408 return c.redirect(
4409 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
4410 );
4411 }
4412
4413 // Post a comment about the auto-resolution
4414 if (mergeResult.resolvedFiles.length > 0) {
4415 await db.insert(prComments).values({
4416 pullRequestId: pr.id,
4417 authorId: user.id,
4418 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
4419 isAiReview: true,
4420 });
4421 }
4422 } else {
a164a6dClaude4423 // Worktree-based merge: supports merge-commit, squash, and fast-forward
4424 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
4425 const gitEnv = {
4426 ...process.env,
4427 GIT_AUTHOR_NAME: user.displayName || user.username,
4428 GIT_AUTHOR_EMAIL: user.email,
4429 GIT_COMMITTER_NAME: user.displayName || user.username,
4430 GIT_COMMITTER_EMAIL: user.email,
4431 };
4432
4433 // Create linked worktree on the base branch
4434 const addWt = Bun.spawn(
4435 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude4436 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4437 );
a164a6dClaude4438 if (await addWt.exited !== 0) {
4439 return c.redirect(
4440 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
4441 );
4442 }
4443
4444 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
4445 let mergeOk = false;
4446
4447 try {
4448 if (mergeStrategy === "squash") {
4449 // Squash: stage all changes without committing
4450 const squashProc = Bun.spawn(
4451 ["git", "merge", "--squash", headSha],
4452 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4453 );
4454 if (await squashProc.exited !== 0) {
4455 const errTxt = await new Response(squashProc.stderr).text();
4456 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
4457 }
4458 // Commit the squashed changes
4459 const commitProc = Bun.spawn(
4460 ["git", "commit", "-m", commitMsg],
4461 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4462 );
4463 if (await commitProc.exited !== 0) {
4464 const errTxt = await new Response(commitProc.stderr).text();
4465 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
4466 }
4467 mergeOk = true;
4468 } else if (mergeStrategy === "ff") {
4469 // Fast-forward only — fail if FF is not possible
4470 const ffProc = Bun.spawn(
4471 ["git", "merge", "--ff-only", headSha],
4472 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4473 );
4474 if (await ffProc.exited !== 0) {
4475 const errTxt = await new Response(ffProc.stderr).text();
4476 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
4477 }
4478 mergeOk = true;
4479 } else {
4480 // Default: merge commit (--no-ff always creates a merge commit)
4481 const mergeProc = Bun.spawn(
4482 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
4483 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4484 );
4485 if (await mergeProc.exited !== 0) {
4486 const errTxt = await new Response(mergeProc.stderr).text();
4487 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
4488 }
4489 mergeOk = true;
4490 }
4491 } catch (err) {
4492 // Always clean up the worktree before redirecting
4493 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
4494 const msg = err instanceof Error ? err.message : "Merge failed";
4495 return c.redirect(
4496 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
4497 );
4498 }
4499
4500 // Clean up worktree (changes are now in the bare repo via linked worktree)
4501 await Bun.spawn(
4502 ["git", "worktree", "remove", "--force", wt],
4503 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4504 ).exited.catch(() => {});
e883329Claude4505
a164a6dClaude4506 if (!mergeOk) {
e883329Claude4507 return c.redirect(
a164a6dClaude4508 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude4509 );
4510 }
4511 }
4512
0074234Claude4513 await db
4514 .update(pullRequests)
4515 .set({
4516 state: "merged",
4517 mergedAt: new Date(),
4518 mergedBy: user.id,
4519 updatedAt: new Date(),
4520 })
4521 .where(eq(pullRequests.id, pr.id));
4522
8809b87Claude4523 // Chat notifier — fan out merge event to Slack/Discord/Teams.
4524 import("../lib/chat-notifier")
4525 .then((m) =>
4526 m.notifyChatChannels({
4527 ownerUserId: resolved.repo.ownerId,
4528 repositoryId: resolved.repo.id,
4529 event: {
4530 event: "pr.merged",
4531 repo: `${ownerName}/${repoName}`,
4532 title: `#${pr.number} ${pr.title}`,
4533 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
4534 actor: user.username,
4535 },
4536 })
4537 )
4538 .catch((err) =>
4539 console.warn(`[chat-notifier] PR merge notify failed:`, err)
4540 );
4541
d62fb36Claude4542 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
4543 // and auto-close each matching open issue with a back-link comment. Bounded
4544 // to the same repo for v1 (cross-repo refs ignored). Failures never block
4545 // the merge redirect.
4546 try {
4547 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
4548 const refs = extractClosingRefsMulti([pr.title, pr.body]);
4549 for (const n of refs) {
4550 const [issue] = await db
4551 .select()
4552 .from(issues)
4553 .where(
4554 and(
4555 eq(issues.repositoryId, resolved.repo.id),
4556 eq(issues.number, n)
4557 )
4558 )
4559 .limit(1);
4560 if (!issue || issue.state !== "open") continue;
4561 await db
4562 .update(issues)
4563 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
4564 .where(eq(issues.id, issue.id));
4565 await db.insert(issueComments).values({
4566 issueId: issue.id,
4567 authorId: user.id,
4568 body: `Closed by pull request #${pr.number}.`,
4569 });
4570 }
4571 } catch {
4572 // Never block the merge on close-keyword failures.
4573 }
4574
0074234Claude4575 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4576 }
4577);
4578
6fc53bdClaude4579// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
4580// hasn't run yet on this PR.
4581pulls.post(
4582 "/:owner/:repo/pulls/:number/ready",
4583 softAuth,
4584 requireAuth,
04f6b7fClaude4585 requireRepoAccess("write"),
6fc53bdClaude4586 async (c) => {
4587 const { owner: ownerName, repo: repoName } = c.req.param();
4588 const prNum = parseInt(c.req.param("number"), 10);
4589 const user = c.get("user")!;
4590
4591 const resolved = await resolveRepo(ownerName, repoName);
4592 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4593
4594 const [pr] = await db
4595 .select()
4596 .from(pullRequests)
4597 .where(
4598 and(
4599 eq(pullRequests.repositoryId, resolved.repo.id),
4600 eq(pullRequests.number, prNum)
4601 )
4602 )
4603 .limit(1);
4604 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4605
4606 // Only the author or repo owner can toggle draft state.
4607 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
4608 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4609 }
4610
4611 if (pr.state === "open" && pr.isDraft) {
4612 await db
4613 .update(pullRequests)
4614 .set({ isDraft: false, updatedAt: new Date() })
4615 .where(eq(pullRequests.id, pr.id));
4616
4617 if (isAiReviewEnabled()) {
4618 triggerAiReview(
4619 ownerName,
4620 repoName,
4621 pr.id,
4622 pr.title,
0316dbbClaude4623 pr.body || "",
6fc53bdClaude4624 pr.baseBranch,
4625 pr.headBranch
4626 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
4627 }
4628 }
4629
4630 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4631 }
4632);
4633
4634// Convert a PR back to draft.
4635pulls.post(
4636 "/:owner/:repo/pulls/:number/draft",
4637 softAuth,
4638 requireAuth,
04f6b7fClaude4639 requireRepoAccess("write"),
6fc53bdClaude4640 async (c) => {
4641 const { owner: ownerName, repo: repoName } = c.req.param();
4642 const prNum = parseInt(c.req.param("number"), 10);
4643 const user = c.get("user")!;
4644
4645 const resolved = await resolveRepo(ownerName, repoName);
4646 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4647
4648 const [pr] = await db
4649 .select()
4650 .from(pullRequests)
4651 .where(
4652 and(
4653 eq(pullRequests.repositoryId, resolved.repo.id),
4654 eq(pullRequests.number, prNum)
4655 )
4656 )
4657 .limit(1);
4658 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4659
4660 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
4661 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4662 }
4663
4664 if (pr.state === "open" && !pr.isDraft) {
4665 await db
4666 .update(pullRequests)
4667 .set({ isDraft: true, updatedAt: new Date() })
4668 .where(eq(pullRequests.id, pr.id));
4669 }
4670
4671 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4672 }
4673);
4674
0074234Claude4675// Close PR
4676pulls.post(
4677 "/:owner/:repo/pulls/:number/close",
4678 softAuth,
4679 requireAuth,
04f6b7fClaude4680 requireRepoAccess("write"),
0074234Claude4681 async (c) => {
4682 const { owner: ownerName, repo: repoName } = c.req.param();
4683 const prNum = parseInt(c.req.param("number"), 10);
4684
4685 const resolved = await resolveRepo(ownerName, repoName);
4686 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4687
4688 await db
4689 .update(pullRequests)
4690 .set({
4691 state: "closed",
4692 closedAt: new Date(),
4693 updatedAt: new Date(),
4694 })
4695 .where(
4696 and(
4697 eq(pullRequests.repositoryId, resolved.repo.id),
4698 eq(pullRequests.number, prNum)
4699 )
4700 );
4701
4702 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4703 }
4704);
4705
c3e0c07Claude4706// Re-run AI review on demand (e.g. after a force-push). Bypasses the
4707// idempotency marker via { force: true }. Write-access only.
4708pulls.post(
4709 "/:owner/:repo/pulls/:number/ai-rereview",
4710 softAuth,
4711 requireAuth,
4712 requireRepoAccess("write"),
4713 async (c) => {
4714 const { owner: ownerName, repo: repoName } = c.req.param();
4715 const prNum = parseInt(c.req.param("number"), 10);
4716 const resolved = await resolveRepo(ownerName, repoName);
4717 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4718
4719 const [pr] = await db
4720 .select()
4721 .from(pullRequests)
4722 .where(
4723 and(
4724 eq(pullRequests.repositoryId, resolved.repo.id),
4725 eq(pullRequests.number, prNum)
4726 )
4727 )
4728 .limit(1);
4729 if (!pr) {
4730 return c.redirect(`/${ownerName}/${repoName}/pulls`);
4731 }
4732
4733 if (!isAiReviewEnabled()) {
4734 return c.redirect(
4735 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4736 "AI review is not configured (ANTHROPIC_API_KEY)."
4737 )}`
4738 );
4739 }
4740
4741 // Fire-and-forget but with { force: true } to bypass the
4742 // already-reviewed marker. The function still never throws.
4743 triggerAiReview(
4744 ownerName,
4745 repoName,
4746 pr.id,
4747 pr.title || "",
4748 pr.body || "",
4749 pr.baseBranch,
4750 pr.headBranch,
4751 { force: true }
a28cedeClaude4752 ).catch((err) => {
4753 console.warn(
4754 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
4755 err instanceof Error ? err.message : err
4756 );
4757 });
c3e0c07Claude4758
4759 return c.redirect(
4760 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
4761 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
4762 )}`
4763 );
4764 }
4765);
4766
1d4ff60Claude4767// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
4768// the PR's head branch carrying just the new test files. Write-access only.
4769// Idempotent — if `ai:added-tests` was previously applied we redirect with
4770// an `info` banner instead of re-firing.
4771pulls.post(
4772 "/:owner/:repo/pulls/:number/generate-tests",
4773 softAuth,
4774 requireAuth,
4775 requireRepoAccess("write"),
4776 async (c) => {
4777 const { owner: ownerName, repo: repoName } = c.req.param();
4778 const prNum = parseInt(c.req.param("number"), 10);
4779 const resolved = await resolveRepo(ownerName, repoName);
4780 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4781
4782 const [pr] = await db
4783 .select()
4784 .from(pullRequests)
4785 .where(
4786 and(
4787 eq(pullRequests.repositoryId, resolved.repo.id),
4788 eq(pullRequests.number, prNum)
4789 )
4790 )
4791 .limit(1);
4792 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4793
4794 if (!isAiReviewEnabled()) {
4795 return c.redirect(
4796 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4797 "AI test generation is not configured (ANTHROPIC_API_KEY)."
4798 )}`
4799 );
4800 }
4801
4802 // Fire-and-forget. The lib never throws.
4803 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
4804 .then((res) => {
4805 if (!res.ok) {
4806 console.warn(
4807 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
4808 );
4809 }
4810 })
4811 .catch((err) => {
4812 console.warn(
4813 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
4814 err instanceof Error ? err.message : err
4815 );
4816 });
4817
4818 return c.redirect(
4819 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
4820 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
4821 )}`
4822 );
4823 }
4824);
4825
0074234Claude4826export default pulls;