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.tsxBlame5631 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";
f4abb8eClaude17import { eq, and, desc, asc, sql, inArray, ilike, ne, isNotNull } from "drizzle-orm";
0074234Claude18import { db } from "../db";
19import {
20 pullRequests,
21 prComments,
0a67773Claude22 prReviews,
0074234Claude23 repositories,
24 users,
d62fb36Claude25 issues,
26 issueComments,
f4abb8eClaude27 repoCollaborators,
0074234Claude28} from "../db/schema";
29import { Layout } from "../views/layout";
ea9ed4cClaude30import { RepoHeader } from "../views/components";
cb5a796Claude31import { PendingCommentsBanner } from "../views/pending-comments-banner";
47a7a0aClaude32import { DiffView, type InlineDiffComment } from "../views/diff-view";
6fc53bdClaude33import { ReactionsBar } from "../views/reactions";
34import { summariseReactions } from "../lib/reactions";
24cf2caClaude35import { loadPrTemplate } from "../lib/templates";
0074234Claude36import { renderMarkdown } from "../lib/markdown";
15db0e0Claude37import {
38 parseSlashCommand,
39 executeSlashCommand,
40 detectSlashCmdComment,
41 stripSlashCmdMarker,
42} from "../lib/pr-slash-commands";
b584e52Claude43import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude44import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude45import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude46import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
0074234Claude47import { softAuth, requireAuth } from "../middleware/auth";
48import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude49import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude50import {
51 decideInitialStatus,
52 notifyOwnerOfPendingComment,
53 countPendingForRepo,
54} from "../lib/comment-moderation";
0316dbbClaude55import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
79ed944Claude56import {
57 TRIO_COMMENT_MARKER,
58 TRIO_SUMMARY_MARKER,
67dc4e1Claude59 isTrioReviewEnabled,
79ed944Claude60 type TrioPersona,
61} from "../lib/ai-review-trio";
1d4ff60Claude62import {
63 generateTestsForPr,
64 AI_TESTS_MARKER,
65} from "../lib/ai-test-generator";
0316dbbClaude66import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude67import { generatePrSummary } from "../lib/ai-generators";
68import { isAiAvailable } from "../lib/ai-client";
534f04aClaude69import {
70 computePrRiskForPullRequest,
71 getCachedPrRisk,
72 type PrRiskScore,
73} from "../lib/pr-risk";
0316dbbClaude74import { runAllGateChecks } from "../lib/gate";
75import type { GateCheckResult } from "../lib/gate";
76import {
77 matchProtection,
78 countHumanApprovals,
79 listRequiredChecks,
80 passingCheckNames,
81 evaluateProtection,
82} from "../lib/branch-protection";
83import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude84import {
85 listBranches,
86 getRepoPath,
e883329Claude87 resolveRef,
b5dd694Claude88 getBlob,
89 createOrUpdateFileOnBranch,
b558f23Claude90 commitsBetween,
0074234Claude91} from "../git/repository";
b558f23Claude92import type { GitDiffFile, GitCommit } from "../git/repository";
240c477Claude93import { listStatuses } from "../lib/commit-statuses";
94import type { CommitStatus } from "../db/schema";
0074234Claude95import { html } from "hono/html";
4bbacbeClaude96import {
97 getPreviewForBranch,
98 previewStatusLabel,
99} from "../lib/branch-previews";
1e162a8Claude100import {
bb0f894Claude101 Flex,
102 Container,
103 Badge,
104 Button,
105 LinkButton,
106 Form,
107 FormGroup,
108 Input,
109 TextArea,
110 Select,
111 EmptyState,
112 FilterTabs,
113 TabNav,
114 List,
115 ListItem,
116 Text,
117 Alert,
118 MarkdownContent,
119 CommentBox,
120 formatRelative,
121} from "../views/ui";
0074234Claude122
ace34efClaude123import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
74d8c4dClaude124import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
a7460bfClaude125import { BOT_USERNAME } from "../lib/bot-user";
3033f70Claude126import { analyzeImpact, type ImpactAnalysis } from "../lib/pr-impact";
127import { getPreviewDir, isPreviewExpired } from "../lib/pr-stage";
ace34efClaude128
0074234Claude129const pulls = new Hono<AuthEnv>();
130
b078860Claude131/* ──────────────────────────────────────────────────────────────────────
132 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
133 * into the issue tracker or any other route. Tokens come from layout.tsx
134 * `:root` so light/dark stays consistent if/when light mode lands.
135 * ──────────────────────────────────────────────────────────────────── */
136const PRS_LIST_STYLES = `
137 .prs-hero {
138 position: relative;
139 margin: 0 0 var(--space-5);
140 padding: 22px 26px 24px;
141 background: var(--bg-elevated);
142 border: 1px solid var(--border);
143 border-radius: 16px;
144 overflow: hidden;
145 }
146 .prs-hero::before {
147 content: '';
148 position: absolute; top: 0; left: 0; right: 0;
149 height: 2px;
150 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
151 opacity: 0.7;
152 pointer-events: none;
153 }
154 .prs-hero-inner {
155 position: relative;
156 display: flex;
157 justify-content: space-between;
158 align-items: flex-end;
159 gap: 20px;
160 flex-wrap: wrap;
161 }
162 .prs-hero-text { flex: 1; min-width: 280px; }
163 .prs-hero-eyebrow {
164 font-size: 12px;
165 color: var(--text-muted);
166 text-transform: uppercase;
167 letter-spacing: 0.08em;
168 font-weight: 600;
169 margin-bottom: 8px;
170 }
171 .prs-hero-title {
172 font-family: var(--font-display);
173 font-size: clamp(26px, 3.4vw, 34px);
174 font-weight: 800;
175 letter-spacing: -0.025em;
176 line-height: 1.06;
177 margin: 0 0 8px;
178 color: var(--text-strong);
179 }
180 .prs-hero-title .gradient-text {
181 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
182 -webkit-background-clip: text;
183 background-clip: text;
184 -webkit-text-fill-color: transparent;
185 color: transparent;
186 }
187 .prs-hero-sub {
188 font-size: 14.5px;
189 color: var(--text-muted);
190 margin: 0;
191 line-height: 1.5;
192 max-width: 620px;
193 }
194 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
195 .prs-cta {
196 display: inline-flex; align-items: center; gap: 6px;
197 padding: 10px 16px;
198 border-radius: 10px;
199 font-size: 13.5px;
200 font-weight: 600;
201 color: #fff;
202 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
203 border: 1px solid rgba(140,109,255,0.55);
204 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
205 text-decoration: none;
206 transition: transform 120ms ease, box-shadow 160ms ease;
207 }
208 .prs-cta:hover {
209 transform: translateY(-1px);
210 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
211 color: #fff;
212 }
213
214 .prs-tabs {
215 display: flex; flex-wrap: wrap; gap: 6px;
216 margin: 0 0 18px;
217 padding: 6px;
218 background: var(--bg-secondary);
219 border: 1px solid var(--border);
220 border-radius: 12px;
221 }
222 .prs-tab {
223 display: inline-flex; align-items: center; gap: 8px;
224 padding: 7px 13px;
225 font-size: 13px;
226 font-weight: 500;
227 color: var(--text-muted);
228 border-radius: 8px;
229 text-decoration: none;
230 transition: background 120ms ease, color 120ms ease;
231 }
232 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
233 .prs-tab.is-active {
234 background: var(--bg-elevated);
235 color: var(--text-strong);
236 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
237 }
238 .prs-tab-count {
239 display: inline-flex; align-items: center; justify-content: center;
240 min-width: 22px; padding: 2px 7px;
241 font-size: 11.5px;
242 font-weight: 600;
243 border-radius: 9999px;
244 background: var(--bg-tertiary);
245 color: var(--text-muted);
246 }
247 .prs-tab.is-active .prs-tab-count {
248 background: rgba(140,109,255,0.18);
249 color: var(--text-link);
250 }
251
252 .prs-list { display: flex; flex-direction: column; gap: 10px; }
253 .prs-row {
254 position: relative;
255 display: flex; align-items: flex-start; gap: 14px;
256 padding: 14px 16px;
257 background: var(--bg-elevated);
258 border: 1px solid var(--border);
259 border-radius: 12px;
260 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
261 }
262 .prs-row:hover {
263 transform: translateY(-1px);
264 border-color: var(--border-strong);
265 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
266 }
267 .prs-row-icon {
268 flex: 0 0 auto;
269 width: 26px; height: 26px;
270 display: inline-flex; align-items: center; justify-content: center;
271 border-radius: 9999px;
272 font-size: 13px;
273 margin-top: 2px;
274 }
275 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
276 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
277 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
278 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
279 .prs-row-body { flex: 1; min-width: 0; }
280 .prs-row-title {
281 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
282 font-size: 15px; font-weight: 600;
283 color: var(--text-strong);
284 line-height: 1.35;
285 margin: 0 0 6px;
286 }
287 .prs-row-number {
288 color: var(--text-muted);
289 font-weight: 400;
290 font-size: 14px;
291 }
292 .prs-row-meta {
293 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
294 font-size: 12.5px;
295 color: var(--text-muted);
296 }
297 .prs-branch-chips {
298 display: inline-flex; align-items: center; gap: 6px;
299 font-family: var(--font-mono);
300 font-size: 11.5px;
301 }
302 .prs-branch-chip {
303 padding: 2px 8px;
304 border-radius: 9999px;
305 background: var(--bg-tertiary);
306 border: 1px solid var(--border);
307 color: var(--text);
308 }
309 .prs-branch-arrow {
310 color: var(--text-faint);
311 font-size: 11px;
312 }
313 .prs-row-tags {
314 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
315 margin-left: auto;
316 }
317 .prs-tag {
318 display: inline-flex; align-items: center; gap: 4px;
319 padding: 2px 8px;
320 font-size: 11px;
321 font-weight: 600;
322 border-radius: 9999px;
323 border: 1px solid var(--border);
324 background: var(--bg-secondary);
325 color: var(--text-muted);
326 line-height: 1.6;
327 }
328 .prs-tag.is-draft {
329 color: var(--text-muted);
330 border-color: var(--border-strong);
331 }
332 .prs-tag.is-merged {
333 color: var(--text-link);
334 border-color: rgba(140,109,255,0.45);
335 background: rgba(140,109,255,0.10);
336 }
1aef949Claude337 .prs-tag.is-approved {
338 color: #34d399;
339 border-color: rgba(52,211,153,0.40);
340 background: rgba(52,211,153,0.08);
341 }
342 .prs-tag.is-changes {
343 color: #f87171;
344 border-color: rgba(248,113,113,0.40);
345 background: rgba(248,113,113,0.08);
346 }
b078860Claude347
348 .prs-empty {
ea9ed4cClaude349 position: relative;
350 padding: 56px 32px;
b078860Claude351 text-align: center;
352 border: 1px dashed var(--border);
ea9ed4cClaude353 border-radius: 16px;
354 background: var(--bg-elevated);
b078860Claude355 color: var(--text-muted);
ea9ed4cClaude356 overflow: hidden;
b078860Claude357 }
ea9ed4cClaude358 .prs-empty::before {
359 content: '';
360 position: absolute;
361 inset: -40% -20% auto auto;
362 width: 320px; height: 320px;
363 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
364 filter: blur(70px);
365 opacity: 0.55;
366 pointer-events: none;
367 animation: prsEmptyOrb 16s ease-in-out infinite;
368 }
369 @keyframes prsEmptyOrb {
370 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
371 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
372 }
373 @media (prefers-reduced-motion: reduce) {
374 .prs-empty::before { animation: none; }
375 }
376 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude377 .prs-empty strong {
378 display: block;
379 color: var(--text-strong);
ea9ed4cClaude380 font-family: var(--font-display);
381 font-size: 22px;
382 font-weight: 700;
383 letter-spacing: -0.018em;
384 margin-bottom: 2px;
385 }
386 .prs-empty-sub {
387 font-size: 14.5px;
388 color: var(--text-muted);
389 line-height: 1.55;
390 max-width: 460px;
391 margin: 0 0 18px;
b078860Claude392 }
ea9ed4cClaude393 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude394
395 @media (max-width: 720px) {
396 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
397 .prs-hero-actions { width: 100%; }
398 .prs-row-tags { margin-left: 0; }
399 }
f1dc7c7Claude400
401 /* Additional mobile rules. Additive only. */
402 @media (max-width: 720px) {
403 .prs-hero { padding: 18px 18px 20px; }
404 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
405 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
406 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
407 .prs-row { padding: 12px 14px; gap: 10px; }
408 .prs-row-icon { width: 24px; height: 24px; }
409 }
f5b9ef5Claude410
411 /* ─── Sort controls (PR list) ─── */
412 .prs-sort-row {
413 display: flex;
414 align-items: center;
415 gap: 6px;
416 margin: 0 0 12px;
417 flex-wrap: wrap;
418 }
419 .prs-sort-label {
420 font-size: 12.5px;
421 color: var(--text-muted);
422 font-weight: 600;
423 margin-right: 2px;
424 }
425 .prs-sort-opt {
426 font-size: 12.5px;
427 color: var(--text-muted);
428 text-decoration: none;
429 padding: 3px 10px;
430 border-radius: 9999px;
431 border: 1px solid transparent;
432 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
433 }
434 .prs-sort-opt:hover {
435 background: var(--bg-hover);
436 color: var(--text);
437 }
438 .prs-sort-opt.is-active {
439 background: rgba(140,109,255,0.12);
440 color: var(--text-link);
441 border-color: rgba(140,109,255,0.35);
442 font-weight: 600;
443 }
b078860Claude444`;
445
446/* ──────────────────────────────────────────────────────────────────────
447 * Inline CSS for the detail page. Same `.prs-*` namespace.
448 * ──────────────────────────────────────────────────────────────────── */
449const PRS_DETAIL_STYLES = `
450 .prs-detail-hero {
451 position: relative;
452 margin: 0 0 var(--space-4);
453 padding: 24px 26px;
454 background: var(--bg-elevated);
455 border: 1px solid var(--border);
456 border-radius: 16px;
457 overflow: hidden;
458 }
459 .prs-detail-hero::before {
460 content: '';
461 position: absolute; top: 0; left: 0; right: 0;
462 height: 2px;
463 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
464 opacity: 0.7;
465 pointer-events: none;
466 }
467 .prs-detail-title {
468 font-family: var(--font-display);
469 font-size: clamp(22px, 2.6vw, 28px);
470 font-weight: 700;
471 letter-spacing: -0.022em;
472 line-height: 1.2;
473 color: var(--text-strong);
474 margin: 0 0 12px;
475 }
476 .prs-detail-num {
477 color: var(--text-muted);
478 font-weight: 400;
479 }
480 .prs-state-pill {
481 display: inline-flex; align-items: center; gap: 6px;
482 padding: 6px 12px;
483 border-radius: 9999px;
484 font-size: 12.5px;
485 font-weight: 600;
486 line-height: 1;
487 border: 1px solid transparent;
488 }
489 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
490 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
491 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
492 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
493
74d8c4dClaude494 .prs-size-badge {
495 display: inline-flex;
496 align-items: center;
497 padding: 2px 8px;
498 border-radius: 20px;
499 font-size: 11px;
500 font-weight: 700;
501 letter-spacing: 0.04em;
502 border: 1px solid currentColor;
503 opacity: 0.85;
504 }
505
b078860Claude506 .prs-detail-meta {
507 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
508 font-size: 13px;
509 color: var(--text-muted);
510 }
511 .prs-detail-meta strong { color: var(--text); }
512 .prs-detail-branches {
513 display: inline-flex; align-items: center; gap: 6px;
514 font-family: var(--font-mono);
515 font-size: 12px;
516 }
517 .prs-branch-pill {
518 padding: 3px 9px;
519 border-radius: 9999px;
520 background: var(--bg-tertiary);
521 border: 1px solid var(--border);
522 color: var(--text);
523 }
524 .prs-branch-pill.is-head { color: var(--text-strong); }
525 .prs-branch-arrow-lg {
526 color: var(--accent);
527 font-size: 14px;
528 font-weight: 700;
529 }
0369e77Claude530 .prs-branch-sync {
531 display: inline-flex; align-items: center; gap: 4px;
532 font-size: 11.5px; font-weight: 600;
533 padding: 2px 8px;
534 border-radius: 9999px;
535 border: 1px solid var(--border);
536 background: var(--bg-secondary);
537 color: var(--text-muted);
538 cursor: default;
539 }
540 .prs-branch-sync.is-behind {
541 color: #f87171;
542 border-color: rgba(248,113,113,0.35);
543 background: rgba(248,113,113,0.07);
544 }
545 .prs-branch-sync.is-synced {
546 color: #34d399;
547 border-color: rgba(52,211,153,0.35);
548 background: rgba(52,211,153,0.07);
549 }
b078860Claude550
551 .prs-detail-actions {
552 display: inline-flex; gap: 8px; margin-left: auto;
553 }
554
555 .prs-detail-tabs {
556 display: flex; gap: 4px;
557 margin: 0 0 16px;
558 border-bottom: 1px solid var(--border);
559 }
560 .prs-detail-tab {
561 padding: 10px 14px;
562 font-size: 13.5px;
563 font-weight: 500;
564 color: var(--text-muted);
565 text-decoration: none;
566 border-bottom: 2px solid transparent;
567 transition: color 120ms ease, border-color 120ms ease;
568 margin-bottom: -1px;
569 }
570 .prs-detail-tab:hover { color: var(--text); }
571 .prs-detail-tab.is-active {
572 color: var(--text-strong);
573 border-bottom-color: var(--accent);
574 }
575 .prs-detail-tab-count {
576 display: inline-flex; align-items: center; justify-content: center;
577 min-width: 20px; padding: 0 6px; margin-left: 6px;
578 height: 18px;
579 font-size: 11px;
580 font-weight: 600;
581 border-radius: 9999px;
582 background: var(--bg-tertiary);
583 color: var(--text-muted);
584 }
585
586 /* Gate / check status section */
587 .prs-gate-card {
588 margin-top: 20px;
589 background: var(--bg-elevated);
590 border: 1px solid var(--border);
591 border-radius: 14px;
592 overflow: hidden;
593 }
594 .prs-gate-head {
595 display: flex; align-items: center; gap: 10px;
596 padding: 14px 18px;
597 border-bottom: 1px solid var(--border);
598 }
599 .prs-gate-head h3 {
600 margin: 0;
601 font-size: 14px;
602 font-weight: 600;
603 color: var(--text-strong);
604 }
605 .prs-gate-summary {
606 margin-left: auto;
607 font-size: 12px;
608 color: var(--text-muted);
609 }
610 .prs-gate-row {
611 display: flex; align-items: center; gap: 12px;
612 padding: 12px 18px;
613 border-bottom: 1px solid var(--border-subtle);
614 }
615 .prs-gate-row:last-child { border-bottom: 0; }
616 .prs-gate-icon {
617 flex: 0 0 auto;
618 width: 22px; height: 22px;
619 display: inline-flex; align-items: center; justify-content: center;
620 border-radius: 9999px;
621 font-size: 12px;
622 font-weight: 700;
623 }
624 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
625 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
626 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
627 .prs-gate-name {
628 font-size: 13px;
629 font-weight: 600;
630 color: var(--text);
631 min-width: 140px;
632 }
633 .prs-gate-details {
634 flex: 1; min-width: 0;
635 font-size: 12.5px;
636 color: var(--text-muted);
637 }
638 .prs-gate-pill {
639 flex: 0 0 auto;
640 padding: 3px 10px;
641 border-radius: 9999px;
642 font-size: 11px;
643 font-weight: 600;
644 line-height: 1.5;
645 border: 1px solid transparent;
646 }
647 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
648 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
649 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
650 .prs-gate-footer {
651 padding: 12px 18px;
652 background: var(--bg-secondary);
653 font-size: 12px;
654 color: var(--text-muted);
655 }
656
657 /* Comment cards */
658 .prs-comment {
659 margin-top: 14px;
660 background: var(--bg-elevated);
661 border: 1px solid var(--border);
662 border-radius: 12px;
663 overflow: hidden;
664 }
665 .prs-comment-head {
666 display: flex; align-items: center; gap: 10px;
667 padding: 10px 14px;
668 background: var(--bg-secondary);
669 border-bottom: 1px solid var(--border);
670 font-size: 13px;
671 flex-wrap: wrap;
672 }
673 .prs-comment-head strong { color: var(--text-strong); }
674 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
675 .prs-comment-loc {
676 font-family: var(--font-mono);
677 font-size: 11.5px;
678 color: var(--text-muted);
679 background: var(--bg-tertiary);
680 padding: 2px 8px;
681 border-radius: 6px;
682 }
683 .prs-comment-body { padding: 14px 18px; }
684 .prs-comment.is-ai {
685 border-color: rgba(140,109,255,0.45);
686 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
687 }
688 .prs-comment.is-ai .prs-comment-head {
689 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
690 border-bottom-color: rgba(140,109,255,0.30);
691 }
692 .prs-ai-badge {
693 display: inline-flex; align-items: center; gap: 4px;
694 padding: 2px 9px;
695 font-size: 10.5px;
696 font-weight: 700;
697 letter-spacing: 0.04em;
698 text-transform: uppercase;
699 color: #fff;
700 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
701 border-radius: 9999px;
702 }
a7460bfClaude703 .prs-bot-badge {
704 display: inline-flex; align-items: center; gap: 3px;
705 padding: 1px 7px;
706 font-size: 10px;
707 font-weight: 600;
708 color: var(--fg-muted);
709 background: var(--bg-elevated);
710 border: 1px solid var(--border);
711 border-radius: 9999px;
712 }
b078860Claude713
714 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
715 .prs-files-card {
716 margin-top: 18px;
717 padding: 14px 18px;
718 display: flex; align-items: center; gap: 14px;
719 background: var(--bg-elevated);
720 border: 1px solid var(--border);
721 border-radius: 12px;
722 text-decoration: none;
723 color: inherit;
724 transition: border-color 120ms ease, transform 140ms ease;
725 }
726 .prs-files-card:hover {
727 border-color: rgba(140,109,255,0.45);
728 transform: translateY(-1px);
729 }
730 .prs-files-card-icon {
731 width: 36px; height: 36px;
732 display: inline-flex; align-items: center; justify-content: center;
733 border-radius: 10px;
734 background: rgba(140,109,255,0.12);
735 color: var(--text-link);
736 font-size: 18px;
737 }
738 .prs-files-card-text { flex: 1; min-width: 0; }
739 .prs-files-card-title {
740 font-size: 14px;
741 font-weight: 600;
742 color: var(--text-strong);
743 margin: 0 0 2px;
744 }
745 .prs-files-card-sub {
746 font-size: 12.5px;
747 color: var(--text-muted);
748 margin: 0;
749 }
750 .prs-files-card-cta {
751 font-size: 12.5px;
752 color: var(--text-link);
753 font-weight: 600;
754 }
755
756 /* Merge area */
757 .prs-merge-card {
758 position: relative;
759 margin-top: 22px;
760 padding: 18px;
761 background: var(--bg-elevated);
762 border-radius: 14px;
763 overflow: hidden;
764 }
765 .prs-merge-card::before {
766 content: '';
767 position: absolute; inset: 0;
768 padding: 1px;
769 border-radius: 14px;
770 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
771 -webkit-mask:
772 linear-gradient(#000 0 0) content-box,
773 linear-gradient(#000 0 0);
774 -webkit-mask-composite: xor;
775 mask-composite: exclude;
776 pointer-events: none;
777 }
778 .prs-merge-card.is-closed::before { background: var(--border-strong); }
779 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
780 .prs-merge-head {
781 display: flex; align-items: center; gap: 12px;
782 margin-bottom: 12px;
783 }
784 .prs-merge-head strong {
785 font-family: var(--font-display);
786 font-size: 15px;
787 color: var(--text-strong);
788 font-weight: 700;
789 }
790 .prs-merge-sub {
791 font-size: 13px;
792 color: var(--text-muted);
793 margin: 0 0 12px;
794 }
795 .prs-merge-actions {
796 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
797 }
798 .prs-merge-btn {
799 display: inline-flex; align-items: center; gap: 6px;
800 padding: 9px 16px;
801 border-radius: 10px;
802 font-size: 13.5px;
803 font-weight: 600;
804 color: #fff;
805 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
806 border: 1px solid rgba(52,211,153,0.55);
807 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
808 cursor: pointer;
809 transition: transform 120ms ease, box-shadow 160ms ease;
810 }
811 .prs-merge-btn:hover {
812 transform: translateY(-1px);
813 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
814 }
815 .prs-merge-btn[disabled],
816 .prs-merge-btn.is-disabled {
817 opacity: 0.55;
818 cursor: not-allowed;
819 transform: none;
820 box-shadow: none;
821 }
822 .prs-merge-ready-btn {
823 display: inline-flex; align-items: center; gap: 6px;
824 padding: 9px 16px;
825 border-radius: 10px;
826 font-size: 13.5px;
827 font-weight: 600;
828 color: #fff;
829 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
830 border: 1px solid rgba(140,109,255,0.55);
831 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
832 cursor: pointer;
833 transition: transform 120ms ease, box-shadow 160ms ease;
834 }
835 .prs-merge-ready-btn:hover {
836 transform: translateY(-1px);
837 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
838 }
839 .prs-merge-back-draft {
840 background: none; border: 1px solid var(--border-strong);
841 color: var(--text-muted);
842 padding: 9px 14px; border-radius: 10px;
843 font-size: 13px; cursor: pointer;
844 }
845 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
846
a164a6dClaude847 /* Merge strategy selector */
848 .prs-merge-strategy-wrap {
849 display: inline-flex; align-items: center;
850 background: var(--bg-elevated);
851 border: 1px solid var(--border);
852 border-radius: 10px;
853 overflow: hidden;
854 }
855 .prs-merge-strategy-label {
856 font-size: 11.5px; font-weight: 600;
857 color: var(--text-muted);
858 padding: 0 10px 0 12px;
859 white-space: nowrap;
860 }
861 .prs-merge-strategy-select {
862 background: transparent;
863 border: none;
864 color: var(--text);
865 font-size: 13px;
866 padding: 7px 10px 7px 4px;
867 cursor: pointer;
868 outline: none;
869 appearance: auto;
870 }
871 .prs-merge-strategy-select:focus { outline: 2px solid rgba(140,109,255,0.45); }
872
0a67773Claude873 /* Review summary banner */
874 .prs-review-summary {
875 display: flex; flex-direction: column; gap: 6px;
876 padding: 12px 16px;
877 background: var(--bg-elevated);
878 border: 1px solid var(--border);
879 border-radius: var(--r-md, 8px);
880 margin-bottom: 12px;
881 }
882 .prs-review-row {
883 display: flex; align-items: center; gap: 10px;
884 font-size: 13px;
885 }
886 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
887 .prs-review-approved .prs-review-icon { color: #34d399; }
888 .prs-review-changes .prs-review-icon { color: #f87171; }
ace34efClaude889 .prs-reviewer-avatar {
890 width: 24px; height: 24px; border-radius: 50%;
891 background: var(--accent); color: #fff;
892 display: flex; align-items: center; justify-content: center;
893 font-size: 11px; font-weight: 700; flex-shrink: 0;
894 }
0a67773Claude895
896 /* Review action buttons */
897 .prs-review-approve-btn {
898 display: inline-flex; align-items: center; gap: 5px;
899 padding: 8px 14px; border-radius: 8px; font-size: 13px;
900 font-weight: 600; cursor: pointer;
901 background: rgba(52,211,153,0.12);
902 color: #34d399;
903 border: 1px solid rgba(52,211,153,0.35);
904 transition: background 120ms;
905 }
906 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
907 .prs-review-changes-btn {
908 display: inline-flex; align-items: center; gap: 5px;
909 padding: 8px 14px; border-radius: 8px; font-size: 13px;
910 font-weight: 600; cursor: pointer;
911 background: rgba(248,113,113,0.10);
912 color: #f87171;
913 border: 1px solid rgba(248,113,113,0.30);
914 transition: background 120ms;
915 }
916 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
917
b078860Claude918 /* Inline form helpers */
919 .prs-inline-form { display: inline-flex; }
920
921 /* Comment composer */
922 .prs-composer { margin-top: 22px; }
923 .prs-composer textarea {
924 border-radius: 12px;
925 }
926
927 @media (max-width: 720px) {
928 .prs-detail-actions { margin-left: 0; }
929 .prs-merge-actions { width: 100%; }
930 .prs-merge-actions > * { flex: 1; min-width: 0; }
931 }
f1dc7c7Claude932
933 /* Additional mobile rules. Additive only. */
934 @media (max-width: 720px) {
935 .prs-detail-hero { padding: 18px; }
936 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
937 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
938 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
939 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
940 .prs-gate-name { min-width: 0; }
941 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
942 .prs-gate-summary { margin-left: 0; }
943 .prs-merge-btn,
944 .prs-merge-ready-btn,
945 .prs-merge-back-draft { min-height: 44px; }
946 .prs-comment-body { padding: 12px 14px; }
947 .prs-comment-head { padding: 10px 12px; }
948 .prs-files-card { padding: 12px 14px; }
949 }
3c03977Claude950
951 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
952 .live-pill {
953 display: inline-flex;
954 align-items: center;
955 gap: 8px;
956 padding: 4px 10px 4px 8px;
957 margin-left: 6px;
958 background: var(--bg-elevated);
959 border: 1px solid var(--border);
960 border-radius: 9999px;
961 font-size: 12px;
962 color: var(--text-muted);
963 line-height: 1;
964 vertical-align: middle;
965 }
966 .live-pill.is-busy { color: var(--text); }
967 .live-pill-dot {
968 width: 8px; height: 8px;
969 border-radius: 9999px;
970 background: #34d399;
971 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
972 animation: live-pulse 1.6s ease-in-out infinite;
973 }
974 @keyframes live-pulse {
975 0%, 100% { opacity: 1; }
976 50% { opacity: 0.55; }
977 }
978 .live-avatars {
979 display: inline-flex;
980 margin-left: 2px;
981 }
982 .live-avatar {
983 display: inline-flex;
984 align-items: center;
985 justify-content: center;
986 width: 22px; height: 22px;
987 border-radius: 9999px;
988 font-size: 10px;
989 font-weight: 700;
990 color: #0b1020;
991 margin-left: -6px;
992 border: 2px solid var(--bg-elevated);
993 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
994 }
995 .live-avatar:first-child { margin-left: 0; }
996 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
997 .live-cursor-host {
998 position: relative;
999 }
1000 .live-cursor-overlay {
1001 position: absolute;
1002 inset: 0;
1003 pointer-events: none;
1004 overflow: hidden;
1005 border-radius: inherit;
1006 }
1007 .live-cursor {
1008 position: absolute;
1009 width: 2px;
1010 height: 18px;
1011 border-radius: 2px;
1012 transform: translate(-1px, 0);
1013 transition: transform 80ms linear, opacity 200ms ease;
1014 }
1015 .live-cursor::after {
1016 content: attr(data-label);
1017 position: absolute;
1018 top: -16px;
1019 left: -2px;
1020 font-size: 10px;
1021 line-height: 1;
1022 color: #0b1020;
1023 background: inherit;
1024 padding: 2px 5px;
1025 border-radius: 4px 4px 4px 0;
1026 white-space: nowrap;
1027 font-weight: 600;
1028 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
1029 }
1030 .live-cursor.is-idle { opacity: 0.4; }
1031 .live-edit-tag {
1032 display: inline-block;
1033 margin-left: 6px;
1034 padding: 1px 6px;
1035 font-size: 10px;
1036 font-weight: 600;
1037 letter-spacing: 0.02em;
1038 color: #0b1020;
1039 border-radius: 9999px;
1040 }
15db0e0Claude1041
1042 /* ─── Slash-command pill + composer hint ─── */
1043 .slash-hint {
1044 display: inline-flex;
1045 align-items: center;
1046 gap: 6px;
1047 margin-top: 6px;
1048 padding: 3px 9px;
1049 font-size: 11.5px;
1050 color: var(--text-muted);
1051 background: var(--bg-elevated);
1052 border: 1px dashed var(--border);
1053 border-radius: 9999px;
1054 width: fit-content;
1055 }
1056 .slash-hint code {
1057 background: rgba(110, 168, 255, 0.12);
1058 color: var(--text-strong);
1059 padding: 0 5px;
1060 border-radius: 4px;
1061 font-size: 11px;
1062 }
1063 .slash-pill {
1064 display: grid;
1065 grid-template-columns: auto 1fr auto;
1066 align-items: center;
1067 column-gap: 10px;
1068 row-gap: 6px;
1069 margin: 10px 0;
1070 padding: 10px 14px;
1071 background: linear-gradient(
1072 135deg,
1073 rgba(110, 168, 255, 0.08),
1074 rgba(163, 113, 247, 0.06)
1075 );
1076 border: 1px solid rgba(110, 168, 255, 0.32);
1077 border-left: 3px solid var(--accent, #6ea8ff);
1078 border-radius: var(--radius);
1079 font-size: 13px;
1080 color: var(--text);
1081 }
1082 .slash-pill-icon {
1083 font-size: 14px;
1084 line-height: 1;
1085 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
1086 }
1087 .slash-pill-actor { color: var(--text-muted); }
1088 .slash-pill-actor strong { color: var(--text-strong); }
1089 .slash-pill-cmd {
1090 background: rgba(110, 168, 255, 0.16);
1091 color: var(--text-strong);
1092 padding: 1px 6px;
1093 border-radius: 4px;
1094 font-size: 12.5px;
1095 }
1096 .slash-pill-time {
1097 color: var(--text-muted);
1098 font-size: 12px;
1099 justify-self: end;
1100 }
1101 .slash-pill-body {
1102 grid-column: 1 / -1;
1103 color: var(--text);
1104 font-size: 13px;
1105 line-height: 1.55;
1106 }
1107 .slash-pill-body p:first-child { margin-top: 0; }
1108 .slash-pill-body p:last-child { margin-bottom: 0; }
1109 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
1110 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1111 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
1112 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
3033f70Claude1113 .slash-pill.slash-cmd-stage { border-left-color: #36c5d6; }
4bbacbeClaude1114
1115 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
1116 .preview-prpill {
1117 display: inline-flex; align-items: center; gap: 6px;
1118 padding: 3px 10px;
1119 border-radius: 9999px;
1120 font-family: var(--font-mono);
1121 font-size: 11.5px;
1122 font-weight: 600;
1123 background: rgba(255,255,255,0.04);
1124 color: var(--text-muted);
1125 text-decoration: none;
1126 border: 1px solid var(--border);
1127 }
1128 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
1129 .preview-prpill .preview-prpill-dot {
1130 width: 7px; height: 7px;
1131 border-radius: 9999px;
1132 background: currentColor;
1133 }
1134 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1135 .preview-prpill.is-building .preview-prpill-dot {
1136 animation: previewPrPulse 1.4s ease-in-out infinite;
1137 }
1138 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1139 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1140 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1141 @keyframes previewPrPulse {
1142 0%, 100% { opacity: 1; }
1143 50% { opacity: 0.4; }
1144 }
79ed944Claude1145
1146 /* ─── AI Trio Review — 3-column verdict cards ─── */
1147 .trio-wrap {
1148 margin-top: 18px;
1149 padding: 16px;
1150 background: var(--bg-elevated);
1151 border: 1px solid var(--border);
1152 border-radius: 14px;
1153 }
1154 .trio-header {
1155 display: flex; align-items: center; gap: 10px;
1156 margin: 0 0 12px;
1157 font-size: 13.5px;
1158 color: var(--text);
1159 }
1160 .trio-header strong { color: var(--text-strong); }
1161 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1162 .trio-header-dot {
1163 width: 8px; height: 8px; border-radius: 9999px;
1164 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1165 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1166 }
1167 .trio-grid {
1168 display: grid;
1169 grid-template-columns: repeat(3, minmax(0, 1fr));
1170 gap: 12px;
1171 }
1172 .trio-card {
1173 background: var(--bg-secondary);
1174 border: 1px solid var(--border);
1175 border-radius: 12px;
1176 overflow: hidden;
1177 display: flex; flex-direction: column;
1178 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1179 }
1180 .trio-card-head {
1181 display: flex; align-items: center; gap: 8px;
1182 padding: 10px 12px;
1183 border-bottom: 1px solid var(--border);
1184 background: rgba(255,255,255,0.02);
1185 font-size: 13px;
1186 }
1187 .trio-card-icon {
1188 display: inline-flex; align-items: center; justify-content: center;
1189 width: 22px; height: 22px;
1190 border-radius: 9999px;
1191 font-size: 12px;
1192 background: rgba(255,255,255,0.05);
1193 }
1194 .trio-card-title {
1195 color: var(--text-strong);
1196 font-weight: 600;
1197 letter-spacing: 0.01em;
1198 }
1199 .trio-card-verdict {
1200 margin-left: auto;
1201 font-size: 11px;
1202 font-weight: 700;
1203 letter-spacing: 0.06em;
1204 text-transform: uppercase;
1205 padding: 3px 9px;
1206 border-radius: 9999px;
1207 background: var(--bg-tertiary);
1208 color: var(--text-muted);
1209 border: 1px solid var(--border-strong);
1210 }
1211 .trio-card-body {
1212 padding: 12px 14px;
1213 font-size: 13px;
1214 color: var(--text);
1215 flex: 1;
1216 min-height: 64px;
1217 line-height: 1.55;
1218 }
1219 .trio-card-body p { margin: 0 0 8px; }
1220 .trio-card-body p:last-child { margin-bottom: 0; }
1221 .trio-card-body ul { margin: 0; padding-left: 18px; }
1222 .trio-card-body code {
1223 font-family: var(--font-mono);
1224 font-size: 12px;
1225 background: var(--bg-tertiary);
1226 padding: 1px 6px;
1227 border-radius: 5px;
1228 }
1229 .trio-card-empty {
1230 color: var(--text-muted);
1231 font-style: italic;
1232 font-size: 12.5px;
1233 }
1234
1235 /* Pass state — neutral, no accent. */
1236 .trio-card.is-pass .trio-card-verdict {
1237 color: var(--green);
1238 border-color: rgba(52,211,153,0.35);
1239 background: rgba(52,211,153,0.12);
1240 }
1241
1242 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1243 .trio-card.trio-security.is-fail {
1244 border-color: rgba(248,113,113,0.55);
1245 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1246 }
1247 .trio-card.trio-security.is-fail .trio-card-head {
1248 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1249 border-bottom-color: rgba(248,113,113,0.30);
1250 }
1251 .trio-card.trio-security.is-fail .trio-card-verdict {
1252 color: #fecaca;
1253 border-color: rgba(248,113,113,0.55);
1254 background: rgba(248,113,113,0.20);
1255 }
1256
1257 .trio-card.trio-correctness.is-fail {
1258 border-color: rgba(251,191,36,0.55);
1259 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1260 }
1261 .trio-card.trio-correctness.is-fail .trio-card-head {
1262 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1263 border-bottom-color: rgba(251,191,36,0.30);
1264 }
1265 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1266 color: #fde68a;
1267 border-color: rgba(251,191,36,0.55);
1268 background: rgba(251,191,36,0.20);
1269 }
1270
1271 .trio-card.trio-style.is-fail {
1272 border-color: rgba(96,165,250,0.55);
1273 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1274 }
1275 .trio-card.trio-style.is-fail .trio-card-head {
1276 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1277 border-bottom-color: rgba(96,165,250,0.30);
1278 }
1279 .trio-card.trio-style.is-fail .trio-card-verdict {
1280 color: #bfdbfe;
1281 border-color: rgba(96,165,250,0.55);
1282 background: rgba(96,165,250,0.20);
1283 }
1284
1285 /* Disagreement callout strip — yellow, prominent. */
1286 .trio-disagreement-strip {
1287 display: flex;
1288 gap: 12px;
1289 margin-top: 14px;
1290 padding: 12px 14px;
1291 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1292 border: 1px solid rgba(251,191,36,0.45);
1293 border-radius: 10px;
1294 color: var(--text);
1295 font-size: 13px;
1296 }
1297 .trio-disagreement-icon {
1298 flex: 0 0 auto;
1299 width: 26px; height: 26px;
1300 display: inline-flex; align-items: center; justify-content: center;
1301 border-radius: 9999px;
1302 background: rgba(251,191,36,0.25);
1303 color: #fde68a;
1304 font-size: 14px;
1305 }
1306 .trio-disagreement-body strong {
1307 display: block;
1308 color: #fde68a;
1309 margin: 0 0 4px;
1310 font-weight: 700;
1311 }
1312 .trio-disagreement-list {
1313 margin: 0;
1314 padding-left: 18px;
1315 color: var(--text);
1316 font-size: 12.5px;
1317 line-height: 1.55;
1318 }
1319 .trio-disagreement-list code {
1320 font-family: var(--font-mono);
1321 font-size: 11.5px;
1322 background: var(--bg-tertiary);
1323 padding: 1px 5px;
1324 border-radius: 4px;
1325 }
1326
1327 @media (max-width: 720px) {
1328 .trio-grid { grid-template-columns: 1fr; }
1329 .trio-wrap { padding: 12px; }
1330 }
6d1bbc2Claude1331
1332 /* ─── Task list progress pill ─── */
1333 .prs-tasks-pill {
1334 display: inline-flex; align-items: center; gap: 5px;
1335 font-size: 11.5px; font-weight: 600;
1336 padding: 2px 9px; border-radius: 9999px;
1337 border: 1px solid var(--border);
1338 background: var(--bg-elevated);
1339 color: var(--text-muted);
1340 }
1341 .prs-tasks-pill.is-complete {
1342 color: #34d399;
1343 border-color: rgba(52,211,153,0.40);
1344 background: rgba(52,211,153,0.08);
1345 }
1346 .prs-tasks-progress { display: inline-block; width: 36px; height: 4px; border-radius: 9999px; background: var(--border); overflow: hidden; vertical-align: middle; }
1347 .prs-tasks-progress-bar { height: 100%; border-radius: 9999px; background: #34d399; }
1348
1349 /* ─── Update branch button ─── */
1350 .prs-update-branch-btn {
1351 display: inline-flex; align-items: center; gap: 5px;
1352 padding: 4px 12px; border-radius: 8px; font-size: 12.5px;
1353 font-weight: 600; cursor: pointer;
1354 background: rgba(96,165,250,0.10);
1355 color: #60a5fa;
1356 border: 1px solid rgba(96,165,250,0.30);
1357 transition: background 120ms;
1358 }
1359 .prs-update-branch-btn:hover { background: rgba(96,165,250,0.20); }
1360
1361 /* ─── Linked issues panel ─── */
1362 .prs-linked-issues {
1363 margin-top: 16px;
1364 border: 1px solid var(--border);
1365 border-radius: 12px;
1366 overflow: hidden;
1367 }
1368 .prs-linked-issues-head {
1369 display: flex; align-items: center; justify-content: space-between;
1370 padding: 10px 16px;
1371 background: var(--bg-elevated);
1372 border-bottom: 1px solid var(--border);
1373 font-size: 13px; font-weight: 600; color: var(--text);
1374 }
1375 .prs-linked-issues-count {
1376 font-size: 11px; font-weight: 700;
1377 padding: 1px 7px; border-radius: 9999px;
1378 background: var(--bg-tertiary);
1379 color: var(--text-muted);
1380 }
1381 .prs-linked-issue-row {
1382 display: flex; align-items: center; gap: 10px;
1383 padding: 9px 16px;
1384 border-bottom: 1px solid var(--border);
1385 font-size: 13px;
1386 text-decoration: none; color: inherit;
1387 }
1388 .prs-linked-issue-row:last-child { border-bottom: none; }
1389 .prs-linked-issue-row:hover { background: var(--bg-hover); }
1390 .prs-linked-issue-icon { flex: 0 0 auto; font-size: 14px; }
1391 .prs-linked-issue-icon.is-open { color: #34d399; }
1392 .prs-linked-issue-icon.is-closed { color: #8b949e; }
1393 .prs-linked-issue-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1394 .prs-linked-issue-num { color: var(--text-muted); font-size: 12px; }
1395 .prs-linked-issue-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
1396 .prs-linked-issue-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
1397 .prs-linked-issue-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
b558f23Claude1398
1399 /* ─── Commits tab ─── */
1400 .prs-commits-list { display: flex; flex-direction: column; gap: 0; margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1401 .prs-commit-row { display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit; }
1402 .prs-commit-row:last-child { border-bottom: none; }
1403 .prs-commit-row:hover { background: var(--bg-hover); }
1404 .prs-commit-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-top: 6px; }
1405 .prs-commit-body { flex: 1 1 auto; min-width: 0; }
1406 .prs-commit-msg { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
1407 .prs-commit-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
1408 .prs-commit-sha { flex: 0 0 auto; font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); background: var(--bg-elevated); padding: 2px 7px; border-radius: 6px; border: 1px solid var(--border); text-decoration: none; white-space: nowrap; }
1409 .prs-commit-sha:hover { color: var(--accent); }
1410 .prs-commits-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13.5px; }
1411
1412 /* ─── Edit PR title/body ─── */
1413 .prs-edit-title-wrap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1414 .prs-edit-btn { background: none; border: 1px solid var(--border); color: var(--text-muted); font-size: 12px; padding: 3px 10px; border-radius: 6px; cursor: pointer; transition: color 120ms, border-color 120ms; }
1415 .prs-edit-btn:hover { color: var(--text); border-color: var(--text-muted); }
1416 .prs-edit-form { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
1417 .prs-edit-form input[type=text] { font-size: 15px; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-elevated); color: var(--text); width: 100%; box-sizing: border-box; }
1418 .prs-edit-actions { display: flex; gap: 8px; }
1419 .prs-edit-save-btn { padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; background: var(--accent); color: #fff; border: none; cursor: pointer; }
1420 .prs-edit-cancel-btn { padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; background: var(--bg-elevated); color: var(--text); border: 1px solid var(--border); cursor: pointer; }
240c477Claude1421
1422 /* ─── CI status checks ─── */
1423 .prs-ci-card { margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1424 .prs-ci-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--bg-elevated); border-bottom: 1px solid var(--border); }
1425 .prs-ci-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text); }
1426 .prs-ci-summary { font-size: 12px; color: var(--text-muted); }
1427 .prs-ci-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
1428 .prs-ci-row:last-child { border-bottom: none; }
1429 .prs-ci-icon { flex: 0 0 auto; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; font-size: 11px; font-weight: 700; }
1430 .prs-ci-icon.is-success { background: rgba(52,211,153,0.20); color: #34d399; }
1431 .prs-ci-icon.is-pending { background: rgba(251,191,36,0.20); color: #fbbf24; }
1432 .prs-ci-icon.is-failure, .prs-ci-icon.is-error { background: rgba(248,113,113,0.20); color: #f87171; }
1433 .prs-ci-context { flex: 1 1 auto; font-size: 13px; font-weight: 500; color: var(--text); }
1434 .prs-ci-desc { font-size: 12px; color: var(--text-muted); }
1435 .prs-ci-pill { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 9999px; }
1436 .prs-ci-pill.is-success { color: #34d399; background: rgba(52,211,153,0.10); }
1437 .prs-ci-pill.is-pending { color: #fbbf24; background: rgba(251,191,36,0.10); }
1438 .prs-ci-pill.is-failure, .prs-ci-pill.is-error { color: #f87171; background: rgba(248,113,113,0.10); }
1439 .prs-ci-link { font-size: 12px; color: var(--accent); text-decoration: none; }
1440 .prs-ci-link:hover { text-decoration: underline; }
67dc4e1Claude1441
1442 /* ─── AI Trio verdict pills (header summary) ─── */
1443 .trio-pill {
1444 display: inline-flex; align-items: center; gap: 4px;
1445 padding: 2px 8px;
1446 font-size: 11px;
1447 font-weight: 700;
1448 border-radius: 9999px;
1449 border: 1px solid transparent;
1450 text-decoration: none;
1451 line-height: 1.6;
1452 letter-spacing: 0.01em;
1453 cursor: pointer;
1454 transition: opacity 120ms ease;
1455 }
1456 .trio-pill:hover { opacity: 0.8; }
1457 .trio-pill.is-pass {
1458 color: #34d399;
1459 background: rgba(52,211,153,0.10);
1460 border-color: rgba(52,211,153,0.35);
1461 }
1462 .trio-pill.is-fail {
1463 color: #f87171;
1464 background: rgba(248,113,113,0.10);
1465 border-color: rgba(248,113,113,0.35);
1466 }
1467 .trio-pill.is-pending {
1468 color: var(--text-muted);
1469 background: rgba(255,255,255,0.04);
1470 border-color: var(--border-strong);
1471 }
1472 .trio-pills-wrap {
1473 display: inline-flex; align-items: center; gap: 4px;
1474 }
3033f70Claude1475
1476 /* ─── Merge Impact Analysis panel (.impact-*) ─── */
1477 .impact-panel {
1478 margin-top: 20px;
1479 border: 1px solid var(--border);
1480 border-radius: 12px;
1481 overflow: hidden;
1482 background: var(--bg-elevated);
1483 }
1484 .impact-header {
1485 display: flex;
1486 align-items: center;
1487 gap: 10px;
1488 padding: 12px 16px;
1489 background: var(--bg-elevated);
1490 border-bottom: 1px solid var(--border);
1491 cursor: pointer;
1492 user-select: none;
1493 }
1494 .impact-header:hover { background: var(--bg-hover); }
1495 .impact-score {
1496 display: inline-flex;
1497 align-items: center;
1498 justify-content: center;
1499 min-width: 34px;
1500 height: 24px;
1501 border-radius: 9999px;
1502 font-size: 11.5px;
1503 font-weight: 800;
1504 padding: 0 8px;
1505 letter-spacing: 0.01em;
1506 border: 1.5px solid transparent;
1507 }
1508 .impact-score.score-low {
1509 color: #34d399;
1510 background: rgba(52,211,153,0.12);
1511 border-color: rgba(52,211,153,0.35);
1512 }
1513 .impact-score.score-medium {
1514 color: #fbbf24;
1515 background: rgba(251,191,36,0.12);
1516 border-color: rgba(251,191,36,0.35);
1517 }
1518 .impact-score.score-high {
1519 color: #f87171;
1520 background: rgba(248,113,113,0.12);
1521 border-color: rgba(248,113,113,0.35);
1522 }
1523 .impact-header strong {
1524 font-size: 13.5px;
1525 color: var(--text-strong);
1526 font-weight: 700;
1527 }
1528 .impact-summary {
1529 font-size: 12.5px;
1530 color: var(--text-muted);
1531 flex: 1;
1532 }
1533 .impact-toggle {
1534 background: none;
1535 border: none;
1536 color: var(--text-muted);
1537 font-size: 11px;
1538 cursor: pointer;
1539 padding: 2px 6px;
1540 border-radius: 4px;
1541 transition: color 120ms;
1542 line-height: 1;
1543 }
1544 .impact-toggle:hover { color: var(--text); }
1545 .impact-toggle.is-open { transform: rotate(180deg); }
1546 .impact-body {
1547 padding: 14px 16px;
1548 display: flex;
1549 flex-direction: column;
1550 gap: 14px;
1551 }
1552 .impact-body[hidden] { display: none; }
1553 .impact-section h4 {
1554 margin: 0 0 8px;
1555 font-size: 12.5px;
1556 font-weight: 600;
1557 color: var(--text-muted);
1558 text-transform: uppercase;
1559 letter-spacing: 0.06em;
1560 }
1561 .impact-file-list {
1562 display: flex;
1563 flex-direction: column;
1564 gap: 3px;
1565 margin: 0;
1566 padding: 0;
1567 list-style: none;
1568 }
1569 .impact-file-list li {
1570 font-family: var(--font-mono);
1571 font-size: 12px;
1572 color: var(--text);
1573 padding: 3px 8px;
1574 background: var(--bg-secondary);
1575 border-radius: 5px;
1576 overflow: hidden;
1577 text-overflow: ellipsis;
1578 white-space: nowrap;
1579 }
1580 .impact-downstream .impact-file-list li {
1581 background: rgba(248,113,113,0.06);
1582 border: 1px solid rgba(248,113,113,0.15);
1583 }
1584 .impact-downstream h4 {
1585 color: #f87171;
1586 }
1587 .impact-empty {
1588 font-size: 12.5px;
1589 color: var(--text-muted);
1590 font-style: italic;
1591 }
b078860Claude1592`;
1593
3033f70Claude1594
1595
81c73c1Claude1596/**
1597 * Tiny inline JS that drives the "Suggest description with AI" button.
1598 * On click, gathers form values, POSTs JSON to the given endpoint, and
1599 * pipes the response into the #pr-body textarea. All DOM lookups are
1600 * defensive — element absence is a silent no-op.
1601 *
1602 * Built as a string template so it lives next to its server-side caller
1603 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1604 * to avoid </script> breakouts.
1605 */
1606function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1607 const url = JSON.stringify(endpointUrl)
1608 .split("<").join("\\u003C")
1609 .split(">").join("\\u003E")
1610 .split("&").join("\\u0026");
1611 return (
1612 "(function(){try{" +
1613 "var btn=document.getElementById('ai-suggest-desc');" +
1614 "var status=document.getElementById('ai-suggest-status');" +
1615 "var body=document.getElementById('pr-body');" +
1616 "var form=btn&&btn.closest&&btn.closest('form');" +
1617 "if(!btn||!body||!form)return;" +
1618 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1619 "var fd=new FormData(form);" +
1620 "var title=String(fd.get('title')||'').trim();" +
1621 "var base=String(fd.get('base')||'').trim();" +
1622 "var head=String(fd.get('head')||'').trim();" +
1623 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1624 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1625 "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'})" +
1626 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1627 ".then(function(j){btn.disabled=false;" +
1628 "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;}}" +
1629 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1630 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1631 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1632 "});" +
1633 "}catch(e){}})();"
1634 );
1635}
1636
3c03977Claude1637/**
1638 * Live co-editing client. Connects to the per-PR SSE feed and:
1639 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1640 * status colour per user).
1641 * - Renders tinted cursor caret overlays inside #pr-body and every
1642 * `[data-live-field]` element.
1643 * - Broadcasts the local user's cursor position (selectionStart /
1644 * selectionEnd) debounced at 100ms.
1645 * - Broadcasts content patches (`replace` of the whole textarea —
1646 * last-write-wins v1) debounced at 250ms.
1647 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1648 * to the matching local field if untouched.
1649 *
1650 * All endpoint URLs are JSON-escaped via safe replacements so they
1651 * can't break out of the <script> tag.
1652 */
1653function LIVE_COEDIT_SCRIPT(prId: string): string {
1654 const idJson = JSON.stringify(prId)
1655 .split("<").join("\\u003C")
1656 .split(">").join("\\u003E")
1657 .split("&").join("\\u0026");
1658 return (
1659 "(function(){try{" +
1660 "if(typeof EventSource==='undefined')return;" +
1661 "var prId=" + idJson + ";" +
1662 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1663 "var pill=document.getElementById('live-pill');" +
1664 "var avEl=document.getElementById('live-avatars');" +
1665 "var countEl=document.getElementById('live-count');" +
1666 "var sessionId=null;var myColor=null;" +
1667 "var presence={};" + // sessionId -> {color,status,userId,initials}
1668 "var lastApplied={};" + // field -> last server value (for echo suppression)
1669 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1670 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1671 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1672 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1673 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1674 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1675 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1676 "avEl.innerHTML=html;}}" +
1677 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1678 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1679 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1680 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1681 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1682 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1683 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1684 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1685 "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);}" +
1686 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1687 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1688 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1689 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1690 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1691 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1692 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1693 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1694 "}catch(e){}" +
1695 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1696 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1697 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1698 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1699 "var es;var delay=1000;" +
1700 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1701 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1702 "(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){}});" +
1703 "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){}});" +
1704 "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){}});" +
1705 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1706 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1707 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1708 "var patch=d.patch;if(!patch||!patch.field)return;" +
1709 "var ta=fieldEl(patch.field);if(!ta)return;" +
1710 "if(document.activeElement===ta)return;" + // don't trample local typing
1711 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1712 "}catch(e){}});" +
1713 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1714 "}connect();" +
1715 "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){}}" +
1716 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1717 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1718 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1719 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1720 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1721 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1722 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1723 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1724 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1725 "}" +
1726 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1727 "var live=document.querySelectorAll('[data-live-field]');" +
1728 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1729 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1730 "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){}});" +
1731 "}catch(e){}})();"
1732 );
1733}
1734
0074234Claude1735async function resolveRepo(ownerName: string, repoName: string) {
1736 const [owner] = await db
1737 .select()
1738 .from(users)
1739 .where(eq(users.username, ownerName))
1740 .limit(1);
1741 if (!owner) return null;
1742 const [repo] = await db
1743 .select()
1744 .from(repositories)
1745 .where(
1746 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1747 )
1748 .limit(1);
1749 if (!repo) return null;
1750 return { owner, repo };
1751}
1752
1753// PR Nav helper
1754const PrNav = ({
1755 owner,
1756 repo,
1757 active,
1758}: {
1759 owner: string;
1760 repo: string;
1761 active: "code" | "issues" | "pulls" | "commits";
1762}) => (
bb0f894Claude1763 <TabNav
1764 tabs={[
1765 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1766 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1767 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1768 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1769 ]}
1770 />
0074234Claude1771);
1772
534f04aClaude1773/**
1774 * Block M3 — pre-merge risk score card. Pure presentational helper.
1775 * Rendered in the conversation tab above the gate checks block. Hidden
1776 * entirely when the PR is closed/merged or there is nothing cached and
1777 * nothing in-flight.
1778 */
1779function PrRiskCard({
1780 risk,
1781 calculating,
1782}: {
1783 risk: PrRiskScore | null;
1784 calculating: boolean;
1785}) {
1786 if (!risk) {
1787 return (
1788 <div
1789 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1790 >
1791 <strong style="font-size: 13px; color: var(--text)">
1792 Risk score: calculating…
1793 </strong>
1794 <div style="font-size: 12px; margin-top: 4px">
1795 Refresh in a moment to see the pre-merge risk score for this PR.
1796 </div>
1797 </div>
1798 );
1799 }
1800
1801 const palette = riskBandPalette(risk.band);
1802 const label = riskBandLabel(risk.band);
1803
1804 return (
1805 <div
1806 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1807 >
1808 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1809 <strong>Risk score:</strong>
1810 <span style={`color:${palette.border};font-weight:600`}>
1811 {palette.icon} {label} ({risk.score}/10)
1812 </span>
1813 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1814 {risk.commitSha.slice(0, 7)}
1815 </span>
1816 </div>
1817 {risk.aiSummary && (
1818 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1819 {risk.aiSummary}
1820 </div>
1821 )}
1822 <details style="margin-top:10px">
1823 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1824 See full signal breakdown
1825 </summary>
1826 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1827 <li>files changed: {risk.signals.filesChanged}</li>
1828 <li>
1829 lines added/removed: {risk.signals.linesAdded} /{" "}
1830 {risk.signals.linesRemoved}
1831 </li>
1832 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1833 <li>
1834 schema migration touched:{" "}
1835 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1836 </li>
1837 <li>
1838 locked / sensitive path touched:{" "}
1839 {risk.signals.lockedPathTouched ? "yes" : "no"}
1840 </li>
1841 <li>
1842 adds new dependency:{" "}
1843 {risk.signals.addsNewDependency ? "yes" : "no"}
1844 </li>
1845 <li>
1846 bumps major dependency:{" "}
1847 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1848 </li>
1849 <li>
1850 tests added for new code:{" "}
1851 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1852 </li>
1853 <li>
1854 diff-minus-test ratio:{" "}
1855 {risk.signals.diffMinusTestRatio.toFixed(2)}
1856 </li>
1857 </ul>
1858 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1859 How is this calculated? The score is a transparent sum of
1860 weighted signals — see <code>src/lib/pr-risk.ts</code>
1861 {" "}<code>computePrRiskScore</code>.
1862 </div>
1863 </details>
1864 {calculating && (
1865 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1866 (recomputing for the latest commit — refresh to update)
1867 </div>
1868 )}
1869 </div>
1870 );
1871}
1872
1873function riskBandPalette(band: PrRiskScore["band"]): {
1874 border: string;
1875 icon: string;
1876} {
1877 switch (band) {
1878 case "low":
1879 return { border: "var(--green)", icon: "" };
1880 case "medium":
1881 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1882 case "high":
1883 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1884 case "critical":
1885 return { border: "var(--red)", icon: "\u{1F6D1}" };
1886 }
1887}
1888
1889function riskBandLabel(band: PrRiskScore["band"]): string {
1890 switch (band) {
1891 case "low":
1892 return "LOW";
1893 case "medium":
1894 return "MEDIUM";
1895 case "high":
1896 return "HIGH";
1897 case "critical":
1898 return "CRITICAL";
1899 }
1900}
1901
3033f70Claude1902// ---------------------------------------------------------------------------
1903// Merge Impact Analysis — collapsible panel showing affected files and
1904// downstream repos. Shown on open PRs for users with write access.
1905// ---------------------------------------------------------------------------
1906
1907function ImpactPanel({ analysis, owner }: { analysis: ImpactAnalysis; owner: string }) {
1908 const score = analysis.riskScore;
1909 const band = score <= 30 ? "low" : score <= 60 ? "medium" : "high";
1910 const hasAny =
1911 analysis.affectedTestFiles.length > 0 ||
1912 analysis.affectedFiles.length > 0 ||
1913 analysis.downstreamRepos.length > 0;
1914
1915 return (
1916 <div class="impact-panel">
1917 <div
1918 class="impact-header"
1919 onclick="(function(h){var b=h.parentElement.querySelector('.impact-body');var t=h.querySelector('.impact-toggle');if(!b)return;var hidden=b.hasAttribute('hidden');if(hidden){b.removeAttribute('hidden');t&&t.classList.add('is-open');}else{b.setAttribute('hidden','');t&&t.classList.remove('is-open');}})(this)"
1920 >
1921 <span class={`impact-score score-${band}`}>{score}</span>
1922 <strong>Merge Impact</strong>
1923 <span class="impact-summary">{analysis.riskSummary}</span>
1924 <button class="impact-toggle" type="button" aria-label="Toggle impact panel">
1925
1926 </button>
1927 </div>
1928 <div class="impact-body" hidden>
1929 <div class="impact-section">
1930 <h4>Affected test files ({analysis.affectedTestFiles.length})</h4>
1931 {analysis.affectedTestFiles.length === 0 ? (
1932 <span class="impact-empty">No test files reference the changed files.</span>
1933 ) : (
1934 <ul class="impact-file-list">
1935 {analysis.affectedTestFiles.map((f) => (
1936 <li title={f}>{f}</li>
1937 ))}
1938 </ul>
1939 )}
1940 </div>
1941 <div class="impact-section">
1942 <h4>Affected source files ({analysis.affectedFiles.length})</h4>
1943 {analysis.affectedFiles.length === 0 ? (
1944 <span class="impact-empty">No other source files import the changed files.</span>
1945 ) : (
1946 <ul class="impact-file-list">
1947 {analysis.affectedFiles.map((f) => (
1948 <li title={f}>{f}</li>
1949 ))}
1950 </ul>
1951 )}
1952 </div>
1953 {analysis.downstreamRepos.length > 0 && (
1954 <div class="impact-section impact-downstream">
1955 <h4>Downstream repos ({analysis.downstreamRepos.length})</h4>
1956 <ul class="impact-file-list">
1957 {analysis.downstreamRepos.map((r) => (
1958 <li>
1959 <a
1960 href={`/${r.owner}/${r.repo}`}
1961 style="color:var(--text-link);text-decoration:none"
1962 >
1963 {r.owner}/{r.repo}
1964 </a>
1965 {" "}
1966 <span style="color:var(--text-muted);font-size:11px">
1967 via {r.matchedDependency}
1968 </span>
1969 </li>
1970 ))}
1971 </ul>
1972 </div>
1973 )}
1974 </div>
1975 </div>
1976 );
1977}
1978
422a2d4Claude1979// ---------------------------------------------------------------------------
1980// AI Trio Review — 3-column card grid + disagreement callout.
1981//
1982// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
1983// per run: one per persona (security/correctness/style) plus a top-level
1984// summary. We surface them here as a single grid above the normal
1985// comment stream so reviewers see the verdicts at a glance.
1986// ---------------------------------------------------------------------------
1987
1988const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
1989
1990interface TrioCommentLike {
1991 body: string;
1992}
1993
1994function isTrioComment(body: string | null | undefined): boolean {
1995 if (!body) return false;
1996 return (
1997 body.includes(TRIO_SUMMARY_MARKER) ||
1998 body.includes(TRIO_COMMENT_MARKER.security) ||
1999 body.includes(TRIO_COMMENT_MARKER.correctness) ||
2000 body.includes(TRIO_COMMENT_MARKER.style)
2001 );
2002}
2003
2004function trioPersonaOfComment(body: string): TrioPersona | null {
2005 for (const p of TRIO_PERSONAS) {
2006 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
2007 }
2008 return null;
2009}
2010
2011/**
2012 * Best-effort verdict parse from a persona comment body. The body shape
2013 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
2014 * we only need the "Pass" / "Fail" word from the H2 heading.
2015 */
2016function trioVerdictOfBody(body: string): "pass" | "fail" | null {
2017 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
2018 if (!m) return null;
2019 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
2020}
2021
2022/**
2023 * Parse the disagreement bullet list out of the summary comment so we
2024 * can render it as a polished callout strip. Returns [] when nothing
2025 * matches — the comment author may have edited the marker out.
2026 */
2027function parseDisagreements(summaryBody: string): Array<{
2028 file: string;
2029 failing: string;
2030 passing: string;
2031}> {
2032 const out: Array<{ file: string; failing: string; passing: string }> = [];
2033 // Each disagreement line looks like:
2034 // - `path:42` — security, style say ✗, correctness say ✓
2035 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
2036 let m: RegExpExecArray | null;
2037 while ((m = re.exec(summaryBody)) !== null) {
2038 out.push({
2039 file: m[1].trim(),
2040 failing: m[2].trim().replace(/[,\s]+$/g, ""),
2041 passing: m[3].trim().replace(/[,\s]+$/g, ""),
2042 });
2043 }
2044 return out;
2045}
2046
2047function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
2048 // Find the most recent persona comments + summary. We iterate from
2049 // the end so re-reviews (multiple runs on the same PR) display the
2050 // freshest verdict.
2051 const latest: Partial<Record<TrioPersona, string>> = {};
2052 let summaryBody: string | null = null;
2053 for (let i = comments.length - 1; i >= 0; i--) {
2054 const body = comments[i].body || "";
2055 if (!isTrioComment(body)) continue;
2056 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
2057 summaryBody = body;
2058 continue;
2059 }
2060 const persona = trioPersonaOfComment(body);
2061 if (persona && !latest[persona]) latest[persona] = body;
2062 }
2063 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2064 if (!anyPersona && !summaryBody) return null;
2065
2066 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
2067
2068 return (
67dc4e1Claude2069 <div class="trio-wrap" id="trio-review-section">
422a2d4Claude2070 <div class="trio-header">
2071 <span class="trio-header-dot" aria-hidden="true"></span>
2072 <strong>AI Trio Review</strong>
2073 <span class="trio-header-sub">
2074 Three independent reviewers ran in parallel.
2075 </span>
2076 </div>
2077 <div class="trio-grid">
2078 {TRIO_PERSONAS.map((persona) => {
2079 const body = latest[persona];
2080 const verdict = body ? trioVerdictOfBody(body) : null;
2081 const stateClass =
2082 verdict === "fail"
2083 ? "is-fail"
2084 : verdict === "pass"
2085 ? "is-pass"
2086 : "is-pending";
2087 return (
2088 <div class={`trio-card trio-${persona} ${stateClass}`}>
2089 <div class="trio-card-head">
2090 <span class="trio-card-icon" aria-hidden="true">
2091 {persona === "security"
2092 ? "🛡"
2093 : persona === "correctness"
2094 ? "✓"
2095 : "✎"}
2096 </span>
2097 <strong class="trio-card-title">
2098 {persona[0].toUpperCase() + persona.slice(1)}
2099 </strong>
2100 <span class="trio-card-verdict">
2101 {verdict === "pass"
2102 ? "Pass"
2103 : verdict === "fail"
2104 ? "Fail"
2105 : "Pending"}
2106 </span>
2107 </div>
2108 <div class="trio-card-body">
2109 {body ? (
2110 <MarkdownContent
2111 html={renderMarkdown(stripTrioHeading(body))}
2112 />
2113 ) : (
2114 <span class="trio-card-empty">
2115 Awaiting reviewer output.
2116 </span>
2117 )}
2118 </div>
2119 </div>
2120 );
2121 })}
2122 </div>
2123 {disagreements.length > 0 && (
2124 <div class="trio-disagreement-strip" role="note">
2125 <span class="trio-disagreement-icon" aria-hidden="true">
2126
2127 </span>
2128 <div class="trio-disagreement-body">
2129 <strong>Reviewers disagree — review carefully.</strong>
2130 <ul class="trio-disagreement-list">
2131 {disagreements.map((d) => (
2132 <li>
2133 <code>{d.file}</code> — {d.failing} says ✗,{" "}
2134 {d.passing} says ✓
2135 </li>
2136 ))}
2137 </ul>
2138 </div>
2139 </div>
2140 )}
2141 </div>
2142 );
2143}
2144
2145/**
2146 * Strip the marker comment + first H2 heading from a persona body so
2147 * the card body shows just the findings list (verdict is already in
2148 * the card head). Best-effort — malformed bodies render whole.
2149 */
2150function stripTrioHeading(body: string): string {
2151 return body
2152 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
2153 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
2154 .trim();
2155}
2156
67dc4e1Claude2157/**
2158 * Three small verdict pills rendered inline in the PR header. Each pill
2159 * links to the `#trio-review-section` anchor so clicking scrolls to the
2160 * full card grid. Only shown when `AI_TRIO_REVIEW_ENABLED=1` and at
2161 * least one persona comment exists.
2162 */
2163function TrioVerdictPills({
2164 comments,
2165}: {
2166 comments: TrioCommentLike[];
2167}) {
2168 if (!isTrioReviewEnabled()) return null;
2169
2170 // Find latest persona verdicts (same logic as TrioReviewGrid).
2171 const latest: Partial<Record<TrioPersona, string>> = {};
2172 for (let i = comments.length - 1; i >= 0; i--) {
2173 const body = comments[i].body || "";
2174 if (!isTrioComment(body)) continue;
2175 if (body.includes(TRIO_SUMMARY_MARKER)) continue;
2176 const persona = trioPersonaOfComment(body);
2177 if (persona && !latest[persona]) latest[persona] = body;
2178 }
2179
2180 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2181 if (!anyPersona) return null;
2182
2183 const PERSONA_LABEL: Record<TrioPersona, string> = {
2184 security: "Security",
2185 correctness: "Correctness",
2186 style: "Style",
2187 };
2188 const PERSONA_ICON: Record<TrioPersona, string> = {
2189 security: "🛡",
2190 correctness: "✓",
2191 style: "✎",
2192 };
2193
2194 return (
2195 <span class="trio-pills-wrap" aria-label="AI Trio Review verdicts">
2196 {TRIO_PERSONAS.map((persona) => {
2197 const body = latest[persona];
2198 const verdict = body ? trioVerdictOfBody(body) : null;
2199 const stateClass =
2200 verdict === "pass"
2201 ? "is-pass"
2202 : verdict === "fail"
2203 ? "is-fail"
2204 : "is-pending";
2205 const glyph =
2206 verdict === "pass" ? "✓" : verdict === "fail" ? "✗" : "⟳";
2207 return (
2208 <a
2209 href="#trio-review-section"
2210 class={`trio-pill ${stateClass}`}
2211 title={`AI ${PERSONA_LABEL[persona]} Review — ${verdict === "pass" ? "Pass" : verdict === "fail" ? "Fail" : "Pending"}`}
2212 >
2213 <span aria-hidden="true">{PERSONA_ICON[persona]}</span>
2214 {PERSONA_LABEL[persona]} {glyph}
2215 </a>
2216 );
2217 })}
2218 </span>
2219 );
2220}
2221
0074234Claude2222// List PRs
04f6b7fClaude2223pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2224 const { owner: ownerName, repo: repoName } = c.req.param();
2225 const user = c.get("user");
2226 const state = c.req.query("state") || "open";
d790b49Claude2227 const searchQ = c.req.query("q")?.trim() || "";
80bd7c8Claude2228 const authorFilter = c.req.query("author")?.trim() || "";
f5b9ef5Claude2229 const sortPr = (c.req.query("sort") || "newest").trim();
0074234Claude2230
ea9ed4cClaude2231 // ── Loading skeleton (flag-gated) ──
2232 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
2233 // the user see the page structure before counts + select resolve.
2234 // Behind a flag for now — we don't ship flashes.
2235 if (c.req.query("skeleton") === "1") {
2236 return c.html(
2237 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2238 <RepoHeader owner={ownerName} repo={repoName} />
2239 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2240 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2241 <style
2242 dangerouslySetInnerHTML={{
2243 __html: `
404b398Claude2244 .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; }
2245 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude2246 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
2247 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
2248 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
2249 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
2250 .prs-skel-row { height: 76px; border-radius: 12px; }
2251 `,
2252 }}
2253 />
2254 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
2255 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
2256 <div class="prs-skel-list" aria-hidden="true">
2257 {Array.from({ length: 6 }).map(() => (
2258 <div class="prs-skel prs-skel-row" />
2259 ))}
2260 </div>
2261 <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">
2262 Loading pull requests for {ownerName}/{repoName}…
2263 </span>
2264 </Layout>
2265 );
2266 }
2267
0074234Claude2268 const resolved = await resolveRepo(ownerName, repoName);
2269 if (!resolved) return c.notFound();
2270
6fc53bdClaude2271 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
2272 const stateFilter =
2273 state === "draft"
2274 ? and(
2275 eq(pullRequests.state, "open"),
2276 eq(pullRequests.isDraft, true)
2277 )
2278 : eq(pullRequests.state, state);
2279
0074234Claude2280 const prList = await db
2281 .select({
2282 pr: pullRequests,
2283 author: { username: users.username },
2284 })
2285 .from(pullRequests)
2286 .innerJoin(users, eq(pullRequests.authorId, users.id))
2287 .where(
d790b49Claude2288 and(
2289 eq(pullRequests.repositoryId, resolved.repo.id),
2290 stateFilter,
2291 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
80bd7c8Claude2292 authorFilter ? eq(users.username, authorFilter) : undefined,
d790b49Claude2293 )
0074234Claude2294 )
f5b9ef5Claude2295 .orderBy(
2296 sortPr === "oldest" ? asc(pullRequests.createdAt)
2297 : sortPr === "updated" ? desc(pullRequests.updatedAt)
2298 : desc(pullRequests.createdAt) // newest (default)
2299 );
0074234Claude2300
0369e77Claude2301 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude2302 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude2303 const commentCountMap = new Map<string, number>();
1aef949Claude2304 if (prList.length > 0) {
2305 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude2306 const [reviewRows, commentRows] = await Promise.all([
2307 db
2308 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
2309 .from(prReviews)
2310 .where(inArray(prReviews.pullRequestId, prIds)),
2311 db
2312 .select({
2313 prId: prComments.pullRequestId,
2314 cnt: sql<number>`count(*)::int`,
2315 })
2316 .from(prComments)
2317 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
2318 .groupBy(prComments.pullRequestId),
2319 ]);
1aef949Claude2320 for (const r of reviewRows) {
2321 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
2322 if (r.state === "approved") entry.approved = true;
2323 if (r.state === "changes_requested") entry.changesRequested = true;
2324 reviewMap.set(r.prId, entry);
2325 }
0369e77Claude2326 for (const r of commentRows) {
2327 commentCountMap.set(r.prId, Number(r.cnt));
2328 }
1aef949Claude2329 }
2330
0074234Claude2331 const [counts] = await db
2332 .select({
2333 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude2334 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude2335 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
2336 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
2337 })
2338 .from(pullRequests)
2339 .where(eq(pullRequests.repositoryId, resolved.repo.id));
2340
b078860Claude2341 const openCount = counts?.open ?? 0;
2342 const mergedCount = counts?.merged ?? 0;
2343 const closedCount = counts?.closed ?? 0;
2344 const draftCount = counts?.draft ?? 0;
2345 const allCount = openCount + mergedCount + closedCount;
2346
2347 // "All" is presentational only — the DB query for state='all' matches
2348 // nothing, so we render a friendlier empty state when picked. We do NOT
2349 // change the query logic to keep this commit purely visual.
80bd7c8Claude2350 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude2351 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude2352 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
2353 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
2354 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
2355 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
2356 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude2357 ];
2358 const isAllState = state === "all";
cb5a796Claude2359 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
2360 const prListPendingCount = viewerIsOwnerOnPrList
2361 ? await countPendingForRepo(resolved.repo.id)
2362 : 0;
b078860Claude2363
0074234Claude2364 return c.html(
2365 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2366 <RepoHeader owner={ownerName} repo={repoName} />
2367 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2368 <PendingCommentsBanner
2369 owner={ownerName}
2370 repo={repoName}
2371 count={prListPendingCount}
2372 />
b078860Claude2373 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2374
2375 <div class="prs-hero">
2376 <div class="prs-hero-inner">
2377 <div class="prs-hero-text">
2378 <div class="prs-hero-eyebrow">Pull requests</div>
2379 <h1 class="prs-hero-title">
2380 Review, <span class="gradient-text">merge with AI</span>.
2381 </h1>
2382 <p class="prs-hero-sub">
2383 {openCount === 0 && allCount === 0
2384 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2385 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
2386 </p>
2387 </div>
7a28902Claude2388 <div class="prs-hero-actions">
2389 <a
2390 href={`/${ownerName}/${repoName}/pulls/insights`}
2391 class="prs-cta"
2392 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
2393 >
2394 Insights
2395 </a>
2396 {user && (
b078860Claude2397 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
2398 + New pull request
2399 </a>
7a28902Claude2400 )}
2401 </div>
b078860Claude2402 </div>
2403 </div>
2404
2405 <nav class="prs-tabs" aria-label="Pull request filters">
2406 {tabPills.map((t) => {
2407 const isActive =
2408 state === t.key ||
2409 (t.key === "open" &&
2410 state !== "merged" &&
2411 state !== "closed" &&
2412 state !== "all" &&
2413 state !== "draft");
2414 return (
2415 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2416 <span>{t.label}</span>
2417 <span class="prs-tab-count">{t.count}</span>
2418 </a>
2419 );
2420 })}
2421 </nav>
2422
d790b49Claude2423 <form
2424 method="get"
2425 action={`/${ownerName}/${repoName}/pulls`}
2426 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2427 >
2428 <input type="hidden" name="state" value={state} />
2429 <input
2430 type="search"
2431 name="q"
2432 value={searchQ}
2433 placeholder="Search pull requests…"
2434 class="issues-search-input"
2435 style="flex:1;max-width:380px"
2436 />
80bd7c8Claude2437 <input
2438 type="text"
2439 name="author"
2440 value={authorFilter}
2441 placeholder="Filter by author…"
2442 class="issues-search-input"
2443 style="max-width:200px"
2444 />
d790b49Claude2445 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2446 {(searchQ || authorFilter) && (
d790b49Claude2447 <a
2448 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2449 class="issues-filter-clear"
2450 >
2451 Clear
2452 </a>
2453 )}
2454 </form>
f5b9ef5Claude2455
2456 <div class="prs-sort-row">
2457 <span class="prs-sort-label">Sort:</span>
2458 {(["newest", "oldest", "updated"] as const).map((s) => (
2459 <a
2460 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2461 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2462 >
2463 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2464 </a>
2465 ))}
2466 </div>
2467
0074234Claude2468 {prList.length === 0 ? (
b078860Claude2469 <div class="prs-empty">
ea9ed4cClaude2470 <div class="prs-empty-inner">
2471 <strong>
80bd7c8Claude2472 {searchQ || authorFilter
2473 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2474 : isAllState
2475 ? "Pick a filter above to browse PRs."
2476 : `No ${state} pull requests.`}
ea9ed4cClaude2477 </strong>
2478 <p class="prs-empty-sub">
80bd7c8Claude2479 {searchQ || authorFilter
2480 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2481 : state === "open"
2482 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2483 : isAllState
2484 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2485 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2486 </p>
2487 <div class="prs-empty-cta">
80bd7c8Claude2488 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2489 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2490 + New pull request
2491 </a>
2492 )}
80bd7c8Claude2493 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2494 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2495 View open PRs
2496 </a>
2497 )}
2498 <a href={`/${ownerName}/${repoName}`} class="btn">
2499 Back to code
2500 </a>
2501 </div>
2502 </div>
b078860Claude2503 </div>
0074234Claude2504 ) : (
b078860Claude2505 <div class="prs-list">
2506 {prList.map(({ pr, author }) => {
2507 const stateClass =
2508 pr.state === "open"
2509 ? pr.isDraft
2510 ? "state-draft"
2511 : "state-open"
2512 : pr.state === "merged"
2513 ? "state-merged"
2514 : "state-closed";
2515 const icon =
2516 pr.state === "open"
2517 ? pr.isDraft
2518 ? "◌"
2519 : "○"
2520 : pr.state === "merged"
2521 ? "⮌"
2522 : "✓";
1aef949Claude2523 const rv = reviewMap.get(pr.id);
b078860Claude2524 return (
2525 <a
2526 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2527 class="prs-row"
2528 style="text-decoration:none;color:inherit"
0074234Claude2529 >
b078860Claude2530 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2531 {icon}
0074234Claude2532 </div>
b078860Claude2533 <div class="prs-row-body">
2534 <h3 class="prs-row-title">
2535 <span>{pr.title}</span>
2536 <span class="prs-row-number">#{pr.number}</span>
2537 </h3>
2538 <div class="prs-row-meta">
2539 <span
2540 class="prs-branch-chips"
2541 title={`${pr.headBranch} into ${pr.baseBranch}`}
2542 >
2543 <span class="prs-branch-chip">{pr.headBranch}</span>
2544 <span class="prs-branch-arrow">{"→"}</span>
2545 <span class="prs-branch-chip">{pr.baseBranch}</span>
2546 </span>
2547 <span>
2548 by{" "}
2549 <strong style="color:var(--text)">
2550 {author.username}
2551 </strong>{" "}
2552 {formatRelative(pr.createdAt)}
2553 </span>
2554 <span class="prs-row-tags">
2555 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2556 {pr.state === "merged" && (
2557 <span class="prs-tag is-merged">Merged</span>
2558 )}
1aef949Claude2559 {rv?.approved && !rv.changesRequested && (
2560 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
2561 )}
2562 {rv?.changesRequested && (
2563 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
2564 )}
0369e77Claude2565 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
2566 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
2567 💬 {commentCountMap.get(pr.id)}
2568 </span>
2569 )}
b078860Claude2570 </span>
2571 </div>
0074234Claude2572 </div>
b078860Claude2573 </a>
2574 );
2575 })}
2576 </div>
0074234Claude2577 )}
2578 </Layout>
2579 );
2580});
2581
7a28902Claude2582/* ─────────────────────────────────────────────────────────────────────────
2583 * PR Insights — 90-day analytics for the pull request activity of a repo.
2584 * Route: GET /:owner/:repo/pulls/insights
2585 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
2586 * "insights" is not swallowed by the :number param.
2587 * ───────────────────────────────────────────────────────────────────────── */
2588
2589/** Format a millisecond duration as human-readable string. */
2590function formatMsDuration(ms: number): string {
2591 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
2592 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
2593 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
2594 return `${Math.round(ms / 86_400_000)}d`;
2595}
2596
2597/** Format an ISO week string as "Jan 15". */
2598function formatWeekLabel(isoWeek: string): string {
2599 try {
2600 const d = new Date(isoWeek);
2601 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2602 } catch {
2603 return isoWeek.slice(5, 10);
2604 }
2605}
2606
2607const PR_INSIGHTS_STYLES = `
2608 .pri-page { padding-bottom: 48px; }
2609 .pri-hero {
2610 position: relative;
2611 margin: 0 0 var(--space-5);
2612 padding: 22px 26px 24px;
2613 background: var(--bg-elevated);
2614 border: 1px solid var(--border);
2615 border-radius: 16px;
2616 overflow: hidden;
2617 }
2618 .pri-hero::before {
2619 content: '';
2620 position: absolute; top: 0; left: 0; right: 0;
2621 height: 2px;
2622 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2623 opacity: 0.7;
2624 pointer-events: none;
2625 }
2626 .pri-hero-eyebrow {
2627 font-size: 12px;
2628 color: var(--text-muted);
2629 text-transform: uppercase;
2630 letter-spacing: 0.08em;
2631 font-weight: 600;
2632 margin-bottom: 8px;
2633 }
2634 .pri-hero-title {
2635 font-family: var(--font-display);
2636 font-size: clamp(26px, 3.4vw, 34px);
2637 font-weight: 800;
2638 letter-spacing: -0.025em;
2639 line-height: 1.06;
2640 margin: 0 0 8px;
2641 color: var(--text-strong);
2642 }
2643 .pri-hero-title .gradient-text {
2644 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2645 -webkit-background-clip: text;
2646 background-clip: text;
2647 -webkit-text-fill-color: transparent;
2648 color: transparent;
2649 }
2650 .pri-hero-sub {
2651 font-size: 14.5px;
2652 color: var(--text-muted);
2653 margin: 0;
2654 line-height: 1.5;
2655 }
2656 .pri-section { margin-bottom: 32px; }
2657 .pri-section-title {
2658 font-size: 13px;
2659 font-weight: 700;
2660 text-transform: uppercase;
2661 letter-spacing: 0.06em;
2662 color: var(--text-muted);
2663 margin: 0 0 14px;
2664 }
2665 .pri-cards {
2666 display: grid;
2667 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
2668 gap: 12px;
2669 }
2670 .pri-card {
2671 padding: 16px 18px;
2672 background: var(--bg-elevated);
2673 border: 1px solid var(--border);
2674 border-radius: 12px;
2675 }
2676 .pri-card-label {
2677 font-size: 12px;
2678 font-weight: 600;
2679 color: var(--text-muted);
2680 text-transform: uppercase;
2681 letter-spacing: 0.05em;
2682 margin-bottom: 6px;
2683 }
2684 .pri-card-value {
2685 font-size: 28px;
2686 font-weight: 800;
2687 letter-spacing: -0.04em;
2688 color: var(--text-strong);
2689 line-height: 1;
2690 }
2691 .pri-card-sub {
2692 font-size: 12px;
2693 color: var(--text-muted);
2694 margin-top: 4px;
2695 }
2696 .pri-chart {
2697 display: flex;
2698 align-items: flex-end;
2699 gap: 6px;
2700 height: 120px;
2701 background: var(--bg-elevated);
2702 border: 1px solid var(--border);
2703 border-radius: 12px;
2704 padding: 16px 16px 0;
2705 }
2706 .pri-bar-col {
2707 flex: 1;
2708 display: flex;
2709 flex-direction: column;
2710 align-items: center;
2711 justify-content: flex-end;
2712 height: 100%;
2713 gap: 4px;
2714 }
2715 .pri-bar {
2716 width: 100%;
2717 min-height: 4px;
2718 border-radius: 4px 4px 0 0;
2719 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 100%);
2720 transition: opacity 140ms;
2721 }
2722 .pri-bar:hover { opacity: 0.8; }
2723 .pri-bar-label {
2724 font-size: 10px;
2725 color: var(--text-muted);
2726 text-align: center;
2727 padding-bottom: 8px;
2728 white-space: nowrap;
2729 overflow: hidden;
2730 text-overflow: ellipsis;
2731 max-width: 100%;
2732 }
2733 .pri-table {
2734 width: 100%;
2735 border-collapse: collapse;
2736 font-size: 13.5px;
2737 }
2738 .pri-table th {
2739 text-align: left;
2740 font-size: 12px;
2741 font-weight: 600;
2742 text-transform: uppercase;
2743 letter-spacing: 0.05em;
2744 color: var(--text-muted);
2745 padding: 8px 12px;
2746 border-bottom: 1px solid var(--border);
2747 }
2748 .pri-table td {
2749 padding: 10px 12px;
2750 border-bottom: 1px solid var(--border);
2751 color: var(--text);
2752 }
2753 .pri-table tr:last-child td { border-bottom: none; }
2754 .pri-table-wrap {
2755 background: var(--bg-elevated);
2756 border: 1px solid var(--border);
2757 border-radius: 12px;
2758 overflow: hidden;
2759 }
2760 .pri-age-row {
2761 display: flex;
2762 align-items: center;
2763 gap: 12px;
2764 padding: 10px 0;
2765 border-bottom: 1px solid var(--border);
2766 font-size: 13.5px;
2767 }
2768 .pri-age-row:last-child { border-bottom: none; }
2769 .pri-age-label {
2770 flex: 0 0 80px;
2771 color: var(--text-muted);
2772 font-size: 12.5px;
2773 font-weight: 600;
2774 }
2775 .pri-age-bar-wrap {
2776 flex: 1;
2777 height: 8px;
2778 background: var(--bg-secondary);
2779 border-radius: 9999px;
2780 overflow: hidden;
2781 }
2782 .pri-age-bar {
2783 height: 100%;
2784 border-radius: 9999px;
2785 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
2786 min-width: 4px;
2787 }
2788 .pri-age-count {
2789 flex: 0 0 32px;
2790 text-align: right;
2791 font-weight: 600;
2792 color: var(--text-strong);
2793 font-size: 13px;
2794 }
2795 .pri-sparkline {
2796 display: flex;
2797 align-items: flex-end;
2798 gap: 3px;
2799 height: 40px;
2800 }
2801 .pri-spark-bar {
2802 flex: 1;
2803 min-height: 2px;
2804 border-radius: 2px 2px 0 0;
2805 background: var(--accent, #8c6dff);
2806 opacity: 0.7;
2807 }
2808 .pri-empty {
2809 color: var(--text-muted);
2810 font-size: 14px;
2811 padding: 24px 0;
2812 text-align: center;
2813 }
2814 @media (max-width: 600px) {
2815 .pri-cards { grid-template-columns: repeat(2, 1fr); }
2816 .pri-hero { padding: 18px 18px 20px; }
2817 }
2818`;
2819
2820pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
2821 const { owner: ownerName, repo: repoName } = c.req.param();
2822 const user = c.get("user");
2823
2824 const resolved = await resolveRepo(ownerName, repoName);
2825 if (!resolved) return c.notFound();
2826
2827 const repoId = resolved.repo.id;
2828 const now = Date.now();
2829
2830 // 1. Merged PRs in last 90 days (avg merge time)
2831 const mergedPRs = await db
2832 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
2833 .from(pullRequests)
2834 .where(and(
2835 eq(pullRequests.repositoryId, repoId),
2836 eq(pullRequests.state, "merged"),
2837 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2838 ));
2839
2840 const avgMergeMs = mergedPRs.length > 0
2841 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
2842 : null;
2843
2844 // 2. PR throughput (last 8 weeks)
2845 const weeklyPRs = await db
2846 .select({
2847 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
2848 count: sql<number>`count(*)::int`,
2849 })
2850 .from(pullRequests)
2851 .where(and(
2852 eq(pullRequests.repositoryId, repoId),
2853 sql`${pullRequests.createdAt} > now() - interval '56 days'`
2854 ))
2855 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
2856 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
2857
2858 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
2859
2860 // 3. PR merge rate (last 90 days)
2861 const [rateCounts] = await db
2862 .select({
2863 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
2864 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
2865 })
2866 .from(pullRequests)
2867 .where(and(
2868 eq(pullRequests.repositoryId, repoId),
2869 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2870 ));
2871
2872 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
2873 const mergeRate = totalResolved > 0
2874 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
2875 : null;
2876
2877 // 4. Top reviewers (last 90 days)
2878 const reviewerCounts = await db
2879 .select({
2880 userId: prReviews.reviewerId,
2881 username: users.username,
2882 count: sql<number>`count(*)::int`,
2883 })
2884 .from(prReviews)
2885 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2886 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
2887 .where(and(
2888 eq(pullRequests.repositoryId, repoId),
2889 sql`${prReviews.createdAt} > now() - interval '90 days'`
2890 ))
2891 .groupBy(prReviews.reviewerId, users.username)
2892 .orderBy(desc(sql`count(*)`))
2893 .limit(5);
2894
2895 // 5. Average reviews per merged PR
2896 const [avgReviewRow] = await db
2897 .select({
2898 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
2899 })
2900 .from(pullRequests)
2901 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2902 .where(and(
2903 eq(pullRequests.repositoryId, repoId),
2904 eq(pullRequests.state, "merged"),
2905 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2906 ));
2907
2908 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
2909 ? Math.round(avgReviewRow.avgReviews * 10) / 10
2910 : null;
2911
2912 // 6. Review turnaround — avg time from PR open to first review
2913 const prsWithReviews = await db
2914 .select({
2915 createdAt: pullRequests.createdAt,
2916 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
2917 })
2918 .from(pullRequests)
2919 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2920 .where(and(
2921 eq(pullRequests.repositoryId, repoId),
2922 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2923 ))
2924 .groupBy(pullRequests.id, pullRequests.createdAt);
2925
2926 const avgReviewTurnaroundMs = prsWithReviews.length > 0
2927 ? prsWithReviews.reduce((s, row) => {
2928 const firstMs = new Date(row.firstReview).getTime();
2929 return s + Math.max(0, firstMs - row.createdAt.getTime());
2930 }, 0) / prsWithReviews.length
2931 : null;
2932
2933 // 7. Open PRs by age bucket
2934 const openPRs = await db
2935 .select({ createdAt: pullRequests.createdAt })
2936 .from(pullRequests)
2937 .where(and(
2938 eq(pullRequests.repositoryId, repoId),
2939 eq(pullRequests.state, "open")
2940 ));
2941
2942 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
2943 for (const { createdAt } of openPRs) {
2944 const ageDays = (now - createdAt.getTime()) / 86_400_000;
2945 if (ageDays < 1) ageBuckets.lt1d++;
2946 else if (ageDays < 3) ageBuckets.d1to3++;
2947 else if (ageDays < 7) ageBuckets.d3to7++;
2948 else if (ageDays < 30) ageBuckets.d7to30++;
2949 else ageBuckets.gt30d++;
2950 }
2951 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
2952
2953 // 8. 7-day merge sparkline
2954 const sparklineRows = await db
2955 .select({
2956 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
2957 count: sql<number>`count(*)::int`,
2958 })
2959 .from(pullRequests)
2960 .where(and(
2961 eq(pullRequests.repositoryId, repoId),
2962 eq(pullRequests.state, "merged"),
2963 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
2964 ))
2965 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
2966 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
2967
2968 const sparkMap = new Map<string, number>();
2969 for (const row of sparklineRows) {
2970 sparkMap.set(row.day.slice(0, 10), row.count);
2971 }
2972 const sparkline: number[] = [];
2973 for (let i = 6; i >= 0; i--) {
2974 const d = new Date(now - i * 86_400_000);
2975 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
2976 }
2977 const maxSpark = Math.max(1, ...sparkline);
2978
2979 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
2980 { label: "< 1 day", key: "lt1d" },
2981 { label: "1–3 days", key: "d1to3" },
2982 { label: "3–7 days", key: "d3to7" },
2983 { label: "7–30 days", key: "d7to30" },
2984 { label: "> 30 days", key: "gt30d" },
2985 ];
2986
2987 return c.html(
2988 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
2989 <RepoHeader owner={ownerName} repo={repoName} />
2990 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2991 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
2992
2993 <div class="pri-page">
2994 {/* Hero */}
2995 <div class="pri-hero">
2996 <div class="pri-hero-eyebrow">Pull requests</div>
2997 <h1 class="pri-hero-title">
2998 PR <span class="gradient-text">Insights</span>
2999 </h1>
3000 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
3001 </div>
3002
3003 {/* Stat cards */}
3004 <div class="pri-section">
3005 <div class="pri-section-title">At a glance</div>
3006 <div class="pri-cards">
3007 <div class="pri-card">
3008 <div class="pri-card-label">Avg merge time</div>
3009 <div class="pri-card-value">
3010 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
3011 </div>
3012 <div class="pri-card-sub">last 90 days</div>
3013 </div>
3014 <div class="pri-card">
3015 <div class="pri-card-label">Total merged</div>
3016 <div class="pri-card-value">{mergedPRs.length}</div>
3017 <div class="pri-card-sub">last 90 days</div>
3018 </div>
3019 <div class="pri-card">
3020 <div class="pri-card-label">Open PRs</div>
3021 <div class="pri-card-value">{openPRs.length}</div>
3022 <div class="pri-card-sub">right now</div>
3023 </div>
3024 <div class="pri-card">
3025 <div class="pri-card-label">Merge rate</div>
3026 <div class="pri-card-value">
3027 {mergeRate != null ? `${mergeRate}%` : "—"}
3028 </div>
3029 <div class="pri-card-sub">merged vs closed</div>
3030 </div>
3031 <div class="pri-card">
3032 <div class="pri-card-label">Avg reviews / PR</div>
3033 <div class="pri-card-value">
3034 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
3035 </div>
3036 <div class="pri-card-sub">merged PRs, 90d</div>
3037 </div>
3038 <div class="pri-card">
3039 <div class="pri-card-label">Top reviewer</div>
3040 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
3041 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
3042 </div>
3043 <div class="pri-card-sub">
3044 {reviewerCounts.length > 0
3045 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
3046 : "no reviews yet"}
3047 </div>
3048 </div>
3049 </div>
3050 </div>
3051
3052 {/* Review turnaround */}
3053 <div class="pri-section">
3054 <div class="pri-section-title">Review turnaround</div>
3055 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
3056 <div class="pri-card">
3057 <div class="pri-card-label">Avg time to first review</div>
3058 <div class="pri-card-value">
3059 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
3060 </div>
3061 <div class="pri-card-sub">
3062 {prsWithReviews.length > 0
3063 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
3064 : "no reviewed PRs in 90d"}
3065 </div>
3066 </div>
3067 </div>
3068 </div>
3069
3070 {/* Weekly throughput bar chart */}
3071 <div class="pri-section">
3072 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
3073 {weeklyPRs.length === 0 ? (
3074 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
3075 ) : (
3076 <div class="pri-chart">
3077 {weeklyPRs.map((w) => (
3078 <div class="pri-bar-col">
3079 <div
3080 class="pri-bar"
3081 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
3082 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
3083 />
3084 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
3085 </div>
3086 ))}
3087 </div>
3088 )}
3089 </div>
3090
3091 {/* 7-day merge sparkline */}
3092 <div class="pri-section">
3093 <div class="pri-section-title">Merges this week (daily)</div>
3094 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
3095 <div class="pri-sparkline">
3096 {sparkline.map((v) => (
3097 <div
3098 class="pri-spark-bar"
3099 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
3100 title={`${v} merge${v === 1 ? "" : "s"}`}
3101 />
3102 ))}
3103 </div>
3104 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
3105 <span>7 days ago</span>
3106 <span>Today</span>
3107 </div>
3108 </div>
3109 </div>
3110
3111 {/* Top reviewers table */}
3112 <div class="pri-section">
3113 <div class="pri-section-title">Top reviewers (last 90 days)</div>
3114 {reviewerCounts.length === 0 ? (
3115 <div class="pri-empty">No reviews posted in the last 90 days.</div>
3116 ) : (
3117 <div class="pri-table-wrap">
3118 <table class="pri-table">
3119 <thead>
3120 <tr>
3121 <th>#</th>
3122 <th>Reviewer</th>
3123 <th>Reviews</th>
3124 </tr>
3125 </thead>
3126 <tbody>
3127 {reviewerCounts.map((r, i) => (
3128 <tr>
3129 <td style="color:var(--text-muted)">{i + 1}</td>
3130 <td>
3131 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
3132 {r.username}
3133 </a>
3134 </td>
3135 <td style="font-weight:600">{r.count}</td>
3136 </tr>
3137 ))}
3138 </tbody>
3139 </table>
3140 </div>
3141 )}
3142 </div>
3143
3144 {/* Open PRs by age */}
3145 <div class="pri-section">
3146 <div class="pri-section-title">Open PRs by age</div>
3147 {openPRs.length === 0 ? (
3148 <div class="pri-empty">No open pull requests.</div>
3149 ) : (
3150 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
3151 {ageBucketDefs.map(({ label, key }) => (
3152 <div class="pri-age-row">
3153 <span class="pri-age-label">{label}</span>
3154 <div class="pri-age-bar-wrap">
3155 <div
3156 class="pri-age-bar"
3157 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
3158 />
3159 </div>
3160 <span class="pri-age-count">{ageBuckets[key]}</span>
3161 </div>
3162 ))}
3163 </div>
3164 )}
3165 </div>
3166
3167 {/* Back link */}
3168 <div>
3169 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
3170 {"←"} Back to pull requests
3171 </a>
3172 </div>
3173 </div>
3174 </Layout>
3175 );
3176});
3177
0074234Claude3178// New PR form
3179pulls.get(
3180 "/:owner/:repo/pulls/new",
3181 softAuth,
3182 requireAuth,
04f6b7fClaude3183 requireRepoAccess("write"),
0074234Claude3184 async (c) => {
3185 const { owner: ownerName, repo: repoName } = c.req.param();
3186 const user = c.get("user")!;
3187 const branches = await listBranches(ownerName, repoName);
3188 const error = c.req.query("error");
3189 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude3190 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude3191
3192 return c.html(
3193 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
3194 <RepoHeader owner={ownerName} repo={repoName} />
3195 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude3196 <Container maxWidth={800}>
3197 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude3198 {error && (
bb0f894Claude3199 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude3200 )}
0316dbbClaude3201 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
3202 <Flex gap={12} align="center" style="margin-bottom: 16px">
3203 <Select name="base">
0074234Claude3204 {branches.map((b) => (
3205 <option value={b} selected={b === defaultBase}>
3206 {b}
3207 </option>
3208 ))}
bb0f894Claude3209 </Select>
3210 <Text muted>&larr;</Text>
3211 <Select name="head">
0074234Claude3212 {branches
3213 .filter((b) => b !== defaultBase)
3214 .concat(defaultBase === branches[0] ? [] : [branches[0]])
3215 .map((b) => (
3216 <option value={b}>{b}</option>
3217 ))}
bb0f894Claude3218 </Select>
3219 </Flex>
3220 <FormGroup>
3221 <Input
0074234Claude3222 name="title"
3223 required
3224 placeholder="Title"
bb0f894Claude3225 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]3226 aria-label="Pull request title"
0074234Claude3227 />
bb0f894Claude3228 </FormGroup>
3229 <FormGroup>
3230 <TextArea
0074234Claude3231 name="body"
81c73c1Claude3232 id="pr-body"
0074234Claude3233 rows={8}
3234 placeholder="Description (Markdown supported)"
bb0f894Claude3235 mono
0074234Claude3236 />
bb0f894Claude3237 </FormGroup>
81c73c1Claude3238 <Flex gap={8} align="center">
3239 <Button type="submit" variant="primary">
3240 Create pull request
3241 </Button>
3242 <button
3243 type="button"
3244 id="ai-suggest-desc"
3245 class="btn"
3246 style="font-weight:500"
3247 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
3248 >
3249 Suggest description with AI
3250 </button>
3251 <span
3252 id="ai-suggest-status"
3253 style="color:var(--text-muted);font-size:13px"
3254 />
3255 </Flex>
bb0f894Claude3256 </Form>
81c73c1Claude3257 <script
3258 dangerouslySetInnerHTML={{
3259 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
3260 }}
3261 />
bb0f894Claude3262 </Container>
0074234Claude3263 </Layout>
3264 );
3265 }
3266);
3267
81c73c1Claude3268// AI-suggested PR description — JSON endpoint driven by the form button.
3269// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
3270// 200; the inline script reads `ok` to decide what to do.
3271pulls.post(
3272 "/:owner/:repo/ai/pr-description",
3273 softAuth,
3274 requireAuth,
3275 requireRepoAccess("write"),
3276 async (c) => {
3277 const { owner: ownerName, repo: repoName } = c.req.param();
3278 if (!isAiAvailable()) {
3279 return c.json({
3280 ok: false,
3281 error: "AI is not available — set ANTHROPIC_API_KEY.",
3282 });
3283 }
3284 const body = await c.req.parseBody();
3285 const title = String(body.title || "").trim();
3286 const baseBranch = String(body.base || "").trim();
3287 const headBranch = String(body.head || "").trim();
3288 if (!baseBranch || !headBranch) {
3289 return c.json({ ok: false, error: "Pick base + head branches first." });
3290 }
3291 if (baseBranch === headBranch) {
3292 return c.json({ ok: false, error: "Base and head must differ." });
3293 }
3294
3295 let diff = "";
3296 try {
3297 const cwd = getRepoPath(ownerName, repoName);
3298 const proc = Bun.spawn(
3299 [
3300 "git",
3301 "diff",
3302 `${baseBranch}...${headBranch}`,
3303 "--",
3304 ],
3305 { cwd, stdout: "pipe", stderr: "pipe" }
3306 );
6ea2109Claude3307 // 30s ceiling — without this a pathological diff (huge binary or
3308 // a corrupt ref) hangs the request indefinitely.
3309 const killer = setTimeout(() => proc.kill(), 30_000);
3310 try {
3311 diff = await new Response(proc.stdout).text();
3312 await proc.exited;
3313 } finally {
3314 clearTimeout(killer);
3315 }
81c73c1Claude3316 } catch {
3317 diff = "";
3318 }
3319 if (!diff.trim()) {
3320 return c.json({
3321 ok: false,
3322 error: "No diff between branches — nothing to summarise.",
3323 });
3324 }
3325
3326 let summary = "";
3327 try {
3328 summary = await generatePrSummary(title || "(untitled)", diff);
3329 } catch (err) {
3330 const msg = err instanceof Error ? err.message : "AI request failed.";
3331 return c.json({ ok: false, error: msg });
3332 }
3333 if (!summary.trim()) {
3334 return c.json({ ok: false, error: "AI returned an empty draft." });
3335 }
3336 return c.json({ ok: true, body: summary });
3337 }
3338);
3339
0074234Claude3340// Create PR
3341pulls.post(
3342 "/:owner/:repo/pulls/new",
3343 softAuth,
3344 requireAuth,
04f6b7fClaude3345 requireRepoAccess("write"),
0074234Claude3346 async (c) => {
3347 const { owner: ownerName, repo: repoName } = c.req.param();
3348 const user = c.get("user")!;
3349 const body = await c.req.parseBody();
3350 const title = String(body.title || "").trim();
3351 const prBody = String(body.body || "").trim();
3352 const baseBranch = String(body.base || "main");
3353 const headBranch = String(body.head || "");
3354
3355 if (!title || !headBranch) {
3356 return c.redirect(
3357 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
3358 );
3359 }
3360
3361 if (baseBranch === headBranch) {
3362 return c.redirect(
3363 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
3364 );
3365 }
3366
3367 const resolved = await resolveRepo(ownerName, repoName);
3368 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3369
6fc53bdClaude3370 const isDraft = String(body.draft || "") === "1";
3371
0074234Claude3372 const [pr] = await db
3373 .insert(pullRequests)
3374 .values({
3375 repositoryId: resolved.repo.id,
3376 authorId: user.id,
3377 title,
3378 body: prBody || null,
3379 baseBranch,
3380 headBranch,
6fc53bdClaude3381 isDraft,
0074234Claude3382 })
3383 .returning();
3384
6fc53bdClaude3385 // Skip AI review on drafts — it runs again when the PR is marked ready.
3386 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3387 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3388 (err) => console.error("[ai-review] Failed:", err)
3389 );
3390 }
3391
3cbe3d6Claude3392 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3393 triggerPrTriage({
3394 ownerName,
3395 repoName,
3396 repositoryId: resolved.repo.id,
3397 prId: pr.id,
3398 prAuthorId: user.id,
3399 title,
3400 body: prBody,
3401 baseBranch,
3402 headBranch,
3403 }).catch((err) => console.error("[pr-triage] Failed:", err));
3404
1d4ff60Claude3405 // Chat notifier — fan out to Slack/Discord/Teams.
3406 import("../lib/chat-notifier")
3407 .then((m) =>
3408 m.notifyChatChannels({
3409 ownerUserId: resolved.repo.ownerId,
3410 repositoryId: resolved.repo.id,
3411 event: {
3412 event: "pr.opened",
3413 repo: `${ownerName}/${repoName}`,
3414 title: `#${pr.number} ${title}`,
3415 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3416 body: prBody || undefined,
3417 actor: user.username,
3418 },
3419 })
3420 )
3421 .catch((err) =>
3422 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3423 );
3424
9dd96b9Test User3425 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude3426 import("../lib/auto-merge")
3427 .then((m) => m.tryAutoMergeNow(pr.id))
3428 .catch((err) => {
3429 console.warn(
3430 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
3431 err instanceof Error ? err.message : err
3432 );
3433 });
9dd96b9Test User3434
1df50d5Claude3435 // Migration 0077 — PR preview build. Fire-and-forget; skips when
3436 // PREVIEW_DOMAIN is unset or the repo has no preview_build_command.
3437 // Resolve head SHA asynchronously so we don't block the redirect.
3438 resolveRef(ownerName, repoName, headBranch)
3439 .then((headSha) => {
3440 if (!headSha) return;
3441 return import("../lib/preview-builder").then((m) =>
3442 m.buildPreview(pr.id, resolved.repo.id, headSha)
3443 );
3444 })
3445 .catch(() => {});
3446
0074234Claude3447 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
3448 }
3449);
3450
3451// View single PR
04f6b7fClaude3452pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude3453 const { owner: ownerName, repo: repoName } = c.req.param();
3454 const prNum = parseInt(c.req.param("number"), 10);
3455 const user = c.get("user");
3456 const tab = c.req.query("tab") || "conversation";
3457
3458 const resolved = await resolveRepo(ownerName, repoName);
3459 if (!resolved) return c.notFound();
3460
3461 const [pr] = await db
3462 .select()
3463 .from(pullRequests)
3464 .where(
3465 and(
3466 eq(pullRequests.repositoryId, resolved.repo.id),
3467 eq(pullRequests.number, prNum)
3468 )
3469 )
3470 .limit(1);
3471
3472 if (!pr) return c.notFound();
3473
3474 const [author] = await db
3475 .select()
3476 .from(users)
3477 .where(eq(users.id, pr.authorId))
3478 .limit(1);
3479
cb5a796Claude3480 const allCommentsRaw = await db
0074234Claude3481 .select({
3482 comment: prComments,
cb5a796Claude3483 author: { id: users.id, username: users.username },
0074234Claude3484 })
3485 .from(prComments)
3486 .innerJoin(users, eq(prComments.authorId, users.id))
3487 .where(eq(prComments.pullRequestId, pr.id))
3488 .orderBy(asc(prComments.createdAt));
3489
cb5a796Claude3490 // Filter pending/rejected/spam for non-owner, non-author viewers.
3491 // Owner always sees everything; comment author sees their own pending
3492 // with an "Awaiting approval" badge in the render below.
3493 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
3494 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
3495 if (viewerIsRepoOwner) return true;
3496 if (comment.moderationStatus === "approved") return true;
3497 if (
3498 user &&
3499 cAuthor.id === user.id &&
3500 comment.moderationStatus === "pending"
3501 ) {
3502 return true;
3503 }
3504 return false;
3505 });
3506 const prPendingCount = viewerIsRepoOwner
3507 ? await countPendingForRepo(resolved.repo.id)
3508 : 0;
3509
6fc53bdClaude3510 // Reactions for the PR body + each comment, in parallel.
3511 const [prReactions, ...prCommentReactions] = await Promise.all([
3512 summariseReactions("pr", pr.id, user?.id),
3513 ...comments.map((row) =>
3514 summariseReactions("pr_comment", row.comment.id, user?.id)
3515 ),
3516 ]);
3517
0a67773Claude3518 // Formal reviews (Approve / Request Changes)
3519 const reviewRows = await db
3520 .select({
3521 id: prReviews.id,
3522 state: prReviews.state,
3523 body: prReviews.body,
3524 isAi: prReviews.isAi,
3525 createdAt: prReviews.createdAt,
3526 reviewerUsername: users.username,
3527 reviewerId: prReviews.reviewerId,
3528 })
3529 .from(prReviews)
3530 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3531 .where(eq(prReviews.pullRequestId, pr.id))
3532 .orderBy(asc(prReviews.createdAt));
3533 // Most recent review per reviewer determines the current state
3534 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
3535 for (const r of reviewRows) {
3536 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
3537 }
3538 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
3539 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
3540 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
3541
ace34efClaude3542 // Suggested reviewers — best-effort, never throws
3543 let reviewerSuggestions: ReviewerCandidate[] = [];
3544 try {
3545 if (user) {
3546 reviewerSuggestions = await suggestReviewers(
3547 ownerName, repoName, pr.headBranch, pr.baseBranch,
3548 pr.authorId, resolved.repo.id
3549 );
3550 }
3551 } catch {
3552 // silent degradation
3553 }
3554
0074234Claude3555 const canManage =
3556 user &&
3557 (user.id === resolved.owner.id || user.id === pr.authorId);
3558
1d4ff60Claude3559 // Has any previous AI-test-generator run already tagged this PR? Used
3560 // both to hide the "Generate tests with AI" button and to short-circuit
3561 // the explicit POST handler.
3562 const hasAiTestsMarker = comments.some(({ comment }) =>
3563 (comment.body || "").includes(AI_TESTS_MARKER)
3564 );
3565
e883329Claude3566 const error = c.req.query("error");
c3e0c07Claude3567 const info = c.req.query("info");
e883329Claude3568
3569 // Get gate check status for open PRs
3570 let gateChecks: GateCheckResult[] = [];
240c477Claude3571 let ciStatuses: CommitStatus[] = [];
e883329Claude3572 if (pr.state === "open") {
3573 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3574 if (headSha) {
3575 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
3576 const aiApproved = aiComments.length === 0 || aiComments.some(
3577 ({ comment }) => comment.body.includes("**Approved**")
3578 );
240c477Claude3579 const [gateResult, fetchedCiStatuses] = await Promise.all([
3580 runAllGateChecks(
3581 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
3582 ),
3583 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
3584 ]);
e883329Claude3585 gateChecks = gateResult.checks;
240c477Claude3586 ciStatuses = fetchedCiStatuses;
e883329Claude3587 }
3588 }
3589
534f04aClaude3590 // Block M3 — pre-merge risk score. Cache-only on the request path so
3591 // the page never waits on Haiku. On a cache miss for an open PR we
3592 // kick off the computation fire-and-forget; the next refresh shows it.
3593 let prRisk: PrRiskScore | null = null;
3594 let prRiskCalculating = false;
3595 if (pr.state === "open") {
3596 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
3597 if (!prRisk) {
3598 prRiskCalculating = true;
a28cedeClaude3599 void computePrRiskForPullRequest(pr.id).catch((err) => {
3600 console.warn(
3601 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
3602 err instanceof Error ? err.message : err
3603 );
3604 });
534f04aClaude3605 }
3606 }
3607
4bbacbeClaude3608 // Migration 0062 — per-branch preview URL. The head branch always
3609 // has a preview row (unless it's the default branch, which never
3610 // happens for an open PR) once it has been pushed at least once.
3611 const preview = await getPreviewForBranch(
3612 (resolved.repo as { id: string }).id,
3613 pr.headBranch
3614 );
3615
0369e77Claude3616 // Branch ahead/behind counts — how many commits head is ahead of base and
3617 // how many commits base has advanced since head branched off.
3618 let branchAhead = 0;
3619 let branchBehind = 0;
3620 if (pr.state === "open") {
3621 try {
3622 const repoDir = getRepoPath(ownerName, repoName);
3623 const [aheadProc, behindProc] = [
3624 Bun.spawn(
3625 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
3626 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3627 ),
3628 Bun.spawn(
3629 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
3630 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3631 ),
3632 ];
3633 const [aheadTxt, behindTxt] = await Promise.all([
3634 new Response(aheadProc.stdout).text(),
3635 new Response(behindProc.stdout).text(),
3636 ]);
3637 await Promise.all([aheadProc.exited, behindProc.exited]);
3638 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
3639 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
3640 } catch { /* non-blocking */ }
3641 }
3642
6d1bbc2Claude3643 // Linked issues — parse closing keywords from PR title+body, look up issues
3644 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
3645 try {
3646 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3647 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3648 if (refs.length > 0) {
3649 linkedIssues = await db
3650 .select({ number: issues.number, title: issues.title, state: issues.state })
3651 .from(issues)
3652 .where(and(
3653 eq(issues.repositoryId, resolved.repo.id),
3654 inArray(issues.number, refs),
3655 ));
3656 }
3657 } catch { /* non-blocking */ }
3658
3659 // Task list progress — count markdown checkboxes in PR body
3660 let taskTotal = 0;
3661 let taskChecked = 0;
3662 if (pr.body) {
3663 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
3664 taskTotal++;
3665 if (m[1].trim() !== "") taskChecked++;
3666 }
3667 }
3668
74d8c4dClaude3669 // M15 — PR size badge (best-effort, non-blocking)
3670 let prSizeInfo: PrSizeInfo | null = null;
3671 try {
3672 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
3673 } catch { /* swallow — purely cosmetic */ }
3674
3033f70Claude3675 // Merge impact analysis — only for open PRs with write access (cached, fast)
3676 let impactAnalysis: ImpactAnalysis | null = null;
3677 if (pr.state === "open" && canManage) {
3678 try {
3679 impactAnalysis = await analyzeImpact(resolved.repo.id, pr.id);
3680 } catch { /* non-blocking */ }
3681 }
3682
47a7a0aClaude3683 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude3684 let diffRaw = "";
3685 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude3686 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude3687 if (tab === "files") {
3688 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude3689 // Run the two git diffs in parallel — they're independent reads of
3690 // the same range. Previously sequential, doubling the wall time on
3691 // big PRs (100+ files = 10-30s for no reason).
0074234Claude3692 const proc = Bun.spawn(
3693 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
3694 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3695 );
3696 const statProc = Bun.spawn(
3697 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
3698 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3699 );
6ea2109Claude3700 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
3701 // would otherwise hang the whole request.
3702 const killer = setTimeout(() => {
3703 proc.kill();
3704 statProc.kill();
3705 }, 30_000);
3706 let stat = "";
3707 try {
3708 [diffRaw, stat] = await Promise.all([
3709 new Response(proc.stdout).text(),
3710 new Response(statProc.stdout).text(),
3711 ]);
3712 await Promise.all([proc.exited, statProc.exited]);
3713 } finally {
3714 clearTimeout(killer);
3715 }
0074234Claude3716
3717 diffFiles = stat
3718 .trim()
3719 .split("\n")
3720 .filter(Boolean)
3721 .map((line) => {
3722 const [add, del, filePath] = line.split("\t");
3723 return {
3724 path: filePath,
3725 status: "modified",
3726 additions: add === "-" ? 0 : parseInt(add, 10),
3727 deletions: del === "-" ? 0 : parseInt(del, 10),
3728 patch: "",
3729 };
3730 });
47a7a0aClaude3731
3732 // Fetch inline comments (file+line anchored) for the files tab
3733 const inlineRows = await db
3734 .select({
3735 id: prComments.id,
3736 filePath: prComments.filePath,
3737 lineNumber: prComments.lineNumber,
3738 body: prComments.body,
3739 isAiReview: prComments.isAiReview,
3740 createdAt: prComments.createdAt,
3741 authorUsername: users.username,
3742 })
3743 .from(prComments)
3744 .innerJoin(users, eq(prComments.authorId, users.id))
3745 .where(
3746 and(
3747 eq(prComments.pullRequestId, pr.id),
3748 eq(prComments.moderationStatus, "approved"),
3749 )
3750 )
3751 .orderBy(asc(prComments.createdAt));
3752
3753 diffInlineComments = inlineRows
3754 .filter(r => r.filePath != null && r.lineNumber != null)
3755 .map(r => ({
3756 id: r.id,
3757 filePath: r.filePath!,
3758 lineNumber: r.lineNumber!,
3759 authorUsername: r.authorUsername,
3760 body: renderMarkdown(r.body),
3761 isAiReview: r.isAiReview,
3762 createdAt: r.createdAt.toISOString(),
3763 }));
0074234Claude3764 }
3765
b078860Claude3766 // ─── Derived visual state ───
3767 const stateKey =
3768 pr.state === "open"
3769 ? pr.isDraft
3770 ? "draft"
3771 : "open"
3772 : pr.state;
3773 const stateLabel =
3774 stateKey === "open"
3775 ? "Open"
3776 : stateKey === "draft"
3777 ? "Draft"
3778 : stateKey === "merged"
3779 ? "Merged"
3780 : "Closed";
3781 const stateIcon =
3782 stateKey === "open"
3783 ? "○"
3784 : stateKey === "draft"
3785 ? "◌"
3786 : stateKey === "merged"
3787 ? "⮌"
3788 : "✓";
3789 const commentCount = comments.length;
3790 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
3791 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
3792 const mergeBlocked =
3793 gateChecks.length > 0 &&
3794 gateChecks.some(
3795 (c) => !c.passed && c.name !== "Merge check"
3796 );
3797
b558f23Claude3798 // Commits tab — list commits included in this PR (base..head range)
3799 let prCommits: GitCommit[] = [];
3800 if (tab === "commits") {
3801 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
3802 }
3803
0074234Claude3804 return c.html(
3805 <Layout
3806 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
3807 user={user}
3808 >
3809 <RepoHeader owner={ownerName} repo={repoName} />
3810 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude3811 <PendingCommentsBanner
3812 owner={ownerName}
3813 repo={repoName}
3814 count={prPendingCount}
3815 />
b078860Claude3816 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude3817 <div
3818 id="live-comment-banner"
3819 class="alert"
3820 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
3821 >
3822 <strong class="js-live-count">0</strong> new comment(s) —{" "}
3823 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
3824 reload to view
3825 </a>
3826 </div>
3827 <script
3828 dangerouslySetInnerHTML={{
3829 __html: liveCommentBannerScript({
3830 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
3831 bannerElementId: "live-comment-banner",
3832 }),
3833 }}
3834 />
b078860Claude3835
3836 <div class="prs-detail-hero">
b558f23Claude3837 <div class="prs-edit-title-wrap">
3838 <h1 class="prs-detail-title" id="pr-title-display">
3839 {pr.title}{" "}
3840 <span class="prs-detail-num">#{pr.number}</span>
3841 </h1>
3842 {canManage && pr.state === "open" && (
3843 <button
3844 type="button"
3845 class="prs-edit-btn"
3846 id="pr-edit-toggle"
3847 onclick={`
3848 document.getElementById('pr-title-display').style.display='none';
3849 document.getElementById('pr-edit-toggle').style.display='none';
3850 document.getElementById('pr-edit-form').style.display='flex';
3851 document.getElementById('pr-title-input').focus();
3852 `}
3853 >
3854 Edit
3855 </button>
3856 )}
3857 </div>
3858 {canManage && pr.state === "open" && (
3859 <form
3860 id="pr-edit-form"
3861 method="post"
3862 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
3863 class="prs-edit-form"
3864 style="display:none"
3865 >
3866 <input
3867 id="pr-title-input"
3868 type="text"
3869 name="title"
3870 value={pr.title}
3871 required
3872 maxlength={256}
3873 placeholder="Pull request title"
3874 />
3875 <div class="prs-edit-actions">
3876 <button type="submit" class="prs-edit-save-btn">Save</button>
3877 <button
3878 type="button"
3879 class="prs-edit-cancel-btn"
3880 onclick={`
3881 document.getElementById('pr-edit-form').style.display='none';
3882 document.getElementById('pr-title-display').style.display='';
3883 document.getElementById('pr-edit-toggle').style.display='';
3884 `}
3885 >
3886 Cancel
3887 </button>
3888 </div>
3889 </form>
3890 )}
b078860Claude3891 <div class="prs-detail-meta">
3892 <span class={`prs-state-pill state-${stateKey}`}>
3893 <span aria-hidden="true">{stateIcon}</span>
3894 <span>{stateLabel}</span>
3895 </span>
74d8c4dClaude3896 {prSizeInfo && (
3897 <span
3898 class="prs-size-badge"
3899 style={`color:${prSizeInfo.color};background:${prSizeInfo.bgColor}`}
3900 title={`${prSizeInfo.linesChanged} lines changed (+${prSizeInfo.added} −${prSizeInfo.deleted})`}
3901 >
3902 {prSizeInfo.label}
3903 </span>
3904 )}
67dc4e1Claude3905 <TrioVerdictPills
3906 comments={comments.map(({ comment }) => comment)}
3907 />
b078860Claude3908 <span>
3909 <strong>{author?.username}</strong> wants to merge
3910 </span>
3911 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
3912 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
3913 <span class="prs-branch-arrow-lg">{"→"}</span>
3914 <span class="prs-branch-pill">{pr.baseBranch}</span>
3915 </span>
0369e77Claude3916 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
3917 <span
3918 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
3919 title={branchBehind > 0
3920 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
3921 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
3922 >
3923 {branchAhead > 0 ? `↑${branchAhead}` : ""}
3924 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
3925 {branchBehind > 0 ? `↓${branchBehind}` : ""}
3926 </span>
3927 )}
b078860Claude3928 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude3929 {taskTotal > 0 && (
3930 <span
3931 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
3932 title={`${taskChecked} of ${taskTotal} tasks completed`}
3933 >
3934 <span class="prs-tasks-progress" aria-hidden="true">
3935 <span
3936 class="prs-tasks-progress-bar"
3937 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
3938 ></span>
3939 </span>
3940 {taskChecked}/{taskTotal} tasks
3941 </span>
3942 )}
3943 {canManage && pr.state === "open" && branchBehind > 0 && (
3944 <form
3945 method="post"
3946 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
3947 class="prs-inline-form"
3948 >
3949 <button
3950 type="submit"
3951 class="prs-update-branch-btn"
3952 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
3953 >
3954 ↑ Update branch
3955 </button>
3956 </form>
3957 )}
3c03977Claude3958 <span
3959 id="live-pill"
3960 class="live-pill"
3961 title="People editing this PR right now"
3962 >
3963 <span class="live-pill-dot" aria-hidden="true"></span>
3964 <span>
3965 Live: <strong id="live-count">0</strong> editing
3966 </span>
3967 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
3968 </span>
4bbacbeClaude3969 {preview && (
3970 <a
3971 class={`preview-prpill is-${preview.status}`}
3972 href={
3973 preview.status === "ready"
3974 ? preview.previewUrl
3975 : `/${ownerName}/${repoName}/previews`
3976 }
3977 target={preview.status === "ready" ? "_blank" : undefined}
3978 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
3979 title={`Preview · ${previewStatusLabel(preview.status)}`}
3980 >
3981 <span class="preview-prpill-dot" aria-hidden="true"></span>
3982 <span>Preview: </span>
3983 <span>{previewStatusLabel(preview.status)}</span>
3984 </a>
3985 )}
b078860Claude3986 {canManage && pr.state === "open" && pr.isDraft && (
3987 <form
3988 method="post"
3989 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3990 class="prs-inline-form prs-detail-actions"
3991 >
3992 <button type="submit" class="prs-merge-ready-btn">
3993 Ready for review
3994 </button>
3995 </form>
3996 )}
3997 </div>
3998 </div>
3c03977Claude3999 <script
4000 dangerouslySetInnerHTML={{
4001 __html: LIVE_COEDIT_SCRIPT(pr.id),
4002 }}
4003 />
829a046Claude4004 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude4005 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude4006 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude4007
b078860Claude4008 <nav class="prs-detail-tabs" aria-label="Pull request sections">
4009 <a
4010 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
4011 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
4012 >
4013 Conversation
4014 <span class="prs-detail-tab-count">{commentCount}</span>
4015 </a>
b558f23Claude4016 <a
4017 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
4018 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
4019 >
4020 Commits
4021 {branchAhead > 0 && (
4022 <span class="prs-detail-tab-count">{branchAhead}</span>
4023 )}
4024 </a>
b078860Claude4025 <a
4026 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
4027 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4028 >
4029 Files changed
4030 {diffFiles.length > 0 && (
4031 <span class="prs-detail-tab-count">{diffFiles.length}</span>
4032 )}
4033 </a>
4034 </nav>
4035
b558f23Claude4036 {tab === "commits" ? (
4037 <div class="prs-commits-list">
4038 {prCommits.length === 0 ? (
4039 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
4040 ) : (
4041 prCommits.map((commit) => (
4042 <div class="prs-commit-row">
4043 <span class="prs-commit-dot" aria-hidden="true"></span>
4044 <div class="prs-commit-body">
4045 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
4046 <div class="prs-commit-meta">
4047 <strong>{commit.author}</strong> committed{" "}
4048 {formatRelative(new Date(commit.date))}
4049 </div>
4050 </div>
4051 <a
4052 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
4053 class="prs-commit-sha"
4054 title="View commit"
4055 >
4056 {commit.sha.slice(0, 7)}
4057 </a>
4058 </div>
4059 ))
4060 )}
4061 </div>
4062 ) : tab === "files" ? (
ea9ed4cClaude4063 <DiffView
4064 raw={diffRaw}
4065 files={diffFiles}
4066 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
47a7a0aClaude4067 inlineComments={diffInlineComments}
4068 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
b5dd694Claude4069 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
ea9ed4cClaude4070 />
b078860Claude4071 ) : (
4072 <>
4073 {pr.body && (
4074 <CommentBox
4075 author={author?.username ?? "unknown"}
4076 date={pr.createdAt}
4077 body={renderMarkdown(pr.body)}
4078 />
4079 )}
4080
422a2d4Claude4081 {/* Block H — AI trio review (security/correctness/style). When
4082 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
4083 hoisted into a 3-column card grid above the normal comment
4084 stream so reviewers see verdicts at a glance. Disagreements
4085 are surfaced as a yellow callout. */}
4086 <TrioReviewGrid
4087 comments={comments.map(({ comment }) => comment)}
4088 />
4089
15db0e0Claude4090 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude4091 // Skip trio comments — already rendered in TrioReviewGrid above.
4092 if (isTrioComment(comment.body)) return null;
15db0e0Claude4093 const slashCmd = detectSlashCmdComment(comment.body);
4094 if (slashCmd) {
4095 const visible = stripSlashCmdMarker(comment.body);
4096 return (
4097 <div class={`slash-pill slash-cmd-${slashCmd}`}>
4098 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
4099 <span class="slash-pill-actor">
4100 <strong>{commentAuthor.username}</strong>
4101 {" ran "}
4102 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude4103 </span>
15db0e0Claude4104 <span class="slash-pill-time">
4105 {formatRelative(comment.createdAt)}
4106 </span>
4107 <div class="slash-pill-body">
4108 <MarkdownContent html={renderMarkdown(visible)} />
4109 </div>
4110 </div>
4111 );
4112 }
cb5a796Claude4113 const isPending = comment.moderationStatus === "pending";
15db0e0Claude4114 return (
cb5a796Claude4115 <div
4116 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
4117 >
15db0e0Claude4118 <div class="prs-comment-head">
4119 <strong>{commentAuthor.username}</strong>
a7460bfClaude4120 {commentAuthor.username === BOT_USERNAME && (
4121 <span class="prs-bot-badge">&#x1F916; bot</span>
4122 )}
15db0e0Claude4123 {comment.isAiReview && (
4124 <span class="prs-ai-badge">AI Review</span>
4125 )}
cb5a796Claude4126 {isPending && (
4127 <span
4128 class="modq-pending-badge"
4129 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
4130 >
4131 Awaiting approval
4132 </span>
4133 )}
15db0e0Claude4134 <span class="prs-comment-time">
4135 commented {formatRelative(comment.createdAt)}
4136 </span>
4137 {comment.filePath && (
4138 <span class="prs-comment-loc">
4139 {comment.filePath}
4140 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
4141 </span>
4142 )}
4143 </div>
4144 <div class="prs-comment-body">
4145 <MarkdownContent html={renderMarkdown(comment.body)} />
4146 </div>
0074234Claude4147 </div>
15db0e0Claude4148 );
4149 })}
0074234Claude4150
b078860Claude4151 {/* Quick link to the Files changed tab when there's a diff to look at. */}
4152 {pr.state !== "merged" && (
4153 <a
4154 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4155 class="prs-files-card"
4156 >
4157 <span class="prs-files-card-icon" aria-hidden="true">
4158 {"▤"}
4159 </span>
4160 <div class="prs-files-card-text">
4161 <p class="prs-files-card-title">Files changed</p>
4162 <p class="prs-files-card-sub">
4163 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
4164 </p>
e883329Claude4165 </div>
b078860Claude4166 <span class="prs-files-card-cta">View diff {"→"}</span>
4167 </a>
4168 )}
4169
6d1bbc2Claude4170 {linkedIssues.length > 0 && (
4171 <div class="prs-linked-issues">
4172 <div class="prs-linked-issues-head">
4173 <span>Closing issues</span>
4174 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
4175 </div>
4176 {linkedIssues.map((issue) => (
4177 <a
4178 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
4179 class="prs-linked-issue-row"
4180 >
4181 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
4182 {issue.state === "open" ? "○" : "✓"}
4183 </span>
4184 <span class="prs-linked-issue-title">{issue.title}</span>
4185 <span class="prs-linked-issue-num">#{issue.number}</span>
4186 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
4187 {issue.state}
4188 </span>
4189 </a>
4190 ))}
4191 </div>
4192 )}
4193
b078860Claude4194 {error && (
4195 <div
4196 class="auth-error"
4197 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)"
4198 >
4199 {decodeURIComponent(error)}
4200 </div>
4201 )}
4202
4203 {info && (
4204 <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)">
4205 {decodeURIComponent(info)}
4206 </div>
4207 )}
e883329Claude4208
b078860Claude4209 {pr.state === "open" && (prRisk || prRiskCalculating) && (
4210 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
4211 )}
4212
3033f70Claude4213 {/* ─── Merge Impact Analysis panel ─────────────────────── */}
4214 {impactAnalysis && pr.state === "open" && (
4215 <ImpactPanel analysis={impactAnalysis} owner={ownerName} />
4216 )}
4217
0a67773Claude4218 {/* ─── Review summary ─────────────────────────────────── */}
4219 {(approvals.length > 0 || changesRequested.length > 0) && (
4220 <div class="prs-review-summary">
4221 {approvals.length > 0 && (
4222 <div class="prs-review-row prs-review-approved">
4223 <span class="prs-review-icon">✓</span>
4224 <span>
4225 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4226 approved this pull request
4227 </span>
4228 </div>
4229 )}
4230 {changesRequested.length > 0 && (
4231 <div class="prs-review-row prs-review-changes">
4232 <span class="prs-review-icon">✗</span>
4233 <span>
4234 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4235 requested changes
4236 </span>
4237 </div>
4238 )}
4239 </div>
4240 )}
4241
ace34efClaude4242 {/* Suggested reviewers */}
4243 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
4244 <div class="prs-review-summary" style="margin-top:12px">
4245 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
4246 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
4247 Suggested reviewers
4248 </span>
4249 {reviewerSuggestions.map((r) => (
4250 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
4251 style="display:flex;align-items:center;gap:8px;width:100%">
4252 <input type="hidden" name="reviewerId" value={r.userId} />
4253 <span class="prs-reviewer-avatar">
4254 {r.username.slice(0, 1).toUpperCase()}
4255 </span>
4256 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
4257 {r.username}
4258 </a>
4259 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
4260 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
4261 Request
4262 </button>
4263 </form>
4264 ))}
4265 </div>
4266 </div>
4267 )}
4268
b078860Claude4269 {pr.state === "open" && gateChecks.length > 0 && (
4270 <div class="prs-gate-card">
4271 <div class="prs-gate-head">
4272 <h3>Gate checks</h3>
4273 <span class="prs-gate-summary">
4274 {gatesAllPassed
4275 ? `All ${gateChecks.length} checks passed`
4276 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
4277 </span>
c3e0c07Claude4278 </div>
b078860Claude4279 {gateChecks.map((check) => {
4280 const isAi = /ai.*review/i.test(check.name);
4281 const isSkip = check.skipped === true;
4282 const statusClass = isSkip
4283 ? "is-skip"
4284 : check.passed
4285 ? "is-pass"
4286 : "is-fail";
4287 const statusGlyph = isSkip
4288 ? "—"
4289 : check.passed
4290 ? "✓"
4291 : "✗";
4292 const statusLabel = isSkip
4293 ? "Skipped"
4294 : check.passed
4295 ? "Passed"
4296 : "Failing";
4297 return (
4298 <div
4299 class="prs-gate-row"
4300 style={
4301 isAi
4302 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
4303 : ""
4304 }
4305 >
4306 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
4307 {statusGlyph}
4308 </span>
4309 <span class="prs-gate-name">
4310 {check.name}
4311 {isAi && (
4312 <span
4313 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"
4314 >
4315 AI
4316 </span>
4317 )}
4318 </span>
4319 <span class="prs-gate-details">{check.details}</span>
4320 <span class={`prs-gate-pill ${statusClass}`}>
4321 {statusLabel}
e883329Claude4322 </span>
4323 </div>
b078860Claude4324 );
4325 })}
4326 <div class="prs-gate-footer">
4327 {gatesAllPassed
4328 ? "All checks passed — ready to merge."
4329 : gateChecks.some(
4330 (c) => !c.passed && c.name === "Merge check"
4331 )
4332 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
4333 : "Some checks failed — resolve issues before merging."}
4334 {aiReviewCount > 0 && (
4335 <>
4336 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
4337 </>
4338 )}
4339 </div>
4340 </div>
4341 )}
4342
240c477Claude4343 {pr.state === "open" && ciStatuses.length > 0 && (
4344 <div class="prs-ci-card">
4345 <div class="prs-ci-head">
4346 <h3>CI checks</h3>
4347 <span class="prs-ci-summary">
4348 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
4349 </span>
4350 </div>
4351 {ciStatuses.map((status) => {
4352 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
4353 return (
4354 <div class="prs-ci-row">
4355 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
4356 <span class="prs-ci-context">{status.context}</span>
4357 {status.description && (
4358 <span class="prs-ci-desc">{status.description}</span>
4359 )}
4360 <span class={`prs-ci-pill is-${status.state}`}>
4361 {status.state}
4362 </span>
4363 {status.targetUrl && (
4364 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
4365 )}
4366 </div>
4367 );
4368 })}
4369 </div>
4370 )}
4371
b078860Claude4372 {/* ─── Merge area / state-aware action card ─────────────── */}
4373 {user && pr.state === "open" && (
4374 <div
4375 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
4376 >
4377 <div class="prs-merge-head">
4378 <strong>
4379 {pr.isDraft
4380 ? "Draft — ready for review?"
4381 : mergeBlocked
4382 ? "Merge blocked"
4383 : "Ready to merge"}
4384 </strong>
e883329Claude4385 </div>
b078860Claude4386 <p class="prs-merge-sub">
4387 {pr.isDraft
4388 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
4389 : mergeBlocked
4390 ? "Resolve the failing gate checks above before this PR can land."
4391 : gateChecks.length > 0
4392 ? gatesAllPassed
4393 ? "All gates green. Merge will fast-forward into the base branch."
4394 : "Conflicts will be auto-resolved by GlueCron AI on merge."
4395 : "Run gate checks by refreshing once your branch has a recent commit."}
4396 </p>
4397 <Form
4398 method="post"
4399 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
4400 >
4401 <FormGroup>
3c03977Claude4402 <div class="live-cursor-host" style="position:relative">
4403 <textarea
4404 name="body"
4405 id="pr-comment-body"
4406 data-live-field="comment_new"
6cd2f0eClaude4407 data-md-preview=""
3c03977Claude4408 rows={5}
4409 required
4410 placeholder="Leave a comment... (Markdown supported)"
4411 style="font-family:var(--font-mono);font-size:13px;width:100%"
4412 ></textarea>
4413 </div>
15db0e0Claude4414 <span class="slash-hint" title="Type a slash-command as the first line">
4415 Type <code>/</code> for commands —{" "}
4416 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
3033f70Claude4417 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>,{" "}
4418 <code>/stage</code>
15db0e0Claude4419 </span>
b078860Claude4420 </FormGroup>
4421 <div class="prs-merge-actions">
4422 <Button type="submit" variant="primary">
4423 Comment
4424 </Button>
0a67773Claude4425 {user && user.id !== pr.authorId && pr.state === "open" && (
4426 <>
4427 <button
4428 type="submit"
4429 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4430 name="review_state"
4431 value="approved"
4432 class="prs-review-approve-btn"
4433 title="Approve this pull request"
4434 >
4435 ✓ Approve
4436 </button>
4437 <button
4438 type="submit"
4439 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4440 name="review_state"
4441 value="changes_requested"
4442 class="prs-review-changes-btn"
4443 title="Request changes before merging"
4444 >
4445 ✗ Request changes
4446 </button>
4447 </>
4448 )}
b078860Claude4449 {canManage && (
4450 <>
4451 {pr.isDraft ? (
4452 <button
4453 type="submit"
4454 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4455 formnovalidate
4456 class="prs-merge-ready-btn"
4457 >
4458 Ready for review
4459 </button>
4460 ) : (
a164a6dClaude4461 <>
4462 <div class="prs-merge-strategy-wrap">
4463 <span class="prs-merge-strategy-label">Strategy</span>
4464 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
4465 <option value="merge">Merge commit</option>
4466 <option value="squash">Squash and merge</option>
4467 <option value="ff">Fast-forward</option>
4468 </select>
4469 </div>
4470 <button
4471 type="submit"
4472 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
4473 formnovalidate
4474 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
4475 title={
4476 mergeBlocked
4477 ? "Failing gate checks must be resolved before this PR can merge."
4478 : "Merge pull request"
4479 }
4480 >
4481 {"✔"} Merge pull request
4482 </button>
4483 </>
b078860Claude4484 )}
4485 {!pr.isDraft && (
4486 <button
0074234Claude4487 type="submit"
b078860Claude4488 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
4489 formnovalidate
4490 class="prs-merge-back-draft"
4491 title="Convert back to draft"
0074234Claude4492 >
b078860Claude4493 Convert to draft
4494 </button>
4495 )}
4496 {isAiReviewEnabled() && (
4497 <button
4498 type="submit"
4499 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
4500 formnovalidate
4501 class="btn"
4502 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
4503 >
4504 Re-run AI review
4505 </button>
4506 )}
1d4ff60Claude4507 {isAiReviewEnabled() && !hasAiTestsMarker && (
4508 <button
4509 type="submit"
4510 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
4511 formnovalidate
4512 class="btn"
4513 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."
4514 >
4515 Generate tests with AI
4516 </button>
4517 )}
b078860Claude4518 <Button
4519 type="submit"
4520 variant="danger"
4521 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
4522 >
4523 Close
4524 </Button>
4525 </>
4526 )}
4527 </div>
4528 </Form>
4529 </div>
4530 )}
4531
4532 {/* Read-only footers for non-open states. */}
4533 {pr.state === "merged" && (
4534 <div class="prs-merge-card is-merged">
4535 <div class="prs-merge-head">
4536 <strong>{"⮌"} Merged</strong>
0074234Claude4537 </div>
b078860Claude4538 <p class="prs-merge-sub">
4539 This pull request was merged into{" "}
4540 <code>{pr.baseBranch}</code>.
4541 </p>
4542 </div>
4543 )}
4544 {pr.state === "closed" && (
4545 <div class="prs-merge-card is-closed">
4546 <div class="prs-merge-head">
4547 <strong>{"✕"} Closed without merging</strong>
4548 </div>
4549 <p class="prs-merge-sub">
4550 This pull request was closed and not merged.
4551 </p>
4552 </div>
4553 )}
4554 </>
4555 )}
0074234Claude4556 </Layout>
4557 );
4558});
4559
6d1bbc2Claude4560// Update branch — merge base into head so the PR branch is up to date.
4561// Uses a git worktree so the bare repo stays clean. Write access required.
4562pulls.post(
4563 "/:owner/:repo/pulls/:number/update-branch",
4564 softAuth,
4565 requireAuth,
4566 requireRepoAccess("write"),
4567 async (c) => {
4568 const { owner: ownerName, repo: repoName } = c.req.param();
4569 const prNum = parseInt(c.req.param("number"), 10);
4570 const user = c.get("user")!;
4571 const resolved = await resolveRepo(ownerName, repoName);
4572 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4573
4574 const [pr] = await db
4575 .select()
4576 .from(pullRequests)
4577 .where(and(
4578 eq(pullRequests.repositoryId, resolved.repo.id),
4579 eq(pullRequests.number, prNum),
4580 ))
4581 .limit(1);
4582 if (!pr || pr.state !== "open") {
4583 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4584 }
4585
4586 const repoDir = getRepoPath(ownerName, repoName);
4587 const wt = `${repoDir}/_update_wt_${Date.now()}`;
4588 const gitEnv = {
4589 ...process.env,
4590 GIT_AUTHOR_NAME: user.displayName || user.username,
4591 GIT_AUTHOR_EMAIL: user.email,
4592 GIT_COMMITTER_NAME: user.displayName || user.username,
4593 GIT_COMMITTER_EMAIL: user.email,
4594 };
4595
4596 const addWt = Bun.spawn(
4597 ["git", "worktree", "add", wt, pr.headBranch],
4598 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4599 );
4600 if (await addWt.exited !== 0) {
4601 return c.redirect(
4602 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
4603 );
4604 }
4605
4606 let ok = false;
4607 try {
4608 const mergeProc = Bun.spawn(
4609 ["git", "merge", "--no-edit", pr.baseBranch],
4610 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
4611 );
4612 if (await mergeProc.exited === 0) {
4613 ok = true;
4614 } else {
4615 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4616 }
4617 } catch {
4618 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4619 }
4620
4621 await Bun.spawn(
4622 ["git", "worktree", "remove", "--force", wt],
4623 { cwd: repoDir }
4624 ).exited.catch(() => {});
4625
4626 if (ok) {
4627 return c.redirect(
4628 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
4629 );
4630 }
4631 return c.redirect(
4632 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
4633 );
4634 }
4635);
4636
b558f23Claude4637// Edit PR title (and optionally body). Owner or author only.
4638pulls.post(
4639 "/:owner/:repo/pulls/:number/edit",
4640 softAuth,
4641 requireAuth,
4642 requireRepoAccess("write"),
4643 async (c) => {
4644 const { owner: ownerName, repo: repoName } = c.req.param();
4645 const prNum = parseInt(c.req.param("number"), 10);
4646 const user = c.get("user")!;
4647 const resolved = await resolveRepo(ownerName, repoName);
4648 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4649
4650 const [pr] = await db
4651 .select()
4652 .from(pullRequests)
4653 .where(and(
4654 eq(pullRequests.repositoryId, resolved.repo.id),
4655 eq(pullRequests.number, prNum),
4656 ))
4657 .limit(1);
4658 if (!pr || pr.state !== "open") {
4659 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4660 }
4661 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
4662 if (!canEdit) {
4663 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4664 }
4665
4666 const body = await c.req.parseBody();
4667 const newTitle = String(body.title || "").trim().slice(0, 256);
4668 if (!newTitle) {
4669 return c.redirect(
4670 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
4671 );
4672 }
4673
4674 await db
4675 .update(pullRequests)
4676 .set({ title: newTitle, updatedAt: new Date() })
4677 .where(eq(pullRequests.id, pr.id));
4678
4679 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
4680 }
4681);
4682
cb5a796Claude4683// Add comment to PR.
4684//
4685// Permission model mirrors `issues.tsx`: any logged-in user with read
4686// access can submit; `decideInitialStatus` routes non-collaborators
4687// through the moderation queue. Slash commands only fire when the
4688// comment is auto-approved — we don't want a banned/pending comment to
4689// silently trigger AI work on the PR.
0074234Claude4690pulls.post(
4691 "/:owner/:repo/pulls/:number/comment",
4692 softAuth,
4693 requireAuth,
cb5a796Claude4694 requireRepoAccess("read"),
0074234Claude4695 async (c) => {
4696 const { owner: ownerName, repo: repoName } = c.req.param();
4697 const prNum = parseInt(c.req.param("number"), 10);
4698 const user = c.get("user")!;
4699 const body = await c.req.parseBody();
4700 const commentBody = String(body.body || "").trim();
47a7a0aClaude4701 const filePathRaw = String(body.file_path || "").trim();
4702 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
4703 const inlineFilePath = filePathRaw || undefined;
4704 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude4705
4706 if (!commentBody) {
4707 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4708 }
4709
4710 const resolved = await resolveRepo(ownerName, repoName);
4711 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4712
4713 const [pr] = await db
4714 .select()
4715 .from(pullRequests)
4716 .where(
4717 and(
4718 eq(pullRequests.repositoryId, resolved.repo.id),
4719 eq(pullRequests.number, prNum)
4720 )
4721 )
4722 .limit(1);
4723
4724 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4725
cb5a796Claude4726 const decision = await decideInitialStatus({
4727 commenterUserId: user.id,
4728 repositoryId: resolved.repo.id,
4729 kind: "pr",
4730 threadId: pr.id,
4731 });
4732
d4ac5c3Claude4733 const [inserted] = await db
4734 .insert(prComments)
4735 .values({
4736 pullRequestId: pr.id,
4737 authorId: user.id,
4738 body: commentBody,
cb5a796Claude4739 moderationStatus: decision.status,
47a7a0aClaude4740 filePath: inlineFilePath,
4741 lineNumber: inlineLineNumber,
d4ac5c3Claude4742 })
4743 .returning();
4744
cb5a796Claude4745 // Live update: only when the comment is actually visible.
4746 if (inserted && decision.status === "approved") {
d4ac5c3Claude4747 try {
4748 const { publish } = await import("../lib/sse");
4749 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
4750 event: "pr-comment",
4751 data: {
4752 pullRequestId: pr.id,
4753 commentId: inserted.id,
4754 authorId: user.id,
4755 authorUsername: user.username,
4756 },
4757 });
4758 } catch {
4759 /* SSE is best-effort */
4760 }
4761 }
0074234Claude4762
cb5a796Claude4763 if (decision.status === "pending") {
4764 void notifyOwnerOfPendingComment({
4765 repositoryId: resolved.repo.id,
4766 commenterUsername: user.username,
4767 kind: "pr",
4768 threadNumber: prNum,
4769 ownerUsername: ownerName,
4770 repoName,
4771 });
4772 return c.redirect(
4773 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4774 );
4775 }
4776 if (decision.status === "rejected") {
4777 // Silent ban path — same UX as 'pending' so we don't leak the gate.
4778 return c.redirect(
4779 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4780 );
4781 }
4782
15db0e0Claude4783 // Slash-command handoff. We always store the original comment above
4784 // first so free-form text that happens to start with `/` is preserved
4785 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude4786 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude4787 const parsed = parseSlashCommand(commentBody);
4788 if (parsed) {
4789 try {
4790 const result = await executeSlashCommand({
4791 command: parsed.command,
4792 args: parsed.args,
4793 prId: pr.id,
4794 userId: user.id,
4795 repositoryId: resolved.repo.id,
4796 });
4797 await db.insert(prComments).values({
4798 pullRequestId: pr.id,
4799 authorId: user.id,
4800 body: result.body,
4801 });
4802 } catch (err) {
4803 // Defence-in-depth — executeSlashCommand promises not to throw,
4804 // but if it ever does we want the PR thread to know.
4805 await db
4806 .insert(prComments)
4807 .values({
4808 pullRequestId: pr.id,
4809 authorId: user.id,
4810 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
4811 })
4812 .catch(() => {});
4813 }
4814 }
4815
47a7a0aClaude4816 // Inline comments go back to the files tab; conversation comments to the conversation tab
4817 const redirectTab = inlineFilePath ? "?tab=files" : "";
4818 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude4819 }
4820);
4821
b5dd694Claude4822// Apply a suggestion from a PR comment — commits the suggested code to the
4823// head branch on behalf of the logged-in user.
4824pulls.post(
4825 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
4826 softAuth,
4827 requireAuth,
4828 requireRepoAccess("read"),
4829 async (c) => {
4830 const { owner: ownerName, repo: repoName } = c.req.param();
4831 const prNum = parseInt(c.req.param("number"), 10);
4832 const commentId = c.req.param("commentId"); // UUID
4833 const user = c.get("user")!;
4834
4835 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
4836
4837 const resolved = await resolveRepo(ownerName, repoName);
4838 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4839
4840 const [pr] = await db
4841 .select()
4842 .from(pullRequests)
4843 .where(
4844 and(
4845 eq(pullRequests.repositoryId, resolved.repo.id),
4846 eq(pullRequests.number, prNum)
4847 )
4848 )
4849 .limit(1);
4850
4851 if (!pr || pr.state !== "open") {
4852 return c.redirect(`${backUrl}&error=pr_not_open`);
4853 }
4854
4855 // Only PR author or repo owner may apply suggestions.
4856 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
4857 return c.redirect(`${backUrl}&error=forbidden`);
4858 }
4859
4860 // Load the comment.
4861 const [comment] = await db
4862 .select()
4863 .from(prComments)
4864 .where(
4865 and(
4866 eq(prComments.id, commentId),
4867 eq(prComments.pullRequestId, pr.id)
4868 )
4869 )
4870 .limit(1);
4871
4872 if (!comment) {
4873 return c.redirect(`${backUrl}&error=comment_not_found`);
4874 }
4875
4876 // Parse suggestion block from comment body.
4877 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
4878 if (!m) {
4879 return c.redirect(`${backUrl}&error=no_suggestion`);
4880 }
4881 const suggestionCode = m[1];
4882
4883 // Get the commenter's details for the commit message co-author line.
4884 const [commenter] = await db
4885 .select()
4886 .from(users)
4887 .where(eq(users.id, comment.authorId))
4888 .limit(1);
4889
4890 // Fetch current file content from head branch.
4891 if (!comment.filePath) {
4892 return c.redirect(`${backUrl}&error=file_not_found`);
4893 }
4894 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
4895 if (!blob) {
4896 return c.redirect(`${backUrl}&error=file_not_found`);
4897 }
4898
4899 // Apply the patch — replace the target line(s) with suggestion lines.
4900 const lines = blob.content.split('\n');
4901 const lineIdx = (comment.lineNumber ?? 1) - 1;
4902 if (lineIdx < 0 || lineIdx >= lines.length) {
4903 return c.redirect(`${backUrl}&error=line_out_of_range`);
4904 }
4905 const suggestionLines = suggestionCode.split('\n');
4906 lines.splice(lineIdx, 1, ...suggestionLines);
4907 const newContent = lines.join('\n');
4908
4909 // Commit the change.
4910 const coAuthorLine = commenter
4911 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
4912 : "";
4913 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
4914
4915 const result = await createOrUpdateFileOnBranch({
4916 owner: ownerName,
4917 name: repoName,
4918 branch: pr.headBranch,
4919 filePath: comment.filePath,
4920 bytes: new TextEncoder().encode(newContent),
4921 message: commitMessage,
4922 authorName: user.username,
4923 authorEmail: `${user.username}@users.noreply.gluecron.com`,
4924 });
4925
4926 if ("error" in result) {
4927 return c.redirect(`${backUrl}&error=apply_failed`);
4928 }
4929
4930 // Post a follow-up comment noting the suggestion was applied.
4931 await db.insert(prComments).values({
4932 pullRequestId: pr.id,
4933 authorId: user.id,
4934 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
4935 });
4936
4937 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
4938 }
4939);
4940
0a67773Claude4941// Formal review — Approve / Request Changes / Comment
4942pulls.post(
4943 "/:owner/:repo/pulls/:number/review",
4944 softAuth,
4945 requireAuth,
4946 requireRepoAccess("read"),
4947 async (c) => {
4948 const { owner: ownerName, repo: repoName } = c.req.param();
4949 const prNum = parseInt(c.req.param("number"), 10);
4950 const user = c.get("user")!;
4951 const body = await c.req.parseBody();
4952 const reviewBody = String(body.body || "").trim();
4953 const reviewState = String(body.review_state || "commented");
4954
4955 const validStates = ["approved", "changes_requested", "commented"];
4956 if (!validStates.includes(reviewState)) {
4957 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4958 }
4959
4960 const resolved = await resolveRepo(ownerName, repoName);
4961 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4962
4963 const [pr] = await db
4964 .select()
4965 .from(pullRequests)
4966 .where(
4967 and(
4968 eq(pullRequests.repositoryId, resolved.repo.id),
4969 eq(pullRequests.number, prNum)
4970 )
4971 )
4972 .limit(1);
4973 if (!pr || pr.state !== "open") {
4974 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4975 }
4976 // Authors can't review their own PR
4977 if (pr.authorId === user.id) {
4978 return c.redirect(
4979 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
4980 );
4981 }
4982
4983 await db.insert(prReviews).values({
4984 pullRequestId: pr.id,
4985 reviewerId: user.id,
4986 state: reviewState,
4987 body: reviewBody || null,
4988 });
4989
4990 const stateLabel =
4991 reviewState === "approved" ? "Approved"
4992 : reviewState === "changes_requested" ? "Changes requested"
4993 : "Commented";
4994 return c.redirect(
4995 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
4996 );
4997 }
4998);
4999
e883329Claude5000// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude5001// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
5002// but we keep it at "write" for v1 so trusted collaborators can ship.
5003// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
5004// surface. Branch-protection rules (evaluated below) are the current mechanism
5005// for locking down merges further on specific branches.
0074234Claude5006pulls.post(
5007 "/:owner/:repo/pulls/:number/merge",
5008 softAuth,
5009 requireAuth,
04f6b7fClaude5010 requireRepoAccess("write"),
0074234Claude5011 async (c) => {
5012 const { owner: ownerName, repo: repoName } = c.req.param();
5013 const prNum = parseInt(c.req.param("number"), 10);
5014 const user = c.get("user")!;
5015
a164a6dClaude5016 // Read merge strategy from form (default: merge commit)
5017 let mergeStrategy = "merge";
5018 try {
5019 const body = await c.req.parseBody();
5020 const s = body.merge_strategy;
5021 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
5022 } catch { /* ignore parse errors — default to merge commit */ }
5023
0074234Claude5024 const resolved = await resolveRepo(ownerName, repoName);
5025 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5026
5027 const [pr] = await db
5028 .select()
5029 .from(pullRequests)
5030 .where(
5031 and(
5032 eq(pullRequests.repositoryId, resolved.repo.id),
5033 eq(pullRequests.number, prNum)
5034 )
5035 )
5036 .limit(1);
5037
5038 if (!pr || pr.state !== "open") {
5039 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5040 }
5041
6fc53bdClaude5042 // Draft PRs cannot be merged — must be marked ready first.
5043 if (pr.isDraft) {
5044 return c.redirect(
5045 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5046 "This PR is a draft. Mark it as ready for review before merging."
5047 )}`
5048 );
5049 }
5050
e883329Claude5051 // Resolve head SHA
5052 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
5053 if (!headSha) {
5054 return c.redirect(
5055 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
5056 );
5057 }
5058
5059 // Check if AI review approved this PR
5060 const aiComments = await db
5061 .select()
5062 .from(prComments)
5063 .where(
5064 and(
5065 eq(prComments.pullRequestId, pr.id),
5066 eq(prComments.isAiReview, true)
5067 )
5068 );
5069 const aiApproved = aiComments.length === 0 || aiComments.some(
5070 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude5071 );
e883329Claude5072
5073 // Run all green gate checks (GateTest + mergeability + AI review)
5074 const gateResult = await runAllGateChecks(
5075 ownerName,
5076 repoName,
5077 pr.baseBranch,
5078 pr.headBranch,
5079 headSha,
5080 aiApproved
0074234Claude5081 );
5082
e883329Claude5083 // If GateTest or AI review failed (hard blocks), reject the merge
5084 const hardFailures = gateResult.checks.filter(
5085 (check) => !check.passed && check.name !== "Merge check"
5086 );
5087 if (hardFailures.length > 0) {
5088 const errorMsg = hardFailures
5089 .map((f) => `${f.name}: ${f.details}`)
5090 .join("; ");
0074234Claude5091 return c.redirect(
e883329Claude5092 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude5093 );
5094 }
5095
1e162a8Claude5096 // D5 — Branch-protection enforcement. Looks up the matching rule for the
5097 // base branch and blocks the merge if requireAiApproval / requireGreenGates
5098 // / requireHumanReview / requiredApprovals are not satisfied. Independent
5099 // of repo-global settings, so owners can lock specific branches down
5100 // further than the repo default.
5101 const protectionRule = await matchProtection(
5102 resolved.repo.id,
5103 pr.baseBranch
5104 );
5105 if (protectionRule) {
5106 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude5107 const required = await listRequiredChecks(protectionRule.id);
5108 const passingNames = required.length > 0
5109 ? await passingCheckNames(resolved.repo.id, headSha)
5110 : [];
5111 const decision = evaluateProtection(
5112 protectionRule,
5113 {
5114 aiApproved,
5115 humanApprovalCount: humanApprovals,
5116 gateResultGreen: hardFailures.length === 0,
5117 hasFailedGates: hardFailures.length > 0,
5118 passingCheckNames: passingNames,
5119 },
5120 required.map((r) => r.checkName)
5121 );
1e162a8Claude5122 if (!decision.allowed) {
5123 return c.redirect(
5124 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5125 decision.reasons.join(" ")
5126 )}`
5127 );
5128 }
5129 }
5130
e883329Claude5131 // Attempt the merge — with auto conflict resolution if needed
5132 const repoDir = getRepoPath(ownerName, repoName);
5133 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
5134 const hasConflicts = mergeCheck && !mergeCheck.passed;
5135
5136 if (hasConflicts && isAiReviewEnabled()) {
5137 // Use Claude to auto-resolve conflicts
5138 const mergeResult = await mergeWithAutoResolve(
5139 ownerName,
5140 repoName,
5141 pr.baseBranch,
5142 pr.headBranch,
5143 `Merge pull request #${pr.number}: ${pr.title}`
5144 );
5145
5146 if (!mergeResult.success) {
5147 return c.redirect(
5148 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
5149 );
5150 }
5151
5152 // Post a comment about the auto-resolution
5153 if (mergeResult.resolvedFiles.length > 0) {
5154 await db.insert(prComments).values({
5155 pullRequestId: pr.id,
5156 authorId: user.id,
5157 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
5158 isAiReview: true,
5159 });
5160 }
5161 } else {
a164a6dClaude5162 // Worktree-based merge: supports merge-commit, squash, and fast-forward
5163 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
5164 const gitEnv = {
5165 ...process.env,
5166 GIT_AUTHOR_NAME: user.displayName || user.username,
5167 GIT_AUTHOR_EMAIL: user.email,
5168 GIT_COMMITTER_NAME: user.displayName || user.username,
5169 GIT_COMMITTER_EMAIL: user.email,
5170 };
5171
5172 // Create linked worktree on the base branch
5173 const addWt = Bun.spawn(
5174 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude5175 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5176 );
a164a6dClaude5177 if (await addWt.exited !== 0) {
5178 return c.redirect(
5179 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
5180 );
5181 }
5182
5183 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
5184 let mergeOk = false;
5185
5186 try {
5187 if (mergeStrategy === "squash") {
5188 // Squash: stage all changes without committing
5189 const squashProc = Bun.spawn(
5190 ["git", "merge", "--squash", headSha],
5191 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5192 );
5193 if (await squashProc.exited !== 0) {
5194 const errTxt = await new Response(squashProc.stderr).text();
5195 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
5196 }
5197 // Commit the squashed changes
5198 const commitProc = Bun.spawn(
5199 ["git", "commit", "-m", commitMsg],
5200 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5201 );
5202 if (await commitProc.exited !== 0) {
5203 const errTxt = await new Response(commitProc.stderr).text();
5204 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
5205 }
5206 mergeOk = true;
5207 } else if (mergeStrategy === "ff") {
5208 // Fast-forward only — fail if FF is not possible
5209 const ffProc = Bun.spawn(
5210 ["git", "merge", "--ff-only", headSha],
5211 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5212 );
5213 if (await ffProc.exited !== 0) {
5214 const errTxt = await new Response(ffProc.stderr).text();
5215 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
5216 }
5217 mergeOk = true;
5218 } else {
5219 // Default: merge commit (--no-ff always creates a merge commit)
5220 const mergeProc = Bun.spawn(
5221 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
5222 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5223 );
5224 if (await mergeProc.exited !== 0) {
5225 const errTxt = await new Response(mergeProc.stderr).text();
5226 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
5227 }
5228 mergeOk = true;
5229 }
5230 } catch (err) {
5231 // Always clean up the worktree before redirecting
5232 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
5233 const msg = err instanceof Error ? err.message : "Merge failed";
5234 return c.redirect(
5235 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
5236 );
5237 }
5238
5239 // Clean up worktree (changes are now in the bare repo via linked worktree)
5240 await Bun.spawn(
5241 ["git", "worktree", "remove", "--force", wt],
5242 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5243 ).exited.catch(() => {});
e883329Claude5244
a164a6dClaude5245 if (!mergeOk) {
e883329Claude5246 return c.redirect(
a164a6dClaude5247 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude5248 );
5249 }
5250 }
5251
0074234Claude5252 await db
5253 .update(pullRequests)
5254 .set({
5255 state: "merged",
5256 mergedAt: new Date(),
5257 mergedBy: user.id,
5258 updatedAt: new Date(),
5259 })
5260 .where(eq(pullRequests.id, pr.id));
5261
8809b87Claude5262 // Chat notifier — fan out merge event to Slack/Discord/Teams.
5263 import("../lib/chat-notifier")
5264 .then((m) =>
5265 m.notifyChatChannels({
5266 ownerUserId: resolved.repo.ownerId,
5267 repositoryId: resolved.repo.id,
5268 event: {
5269 event: "pr.merged",
5270 repo: `${ownerName}/${repoName}`,
5271 title: `#${pr.number} ${pr.title}`,
5272 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
5273 actor: user.username,
5274 },
5275 })
5276 )
5277 .catch((err) =>
5278 console.warn(`[chat-notifier] PR merge notify failed:`, err)
5279 );
5280
d62fb36Claude5281 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
5282 // and auto-close each matching open issue with a back-link comment. Bounded
5283 // to the same repo for v1 (cross-repo refs ignored). Failures never block
5284 // the merge redirect.
5285 try {
5286 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
5287 const refs = extractClosingRefsMulti([pr.title, pr.body]);
5288 for (const n of refs) {
5289 const [issue] = await db
5290 .select()
5291 .from(issues)
5292 .where(
5293 and(
5294 eq(issues.repositoryId, resolved.repo.id),
5295 eq(issues.number, n)
5296 )
5297 )
5298 .limit(1);
5299 if (!issue || issue.state !== "open") continue;
5300 await db
5301 .update(issues)
5302 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
5303 .where(eq(issues.id, issue.id));
5304 await db.insert(issueComments).values({
5305 issueId: issue.id,
5306 authorId: user.id,
5307 body: `Closed by pull request #${pr.number}.`,
5308 });
5309 }
5310 } catch {
5311 // Never block the merge on close-keyword failures.
5312 }
5313
0074234Claude5314 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5315 }
5316);
5317
6fc53bdClaude5318// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
5319// hasn't run yet on this PR.
5320pulls.post(
5321 "/:owner/:repo/pulls/:number/ready",
5322 softAuth,
5323 requireAuth,
04f6b7fClaude5324 requireRepoAccess("write"),
6fc53bdClaude5325 async (c) => {
5326 const { owner: ownerName, repo: repoName } = c.req.param();
5327 const prNum = parseInt(c.req.param("number"), 10);
5328 const user = c.get("user")!;
5329
5330 const resolved = await resolveRepo(ownerName, repoName);
5331 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5332
5333 const [pr] = await db
5334 .select()
5335 .from(pullRequests)
5336 .where(
5337 and(
5338 eq(pullRequests.repositoryId, resolved.repo.id),
5339 eq(pullRequests.number, prNum)
5340 )
5341 )
5342 .limit(1);
5343 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5344
5345 // Only the author or repo owner can toggle draft state.
5346 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5347 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5348 }
5349
5350 if (pr.state === "open" && pr.isDraft) {
5351 await db
5352 .update(pullRequests)
5353 .set({ isDraft: false, updatedAt: new Date() })
5354 .where(eq(pullRequests.id, pr.id));
5355
5356 if (isAiReviewEnabled()) {
5357 triggerAiReview(
5358 ownerName,
5359 repoName,
5360 pr.id,
5361 pr.title,
0316dbbClaude5362 pr.body || "",
6fc53bdClaude5363 pr.baseBranch,
5364 pr.headBranch
5365 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
5366 }
5367 }
5368
5369 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5370 }
5371);
5372
5373// Convert a PR back to draft.
5374pulls.post(
5375 "/:owner/:repo/pulls/:number/draft",
5376 softAuth,
5377 requireAuth,
04f6b7fClaude5378 requireRepoAccess("write"),
6fc53bdClaude5379 async (c) => {
5380 const { owner: ownerName, repo: repoName } = c.req.param();
5381 const prNum = parseInt(c.req.param("number"), 10);
5382 const user = c.get("user")!;
5383
5384 const resolved = await resolveRepo(ownerName, repoName);
5385 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5386
5387 const [pr] = await db
5388 .select()
5389 .from(pullRequests)
5390 .where(
5391 and(
5392 eq(pullRequests.repositoryId, resolved.repo.id),
5393 eq(pullRequests.number, prNum)
5394 )
5395 )
5396 .limit(1);
5397 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5398
5399 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5400 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5401 }
5402
5403 if (pr.state === "open" && !pr.isDraft) {
5404 await db
5405 .update(pullRequests)
5406 .set({ isDraft: true, updatedAt: new Date() })
5407 .where(eq(pullRequests.id, pr.id));
5408 }
5409
5410 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5411 }
5412);
5413
0074234Claude5414// Close PR
5415pulls.post(
5416 "/:owner/:repo/pulls/:number/close",
5417 softAuth,
5418 requireAuth,
04f6b7fClaude5419 requireRepoAccess("write"),
0074234Claude5420 async (c) => {
5421 const { owner: ownerName, repo: repoName } = c.req.param();
5422 const prNum = parseInt(c.req.param("number"), 10);
5423
5424 const resolved = await resolveRepo(ownerName, repoName);
5425 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5426
5427 await db
5428 .update(pullRequests)
5429 .set({
5430 state: "closed",
5431 closedAt: new Date(),
5432 updatedAt: new Date(),
5433 })
5434 .where(
5435 and(
5436 eq(pullRequests.repositoryId, resolved.repo.id),
5437 eq(pullRequests.number, prNum)
5438 )
5439 );
5440
5441 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5442 }
5443);
5444
c3e0c07Claude5445// Re-run AI review on demand (e.g. after a force-push). Bypasses the
5446// idempotency marker via { force: true }. Write-access only.
5447pulls.post(
5448 "/:owner/:repo/pulls/:number/ai-rereview",
5449 softAuth,
5450 requireAuth,
5451 requireRepoAccess("write"),
5452 async (c) => {
5453 const { owner: ownerName, repo: repoName } = c.req.param();
5454 const prNum = parseInt(c.req.param("number"), 10);
5455 const resolved = await resolveRepo(ownerName, repoName);
5456 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5457
5458 const [pr] = await db
5459 .select()
5460 .from(pullRequests)
5461 .where(
5462 and(
5463 eq(pullRequests.repositoryId, resolved.repo.id),
5464 eq(pullRequests.number, prNum)
5465 )
5466 )
5467 .limit(1);
5468 if (!pr) {
5469 return c.redirect(`/${ownerName}/${repoName}/pulls`);
5470 }
5471
5472 if (!isAiReviewEnabled()) {
5473 return c.redirect(
5474 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5475 "AI review is not configured (ANTHROPIC_API_KEY)."
5476 )}`
5477 );
5478 }
5479
5480 // Fire-and-forget but with { force: true } to bypass the
5481 // already-reviewed marker. The function still never throws.
5482 triggerAiReview(
5483 ownerName,
5484 repoName,
5485 pr.id,
5486 pr.title || "",
5487 pr.body || "",
5488 pr.baseBranch,
5489 pr.headBranch,
5490 { force: true }
a28cedeClaude5491 ).catch((err) => {
5492 console.warn(
5493 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
5494 err instanceof Error ? err.message : err
5495 );
5496 });
c3e0c07Claude5497
5498 return c.redirect(
5499 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5500 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
5501 )}`
5502 );
5503 }
5504);
5505
1d4ff60Claude5506// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
5507// the PR's head branch carrying just the new test files. Write-access only.
5508// Idempotent — if `ai:added-tests` was previously applied we redirect with
5509// an `info` banner instead of re-firing.
5510pulls.post(
5511 "/:owner/:repo/pulls/:number/generate-tests",
5512 softAuth,
5513 requireAuth,
5514 requireRepoAccess("write"),
5515 async (c) => {
5516 const { owner: ownerName, repo: repoName } = c.req.param();
5517 const prNum = parseInt(c.req.param("number"), 10);
5518 const resolved = await resolveRepo(ownerName, repoName);
5519 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5520
5521 const [pr] = await db
5522 .select()
5523 .from(pullRequests)
5524 .where(
5525 and(
5526 eq(pullRequests.repositoryId, resolved.repo.id),
5527 eq(pullRequests.number, prNum)
5528 )
5529 )
5530 .limit(1);
5531 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5532
5533 if (!isAiReviewEnabled()) {
5534 return c.redirect(
5535 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5536 "AI test generation is not configured (ANTHROPIC_API_KEY)."
5537 )}`
5538 );
5539 }
5540
5541 // Fire-and-forget. The lib never throws.
5542 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
5543 .then((res) => {
5544 if (!res.ok) {
5545 console.warn(
5546 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
5547 );
5548 }
5549 })
5550 .catch((err) => {
5551 console.warn(
5552 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
5553 err instanceof Error ? err.message : err
5554 );
5555 });
5556
5557 return c.redirect(
5558 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5559 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
5560 )}`
5561 );
5562 }
5563);
5564
ace34efClaude5565// ─── Request review ───────────────────────────────────────────────────────────
5566pulls.post(
5567 "/:owner/:repo/pulls/:number/request-review",
5568 softAuth,
5569 requireAuth,
5570 requireRepoAccess("write"),
5571 async (c) => {
5572 const { owner: ownerName, repo: repoName } = c.req.param();
5573 const prNum = parseInt(c.req.param("number"), 10);
5574 const user = c.get("user")!;
5575
5576 const resolved = await resolveRepo(ownerName, repoName);
5577 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5578
5579 const [pr] = await db
5580 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
5581 .from(pullRequests)
5582 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5583 .limit(1);
5584
5585 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5586
5587 const body = await c.req.formData().catch(() => null);
5588 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
5589
5590 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
5591 return c.redirect(
5592 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
5593 );
5594 }
5595
f4abb8eClaude5596 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
5597 // requesting reviews from arbitrary user IDs outside this repository.
5598 const isOwner = reviewerId === resolved.owner.id;
5599 if (!isOwner) {
5600 const [collab] = await db
5601 .select({ id: repoCollaborators.id })
5602 .from(repoCollaborators)
5603 .where(
5604 and(
5605 eq(repoCollaborators.repositoryId, resolved.repo.id),
5606 eq(repoCollaborators.userId, reviewerId),
5607 isNotNull(repoCollaborators.acceptedAt)
5608 )
5609 )
5610 .limit(1);
5611 if (!collab) {
5612 return c.redirect(
5613 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
5614 );
5615 }
5616 }
5617
ace34efClaude5618 const { requestReview } = await import("../lib/reviewer-suggest");
5619 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
5620
5621 const msg = result.ok
5622 ? "Review requested successfully."
5623 : `Failed to request review: ${result.error ?? "unknown error"}`;
5624
5625 return c.redirect(
5626 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
5627 );
5628 }
5629);
5630
0074234Claude5631export default pulls;