Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame5466 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";
b93fc3eClaude69import { getReviewContext, recordPrVisit, type ReviewContext } from "../lib/review-context";
534f04aClaude70import {
71 computePrRiskForPullRequest,
72 getCachedPrRisk,
73 type PrRiskScore,
74} from "../lib/pr-risk";
0316dbbClaude75import { runAllGateChecks } from "../lib/gate";
76import type { GateCheckResult } from "../lib/gate";
77import {
78 matchProtection,
79 countHumanApprovals,
80 listRequiredChecks,
81 passingCheckNames,
82 evaluateProtection,
83} from "../lib/branch-protection";
84import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude85import {
86 listBranches,
87 getRepoPath,
e883329Claude88 resolveRef,
b5dd694Claude89 getBlob,
90 createOrUpdateFileOnBranch,
b558f23Claude91 commitsBetween,
0074234Claude92} from "../git/repository";
b558f23Claude93import type { GitDiffFile, GitCommit } from "../git/repository";
240c477Claude94import { listStatuses } from "../lib/commit-statuses";
95import type { CommitStatus } from "../db/schema";
0074234Claude96import { html } from "hono/html";
4bbacbeClaude97import {
98 getPreviewForBranch,
99 previewStatusLabel,
100} from "../lib/branch-previews";
1e162a8Claude101import {
bb0f894Claude102 Flex,
103 Container,
104 Badge,
105 Button,
106 LinkButton,
107 Form,
108 FormGroup,
109 Input,
110 TextArea,
111 Select,
112 EmptyState,
113 FilterTabs,
114 TabNav,
115 List,
116 ListItem,
117 Text,
118 Alert,
119 MarkdownContent,
120 CommentBox,
121 formatRelative,
122} from "../views/ui";
0074234Claude123
ace34efClaude124import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
74d8c4dClaude125import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
a7460bfClaude126import { BOT_USERNAME } from "../lib/bot-user";
ace34efClaude127
0074234Claude128const pulls = new Hono<AuthEnv>();
129
b078860Claude130/* ──────────────────────────────────────────────────────────────────────
131 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
132 * into the issue tracker or any other route. Tokens come from layout.tsx
133 * `:root` so light/dark stays consistent if/when light mode lands.
134 * ──────────────────────────────────────────────────────────────────── */
135const PRS_LIST_STYLES = `
136 .prs-hero {
137 position: relative;
138 margin: 0 0 var(--space-5);
139 padding: 22px 26px 24px;
140 background: var(--bg-elevated);
141 border: 1px solid var(--border);
142 border-radius: 16px;
143 overflow: hidden;
144 }
145 .prs-hero::before {
146 content: '';
147 position: absolute; top: 0; left: 0; right: 0;
148 height: 2px;
149 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
150 opacity: 0.7;
151 pointer-events: none;
152 }
153 .prs-hero-inner {
154 position: relative;
155 display: flex;
156 justify-content: space-between;
157 align-items: flex-end;
158 gap: 20px;
159 flex-wrap: wrap;
160 }
161 .prs-hero-text { flex: 1; min-width: 280px; }
162 .prs-hero-eyebrow {
163 font-size: 12px;
164 color: var(--text-muted);
165 text-transform: uppercase;
166 letter-spacing: 0.08em;
167 font-weight: 600;
168 margin-bottom: 8px;
169 }
170 .prs-hero-title {
171 font-family: var(--font-display);
172 font-size: clamp(26px, 3.4vw, 34px);
173 font-weight: 800;
174 letter-spacing: -0.025em;
175 line-height: 1.06;
176 margin: 0 0 8px;
177 color: var(--text-strong);
178 }
179 .prs-hero-title .gradient-text {
180 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
181 -webkit-background-clip: text;
182 background-clip: text;
183 -webkit-text-fill-color: transparent;
184 color: transparent;
185 }
186 .prs-hero-sub {
187 font-size: 14.5px;
188 color: var(--text-muted);
189 margin: 0;
190 line-height: 1.5;
191 max-width: 620px;
192 }
193 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
194 .prs-cta {
195 display: inline-flex; align-items: center; gap: 6px;
196 padding: 10px 16px;
197 border-radius: 10px;
198 font-size: 13.5px;
199 font-weight: 600;
200 color: #fff;
201 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
202 border: 1px solid rgba(140,109,255,0.55);
203 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
204 text-decoration: none;
205 transition: transform 120ms ease, box-shadow 160ms ease;
206 }
207 .prs-cta:hover {
208 transform: translateY(-1px);
209 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
210 color: #fff;
211 }
212
213 .prs-tabs {
214 display: flex; flex-wrap: wrap; gap: 6px;
215 margin: 0 0 18px;
216 padding: 6px;
217 background: var(--bg-secondary);
218 border: 1px solid var(--border);
219 border-radius: 12px;
220 }
221 .prs-tab {
222 display: inline-flex; align-items: center; gap: 8px;
223 padding: 7px 13px;
224 font-size: 13px;
225 font-weight: 500;
226 color: var(--text-muted);
227 border-radius: 8px;
228 text-decoration: none;
229 transition: background 120ms ease, color 120ms ease;
230 }
231 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
232 .prs-tab.is-active {
233 background: var(--bg-elevated);
234 color: var(--text-strong);
235 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
236 }
237 .prs-tab-count {
238 display: inline-flex; align-items: center; justify-content: center;
239 min-width: 22px; padding: 2px 7px;
240 font-size: 11.5px;
241 font-weight: 600;
242 border-radius: 9999px;
243 background: var(--bg-tertiary);
244 color: var(--text-muted);
245 }
246 .prs-tab.is-active .prs-tab-count {
247 background: rgba(140,109,255,0.18);
248 color: var(--text-link);
249 }
250
251 .prs-list { display: flex; flex-direction: column; gap: 10px; }
252 .prs-row {
253 position: relative;
254 display: flex; align-items: flex-start; gap: 14px;
255 padding: 14px 16px;
256 background: var(--bg-elevated);
257 border: 1px solid var(--border);
258 border-radius: 12px;
259 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
260 }
261 .prs-row:hover {
262 transform: translateY(-1px);
263 border-color: var(--border-strong);
264 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
265 }
266 .prs-row-icon {
267 flex: 0 0 auto;
268 width: 26px; height: 26px;
269 display: inline-flex; align-items: center; justify-content: center;
270 border-radius: 9999px;
271 font-size: 13px;
272 margin-top: 2px;
273 }
274 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
275 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
276 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
277 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
278 .prs-row-body { flex: 1; min-width: 0; }
279 .prs-row-title {
280 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
281 font-size: 15px; font-weight: 600;
282 color: var(--text-strong);
283 line-height: 1.35;
284 margin: 0 0 6px;
285 }
286 .prs-row-number {
287 color: var(--text-muted);
288 font-weight: 400;
289 font-size: 14px;
290 }
291 .prs-row-meta {
292 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
293 font-size: 12.5px;
294 color: var(--text-muted);
295 }
296 .prs-branch-chips {
297 display: inline-flex; align-items: center; gap: 6px;
298 font-family: var(--font-mono);
299 font-size: 11.5px;
300 }
301 .prs-branch-chip {
302 padding: 2px 8px;
303 border-radius: 9999px;
304 background: var(--bg-tertiary);
305 border: 1px solid var(--border);
306 color: var(--text);
307 }
308 .prs-branch-arrow {
309 color: var(--text-faint);
310 font-size: 11px;
311 }
312 .prs-row-tags {
313 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
314 margin-left: auto;
315 }
316 .prs-tag {
317 display: inline-flex; align-items: center; gap: 4px;
318 padding: 2px 8px;
319 font-size: 11px;
320 font-weight: 600;
321 border-radius: 9999px;
322 border: 1px solid var(--border);
323 background: var(--bg-secondary);
324 color: var(--text-muted);
325 line-height: 1.6;
326 }
327 .prs-tag.is-draft {
328 color: var(--text-muted);
329 border-color: var(--border-strong);
330 }
331 .prs-tag.is-merged {
332 color: var(--text-link);
333 border-color: rgba(140,109,255,0.45);
334 background: rgba(140,109,255,0.10);
335 }
1aef949Claude336 .prs-tag.is-approved {
337 color: #34d399;
338 border-color: rgba(52,211,153,0.40);
339 background: rgba(52,211,153,0.08);
340 }
341 .prs-tag.is-changes {
342 color: #f87171;
343 border-color: rgba(248,113,113,0.40);
344 background: rgba(248,113,113,0.08);
345 }
b078860Claude346
347 .prs-empty {
ea9ed4cClaude348 position: relative;
349 padding: 56px 32px;
b078860Claude350 text-align: center;
351 border: 1px dashed var(--border);
ea9ed4cClaude352 border-radius: 16px;
353 background: var(--bg-elevated);
b078860Claude354 color: var(--text-muted);
ea9ed4cClaude355 overflow: hidden;
b078860Claude356 }
ea9ed4cClaude357 .prs-empty::before {
358 content: '';
359 position: absolute;
360 inset: -40% -20% auto auto;
361 width: 320px; height: 320px;
362 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
363 filter: blur(70px);
364 opacity: 0.55;
365 pointer-events: none;
366 animation: prsEmptyOrb 16s ease-in-out infinite;
367 }
368 @keyframes prsEmptyOrb {
369 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
370 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
371 }
372 @media (prefers-reduced-motion: reduce) {
373 .prs-empty::before { animation: none; }
374 }
375 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude376 .prs-empty strong {
377 display: block;
378 color: var(--text-strong);
ea9ed4cClaude379 font-family: var(--font-display);
380 font-size: 22px;
381 font-weight: 700;
382 letter-spacing: -0.018em;
383 margin-bottom: 2px;
384 }
385 .prs-empty-sub {
386 font-size: 14.5px;
387 color: var(--text-muted);
388 line-height: 1.55;
389 max-width: 460px;
390 margin: 0 0 18px;
b078860Claude391 }
ea9ed4cClaude392 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude393
394 @media (max-width: 720px) {
395 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
396 .prs-hero-actions { width: 100%; }
397 .prs-row-tags { margin-left: 0; }
398 }
f1dc7c7Claude399
400 /* Additional mobile rules. Additive only. */
401 @media (max-width: 720px) {
402 .prs-hero { padding: 18px 18px 20px; }
403 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
404 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
405 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
406 .prs-row { padding: 12px 14px; gap: 10px; }
407 .prs-row-icon { width: 24px; height: 24px; }
408 }
f5b9ef5Claude409
410 /* ─── Sort controls (PR list) ─── */
411 .prs-sort-row {
412 display: flex;
413 align-items: center;
414 gap: 6px;
415 margin: 0 0 12px;
416 flex-wrap: wrap;
417 }
418 .prs-sort-label {
419 font-size: 12.5px;
420 color: var(--text-muted);
421 font-weight: 600;
422 margin-right: 2px;
423 }
424 .prs-sort-opt {
425 font-size: 12.5px;
426 color: var(--text-muted);
427 text-decoration: none;
428 padding: 3px 10px;
429 border-radius: 9999px;
430 border: 1px solid transparent;
431 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
432 }
433 .prs-sort-opt:hover {
434 background: var(--bg-hover);
435 color: var(--text);
436 }
437 .prs-sort-opt.is-active {
438 background: rgba(140,109,255,0.12);
439 color: var(--text-link);
440 border-color: rgba(140,109,255,0.35);
441 font-weight: 600;
442 }
b078860Claude443`;
444
445/* ──────────────────────────────────────────────────────────────────────
446 * Inline CSS for the detail page. Same `.prs-*` namespace.
447 * ──────────────────────────────────────────────────────────────────── */
448const PRS_DETAIL_STYLES = `
449 .prs-detail-hero {
450 position: relative;
451 margin: 0 0 var(--space-4);
452 padding: 24px 26px;
453 background: var(--bg-elevated);
454 border: 1px solid var(--border);
455 border-radius: 16px;
456 overflow: hidden;
457 }
458 .prs-detail-hero::before {
459 content: '';
460 position: absolute; top: 0; left: 0; right: 0;
461 height: 2px;
462 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
463 opacity: 0.7;
464 pointer-events: none;
465 }
466 .prs-detail-title {
467 font-family: var(--font-display);
468 font-size: clamp(22px, 2.6vw, 28px);
469 font-weight: 700;
470 letter-spacing: -0.022em;
471 line-height: 1.2;
472 color: var(--text-strong);
473 margin: 0 0 12px;
474 }
475 .prs-detail-num {
476 color: var(--text-muted);
477 font-weight: 400;
478 }
479 .prs-state-pill {
480 display: inline-flex; align-items: center; gap: 6px;
481 padding: 6px 12px;
482 border-radius: 9999px;
483 font-size: 12.5px;
484 font-weight: 600;
485 line-height: 1;
486 border: 1px solid transparent;
487 }
488 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
489 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
490 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
491 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
492
74d8c4dClaude493 .prs-size-badge {
494 display: inline-flex;
495 align-items: center;
496 padding: 2px 8px;
497 border-radius: 20px;
498 font-size: 11px;
499 font-weight: 700;
500 letter-spacing: 0.04em;
501 border: 1px solid currentColor;
502 opacity: 0.85;
503 }
504
b078860Claude505 .prs-detail-meta {
506 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
507 font-size: 13px;
508 color: var(--text-muted);
509 }
510 .prs-detail-meta strong { color: var(--text); }
511 .prs-detail-branches {
512 display: inline-flex; align-items: center; gap: 6px;
513 font-family: var(--font-mono);
514 font-size: 12px;
515 }
516 .prs-branch-pill {
517 padding: 3px 9px;
518 border-radius: 9999px;
519 background: var(--bg-tertiary);
520 border: 1px solid var(--border);
521 color: var(--text);
522 }
523 .prs-branch-pill.is-head { color: var(--text-strong); }
524 .prs-branch-arrow-lg {
525 color: var(--accent);
526 font-size: 14px;
527 font-weight: 700;
528 }
0369e77Claude529 .prs-branch-sync {
530 display: inline-flex; align-items: center; gap: 4px;
531 font-size: 11.5px; font-weight: 600;
532 padding: 2px 8px;
533 border-radius: 9999px;
534 border: 1px solid var(--border);
535 background: var(--bg-secondary);
536 color: var(--text-muted);
537 cursor: default;
538 }
539 .prs-branch-sync.is-behind {
540 color: #f87171;
541 border-color: rgba(248,113,113,0.35);
542 background: rgba(248,113,113,0.07);
543 }
544 .prs-branch-sync.is-synced {
545 color: #34d399;
546 border-color: rgba(52,211,153,0.35);
547 background: rgba(52,211,153,0.07);
548 }
b078860Claude549
550 .prs-detail-actions {
551 display: inline-flex; gap: 8px; margin-left: auto;
552 }
553
554 .prs-detail-tabs {
555 display: flex; gap: 4px;
556 margin: 0 0 16px;
557 border-bottom: 1px solid var(--border);
558 }
559 .prs-detail-tab {
560 padding: 10px 14px;
561 font-size: 13.5px;
562 font-weight: 500;
563 color: var(--text-muted);
564 text-decoration: none;
565 border-bottom: 2px solid transparent;
566 transition: color 120ms ease, border-color 120ms ease;
567 margin-bottom: -1px;
568 }
569 .prs-detail-tab:hover { color: var(--text); }
570 .prs-detail-tab.is-active {
571 color: var(--text-strong);
572 border-bottom-color: var(--accent);
573 }
574 .prs-detail-tab-count {
575 display: inline-flex; align-items: center; justify-content: center;
576 min-width: 20px; padding: 0 6px; margin-left: 6px;
577 height: 18px;
578 font-size: 11px;
579 font-weight: 600;
580 border-radius: 9999px;
581 background: var(--bg-tertiary);
582 color: var(--text-muted);
583 }
584
585 /* Gate / check status section */
586 .prs-gate-card {
587 margin-top: 20px;
588 background: var(--bg-elevated);
589 border: 1px solid var(--border);
590 border-radius: 14px;
591 overflow: hidden;
592 }
593 .prs-gate-head {
594 display: flex; align-items: center; gap: 10px;
595 padding: 14px 18px;
596 border-bottom: 1px solid var(--border);
597 }
598 .prs-gate-head h3 {
599 margin: 0;
600 font-size: 14px;
601 font-weight: 600;
602 color: var(--text-strong);
603 }
604 .prs-gate-summary {
605 margin-left: auto;
606 font-size: 12px;
607 color: var(--text-muted);
608 }
609 .prs-gate-row {
610 display: flex; align-items: center; gap: 12px;
611 padding: 12px 18px;
612 border-bottom: 1px solid var(--border-subtle);
613 }
614 .prs-gate-row:last-child { border-bottom: 0; }
615 .prs-gate-icon {
616 flex: 0 0 auto;
617 width: 22px; height: 22px;
618 display: inline-flex; align-items: center; justify-content: center;
619 border-radius: 9999px;
620 font-size: 12px;
621 font-weight: 700;
622 }
623 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
624 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
625 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
626 .prs-gate-name {
627 font-size: 13px;
628 font-weight: 600;
629 color: var(--text);
630 min-width: 140px;
631 }
632 .prs-gate-details {
633 flex: 1; min-width: 0;
634 font-size: 12.5px;
635 color: var(--text-muted);
636 }
637 .prs-gate-pill {
638 flex: 0 0 auto;
639 padding: 3px 10px;
640 border-radius: 9999px;
641 font-size: 11px;
642 font-weight: 600;
643 line-height: 1.5;
644 border: 1px solid transparent;
645 }
646 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
647 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
648 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
649 .prs-gate-footer {
650 padding: 12px 18px;
651 background: var(--bg-secondary);
652 font-size: 12px;
653 color: var(--text-muted);
654 }
655
656 /* Comment cards */
657 .prs-comment {
658 margin-top: 14px;
659 background: var(--bg-elevated);
660 border: 1px solid var(--border);
661 border-radius: 12px;
662 overflow: hidden;
663 }
664 .prs-comment-head {
665 display: flex; align-items: center; gap: 10px;
666 padding: 10px 14px;
667 background: var(--bg-secondary);
668 border-bottom: 1px solid var(--border);
669 font-size: 13px;
670 flex-wrap: wrap;
671 }
672 .prs-comment-head strong { color: var(--text-strong); }
673 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
674 .prs-comment-loc {
675 font-family: var(--font-mono);
676 font-size: 11.5px;
677 color: var(--text-muted);
678 background: var(--bg-tertiary);
679 padding: 2px 8px;
680 border-radius: 6px;
681 }
682 .prs-comment-body { padding: 14px 18px; }
683 .prs-comment.is-ai {
684 border-color: rgba(140,109,255,0.45);
685 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
686 }
687 .prs-comment.is-ai .prs-comment-head {
688 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
689 border-bottom-color: rgba(140,109,255,0.30);
690 }
691 .prs-ai-badge {
692 display: inline-flex; align-items: center; gap: 4px;
693 padding: 2px 9px;
694 font-size: 10.5px;
695 font-weight: 700;
696 letter-spacing: 0.04em;
697 text-transform: uppercase;
698 color: #fff;
699 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
700 border-radius: 9999px;
701 }
a7460bfClaude702 .prs-bot-badge {
703 display: inline-flex; align-items: center; gap: 3px;
704 padding: 1px 7px;
705 font-size: 10px;
706 font-weight: 600;
707 color: var(--fg-muted);
708 background: var(--bg-elevated);
709 border: 1px solid var(--border);
710 border-radius: 9999px;
711 }
b078860Claude712
713 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
714 .prs-files-card {
715 margin-top: 18px;
716 padding: 14px 18px;
717 display: flex; align-items: center; gap: 14px;
718 background: var(--bg-elevated);
719 border: 1px solid var(--border);
720 border-radius: 12px;
721 text-decoration: none;
722 color: inherit;
723 transition: border-color 120ms ease, transform 140ms ease;
724 }
725 .prs-files-card:hover {
726 border-color: rgba(140,109,255,0.45);
727 transform: translateY(-1px);
728 }
729 .prs-files-card-icon {
730 width: 36px; height: 36px;
731 display: inline-flex; align-items: center; justify-content: center;
732 border-radius: 10px;
733 background: rgba(140,109,255,0.12);
734 color: var(--text-link);
735 font-size: 18px;
736 }
737 .prs-files-card-text { flex: 1; min-width: 0; }
738 .prs-files-card-title {
739 font-size: 14px;
740 font-weight: 600;
741 color: var(--text-strong);
742 margin: 0 0 2px;
743 }
744 .prs-files-card-sub {
745 font-size: 12.5px;
746 color: var(--text-muted);
747 margin: 0;
748 }
749 .prs-files-card-cta {
750 font-size: 12.5px;
751 color: var(--text-link);
752 font-weight: 600;
753 }
754
755 /* Merge area */
756 .prs-merge-card {
757 position: relative;
758 margin-top: 22px;
759 padding: 18px;
760 background: var(--bg-elevated);
761 border-radius: 14px;
762 overflow: hidden;
763 }
764 .prs-merge-card::before {
765 content: '';
766 position: absolute; inset: 0;
767 padding: 1px;
768 border-radius: 14px;
769 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
770 -webkit-mask:
771 linear-gradient(#000 0 0) content-box,
772 linear-gradient(#000 0 0);
773 -webkit-mask-composite: xor;
774 mask-composite: exclude;
775 pointer-events: none;
776 }
777 .prs-merge-card.is-closed::before { background: var(--border-strong); }
778 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
779 .prs-merge-head {
780 display: flex; align-items: center; gap: 12px;
781 margin-bottom: 12px;
782 }
783 .prs-merge-head strong {
784 font-family: var(--font-display);
785 font-size: 15px;
786 color: var(--text-strong);
787 font-weight: 700;
788 }
789 .prs-merge-sub {
790 font-size: 13px;
791 color: var(--text-muted);
792 margin: 0 0 12px;
793 }
794 .prs-merge-actions {
795 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
796 }
797 .prs-merge-btn {
798 display: inline-flex; align-items: center; gap: 6px;
799 padding: 9px 16px;
800 border-radius: 10px;
801 font-size: 13.5px;
802 font-weight: 600;
803 color: #fff;
804 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
805 border: 1px solid rgba(52,211,153,0.55);
806 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
807 cursor: pointer;
808 transition: transform 120ms ease, box-shadow 160ms ease;
809 }
810 .prs-merge-btn:hover {
811 transform: translateY(-1px);
812 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
813 }
814 .prs-merge-btn[disabled],
815 .prs-merge-btn.is-disabled {
816 opacity: 0.55;
817 cursor: not-allowed;
818 transform: none;
819 box-shadow: none;
820 }
821 .prs-merge-ready-btn {
822 display: inline-flex; align-items: center; gap: 6px;
823 padding: 9px 16px;
824 border-radius: 10px;
825 font-size: 13.5px;
826 font-weight: 600;
827 color: #fff;
828 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
829 border: 1px solid rgba(140,109,255,0.55);
830 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
831 cursor: pointer;
832 transition: transform 120ms ease, box-shadow 160ms ease;
833 }
834 .prs-merge-ready-btn:hover {
835 transform: translateY(-1px);
836 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
837 }
838 .prs-merge-back-draft {
839 background: none; border: 1px solid var(--border-strong);
840 color: var(--text-muted);
841 padding: 9px 14px; border-radius: 10px;
842 font-size: 13px; cursor: pointer;
843 }
844 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
845
a164a6dClaude846 /* Merge strategy selector */
847 .prs-merge-strategy-wrap {
848 display: inline-flex; align-items: center;
849 background: var(--bg-elevated);
850 border: 1px solid var(--border);
851 border-radius: 10px;
852 overflow: hidden;
853 }
854 .prs-merge-strategy-label {
855 font-size: 11.5px; font-weight: 600;
856 color: var(--text-muted);
857 padding: 0 10px 0 12px;
858 white-space: nowrap;
859 }
860 .prs-merge-strategy-select {
861 background: transparent;
862 border: none;
863 color: var(--text);
864 font-size: 13px;
865 padding: 7px 10px 7px 4px;
866 cursor: pointer;
867 outline: none;
868 appearance: auto;
869 }
870 .prs-merge-strategy-select:focus { outline: 2px solid rgba(140,109,255,0.45); }
871
0a67773Claude872 /* Review summary banner */
873 .prs-review-summary {
874 display: flex; flex-direction: column; gap: 6px;
875 padding: 12px 16px;
876 background: var(--bg-elevated);
877 border: 1px solid var(--border);
878 border-radius: var(--r-md, 8px);
879 margin-bottom: 12px;
880 }
881 .prs-review-row {
882 display: flex; align-items: center; gap: 10px;
883 font-size: 13px;
884 }
885 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
886 .prs-review-approved .prs-review-icon { color: #34d399; }
887 .prs-review-changes .prs-review-icon { color: #f87171; }
ace34efClaude888 .prs-reviewer-avatar {
889 width: 24px; height: 24px; border-radius: 50%;
890 background: var(--accent); color: #fff;
891 display: flex; align-items: center; justify-content: center;
892 font-size: 11px; font-weight: 700; flex-shrink: 0;
893 }
0a67773Claude894
895 /* Review action buttons */
896 .prs-review-approve-btn {
897 display: inline-flex; align-items: center; gap: 5px;
898 padding: 8px 14px; border-radius: 8px; font-size: 13px;
899 font-weight: 600; cursor: pointer;
900 background: rgba(52,211,153,0.12);
901 color: #34d399;
902 border: 1px solid rgba(52,211,153,0.35);
903 transition: background 120ms;
904 }
905 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
906 .prs-review-changes-btn {
907 display: inline-flex; align-items: center; gap: 5px;
908 padding: 8px 14px; border-radius: 8px; font-size: 13px;
909 font-weight: 600; cursor: pointer;
910 background: rgba(248,113,113,0.10);
911 color: #f87171;
912 border: 1px solid rgba(248,113,113,0.30);
913 transition: background 120ms;
914 }
915 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
916
b078860Claude917 /* Inline form helpers */
918 .prs-inline-form { display: inline-flex; }
919
920 /* Comment composer */
921 .prs-composer { margin-top: 22px; }
922 .prs-composer textarea {
923 border-radius: 12px;
924 }
925
926 @media (max-width: 720px) {
927 .prs-detail-actions { margin-left: 0; }
928 .prs-merge-actions { width: 100%; }
929 .prs-merge-actions > * { flex: 1; min-width: 0; }
930 }
f1dc7c7Claude931
932 /* Additional mobile rules. Additive only. */
933 @media (max-width: 720px) {
934 .prs-detail-hero { padding: 18px; }
935 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
936 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
937 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
938 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
939 .prs-gate-name { min-width: 0; }
940 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
941 .prs-gate-summary { margin-left: 0; }
942 .prs-merge-btn,
943 .prs-merge-ready-btn,
944 .prs-merge-back-draft { min-height: 44px; }
945 .prs-comment-body { padding: 12px 14px; }
946 .prs-comment-head { padding: 10px 12px; }
947 .prs-files-card { padding: 12px 14px; }
948 }
3c03977Claude949
950 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
951 .live-pill {
952 display: inline-flex;
953 align-items: center;
954 gap: 8px;
955 padding: 4px 10px 4px 8px;
956 margin-left: 6px;
957 background: var(--bg-elevated);
958 border: 1px solid var(--border);
959 border-radius: 9999px;
960 font-size: 12px;
961 color: var(--text-muted);
962 line-height: 1;
963 vertical-align: middle;
964 }
965 .live-pill.is-busy { color: var(--text); }
966 .live-pill-dot {
967 width: 8px; height: 8px;
968 border-radius: 9999px;
969 background: #34d399;
970 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
971 animation: live-pulse 1.6s ease-in-out infinite;
972 }
973 @keyframes live-pulse {
974 0%, 100% { opacity: 1; }
975 50% { opacity: 0.55; }
976 }
977 .live-avatars {
978 display: inline-flex;
979 margin-left: 2px;
980 }
981 .live-avatar {
982 display: inline-flex;
983 align-items: center;
984 justify-content: center;
985 width: 22px; height: 22px;
986 border-radius: 9999px;
987 font-size: 10px;
988 font-weight: 700;
989 color: #0b1020;
990 margin-left: -6px;
991 border: 2px solid var(--bg-elevated);
992 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
993 }
994 .live-avatar:first-child { margin-left: 0; }
995 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
996 .live-cursor-host {
997 position: relative;
998 }
999 .live-cursor-overlay {
1000 position: absolute;
1001 inset: 0;
1002 pointer-events: none;
1003 overflow: hidden;
1004 border-radius: inherit;
1005 }
1006 .live-cursor {
1007 position: absolute;
1008 width: 2px;
1009 height: 18px;
1010 border-radius: 2px;
1011 transform: translate(-1px, 0);
1012 transition: transform 80ms linear, opacity 200ms ease;
1013 }
1014 .live-cursor::after {
1015 content: attr(data-label);
1016 position: absolute;
1017 top: -16px;
1018 left: -2px;
1019 font-size: 10px;
1020 line-height: 1;
1021 color: #0b1020;
1022 background: inherit;
1023 padding: 2px 5px;
1024 border-radius: 4px 4px 4px 0;
1025 white-space: nowrap;
1026 font-weight: 600;
1027 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
1028 }
1029 .live-cursor.is-idle { opacity: 0.4; }
1030 .live-edit-tag {
1031 display: inline-block;
1032 margin-left: 6px;
1033 padding: 1px 6px;
1034 font-size: 10px;
1035 font-weight: 600;
1036 letter-spacing: 0.02em;
1037 color: #0b1020;
1038 border-radius: 9999px;
1039 }
15db0e0Claude1040
1041 /* ─── Slash-command pill + composer hint ─── */
1042 .slash-hint {
1043 display: inline-flex;
1044 align-items: center;
1045 gap: 6px;
1046 margin-top: 6px;
1047 padding: 3px 9px;
1048 font-size: 11.5px;
1049 color: var(--text-muted);
1050 background: var(--bg-elevated);
1051 border: 1px dashed var(--border);
1052 border-radius: 9999px;
1053 width: fit-content;
1054 }
1055 .slash-hint code {
1056 background: rgba(110, 168, 255, 0.12);
1057 color: var(--text-strong);
1058 padding: 0 5px;
1059 border-radius: 4px;
1060 font-size: 11px;
1061 }
1062 .slash-pill {
1063 display: grid;
1064 grid-template-columns: auto 1fr auto;
1065 align-items: center;
1066 column-gap: 10px;
1067 row-gap: 6px;
1068 margin: 10px 0;
1069 padding: 10px 14px;
1070 background: linear-gradient(
1071 135deg,
1072 rgba(110, 168, 255, 0.08),
1073 rgba(163, 113, 247, 0.06)
1074 );
1075 border: 1px solid rgba(110, 168, 255, 0.32);
1076 border-left: 3px solid var(--accent, #6ea8ff);
1077 border-radius: var(--radius);
1078 font-size: 13px;
1079 color: var(--text);
1080 }
1081 .slash-pill-icon {
1082 font-size: 14px;
1083 line-height: 1;
1084 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
1085 }
1086 .slash-pill-actor { color: var(--text-muted); }
1087 .slash-pill-actor strong { color: var(--text-strong); }
1088 .slash-pill-cmd {
1089 background: rgba(110, 168, 255, 0.16);
1090 color: var(--text-strong);
1091 padding: 1px 6px;
1092 border-radius: 4px;
1093 font-size: 12.5px;
1094 }
1095 .slash-pill-time {
1096 color: var(--text-muted);
1097 font-size: 12px;
1098 justify-self: end;
1099 }
1100 .slash-pill-body {
1101 grid-column: 1 / -1;
1102 color: var(--text);
1103 font-size: 13px;
1104 line-height: 1.55;
1105 }
1106 .slash-pill-body p:first-child { margin-top: 0; }
1107 .slash-pill-body p:last-child { margin-bottom: 0; }
1108 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
1109 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1110 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
1111 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude1112
1113 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
1114 .preview-prpill {
1115 display: inline-flex; align-items: center; gap: 6px;
1116 padding: 3px 10px;
1117 border-radius: 9999px;
1118 font-family: var(--font-mono);
1119 font-size: 11.5px;
1120 font-weight: 600;
1121 background: rgba(255,255,255,0.04);
1122 color: var(--text-muted);
1123 text-decoration: none;
1124 border: 1px solid var(--border);
1125 }
1126 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
1127 .preview-prpill .preview-prpill-dot {
1128 width: 7px; height: 7px;
1129 border-radius: 9999px;
1130 background: currentColor;
1131 }
1132 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1133 .preview-prpill.is-building .preview-prpill-dot {
1134 animation: previewPrPulse 1.4s ease-in-out infinite;
1135 }
1136 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1137 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1138 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1139 @keyframes previewPrPulse {
1140 0%, 100% { opacity: 1; }
1141 50% { opacity: 0.4; }
1142 }
79ed944Claude1143
1144 /* ─── AI Trio Review — 3-column verdict cards ─── */
1145 .trio-wrap {
1146 margin-top: 18px;
1147 padding: 16px;
1148 background: var(--bg-elevated);
1149 border: 1px solid var(--border);
1150 border-radius: 14px;
1151 }
1152 .trio-header {
1153 display: flex; align-items: center; gap: 10px;
1154 margin: 0 0 12px;
1155 font-size: 13.5px;
1156 color: var(--text);
1157 }
1158 .trio-header strong { color: var(--text-strong); }
1159 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1160 .trio-header-dot {
1161 width: 8px; height: 8px; border-radius: 9999px;
1162 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1163 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1164 }
1165 .trio-grid {
1166 display: grid;
1167 grid-template-columns: repeat(3, minmax(0, 1fr));
1168 gap: 12px;
1169 }
1170 .trio-card {
1171 background: var(--bg-secondary);
1172 border: 1px solid var(--border);
1173 border-radius: 12px;
1174 overflow: hidden;
1175 display: flex; flex-direction: column;
1176 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1177 }
1178 .trio-card-head {
1179 display: flex; align-items: center; gap: 8px;
1180 padding: 10px 12px;
1181 border-bottom: 1px solid var(--border);
1182 background: rgba(255,255,255,0.02);
1183 font-size: 13px;
1184 }
1185 .trio-card-icon {
1186 display: inline-flex; align-items: center; justify-content: center;
1187 width: 22px; height: 22px;
1188 border-radius: 9999px;
1189 font-size: 12px;
1190 background: rgba(255,255,255,0.05);
1191 }
1192 .trio-card-title {
1193 color: var(--text-strong);
1194 font-weight: 600;
1195 letter-spacing: 0.01em;
1196 }
1197 .trio-card-verdict {
1198 margin-left: auto;
1199 font-size: 11px;
1200 font-weight: 700;
1201 letter-spacing: 0.06em;
1202 text-transform: uppercase;
1203 padding: 3px 9px;
1204 border-radius: 9999px;
1205 background: var(--bg-tertiary);
1206 color: var(--text-muted);
1207 border: 1px solid var(--border-strong);
1208 }
1209 .trio-card-body {
1210 padding: 12px 14px;
1211 font-size: 13px;
1212 color: var(--text);
1213 flex: 1;
1214 min-height: 64px;
1215 line-height: 1.55;
1216 }
1217 .trio-card-body p { margin: 0 0 8px; }
1218 .trio-card-body p:last-child { margin-bottom: 0; }
1219 .trio-card-body ul { margin: 0; padding-left: 18px; }
1220 .trio-card-body code {
1221 font-family: var(--font-mono);
1222 font-size: 12px;
1223 background: var(--bg-tertiary);
1224 padding: 1px 6px;
1225 border-radius: 5px;
1226 }
1227 .trio-card-empty {
1228 color: var(--text-muted);
1229 font-style: italic;
1230 font-size: 12.5px;
1231 }
1232
1233 /* Pass state — neutral, no accent. */
1234 .trio-card.is-pass .trio-card-verdict {
1235 color: var(--green);
1236 border-color: rgba(52,211,153,0.35);
1237 background: rgba(52,211,153,0.12);
1238 }
1239
1240 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1241 .trio-card.trio-security.is-fail {
1242 border-color: rgba(248,113,113,0.55);
1243 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1244 }
1245 .trio-card.trio-security.is-fail .trio-card-head {
1246 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1247 border-bottom-color: rgba(248,113,113,0.30);
1248 }
1249 .trio-card.trio-security.is-fail .trio-card-verdict {
1250 color: #fecaca;
1251 border-color: rgba(248,113,113,0.55);
1252 background: rgba(248,113,113,0.20);
1253 }
1254
1255 .trio-card.trio-correctness.is-fail {
1256 border-color: rgba(251,191,36,0.55);
1257 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1258 }
1259 .trio-card.trio-correctness.is-fail .trio-card-head {
1260 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1261 border-bottom-color: rgba(251,191,36,0.30);
1262 }
1263 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1264 color: #fde68a;
1265 border-color: rgba(251,191,36,0.55);
1266 background: rgba(251,191,36,0.20);
1267 }
1268
1269 .trio-card.trio-style.is-fail {
1270 border-color: rgba(96,165,250,0.55);
1271 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1272 }
1273 .trio-card.trio-style.is-fail .trio-card-head {
1274 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1275 border-bottom-color: rgba(96,165,250,0.30);
1276 }
1277 .trio-card.trio-style.is-fail .trio-card-verdict {
1278 color: #bfdbfe;
1279 border-color: rgba(96,165,250,0.55);
1280 background: rgba(96,165,250,0.20);
1281 }
1282
1283 /* Disagreement callout strip — yellow, prominent. */
1284 .trio-disagreement-strip {
1285 display: flex;
1286 gap: 12px;
1287 margin-top: 14px;
1288 padding: 12px 14px;
1289 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1290 border: 1px solid rgba(251,191,36,0.45);
1291 border-radius: 10px;
1292 color: var(--text);
1293 font-size: 13px;
1294 }
1295 .trio-disagreement-icon {
1296 flex: 0 0 auto;
1297 width: 26px; height: 26px;
1298 display: inline-flex; align-items: center; justify-content: center;
1299 border-radius: 9999px;
1300 background: rgba(251,191,36,0.25);
1301 color: #fde68a;
1302 font-size: 14px;
1303 }
1304 .trio-disagreement-body strong {
1305 display: block;
1306 color: #fde68a;
1307 margin: 0 0 4px;
1308 font-weight: 700;
1309 }
1310 .trio-disagreement-list {
1311 margin: 0;
1312 padding-left: 18px;
1313 color: var(--text);
1314 font-size: 12.5px;
1315 line-height: 1.55;
1316 }
1317 .trio-disagreement-list code {
1318 font-family: var(--font-mono);
1319 font-size: 11.5px;
1320 background: var(--bg-tertiary);
1321 padding: 1px 5px;
1322 border-radius: 4px;
1323 }
1324
1325 @media (max-width: 720px) {
1326 .trio-grid { grid-template-columns: 1fr; }
1327 .trio-wrap { padding: 12px; }
1328 }
6d1bbc2Claude1329
1330 /* ─── Task list progress pill ─── */
1331 .prs-tasks-pill {
1332 display: inline-flex; align-items: center; gap: 5px;
1333 font-size: 11.5px; font-weight: 600;
1334 padding: 2px 9px; border-radius: 9999px;
1335 border: 1px solid var(--border);
1336 background: var(--bg-elevated);
1337 color: var(--text-muted);
1338 }
1339 .prs-tasks-pill.is-complete {
1340 color: #34d399;
1341 border-color: rgba(52,211,153,0.40);
1342 background: rgba(52,211,153,0.08);
1343 }
1344 .prs-tasks-progress { display: inline-block; width: 36px; height: 4px; border-radius: 9999px; background: var(--border); overflow: hidden; vertical-align: middle; }
1345 .prs-tasks-progress-bar { height: 100%; border-radius: 9999px; background: #34d399; }
1346
1347 /* ─── Update branch button ─── */
1348 .prs-update-branch-btn {
1349 display: inline-flex; align-items: center; gap: 5px;
1350 padding: 4px 12px; border-radius: 8px; font-size: 12.5px;
1351 font-weight: 600; cursor: pointer;
1352 background: rgba(96,165,250,0.10);
1353 color: #60a5fa;
1354 border: 1px solid rgba(96,165,250,0.30);
1355 transition: background 120ms;
1356 }
1357 .prs-update-branch-btn:hover { background: rgba(96,165,250,0.20); }
1358
1359 /* ─── Linked issues panel ─── */
1360 .prs-linked-issues {
1361 margin-top: 16px;
1362 border: 1px solid var(--border);
1363 border-radius: 12px;
1364 overflow: hidden;
1365 }
1366 .prs-linked-issues-head {
1367 display: flex; align-items: center; justify-content: space-between;
1368 padding: 10px 16px;
1369 background: var(--bg-elevated);
1370 border-bottom: 1px solid var(--border);
1371 font-size: 13px; font-weight: 600; color: var(--text);
1372 }
1373 .prs-linked-issues-count {
1374 font-size: 11px; font-weight: 700;
1375 padding: 1px 7px; border-radius: 9999px;
1376 background: var(--bg-tertiary);
1377 color: var(--text-muted);
1378 }
1379 .prs-linked-issue-row {
1380 display: flex; align-items: center; gap: 10px;
1381 padding: 9px 16px;
1382 border-bottom: 1px solid var(--border);
1383 font-size: 13px;
1384 text-decoration: none; color: inherit;
1385 }
1386 .prs-linked-issue-row:last-child { border-bottom: none; }
1387 .prs-linked-issue-row:hover { background: var(--bg-hover); }
1388 .prs-linked-issue-icon { flex: 0 0 auto; font-size: 14px; }
1389 .prs-linked-issue-icon.is-open { color: #34d399; }
1390 .prs-linked-issue-icon.is-closed { color: #8b949e; }
1391 .prs-linked-issue-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1392 .prs-linked-issue-num { color: var(--text-muted); font-size: 12px; }
1393 .prs-linked-issue-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
1394 .prs-linked-issue-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
1395 .prs-linked-issue-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
b558f23Claude1396
1397 /* ─── Commits tab ─── */
1398 .prs-commits-list { display: flex; flex-direction: column; gap: 0; margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1399 .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; }
1400 .prs-commit-row:last-child { border-bottom: none; }
1401 .prs-commit-row:hover { background: var(--bg-hover); }
1402 .prs-commit-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-top: 6px; }
1403 .prs-commit-body { flex: 1 1 auto; min-width: 0; }
1404 .prs-commit-msg { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
1405 .prs-commit-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
1406 .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; }
1407 .prs-commit-sha:hover { color: var(--accent); }
1408 .prs-commits-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13.5px; }
1409
1410 /* ─── Edit PR title/body ─── */
1411 .prs-edit-title-wrap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1412 .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; }
1413 .prs-edit-btn:hover { color: var(--text); border-color: var(--text-muted); }
1414 .prs-edit-form { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
1415 .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; }
1416 .prs-edit-actions { display: flex; gap: 8px; }
1417 .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; }
1418 .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; }
240c477Claude1419
1420 /* ─── CI status checks ─── */
1421 .prs-ci-card { margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1422 .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); }
1423 .prs-ci-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text); }
1424 .prs-ci-summary { font-size: 12px; color: var(--text-muted); }
1425 .prs-ci-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
1426 .prs-ci-row:last-child { border-bottom: none; }
1427 .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; }
1428 .prs-ci-icon.is-success { background: rgba(52,211,153,0.20); color: #34d399; }
1429 .prs-ci-icon.is-pending { background: rgba(251,191,36,0.20); color: #fbbf24; }
1430 .prs-ci-icon.is-failure, .prs-ci-icon.is-error { background: rgba(248,113,113,0.20); color: #f87171; }
1431 .prs-ci-context { flex: 1 1 auto; font-size: 13px; font-weight: 500; color: var(--text); }
1432 .prs-ci-desc { font-size: 12px; color: var(--text-muted); }
1433 .prs-ci-pill { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 9999px; }
1434 .prs-ci-pill.is-success { color: #34d399; background: rgba(52,211,153,0.10); }
1435 .prs-ci-pill.is-pending { color: #fbbf24; background: rgba(251,191,36,0.10); }
1436 .prs-ci-pill.is-failure, .prs-ci-pill.is-error { color: #f87171; background: rgba(248,113,113,0.10); }
1437 .prs-ci-link { font-size: 12px; color: var(--accent); text-decoration: none; }
1438 .prs-ci-link:hover { text-decoration: underline; }
67dc4e1Claude1439
1440 /* ─── AI Trio verdict pills (header summary) ─── */
1441 .trio-pill {
1442 display: inline-flex; align-items: center; gap: 4px;
1443 padding: 2px 8px;
1444 font-size: 11px;
1445 font-weight: 700;
1446 border-radius: 9999px;
1447 border: 1px solid transparent;
1448 text-decoration: none;
1449 line-height: 1.6;
1450 letter-spacing: 0.01em;
1451 cursor: pointer;
1452 transition: opacity 120ms ease;
1453 }
1454 .trio-pill:hover { opacity: 0.8; }
1455 .trio-pill.is-pass {
1456 color: #34d399;
1457 background: rgba(52,211,153,0.10);
1458 border-color: rgba(52,211,153,0.35);
1459 }
1460 .trio-pill.is-fail {
1461 color: #f87171;
1462 background: rgba(248,113,113,0.10);
1463 border-color: rgba(248,113,113,0.35);
1464 }
1465 .trio-pill.is-pending {
1466 color: var(--text-muted);
1467 background: rgba(255,255,255,0.04);
1468 border-color: var(--border-strong);
1469 }
1470 .trio-pills-wrap {
1471 display: inline-flex; align-items: center; gap: 4px;
1472 }
b078860Claude1473`;
1474
81c73c1Claude1475/**
1476 * Tiny inline JS that drives the "Suggest description with AI" button.
1477 * On click, gathers form values, POSTs JSON to the given endpoint, and
1478 * pipes the response into the #pr-body textarea. All DOM lookups are
1479 * defensive — element absence is a silent no-op.
1480 *
1481 * Built as a string template so it lives next to its server-side caller
1482 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1483 * to avoid </script> breakouts.
1484 */
1485function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1486 const url = JSON.stringify(endpointUrl)
1487 .split("<").join("\\u003C")
1488 .split(">").join("\\u003E")
1489 .split("&").join("\\u0026");
1490 return (
1491 "(function(){try{" +
1492 "var btn=document.getElementById('ai-suggest-desc');" +
1493 "var status=document.getElementById('ai-suggest-status');" +
1494 "var body=document.getElementById('pr-body');" +
1495 "var form=btn&&btn.closest&&btn.closest('form');" +
1496 "if(!btn||!body||!form)return;" +
1497 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1498 "var fd=new FormData(form);" +
1499 "var title=String(fd.get('title')||'').trim();" +
1500 "var base=String(fd.get('base')||'').trim();" +
1501 "var head=String(fd.get('head')||'').trim();" +
1502 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1503 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1504 "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'})" +
1505 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1506 ".then(function(j){btn.disabled=false;" +
1507 "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;}}" +
1508 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1509 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1510 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1511 "});" +
1512 "}catch(e){}})();"
1513 );
1514}
1515
3c03977Claude1516/**
1517 * Live co-editing client. Connects to the per-PR SSE feed and:
1518 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1519 * status colour per user).
1520 * - Renders tinted cursor caret overlays inside #pr-body and every
1521 * `[data-live-field]` element.
1522 * - Broadcasts the local user's cursor position (selectionStart /
1523 * selectionEnd) debounced at 100ms.
1524 * - Broadcasts content patches (`replace` of the whole textarea —
1525 * last-write-wins v1) debounced at 250ms.
1526 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1527 * to the matching local field if untouched.
1528 *
1529 * All endpoint URLs are JSON-escaped via safe replacements so they
1530 * can't break out of the <script> tag.
1531 */
1532function LIVE_COEDIT_SCRIPT(prId: string): string {
1533 const idJson = JSON.stringify(prId)
1534 .split("<").join("\\u003C")
1535 .split(">").join("\\u003E")
1536 .split("&").join("\\u0026");
1537 return (
1538 "(function(){try{" +
1539 "if(typeof EventSource==='undefined')return;" +
1540 "var prId=" + idJson + ";" +
1541 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1542 "var pill=document.getElementById('live-pill');" +
1543 "var avEl=document.getElementById('live-avatars');" +
1544 "var countEl=document.getElementById('live-count');" +
1545 "var sessionId=null;var myColor=null;" +
1546 "var presence={};" + // sessionId -> {color,status,userId,initials}
1547 "var lastApplied={};" + // field -> last server value (for echo suppression)
1548 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1549 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1550 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1551 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1552 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1553 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1554 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1555 "avEl.innerHTML=html;}}" +
1556 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1557 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1558 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1559 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1560 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1561 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1562 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1563 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1564 "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);}" +
1565 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1566 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1567 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1568 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1569 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1570 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1571 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1572 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1573 "}catch(e){}" +
1574 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1575 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1576 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1577 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1578 "var es;var delay=1000;" +
1579 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1580 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1581 "(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){}});" +
1582 "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){}});" +
1583 "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){}});" +
1584 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1585 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1586 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1587 "var patch=d.patch;if(!patch||!patch.field)return;" +
1588 "var ta=fieldEl(patch.field);if(!ta)return;" +
1589 "if(document.activeElement===ta)return;" + // don't trample local typing
1590 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1591 "}catch(e){}});" +
1592 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1593 "}connect();" +
1594 "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){}}" +
1595 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1596 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1597 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1598 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1599 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1600 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1601 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1602 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1603 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1604 "}" +
1605 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1606 "var live=document.querySelectorAll('[data-live-field]');" +
1607 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1608 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1609 "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){}});" +
1610 "}catch(e){}})();"
1611 );
1612}
1613
0074234Claude1614async function resolveRepo(ownerName: string, repoName: string) {
1615 const [owner] = await db
1616 .select()
1617 .from(users)
1618 .where(eq(users.username, ownerName))
1619 .limit(1);
1620 if (!owner) return null;
1621 const [repo] = await db
1622 .select()
1623 .from(repositories)
1624 .where(
1625 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1626 )
1627 .limit(1);
1628 if (!repo) return null;
1629 return { owner, repo };
1630}
1631
1632// PR Nav helper
1633const PrNav = ({
1634 owner,
1635 repo,
1636 active,
1637}: {
1638 owner: string;
1639 repo: string;
1640 active: "code" | "issues" | "pulls" | "commits";
1641}) => (
bb0f894Claude1642 <TabNav
1643 tabs={[
1644 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1645 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1646 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1647 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1648 ]}
1649 />
0074234Claude1650);
1651
534f04aClaude1652/**
1653 * Block M3 — pre-merge risk score card. Pure presentational helper.
1654 * Rendered in the conversation tab above the gate checks block. Hidden
1655 * entirely when the PR is closed/merged or there is nothing cached and
1656 * nothing in-flight.
1657 */
1658function PrRiskCard({
1659 risk,
1660 calculating,
1661}: {
1662 risk: PrRiskScore | null;
1663 calculating: boolean;
1664}) {
1665 if (!risk) {
1666 return (
1667 <div
1668 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1669 >
1670 <strong style="font-size: 13px; color: var(--text)">
1671 Risk score: calculating…
1672 </strong>
1673 <div style="font-size: 12px; margin-top: 4px">
1674 Refresh in a moment to see the pre-merge risk score for this PR.
1675 </div>
1676 </div>
1677 );
1678 }
1679
1680 const palette = riskBandPalette(risk.band);
1681 const label = riskBandLabel(risk.band);
1682
1683 return (
1684 <div
1685 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1686 >
1687 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1688 <strong>Risk score:</strong>
1689 <span style={`color:${palette.border};font-weight:600`}>
1690 {palette.icon} {label} ({risk.score}/10)
1691 </span>
1692 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1693 {risk.commitSha.slice(0, 7)}
1694 </span>
1695 </div>
1696 {risk.aiSummary && (
1697 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1698 {risk.aiSummary}
1699 </div>
1700 )}
1701 <details style="margin-top:10px">
1702 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1703 See full signal breakdown
1704 </summary>
1705 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1706 <li>files changed: {risk.signals.filesChanged}</li>
1707 <li>
1708 lines added/removed: {risk.signals.linesAdded} /{" "}
1709 {risk.signals.linesRemoved}
1710 </li>
1711 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1712 <li>
1713 schema migration touched:{" "}
1714 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1715 </li>
1716 <li>
1717 locked / sensitive path touched:{" "}
1718 {risk.signals.lockedPathTouched ? "yes" : "no"}
1719 </li>
1720 <li>
1721 adds new dependency:{" "}
1722 {risk.signals.addsNewDependency ? "yes" : "no"}
1723 </li>
1724 <li>
1725 bumps major dependency:{" "}
1726 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1727 </li>
1728 <li>
1729 tests added for new code:{" "}
1730 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1731 </li>
1732 <li>
1733 diff-minus-test ratio:{" "}
1734 {risk.signals.diffMinusTestRatio.toFixed(2)}
1735 </li>
1736 </ul>
1737 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1738 How is this calculated? The score is a transparent sum of
1739 weighted signals — see <code>src/lib/pr-risk.ts</code>
1740 {" "}<code>computePrRiskScore</code>.
1741 </div>
1742 </details>
1743 {calculating && (
1744 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1745 (recomputing for the latest commit — refresh to update)
1746 </div>
1747 )}
1748 </div>
1749 );
1750}
1751
1752function riskBandPalette(band: PrRiskScore["band"]): {
1753 border: string;
1754 icon: string;
1755} {
1756 switch (band) {
1757 case "low":
1758 return { border: "var(--green)", icon: "" };
1759 case "medium":
1760 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1761 case "high":
1762 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1763 case "critical":
1764 return { border: "var(--red)", icon: "\u{1F6D1}" };
1765 }
1766}
1767
1768function riskBandLabel(band: PrRiskScore["band"]): string {
1769 switch (band) {
1770 case "low":
1771 return "LOW";
1772 case "medium":
1773 return "MEDIUM";
1774 case "high":
1775 return "HIGH";
1776 case "critical":
1777 return "CRITICAL";
1778 }
1779}
1780
422a2d4Claude1781// ---------------------------------------------------------------------------
1782// AI Trio Review — 3-column card grid + disagreement callout.
1783//
1784// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
1785// per run: one per persona (security/correctness/style) plus a top-level
1786// summary. We surface them here as a single grid above the normal
1787// comment stream so reviewers see the verdicts at a glance.
1788// ---------------------------------------------------------------------------
1789
1790const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
1791
1792interface TrioCommentLike {
1793 body: string;
1794}
1795
1796function isTrioComment(body: string | null | undefined): boolean {
1797 if (!body) return false;
1798 return (
1799 body.includes(TRIO_SUMMARY_MARKER) ||
1800 body.includes(TRIO_COMMENT_MARKER.security) ||
1801 body.includes(TRIO_COMMENT_MARKER.correctness) ||
1802 body.includes(TRIO_COMMENT_MARKER.style)
1803 );
1804}
1805
1806function trioPersonaOfComment(body: string): TrioPersona | null {
1807 for (const p of TRIO_PERSONAS) {
1808 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
1809 }
1810 return null;
1811}
1812
1813/**
1814 * Best-effort verdict parse from a persona comment body. The body shape
1815 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
1816 * we only need the "Pass" / "Fail" word from the H2 heading.
1817 */
1818function trioVerdictOfBody(body: string): "pass" | "fail" | null {
1819 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
1820 if (!m) return null;
1821 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
1822}
1823
1824/**
1825 * Parse the disagreement bullet list out of the summary comment so we
1826 * can render it as a polished callout strip. Returns [] when nothing
1827 * matches — the comment author may have edited the marker out.
1828 */
1829function parseDisagreements(summaryBody: string): Array<{
1830 file: string;
1831 failing: string;
1832 passing: string;
1833}> {
1834 const out: Array<{ file: string; failing: string; passing: string }> = [];
1835 // Each disagreement line looks like:
1836 // - `path:42` — security, style say ✗, correctness say ✓
1837 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
1838 let m: RegExpExecArray | null;
1839 while ((m = re.exec(summaryBody)) !== null) {
1840 out.push({
1841 file: m[1].trim(),
1842 failing: m[2].trim().replace(/[,\s]+$/g, ""),
1843 passing: m[3].trim().replace(/[,\s]+$/g, ""),
1844 });
1845 }
1846 return out;
1847}
1848
1849function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
1850 // Find the most recent persona comments + summary. We iterate from
1851 // the end so re-reviews (multiple runs on the same PR) display the
1852 // freshest verdict.
1853 const latest: Partial<Record<TrioPersona, string>> = {};
1854 let summaryBody: string | null = null;
1855 for (let i = comments.length - 1; i >= 0; i--) {
1856 const body = comments[i].body || "";
1857 if (!isTrioComment(body)) continue;
1858 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
1859 summaryBody = body;
1860 continue;
1861 }
1862 const persona = trioPersonaOfComment(body);
1863 if (persona && !latest[persona]) latest[persona] = body;
1864 }
1865 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1866 if (!anyPersona && !summaryBody) return null;
1867
1868 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
1869
1870 return (
67dc4e1Claude1871 <div class="trio-wrap" id="trio-review-section">
422a2d4Claude1872 <div class="trio-header">
1873 <span class="trio-header-dot" aria-hidden="true"></span>
1874 <strong>AI Trio Review</strong>
1875 <span class="trio-header-sub">
1876 Three independent reviewers ran in parallel.
1877 </span>
1878 </div>
1879 <div class="trio-grid">
1880 {TRIO_PERSONAS.map((persona) => {
1881 const body = latest[persona];
1882 const verdict = body ? trioVerdictOfBody(body) : null;
1883 const stateClass =
1884 verdict === "fail"
1885 ? "is-fail"
1886 : verdict === "pass"
1887 ? "is-pass"
1888 : "is-pending";
1889 return (
1890 <div class={`trio-card trio-${persona} ${stateClass}`}>
1891 <div class="trio-card-head">
1892 <span class="trio-card-icon" aria-hidden="true">
1893 {persona === "security"
1894 ? "🛡"
1895 : persona === "correctness"
1896 ? "✓"
1897 : "✎"}
1898 </span>
1899 <strong class="trio-card-title">
1900 {persona[0].toUpperCase() + persona.slice(1)}
1901 </strong>
1902 <span class="trio-card-verdict">
1903 {verdict === "pass"
1904 ? "Pass"
1905 : verdict === "fail"
1906 ? "Fail"
1907 : "Pending"}
1908 </span>
1909 </div>
1910 <div class="trio-card-body">
1911 {body ? (
1912 <MarkdownContent
1913 html={renderMarkdown(stripTrioHeading(body))}
1914 />
1915 ) : (
1916 <span class="trio-card-empty">
1917 Awaiting reviewer output.
1918 </span>
1919 )}
1920 </div>
1921 </div>
1922 );
1923 })}
1924 </div>
1925 {disagreements.length > 0 && (
1926 <div class="trio-disagreement-strip" role="note">
1927 <span class="trio-disagreement-icon" aria-hidden="true">
1928
1929 </span>
1930 <div class="trio-disagreement-body">
1931 <strong>Reviewers disagree — review carefully.</strong>
1932 <ul class="trio-disagreement-list">
1933 {disagreements.map((d) => (
1934 <li>
1935 <code>{d.file}</code> — {d.failing} says ✗,{" "}
1936 {d.passing} says ✓
1937 </li>
1938 ))}
1939 </ul>
1940 </div>
1941 </div>
1942 )}
1943 </div>
1944 );
1945}
1946
1947/**
1948 * Strip the marker comment + first H2 heading from a persona body so
1949 * the card body shows just the findings list (verdict is already in
1950 * the card head). Best-effort — malformed bodies render whole.
1951 */
1952function stripTrioHeading(body: string): string {
1953 return body
1954 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
1955 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
1956 .trim();
1957}
1958
67dc4e1Claude1959/**
1960 * Three small verdict pills rendered inline in the PR header. Each pill
1961 * links to the `#trio-review-section` anchor so clicking scrolls to the
1962 * full card grid. Only shown when `AI_TRIO_REVIEW_ENABLED=1` and at
1963 * least one persona comment exists.
1964 */
1965function TrioVerdictPills({
1966 comments,
1967}: {
1968 comments: TrioCommentLike[];
1969}) {
1970 if (!isTrioReviewEnabled()) return null;
1971
1972 // Find latest persona verdicts (same logic as TrioReviewGrid).
1973 const latest: Partial<Record<TrioPersona, string>> = {};
1974 for (let i = comments.length - 1; i >= 0; i--) {
1975 const body = comments[i].body || "";
1976 if (!isTrioComment(body)) continue;
1977 if (body.includes(TRIO_SUMMARY_MARKER)) continue;
1978 const persona = trioPersonaOfComment(body);
1979 if (persona && !latest[persona]) latest[persona] = body;
1980 }
1981
1982 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1983 if (!anyPersona) return null;
1984
1985 const PERSONA_LABEL: Record<TrioPersona, string> = {
1986 security: "Security",
1987 correctness: "Correctness",
1988 style: "Style",
1989 };
1990 const PERSONA_ICON: Record<TrioPersona, string> = {
1991 security: "🛡",
1992 correctness: "✓",
1993 style: "✎",
1994 };
1995
1996 return (
1997 <span class="trio-pills-wrap" aria-label="AI Trio Review verdicts">
1998 {TRIO_PERSONAS.map((persona) => {
1999 const body = latest[persona];
2000 const verdict = body ? trioVerdictOfBody(body) : null;
2001 const stateClass =
2002 verdict === "pass"
2003 ? "is-pass"
2004 : verdict === "fail"
2005 ? "is-fail"
2006 : "is-pending";
2007 const glyph =
2008 verdict === "pass" ? "✓" : verdict === "fail" ? "✗" : "⟳";
2009 return (
2010 <a
2011 href="#trio-review-section"
2012 class={`trio-pill ${stateClass}`}
2013 title={`AI ${PERSONA_LABEL[persona]} Review — ${verdict === "pass" ? "Pass" : verdict === "fail" ? "Fail" : "Pending"}`}
2014 >
2015 <span aria-hidden="true">{PERSONA_ICON[persona]}</span>
2016 {PERSONA_LABEL[persona]} {glyph}
2017 </a>
2018 );
2019 })}
2020 </span>
2021 );
2022}
2023
0074234Claude2024// List PRs
04f6b7fClaude2025pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2026 const { owner: ownerName, repo: repoName } = c.req.param();
2027 const user = c.get("user");
2028 const state = c.req.query("state") || "open";
d790b49Claude2029 const searchQ = c.req.query("q")?.trim() || "";
80bd7c8Claude2030 const authorFilter = c.req.query("author")?.trim() || "";
f5b9ef5Claude2031 const sortPr = (c.req.query("sort") || "newest").trim();
0074234Claude2032
ea9ed4cClaude2033 // ── Loading skeleton (flag-gated) ──
2034 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
2035 // the user see the page structure before counts + select resolve.
2036 // Behind a flag for now — we don't ship flashes.
2037 if (c.req.query("skeleton") === "1") {
2038 return c.html(
2039 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2040 <RepoHeader owner={ownerName} repo={repoName} />
2041 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2042 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2043 <style
2044 dangerouslySetInnerHTML={{
2045 __html: `
404b398Claude2046 .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; }
2047 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude2048 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
2049 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
2050 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
2051 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
2052 .prs-skel-row { height: 76px; border-radius: 12px; }
2053 `,
2054 }}
2055 />
2056 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
2057 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
2058 <div class="prs-skel-list" aria-hidden="true">
2059 {Array.from({ length: 6 }).map(() => (
2060 <div class="prs-skel prs-skel-row" />
2061 ))}
2062 </div>
2063 <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">
2064 Loading pull requests for {ownerName}/{repoName}…
2065 </span>
2066 </Layout>
2067 );
2068 }
2069
0074234Claude2070 const resolved = await resolveRepo(ownerName, repoName);
2071 if (!resolved) return c.notFound();
2072
6fc53bdClaude2073 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
2074 const stateFilter =
2075 state === "draft"
2076 ? and(
2077 eq(pullRequests.state, "open"),
2078 eq(pullRequests.isDraft, true)
2079 )
2080 : eq(pullRequests.state, state);
2081
0074234Claude2082 const prList = await db
2083 .select({
2084 pr: pullRequests,
2085 author: { username: users.username },
2086 })
2087 .from(pullRequests)
2088 .innerJoin(users, eq(pullRequests.authorId, users.id))
2089 .where(
d790b49Claude2090 and(
2091 eq(pullRequests.repositoryId, resolved.repo.id),
2092 stateFilter,
2093 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
80bd7c8Claude2094 authorFilter ? eq(users.username, authorFilter) : undefined,
d790b49Claude2095 )
0074234Claude2096 )
f5b9ef5Claude2097 .orderBy(
2098 sortPr === "oldest" ? asc(pullRequests.createdAt)
2099 : sortPr === "updated" ? desc(pullRequests.updatedAt)
2100 : desc(pullRequests.createdAt) // newest (default)
2101 );
0074234Claude2102
0369e77Claude2103 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude2104 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude2105 const commentCountMap = new Map<string, number>();
1aef949Claude2106 if (prList.length > 0) {
2107 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude2108 const [reviewRows, commentRows] = await Promise.all([
2109 db
2110 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
2111 .from(prReviews)
2112 .where(inArray(prReviews.pullRequestId, prIds)),
2113 db
2114 .select({
2115 prId: prComments.pullRequestId,
2116 cnt: sql<number>`count(*)::int`,
2117 })
2118 .from(prComments)
2119 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
2120 .groupBy(prComments.pullRequestId),
2121 ]);
1aef949Claude2122 for (const r of reviewRows) {
2123 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
2124 if (r.state === "approved") entry.approved = true;
2125 if (r.state === "changes_requested") entry.changesRequested = true;
2126 reviewMap.set(r.prId, entry);
2127 }
0369e77Claude2128 for (const r of commentRows) {
2129 commentCountMap.set(r.prId, Number(r.cnt));
2130 }
1aef949Claude2131 }
2132
0074234Claude2133 const [counts] = await db
2134 .select({
2135 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude2136 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude2137 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
2138 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
2139 })
2140 .from(pullRequests)
2141 .where(eq(pullRequests.repositoryId, resolved.repo.id));
2142
b078860Claude2143 const openCount = counts?.open ?? 0;
2144 const mergedCount = counts?.merged ?? 0;
2145 const closedCount = counts?.closed ?? 0;
2146 const draftCount = counts?.draft ?? 0;
2147 const allCount = openCount + mergedCount + closedCount;
2148
2149 // "All" is presentational only — the DB query for state='all' matches
2150 // nothing, so we render a friendlier empty state when picked. We do NOT
2151 // change the query logic to keep this commit purely visual.
80bd7c8Claude2152 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude2153 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude2154 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
2155 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
2156 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
2157 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
2158 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude2159 ];
2160 const isAllState = state === "all";
cb5a796Claude2161 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
2162 const prListPendingCount = viewerIsOwnerOnPrList
2163 ? await countPendingForRepo(resolved.repo.id)
2164 : 0;
b078860Claude2165
0074234Claude2166 return c.html(
2167 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2168 <RepoHeader owner={ownerName} repo={repoName} />
2169 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2170 <PendingCommentsBanner
2171 owner={ownerName}
2172 repo={repoName}
2173 count={prListPendingCount}
2174 />
b078860Claude2175 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2176
2177 <div class="prs-hero">
2178 <div class="prs-hero-inner">
2179 <div class="prs-hero-text">
2180 <div class="prs-hero-eyebrow">Pull requests</div>
2181 <h1 class="prs-hero-title">
2182 Review, <span class="gradient-text">merge with AI</span>.
2183 </h1>
2184 <p class="prs-hero-sub">
2185 {openCount === 0 && allCount === 0
2186 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2187 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
2188 </p>
2189 </div>
7a28902Claude2190 <div class="prs-hero-actions">
2191 <a
2192 href={`/${ownerName}/${repoName}/pulls/insights`}
2193 class="prs-cta"
2194 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
2195 >
2196 Insights
2197 </a>
2198 {user && (
b078860Claude2199 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
2200 + New pull request
2201 </a>
7a28902Claude2202 )}
2203 </div>
b078860Claude2204 </div>
2205 </div>
2206
2207 <nav class="prs-tabs" aria-label="Pull request filters">
2208 {tabPills.map((t) => {
2209 const isActive =
2210 state === t.key ||
2211 (t.key === "open" &&
2212 state !== "merged" &&
2213 state !== "closed" &&
2214 state !== "all" &&
2215 state !== "draft");
2216 return (
2217 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2218 <span>{t.label}</span>
2219 <span class="prs-tab-count">{t.count}</span>
2220 </a>
2221 );
2222 })}
2223 </nav>
2224
d790b49Claude2225 <form
2226 method="get"
2227 action={`/${ownerName}/${repoName}/pulls`}
2228 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2229 >
2230 <input type="hidden" name="state" value={state} />
2231 <input
2232 type="search"
2233 name="q"
2234 value={searchQ}
2235 placeholder="Search pull requests…"
2236 class="issues-search-input"
2237 style="flex:1;max-width:380px"
2238 />
80bd7c8Claude2239 <input
2240 type="text"
2241 name="author"
2242 value={authorFilter}
2243 placeholder="Filter by author…"
2244 class="issues-search-input"
2245 style="max-width:200px"
2246 />
d790b49Claude2247 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2248 {(searchQ || authorFilter) && (
d790b49Claude2249 <a
2250 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2251 class="issues-filter-clear"
2252 >
2253 Clear
2254 </a>
2255 )}
2256 </form>
f5b9ef5Claude2257
2258 <div class="prs-sort-row">
2259 <span class="prs-sort-label">Sort:</span>
2260 {(["newest", "oldest", "updated"] as const).map((s) => (
2261 <a
2262 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2263 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2264 >
2265 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2266 </a>
2267 ))}
2268 </div>
2269
0074234Claude2270 {prList.length === 0 ? (
b078860Claude2271 <div class="prs-empty">
ea9ed4cClaude2272 <div class="prs-empty-inner">
2273 <strong>
80bd7c8Claude2274 {searchQ || authorFilter
2275 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2276 : isAllState
2277 ? "Pick a filter above to browse PRs."
2278 : `No ${state} pull requests.`}
ea9ed4cClaude2279 </strong>
2280 <p class="prs-empty-sub">
80bd7c8Claude2281 {searchQ || authorFilter
2282 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2283 : state === "open"
2284 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2285 : isAllState
2286 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2287 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2288 </p>
2289 <div class="prs-empty-cta">
80bd7c8Claude2290 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2291 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2292 + New pull request
2293 </a>
2294 )}
80bd7c8Claude2295 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2296 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2297 View open PRs
2298 </a>
2299 )}
2300 <a href={`/${ownerName}/${repoName}`} class="btn">
2301 Back to code
2302 </a>
2303 </div>
2304 </div>
b078860Claude2305 </div>
0074234Claude2306 ) : (
b078860Claude2307 <div class="prs-list">
2308 {prList.map(({ pr, author }) => {
2309 const stateClass =
2310 pr.state === "open"
2311 ? pr.isDraft
2312 ? "state-draft"
2313 : "state-open"
2314 : pr.state === "merged"
2315 ? "state-merged"
2316 : "state-closed";
2317 const icon =
2318 pr.state === "open"
2319 ? pr.isDraft
2320 ? "◌"
2321 : "○"
2322 : pr.state === "merged"
2323 ? "⮌"
2324 : "✓";
1aef949Claude2325 const rv = reviewMap.get(pr.id);
b078860Claude2326 return (
2327 <a
2328 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2329 class="prs-row"
2330 style="text-decoration:none;color:inherit"
0074234Claude2331 >
b078860Claude2332 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2333 {icon}
0074234Claude2334 </div>
b078860Claude2335 <div class="prs-row-body">
2336 <h3 class="prs-row-title">
2337 <span>{pr.title}</span>
2338 <span class="prs-row-number">#{pr.number}</span>
2339 </h3>
2340 <div class="prs-row-meta">
2341 <span
2342 class="prs-branch-chips"
2343 title={`${pr.headBranch} into ${pr.baseBranch}`}
2344 >
2345 <span class="prs-branch-chip">{pr.headBranch}</span>
2346 <span class="prs-branch-arrow">{"→"}</span>
2347 <span class="prs-branch-chip">{pr.baseBranch}</span>
2348 </span>
2349 <span>
2350 by{" "}
2351 <strong style="color:var(--text)">
2352 {author.username}
2353 </strong>{" "}
2354 {formatRelative(pr.createdAt)}
2355 </span>
2356 <span class="prs-row-tags">
2357 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2358 {pr.state === "merged" && (
2359 <span class="prs-tag is-merged">Merged</span>
2360 )}
1aef949Claude2361 {rv?.approved && !rv.changesRequested && (
2362 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
2363 )}
2364 {rv?.changesRequested && (
2365 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
2366 )}
0369e77Claude2367 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
2368 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
2369 💬 {commentCountMap.get(pr.id)}
2370 </span>
2371 )}
b078860Claude2372 </span>
2373 </div>
0074234Claude2374 </div>
b078860Claude2375 </a>
2376 );
2377 })}
2378 </div>
0074234Claude2379 )}
2380 </Layout>
2381 );
2382});
2383
7a28902Claude2384/* ─────────────────────────────────────────────────────────────────────────
2385 * PR Insights — 90-day analytics for the pull request activity of a repo.
2386 * Route: GET /:owner/:repo/pulls/insights
2387 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
2388 * "insights" is not swallowed by the :number param.
2389 * ───────────────────────────────────────────────────────────────────────── */
2390
2391/** Format a millisecond duration as human-readable string. */
2392function formatMsDuration(ms: number): string {
2393 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
2394 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
2395 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
2396 return `${Math.round(ms / 86_400_000)}d`;
2397}
2398
2399/** Format an ISO week string as "Jan 15". */
2400function formatWeekLabel(isoWeek: string): string {
2401 try {
2402 const d = new Date(isoWeek);
2403 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2404 } catch {
2405 return isoWeek.slice(5, 10);
2406 }
2407}
2408
2409const PR_INSIGHTS_STYLES = `
2410 .pri-page { padding-bottom: 48px; }
2411 .pri-hero {
2412 position: relative;
2413 margin: 0 0 var(--space-5);
2414 padding: 22px 26px 24px;
2415 background: var(--bg-elevated);
2416 border: 1px solid var(--border);
2417 border-radius: 16px;
2418 overflow: hidden;
2419 }
2420 .pri-hero::before {
2421 content: '';
2422 position: absolute; top: 0; left: 0; right: 0;
2423 height: 2px;
2424 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2425 opacity: 0.7;
2426 pointer-events: none;
2427 }
2428 .pri-hero-eyebrow {
2429 font-size: 12px;
2430 color: var(--text-muted);
2431 text-transform: uppercase;
2432 letter-spacing: 0.08em;
2433 font-weight: 600;
2434 margin-bottom: 8px;
2435 }
2436 .pri-hero-title {
2437 font-family: var(--font-display);
2438 font-size: clamp(26px, 3.4vw, 34px);
2439 font-weight: 800;
2440 letter-spacing: -0.025em;
2441 line-height: 1.06;
2442 margin: 0 0 8px;
2443 color: var(--text-strong);
2444 }
2445 .pri-hero-title .gradient-text {
2446 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2447 -webkit-background-clip: text;
2448 background-clip: text;
2449 -webkit-text-fill-color: transparent;
2450 color: transparent;
2451 }
2452 .pri-hero-sub {
2453 font-size: 14.5px;
2454 color: var(--text-muted);
2455 margin: 0;
2456 line-height: 1.5;
2457 }
2458 .pri-section { margin-bottom: 32px; }
2459 .pri-section-title {
2460 font-size: 13px;
2461 font-weight: 700;
2462 text-transform: uppercase;
2463 letter-spacing: 0.06em;
2464 color: var(--text-muted);
2465 margin: 0 0 14px;
2466 }
2467 .pri-cards {
2468 display: grid;
2469 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
2470 gap: 12px;
2471 }
2472 .pri-card {
2473 padding: 16px 18px;
2474 background: var(--bg-elevated);
2475 border: 1px solid var(--border);
2476 border-radius: 12px;
2477 }
2478 .pri-card-label {
2479 font-size: 12px;
2480 font-weight: 600;
2481 color: var(--text-muted);
2482 text-transform: uppercase;
2483 letter-spacing: 0.05em;
2484 margin-bottom: 6px;
2485 }
2486 .pri-card-value {
2487 font-size: 28px;
2488 font-weight: 800;
2489 letter-spacing: -0.04em;
2490 color: var(--text-strong);
2491 line-height: 1;
2492 }
2493 .pri-card-sub {
2494 font-size: 12px;
2495 color: var(--text-muted);
2496 margin-top: 4px;
2497 }
2498 .pri-chart {
2499 display: flex;
2500 align-items: flex-end;
2501 gap: 6px;
2502 height: 120px;
2503 background: var(--bg-elevated);
2504 border: 1px solid var(--border);
2505 border-radius: 12px;
2506 padding: 16px 16px 0;
2507 }
2508 .pri-bar-col {
2509 flex: 1;
2510 display: flex;
2511 flex-direction: column;
2512 align-items: center;
2513 justify-content: flex-end;
2514 height: 100%;
2515 gap: 4px;
2516 }
2517 .pri-bar {
2518 width: 100%;
2519 min-height: 4px;
2520 border-radius: 4px 4px 0 0;
2521 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 100%);
2522 transition: opacity 140ms;
2523 }
2524 .pri-bar:hover { opacity: 0.8; }
2525 .pri-bar-label {
2526 font-size: 10px;
2527 color: var(--text-muted);
2528 text-align: center;
2529 padding-bottom: 8px;
2530 white-space: nowrap;
2531 overflow: hidden;
2532 text-overflow: ellipsis;
2533 max-width: 100%;
2534 }
2535 .pri-table {
2536 width: 100%;
2537 border-collapse: collapse;
2538 font-size: 13.5px;
2539 }
2540 .pri-table th {
2541 text-align: left;
2542 font-size: 12px;
2543 font-weight: 600;
2544 text-transform: uppercase;
2545 letter-spacing: 0.05em;
2546 color: var(--text-muted);
2547 padding: 8px 12px;
2548 border-bottom: 1px solid var(--border);
2549 }
2550 .pri-table td {
2551 padding: 10px 12px;
2552 border-bottom: 1px solid var(--border);
2553 color: var(--text);
2554 }
2555 .pri-table tr:last-child td { border-bottom: none; }
2556 .pri-table-wrap {
2557 background: var(--bg-elevated);
2558 border: 1px solid var(--border);
2559 border-radius: 12px;
2560 overflow: hidden;
2561 }
2562 .pri-age-row {
2563 display: flex;
2564 align-items: center;
2565 gap: 12px;
2566 padding: 10px 0;
2567 border-bottom: 1px solid var(--border);
2568 font-size: 13.5px;
2569 }
2570 .pri-age-row:last-child { border-bottom: none; }
2571 .pri-age-label {
2572 flex: 0 0 80px;
2573 color: var(--text-muted);
2574 font-size: 12.5px;
2575 font-weight: 600;
2576 }
2577 .pri-age-bar-wrap {
2578 flex: 1;
2579 height: 8px;
2580 background: var(--bg-secondary);
2581 border-radius: 9999px;
2582 overflow: hidden;
2583 }
2584 .pri-age-bar {
2585 height: 100%;
2586 border-radius: 9999px;
2587 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
2588 min-width: 4px;
2589 }
2590 .pri-age-count {
2591 flex: 0 0 32px;
2592 text-align: right;
2593 font-weight: 600;
2594 color: var(--text-strong);
2595 font-size: 13px;
2596 }
2597 .pri-sparkline {
2598 display: flex;
2599 align-items: flex-end;
2600 gap: 3px;
2601 height: 40px;
2602 }
2603 .pri-spark-bar {
2604 flex: 1;
2605 min-height: 2px;
2606 border-radius: 2px 2px 0 0;
2607 background: var(--accent, #8c6dff);
2608 opacity: 0.7;
2609 }
2610 .pri-empty {
2611 color: var(--text-muted);
2612 font-size: 14px;
2613 padding: 24px 0;
2614 text-align: center;
2615 }
2616 @media (max-width: 600px) {
2617 .pri-cards { grid-template-columns: repeat(2, 1fr); }
2618 .pri-hero { padding: 18px 18px 20px; }
2619 }
2620`;
2621
2622pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
2623 const { owner: ownerName, repo: repoName } = c.req.param();
2624 const user = c.get("user");
2625
2626 const resolved = await resolveRepo(ownerName, repoName);
2627 if (!resolved) return c.notFound();
2628
2629 const repoId = resolved.repo.id;
2630 const now = Date.now();
2631
2632 // 1. Merged PRs in last 90 days (avg merge time)
2633 const mergedPRs = await db
2634 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
2635 .from(pullRequests)
2636 .where(and(
2637 eq(pullRequests.repositoryId, repoId),
2638 eq(pullRequests.state, "merged"),
2639 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2640 ));
2641
2642 const avgMergeMs = mergedPRs.length > 0
2643 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
2644 : null;
2645
2646 // 2. PR throughput (last 8 weeks)
2647 const weeklyPRs = await db
2648 .select({
2649 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
2650 count: sql<number>`count(*)::int`,
2651 })
2652 .from(pullRequests)
2653 .where(and(
2654 eq(pullRequests.repositoryId, repoId),
2655 sql`${pullRequests.createdAt} > now() - interval '56 days'`
2656 ))
2657 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
2658 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
2659
2660 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
2661
2662 // 3. PR merge rate (last 90 days)
2663 const [rateCounts] = await db
2664 .select({
2665 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
2666 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
2667 })
2668 .from(pullRequests)
2669 .where(and(
2670 eq(pullRequests.repositoryId, repoId),
2671 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2672 ));
2673
2674 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
2675 const mergeRate = totalResolved > 0
2676 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
2677 : null;
2678
2679 // 4. Top reviewers (last 90 days)
2680 const reviewerCounts = await db
2681 .select({
2682 userId: prReviews.reviewerId,
2683 username: users.username,
2684 count: sql<number>`count(*)::int`,
2685 })
2686 .from(prReviews)
2687 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2688 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
2689 .where(and(
2690 eq(pullRequests.repositoryId, repoId),
2691 sql`${prReviews.createdAt} > now() - interval '90 days'`
2692 ))
2693 .groupBy(prReviews.reviewerId, users.username)
2694 .orderBy(desc(sql`count(*)`))
2695 .limit(5);
2696
2697 // 5. Average reviews per merged PR
2698 const [avgReviewRow] = await db
2699 .select({
2700 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
2701 })
2702 .from(pullRequests)
2703 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2704 .where(and(
2705 eq(pullRequests.repositoryId, repoId),
2706 eq(pullRequests.state, "merged"),
2707 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2708 ));
2709
2710 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
2711 ? Math.round(avgReviewRow.avgReviews * 10) / 10
2712 : null;
2713
2714 // 6. Review turnaround — avg time from PR open to first review
2715 const prsWithReviews = await db
2716 .select({
2717 createdAt: pullRequests.createdAt,
2718 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
2719 })
2720 .from(pullRequests)
2721 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2722 .where(and(
2723 eq(pullRequests.repositoryId, repoId),
2724 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2725 ))
2726 .groupBy(pullRequests.id, pullRequests.createdAt);
2727
2728 const avgReviewTurnaroundMs = prsWithReviews.length > 0
2729 ? prsWithReviews.reduce((s, row) => {
2730 const firstMs = new Date(row.firstReview).getTime();
2731 return s + Math.max(0, firstMs - row.createdAt.getTime());
2732 }, 0) / prsWithReviews.length
2733 : null;
2734
2735 // 7. Open PRs by age bucket
2736 const openPRs = await db
2737 .select({ createdAt: pullRequests.createdAt })
2738 .from(pullRequests)
2739 .where(and(
2740 eq(pullRequests.repositoryId, repoId),
2741 eq(pullRequests.state, "open")
2742 ));
2743
2744 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
2745 for (const { createdAt } of openPRs) {
2746 const ageDays = (now - createdAt.getTime()) / 86_400_000;
2747 if (ageDays < 1) ageBuckets.lt1d++;
2748 else if (ageDays < 3) ageBuckets.d1to3++;
2749 else if (ageDays < 7) ageBuckets.d3to7++;
2750 else if (ageDays < 30) ageBuckets.d7to30++;
2751 else ageBuckets.gt30d++;
2752 }
2753 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
2754
2755 // 8. 7-day merge sparkline
2756 const sparklineRows = await db
2757 .select({
2758 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
2759 count: sql<number>`count(*)::int`,
2760 })
2761 .from(pullRequests)
2762 .where(and(
2763 eq(pullRequests.repositoryId, repoId),
2764 eq(pullRequests.state, "merged"),
2765 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
2766 ))
2767 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
2768 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
2769
2770 const sparkMap = new Map<string, number>();
2771 for (const row of sparklineRows) {
2772 sparkMap.set(row.day.slice(0, 10), row.count);
2773 }
2774 const sparkline: number[] = [];
2775 for (let i = 6; i >= 0; i--) {
2776 const d = new Date(now - i * 86_400_000);
2777 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
2778 }
2779 const maxSpark = Math.max(1, ...sparkline);
2780
2781 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
2782 { label: "< 1 day", key: "lt1d" },
2783 { label: "1–3 days", key: "d1to3" },
2784 { label: "3–7 days", key: "d3to7" },
2785 { label: "7–30 days", key: "d7to30" },
2786 { label: "> 30 days", key: "gt30d" },
2787 ];
2788
2789 return c.html(
2790 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
2791 <RepoHeader owner={ownerName} repo={repoName} />
2792 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2793 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
2794
2795 <div class="pri-page">
2796 {/* Hero */}
2797 <div class="pri-hero">
2798 <div class="pri-hero-eyebrow">Pull requests</div>
2799 <h1 class="pri-hero-title">
2800 PR <span class="gradient-text">Insights</span>
2801 </h1>
2802 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
2803 </div>
2804
2805 {/* Stat cards */}
2806 <div class="pri-section">
2807 <div class="pri-section-title">At a glance</div>
2808 <div class="pri-cards">
2809 <div class="pri-card">
2810 <div class="pri-card-label">Avg merge time</div>
2811 <div class="pri-card-value">
2812 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
2813 </div>
2814 <div class="pri-card-sub">last 90 days</div>
2815 </div>
2816 <div class="pri-card">
2817 <div class="pri-card-label">Total merged</div>
2818 <div class="pri-card-value">{mergedPRs.length}</div>
2819 <div class="pri-card-sub">last 90 days</div>
2820 </div>
2821 <div class="pri-card">
2822 <div class="pri-card-label">Open PRs</div>
2823 <div class="pri-card-value">{openPRs.length}</div>
2824 <div class="pri-card-sub">right now</div>
2825 </div>
2826 <div class="pri-card">
2827 <div class="pri-card-label">Merge rate</div>
2828 <div class="pri-card-value">
2829 {mergeRate != null ? `${mergeRate}%` : "—"}
2830 </div>
2831 <div class="pri-card-sub">merged vs closed</div>
2832 </div>
2833 <div class="pri-card">
2834 <div class="pri-card-label">Avg reviews / PR</div>
2835 <div class="pri-card-value">
2836 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
2837 </div>
2838 <div class="pri-card-sub">merged PRs, 90d</div>
2839 </div>
2840 <div class="pri-card">
2841 <div class="pri-card-label">Top reviewer</div>
2842 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
2843 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
2844 </div>
2845 <div class="pri-card-sub">
2846 {reviewerCounts.length > 0
2847 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
2848 : "no reviews yet"}
2849 </div>
2850 </div>
2851 </div>
2852 </div>
2853
2854 {/* Review turnaround */}
2855 <div class="pri-section">
2856 <div class="pri-section-title">Review turnaround</div>
2857 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
2858 <div class="pri-card">
2859 <div class="pri-card-label">Avg time to first review</div>
2860 <div class="pri-card-value">
2861 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
2862 </div>
2863 <div class="pri-card-sub">
2864 {prsWithReviews.length > 0
2865 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
2866 : "no reviewed PRs in 90d"}
2867 </div>
2868 </div>
2869 </div>
2870 </div>
2871
2872 {/* Weekly throughput bar chart */}
2873 <div class="pri-section">
2874 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
2875 {weeklyPRs.length === 0 ? (
2876 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
2877 ) : (
2878 <div class="pri-chart">
2879 {weeklyPRs.map((w) => (
2880 <div class="pri-bar-col">
2881 <div
2882 class="pri-bar"
2883 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
2884 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
2885 />
2886 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
2887 </div>
2888 ))}
2889 </div>
2890 )}
2891 </div>
2892
2893 {/* 7-day merge sparkline */}
2894 <div class="pri-section">
2895 <div class="pri-section-title">Merges this week (daily)</div>
2896 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
2897 <div class="pri-sparkline">
2898 {sparkline.map((v) => (
2899 <div
2900 class="pri-spark-bar"
2901 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
2902 title={`${v} merge${v === 1 ? "" : "s"}`}
2903 />
2904 ))}
2905 </div>
2906 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
2907 <span>7 days ago</span>
2908 <span>Today</span>
2909 </div>
2910 </div>
2911 </div>
2912
2913 {/* Top reviewers table */}
2914 <div class="pri-section">
2915 <div class="pri-section-title">Top reviewers (last 90 days)</div>
2916 {reviewerCounts.length === 0 ? (
2917 <div class="pri-empty">No reviews posted in the last 90 days.</div>
2918 ) : (
2919 <div class="pri-table-wrap">
2920 <table class="pri-table">
2921 <thead>
2922 <tr>
2923 <th>#</th>
2924 <th>Reviewer</th>
2925 <th>Reviews</th>
2926 </tr>
2927 </thead>
2928 <tbody>
2929 {reviewerCounts.map((r, i) => (
2930 <tr>
2931 <td style="color:var(--text-muted)">{i + 1}</td>
2932 <td>
2933 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
2934 {r.username}
2935 </a>
2936 </td>
2937 <td style="font-weight:600">{r.count}</td>
2938 </tr>
2939 ))}
2940 </tbody>
2941 </table>
2942 </div>
2943 )}
2944 </div>
2945
2946 {/* Open PRs by age */}
2947 <div class="pri-section">
2948 <div class="pri-section-title">Open PRs by age</div>
2949 {openPRs.length === 0 ? (
2950 <div class="pri-empty">No open pull requests.</div>
2951 ) : (
2952 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
2953 {ageBucketDefs.map(({ label, key }) => (
2954 <div class="pri-age-row">
2955 <span class="pri-age-label">{label}</span>
2956 <div class="pri-age-bar-wrap">
2957 <div
2958 class="pri-age-bar"
2959 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
2960 />
2961 </div>
2962 <span class="pri-age-count">{ageBuckets[key]}</span>
2963 </div>
2964 ))}
2965 </div>
2966 )}
2967 </div>
2968
2969 {/* Back link */}
2970 <div>
2971 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
2972 {"←"} Back to pull requests
2973 </a>
2974 </div>
2975 </div>
2976 </Layout>
2977 );
2978});
2979
0074234Claude2980// New PR form
2981pulls.get(
2982 "/:owner/:repo/pulls/new",
2983 softAuth,
2984 requireAuth,
04f6b7fClaude2985 requireRepoAccess("write"),
0074234Claude2986 async (c) => {
2987 const { owner: ownerName, repo: repoName } = c.req.param();
2988 const user = c.get("user")!;
2989 const branches = await listBranches(ownerName, repoName);
2990 const error = c.req.query("error");
2991 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude2992 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude2993
2994 return c.html(
2995 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
2996 <RepoHeader owner={ownerName} repo={repoName} />
2997 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude2998 <Container maxWidth={800}>
2999 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude3000 {error && (
bb0f894Claude3001 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude3002 )}
0316dbbClaude3003 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
3004 <Flex gap={12} align="center" style="margin-bottom: 16px">
3005 <Select name="base">
0074234Claude3006 {branches.map((b) => (
3007 <option value={b} selected={b === defaultBase}>
3008 {b}
3009 </option>
3010 ))}
bb0f894Claude3011 </Select>
3012 <Text muted>&larr;</Text>
3013 <Select name="head">
0074234Claude3014 {branches
3015 .filter((b) => b !== defaultBase)
3016 .concat(defaultBase === branches[0] ? [] : [branches[0]])
3017 .map((b) => (
3018 <option value={b}>{b}</option>
3019 ))}
bb0f894Claude3020 </Select>
3021 </Flex>
3022 <FormGroup>
3023 <Input
0074234Claude3024 name="title"
3025 required
3026 placeholder="Title"
bb0f894Claude3027 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]3028 aria-label="Pull request title"
0074234Claude3029 />
bb0f894Claude3030 </FormGroup>
3031 <FormGroup>
3032 <TextArea
0074234Claude3033 name="body"
81c73c1Claude3034 id="pr-body"
0074234Claude3035 rows={8}
3036 placeholder="Description (Markdown supported)"
bb0f894Claude3037 mono
0074234Claude3038 />
bb0f894Claude3039 </FormGroup>
81c73c1Claude3040 <Flex gap={8} align="center">
3041 <Button type="submit" variant="primary">
3042 Create pull request
3043 </Button>
3044 <button
3045 type="button"
3046 id="ai-suggest-desc"
3047 class="btn"
3048 style="font-weight:500"
3049 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
3050 >
3051 Suggest description with AI
3052 </button>
3053 <span
3054 id="ai-suggest-status"
3055 style="color:var(--text-muted);font-size:13px"
3056 />
3057 </Flex>
bb0f894Claude3058 </Form>
81c73c1Claude3059 <script
3060 dangerouslySetInnerHTML={{
3061 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
3062 }}
3063 />
bb0f894Claude3064 </Container>
0074234Claude3065 </Layout>
3066 );
3067 }
3068);
3069
81c73c1Claude3070// AI-suggested PR description — JSON endpoint driven by the form button.
3071// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
3072// 200; the inline script reads `ok` to decide what to do.
3073pulls.post(
3074 "/:owner/:repo/ai/pr-description",
3075 softAuth,
3076 requireAuth,
3077 requireRepoAccess("write"),
3078 async (c) => {
3079 const { owner: ownerName, repo: repoName } = c.req.param();
3080 if (!isAiAvailable()) {
3081 return c.json({
3082 ok: false,
3083 error: "AI is not available — set ANTHROPIC_API_KEY.",
3084 });
3085 }
3086 const body = await c.req.parseBody();
3087 const title = String(body.title || "").trim();
3088 const baseBranch = String(body.base || "").trim();
3089 const headBranch = String(body.head || "").trim();
3090 if (!baseBranch || !headBranch) {
3091 return c.json({ ok: false, error: "Pick base + head branches first." });
3092 }
3093 if (baseBranch === headBranch) {
3094 return c.json({ ok: false, error: "Base and head must differ." });
3095 }
3096
3097 let diff = "";
3098 try {
3099 const cwd = getRepoPath(ownerName, repoName);
3100 const proc = Bun.spawn(
3101 [
3102 "git",
3103 "diff",
3104 `${baseBranch}...${headBranch}`,
3105 "--",
3106 ],
3107 { cwd, stdout: "pipe", stderr: "pipe" }
3108 );
6ea2109Claude3109 // 30s ceiling — without this a pathological diff (huge binary or
3110 // a corrupt ref) hangs the request indefinitely.
3111 const killer = setTimeout(() => proc.kill(), 30_000);
3112 try {
3113 diff = await new Response(proc.stdout).text();
3114 await proc.exited;
3115 } finally {
3116 clearTimeout(killer);
3117 }
81c73c1Claude3118 } catch {
3119 diff = "";
3120 }
3121 if (!diff.trim()) {
3122 return c.json({
3123 ok: false,
3124 error: "No diff between branches — nothing to summarise.",
3125 });
3126 }
3127
3128 let summary = "";
3129 try {
3130 summary = await generatePrSummary(title || "(untitled)", diff);
3131 } catch (err) {
3132 const msg = err instanceof Error ? err.message : "AI request failed.";
3133 return c.json({ ok: false, error: msg });
3134 }
3135 if (!summary.trim()) {
3136 return c.json({ ok: false, error: "AI returned an empty draft." });
3137 }
3138 return c.json({ ok: true, body: summary });
3139 }
3140);
3141
0074234Claude3142// Create PR
3143pulls.post(
3144 "/:owner/:repo/pulls/new",
3145 softAuth,
3146 requireAuth,
04f6b7fClaude3147 requireRepoAccess("write"),
0074234Claude3148 async (c) => {
3149 const { owner: ownerName, repo: repoName } = c.req.param();
3150 const user = c.get("user")!;
3151 const body = await c.req.parseBody();
3152 const title = String(body.title || "").trim();
3153 const prBody = String(body.body || "").trim();
3154 const baseBranch = String(body.base || "main");
3155 const headBranch = String(body.head || "");
3156
3157 if (!title || !headBranch) {
3158 return c.redirect(
3159 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
3160 );
3161 }
3162
3163 if (baseBranch === headBranch) {
3164 return c.redirect(
3165 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
3166 );
3167 }
3168
3169 const resolved = await resolveRepo(ownerName, repoName);
3170 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3171
6fc53bdClaude3172 const isDraft = String(body.draft || "") === "1";
3173
0074234Claude3174 const [pr] = await db
3175 .insert(pullRequests)
3176 .values({
3177 repositoryId: resolved.repo.id,
3178 authorId: user.id,
3179 title,
3180 body: prBody || null,
3181 baseBranch,
3182 headBranch,
6fc53bdClaude3183 isDraft,
0074234Claude3184 })
3185 .returning();
3186
6fc53bdClaude3187 // Skip AI review on drafts — it runs again when the PR is marked ready.
3188 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3189 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3190 (err) => console.error("[ai-review] Failed:", err)
3191 );
3192 }
3193
3cbe3d6Claude3194 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3195 triggerPrTriage({
3196 ownerName,
3197 repoName,
3198 repositoryId: resolved.repo.id,
3199 prId: pr.id,
3200 prAuthorId: user.id,
3201 title,
3202 body: prBody,
3203 baseBranch,
3204 headBranch,
3205 }).catch((err) => console.error("[pr-triage] Failed:", err));
3206
1d4ff60Claude3207 // Chat notifier — fan out to Slack/Discord/Teams.
3208 import("../lib/chat-notifier")
3209 .then((m) =>
3210 m.notifyChatChannels({
3211 ownerUserId: resolved.repo.ownerId,
3212 repositoryId: resolved.repo.id,
3213 event: {
3214 event: "pr.opened",
3215 repo: `${ownerName}/${repoName}`,
3216 title: `#${pr.number} ${title}`,
3217 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3218 body: prBody || undefined,
3219 actor: user.username,
3220 },
3221 })
3222 )
3223 .catch((err) =>
3224 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3225 );
3226
9dd96b9Test User3227 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude3228 import("../lib/auto-merge")
3229 .then((m) => m.tryAutoMergeNow(pr.id))
3230 .catch((err) => {
3231 console.warn(
3232 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
3233 err instanceof Error ? err.message : err
3234 );
3235 });
9dd96b9Test User3236
1df50d5Claude3237 // Migration 0077 — PR preview build. Fire-and-forget; skips when
3238 // PREVIEW_DOMAIN is unset or the repo has no preview_build_command.
3239 // Resolve head SHA asynchronously so we don't block the redirect.
3240 resolveRef(ownerName, repoName, headBranch)
3241 .then((headSha) => {
3242 if (!headSha) return;
3243 return import("../lib/preview-builder").then((m) =>
3244 m.buildPreview(pr.id, resolved.repo.id, headSha)
3245 );
3246 })
3247 .catch(() => {});
3248
0074234Claude3249 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
3250 }
3251);
3252
3253// View single PR
04f6b7fClaude3254pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude3255 const { owner: ownerName, repo: repoName } = c.req.param();
3256 const prNum = parseInt(c.req.param("number"), 10);
3257 const user = c.get("user");
3258 const tab = c.req.query("tab") || "conversation";
3259
3260 const resolved = await resolveRepo(ownerName, repoName);
3261 if (!resolved) return c.notFound();
3262
3263 const [pr] = await db
3264 .select()
3265 .from(pullRequests)
3266 .where(
3267 and(
3268 eq(pullRequests.repositoryId, resolved.repo.id),
3269 eq(pullRequests.number, prNum)
3270 )
3271 )
3272 .limit(1);
3273
3274 if (!pr) return c.notFound();
3275
3276 const [author] = await db
3277 .select()
3278 .from(users)
3279 .where(eq(users.id, pr.authorId))
3280 .limit(1);
3281
cb5a796Claude3282 const allCommentsRaw = await db
0074234Claude3283 .select({
3284 comment: prComments,
cb5a796Claude3285 author: { id: users.id, username: users.username },
0074234Claude3286 })
3287 .from(prComments)
3288 .innerJoin(users, eq(prComments.authorId, users.id))
3289 .where(eq(prComments.pullRequestId, pr.id))
3290 .orderBy(asc(prComments.createdAt));
3291
cb5a796Claude3292 // Filter pending/rejected/spam for non-owner, non-author viewers.
3293 // Owner always sees everything; comment author sees their own pending
3294 // with an "Awaiting approval" badge in the render below.
3295 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
3296 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
3297 if (viewerIsRepoOwner) return true;
3298 if (comment.moderationStatus === "approved") return true;
3299 if (
3300 user &&
3301 cAuthor.id === user.id &&
3302 comment.moderationStatus === "pending"
3303 ) {
3304 return true;
3305 }
3306 return false;
3307 });
3308 const prPendingCount = viewerIsRepoOwner
3309 ? await countPendingForRepo(resolved.repo.id)
3310 : 0;
3311
6fc53bdClaude3312 // Reactions for the PR body + each comment, in parallel.
3313 const [prReactions, ...prCommentReactions] = await Promise.all([
3314 summariseReactions("pr", pr.id, user?.id),
3315 ...comments.map((row) =>
3316 summariseReactions("pr_comment", row.comment.id, user?.id)
3317 ),
3318 ]);
3319
0a67773Claude3320 // Formal reviews (Approve / Request Changes)
3321 const reviewRows = await db
3322 .select({
3323 id: prReviews.id,
3324 state: prReviews.state,
3325 body: prReviews.body,
3326 isAi: prReviews.isAi,
3327 createdAt: prReviews.createdAt,
3328 reviewerUsername: users.username,
3329 reviewerId: prReviews.reviewerId,
3330 })
3331 .from(prReviews)
3332 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3333 .where(eq(prReviews.pullRequestId, pr.id))
3334 .orderBy(asc(prReviews.createdAt));
3335 // Most recent review per reviewer determines the current state
3336 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
3337 for (const r of reviewRows) {
3338 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
3339 }
3340 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
3341 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
3342 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
3343
ace34efClaude3344 // Suggested reviewers — best-effort, never throws
3345 let reviewerSuggestions: ReviewerCandidate[] = [];
3346 try {
3347 if (user) {
3348 reviewerSuggestions = await suggestReviewers(
3349 ownerName, repoName, pr.headBranch, pr.baseBranch,
3350 pr.authorId, resolved.repo.id
3351 );
3352 }
3353 } catch {
3354 // silent degradation
3355 }
3356
0074234Claude3357 const canManage =
3358 user &&
3359 (user.id === resolved.owner.id || user.id === pr.authorId);
3360
1d4ff60Claude3361 // Has any previous AI-test-generator run already tagged this PR? Used
3362 // both to hide the "Generate tests with AI" button and to short-circuit
3363 // the explicit POST handler.
3364 const hasAiTestsMarker = comments.some(({ comment }) =>
3365 (comment.body || "").includes(AI_TESTS_MARKER)
3366 );
3367
e883329Claude3368 const error = c.req.query("error");
c3e0c07Claude3369 const info = c.req.query("info");
e883329Claude3370
3371 // Get gate check status for open PRs
3372 let gateChecks: GateCheckResult[] = [];
240c477Claude3373 let ciStatuses: CommitStatus[] = [];
e883329Claude3374 if (pr.state === "open") {
3375 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3376 if (headSha) {
3377 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
3378 const aiApproved = aiComments.length === 0 || aiComments.some(
3379 ({ comment }) => comment.body.includes("**Approved**")
3380 );
240c477Claude3381 const [gateResult, fetchedCiStatuses] = await Promise.all([
3382 runAllGateChecks(
3383 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
3384 ),
3385 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
3386 ]);
e883329Claude3387 gateChecks = gateResult.checks;
240c477Claude3388 ciStatuses = fetchedCiStatuses;
e883329Claude3389 }
3390 }
3391
534f04aClaude3392 // Block M3 — pre-merge risk score. Cache-only on the request path so
3393 // the page never waits on Haiku. On a cache miss for an open PR we
3394 // kick off the computation fire-and-forget; the next refresh shows it.
3395 let prRisk: PrRiskScore | null = null;
3396 let prRiskCalculating = false;
3397 if (pr.state === "open") {
3398 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
3399 if (!prRisk) {
3400 prRiskCalculating = true;
a28cedeClaude3401 void computePrRiskForPullRequest(pr.id).catch((err) => {
3402 console.warn(
3403 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
3404 err instanceof Error ? err.message : err
3405 );
3406 });
534f04aClaude3407 }
3408 }
3409
4bbacbeClaude3410 // Migration 0062 — per-branch preview URL. The head branch always
3411 // has a preview row (unless it's the default branch, which never
3412 // happens for an open PR) once it has been pushed at least once.
3413 const preview = await getPreviewForBranch(
3414 (resolved.repo as { id: string }).id,
3415 pr.headBranch
3416 );
3417
0369e77Claude3418 // Branch ahead/behind counts — how many commits head is ahead of base and
3419 // how many commits base has advanced since head branched off.
3420 let branchAhead = 0;
3421 let branchBehind = 0;
3422 if (pr.state === "open") {
3423 try {
3424 const repoDir = getRepoPath(ownerName, repoName);
3425 const [aheadProc, behindProc] = [
3426 Bun.spawn(
3427 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
3428 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3429 ),
3430 Bun.spawn(
3431 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
3432 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3433 ),
3434 ];
3435 const [aheadTxt, behindTxt] = await Promise.all([
3436 new Response(aheadProc.stdout).text(),
3437 new Response(behindProc.stdout).text(),
3438 ]);
3439 await Promise.all([aheadProc.exited, behindProc.exited]);
3440 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
3441 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
3442 } catch { /* non-blocking */ }
3443 }
3444
6d1bbc2Claude3445 // Linked issues — parse closing keywords from PR title+body, look up issues
3446 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
3447 try {
3448 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3449 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3450 if (refs.length > 0) {
3451 linkedIssues = await db
3452 .select({ number: issues.number, title: issues.title, state: issues.state })
3453 .from(issues)
3454 .where(and(
3455 eq(issues.repositoryId, resolved.repo.id),
3456 inArray(issues.number, refs),
3457 ));
3458 }
3459 } catch { /* non-blocking */ }
3460
3461 // Task list progress — count markdown checkboxes in PR body
3462 let taskTotal = 0;
3463 let taskChecked = 0;
3464 if (pr.body) {
3465 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
3466 taskTotal++;
3467 if (m[1].trim() !== "") taskChecked++;
3468 }
3469 }
3470
74d8c4dClaude3471 // M15 — PR size badge (best-effort, non-blocking)
3472 let prSizeInfo: PrSizeInfo | null = null;
3473 try {
3474 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
3475 } catch { /* swallow — purely cosmetic */ }
3476
47a7a0aClaude3477 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude3478 let diffRaw = "";
3479 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude3480 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude3481 if (tab === "files") {
3482 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude3483 // Run the two git diffs in parallel — they're independent reads of
3484 // the same range. Previously sequential, doubling the wall time on
3485 // big PRs (100+ files = 10-30s for no reason).
0074234Claude3486 const proc = Bun.spawn(
3487 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
3488 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3489 );
3490 const statProc = Bun.spawn(
3491 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
3492 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3493 );
6ea2109Claude3494 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
3495 // would otherwise hang the whole request.
3496 const killer = setTimeout(() => {
3497 proc.kill();
3498 statProc.kill();
3499 }, 30_000);
3500 let stat = "";
3501 try {
3502 [diffRaw, stat] = await Promise.all([
3503 new Response(proc.stdout).text(),
3504 new Response(statProc.stdout).text(),
3505 ]);
3506 await Promise.all([proc.exited, statProc.exited]);
3507 } finally {
3508 clearTimeout(killer);
3509 }
0074234Claude3510
3511 diffFiles = stat
3512 .trim()
3513 .split("\n")
3514 .filter(Boolean)
3515 .map((line) => {
3516 const [add, del, filePath] = line.split("\t");
3517 return {
3518 path: filePath,
3519 status: "modified",
3520 additions: add === "-" ? 0 : parseInt(add, 10),
3521 deletions: del === "-" ? 0 : parseInt(del, 10),
3522 patch: "",
3523 };
3524 });
47a7a0aClaude3525
3526 // Fetch inline comments (file+line anchored) for the files tab
3527 const inlineRows = await db
3528 .select({
3529 id: prComments.id,
3530 filePath: prComments.filePath,
3531 lineNumber: prComments.lineNumber,
3532 body: prComments.body,
3533 isAiReview: prComments.isAiReview,
3534 createdAt: prComments.createdAt,
3535 authorUsername: users.username,
3536 })
3537 .from(prComments)
3538 .innerJoin(users, eq(prComments.authorId, users.id))
3539 .where(
3540 and(
3541 eq(prComments.pullRequestId, pr.id),
3542 eq(prComments.moderationStatus, "approved"),
3543 )
3544 )
3545 .orderBy(asc(prComments.createdAt));
3546
3547 diffInlineComments = inlineRows
3548 .filter(r => r.filePath != null && r.lineNumber != null)
3549 .map(r => ({
3550 id: r.id,
3551 filePath: r.filePath!,
3552 lineNumber: r.lineNumber!,
3553 authorUsername: r.authorUsername,
3554 body: renderMarkdown(r.body),
3555 isAiReview: r.isAiReview,
3556 createdAt: r.createdAt.toISOString(),
3557 }));
0074234Claude3558 }
3559
b078860Claude3560 // ─── Derived visual state ───
3561 const stateKey =
3562 pr.state === "open"
3563 ? pr.isDraft
3564 ? "draft"
3565 : "open"
3566 : pr.state;
3567 const stateLabel =
3568 stateKey === "open"
3569 ? "Open"
3570 : stateKey === "draft"
3571 ? "Draft"
3572 : stateKey === "merged"
3573 ? "Merged"
3574 : "Closed";
3575 const stateIcon =
3576 stateKey === "open"
3577 ? "○"
3578 : stateKey === "draft"
3579 ? "◌"
3580 : stateKey === "merged"
3581 ? "⮌"
3582 : "✓";
3583 const commentCount = comments.length;
3584 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
3585 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
3586 const mergeBlocked =
3587 gateChecks.length > 0 &&
3588 gateChecks.some(
3589 (c) => !c.passed && c.name !== "Merge check"
3590 );
3591
b558f23Claude3592 // Commits tab — list commits included in this PR (base..head range)
3593 let prCommits: GitCommit[] = [];
3594 if (tab === "commits") {
3595 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
3596 }
3597
b93fc3eClaude3598 // Review context restore — compute BEFORE recording the visit so the
3599 // previous timestamp is available for the delta calculation.
3600 let reviewCtx: ReviewContext | null = null;
3601 if (user) {
3602 reviewCtx = await getReviewContext(pr.id, user.id, {
3603 ownerName,
3604 repoName,
3605 baseBranch: pr.baseBranch,
3606 headBranch: pr.headBranch,
3607 });
3608 // Fire-and-forget: record the visit AFTER computing context
3609 void recordPrVisit(pr.id, user.id);
3610 }
3611
0074234Claude3612 return c.html(
3613 <Layout
3614 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
3615 user={user}
3616 >
3617 <RepoHeader owner={ownerName} repo={repoName} />
3618 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude3619 <PendingCommentsBanner
3620 owner={ownerName}
3621 repo={repoName}
3622 count={prPendingCount}
3623 />
b078860Claude3624 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude3625 <div
3626 id="live-comment-banner"
3627 class="alert"
3628 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
3629 >
3630 <strong class="js-live-count">0</strong> new comment(s) —{" "}
3631 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
3632 reload to view
3633 </a>
3634 </div>
3635 <script
3636 dangerouslySetInnerHTML={{
3637 __html: liveCommentBannerScript({
3638 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
3639 bannerElementId: "live-comment-banner",
3640 }),
3641 }}
3642 />
b078860Claude3643
b93fc3eClaude3644 {/* Review context restore banner — shown when returning after changes */}
3645 {reviewCtx && (
3646 <div
3647 class="context-restore-banner"
3648 id="review-context"
3649 style="display:flex;align-items:flex-start;gap:12px;padding:12px 16px;margin:0 0 12px;background:var(--bg-elevated,#f8f9fa);border:1px solid var(--border,#e1e4e8);border-left:3px solid var(--accent,#0070f3);border-radius:10px"
3650 >
3651 <span class="context-icon" style="font-size:18px;flex-shrink:0;margin-top:2px" aria-hidden="true">{"↩"}</span>
3652 <div style="flex:1;min-width:0">
3653 <strong style="font-size:13.5px;color:var(--text-strong,#111)">Welcome back</strong>
3654 <p style="margin:4px 0 0;font-size:13px;color:var(--text,#333);line-height:1.5">{reviewCtx.summary}</p>
3655 <small style="font-size:11.5px;color:var(--text-muted,#777)">
3656 Last visited {formatRelative(new Date(reviewCtx.lastVisitedAt))}
3657 {reviewCtx.commitsSince > 0 && ` · ${reviewCtx.commitsSince} new commit${reviewCtx.commitsSince === 1 ? "" : "s"}`}
3658 {reviewCtx.newComments > 0 && ` · ${reviewCtx.newComments} new comment${reviewCtx.newComments === 1 ? "" : "s"}`}
3659 </small>
3660 {reviewCtx.suggestedStartLine && (
3661 <p style="margin:6px 0 0;font-size:12px;color:var(--accent,#0070f3)">
3662 Start at: <code style="font-size:11px">{reviewCtx.suggestedStartLine}</code>
3663 </p>
3664 )}
3665 </div>
3666 <button
3667 type="button"
3668 onclick="this.closest('.context-restore-banner').remove()"
3669 style="flex-shrink:0;background:none;border:none;cursor:pointer;font-size:18px;color:var(--text-muted,#777);padding:0;line-height:1"
3670 aria-label="Dismiss"
3671 >
3672 {"×"}
3673 </button>
3674 </div>
3675 )}
3676
b078860Claude3677 <div class="prs-detail-hero">
b558f23Claude3678 <div class="prs-edit-title-wrap">
3679 <h1 class="prs-detail-title" id="pr-title-display">
3680 {pr.title}{" "}
3681 <span class="prs-detail-num">#{pr.number}</span>
3682 </h1>
3683 {canManage && pr.state === "open" && (
3684 <button
3685 type="button"
3686 class="prs-edit-btn"
3687 id="pr-edit-toggle"
3688 onclick={`
3689 document.getElementById('pr-title-display').style.display='none';
3690 document.getElementById('pr-edit-toggle').style.display='none';
3691 document.getElementById('pr-edit-form').style.display='flex';
3692 document.getElementById('pr-title-input').focus();
3693 `}
3694 >
3695 Edit
3696 </button>
3697 )}
3698 </div>
3699 {canManage && pr.state === "open" && (
3700 <form
3701 id="pr-edit-form"
3702 method="post"
3703 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
3704 class="prs-edit-form"
3705 style="display:none"
3706 >
3707 <input
3708 id="pr-title-input"
3709 type="text"
3710 name="title"
3711 value={pr.title}
3712 required
3713 maxlength={256}
3714 placeholder="Pull request title"
3715 />
3716 <div class="prs-edit-actions">
3717 <button type="submit" class="prs-edit-save-btn">Save</button>
3718 <button
3719 type="button"
3720 class="prs-edit-cancel-btn"
3721 onclick={`
3722 document.getElementById('pr-edit-form').style.display='none';
3723 document.getElementById('pr-title-display').style.display='';
3724 document.getElementById('pr-edit-toggle').style.display='';
3725 `}
3726 >
3727 Cancel
3728 </button>
3729 </div>
3730 </form>
3731 )}
b078860Claude3732 <div class="prs-detail-meta">
3733 <span class={`prs-state-pill state-${stateKey}`}>
3734 <span aria-hidden="true">{stateIcon}</span>
3735 <span>{stateLabel}</span>
3736 </span>
74d8c4dClaude3737 {prSizeInfo && (
3738 <span
3739 class="prs-size-badge"
3740 style={`color:${prSizeInfo.color};background:${prSizeInfo.bgColor}`}
3741 title={`${prSizeInfo.linesChanged} lines changed (+${prSizeInfo.added} −${prSizeInfo.deleted})`}
3742 >
3743 {prSizeInfo.label}
3744 </span>
3745 )}
67dc4e1Claude3746 <TrioVerdictPills
3747 comments={comments.map(({ comment }) => comment)}
3748 />
b078860Claude3749 <span>
3750 <strong>{author?.username}</strong> wants to merge
3751 </span>
3752 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
3753 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
3754 <span class="prs-branch-arrow-lg">{"→"}</span>
3755 <span class="prs-branch-pill">{pr.baseBranch}</span>
3756 </span>
0369e77Claude3757 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
3758 <span
3759 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
3760 title={branchBehind > 0
3761 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
3762 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
3763 >
3764 {branchAhead > 0 ? `↑${branchAhead}` : ""}
3765 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
3766 {branchBehind > 0 ? `↓${branchBehind}` : ""}
3767 </span>
3768 )}
b078860Claude3769 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude3770 {taskTotal > 0 && (
3771 <span
3772 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
3773 title={`${taskChecked} of ${taskTotal} tasks completed`}
3774 >
3775 <span class="prs-tasks-progress" aria-hidden="true">
3776 <span
3777 class="prs-tasks-progress-bar"
3778 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
3779 ></span>
3780 </span>
3781 {taskChecked}/{taskTotal} tasks
3782 </span>
3783 )}
3784 {canManage && pr.state === "open" && branchBehind > 0 && (
3785 <form
3786 method="post"
3787 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
3788 class="prs-inline-form"
3789 >
3790 <button
3791 type="submit"
3792 class="prs-update-branch-btn"
3793 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
3794 >
3795 ↑ Update branch
3796 </button>
3797 </form>
3798 )}
3c03977Claude3799 <span
3800 id="live-pill"
3801 class="live-pill"
3802 title="People editing this PR right now"
3803 >
3804 <span class="live-pill-dot" aria-hidden="true"></span>
3805 <span>
3806 Live: <strong id="live-count">0</strong> editing
3807 </span>
3808 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
3809 </span>
4bbacbeClaude3810 {preview && (
3811 <a
3812 class={`preview-prpill is-${preview.status}`}
3813 href={
3814 preview.status === "ready"
3815 ? preview.previewUrl
3816 : `/${ownerName}/${repoName}/previews`
3817 }
3818 target={preview.status === "ready" ? "_blank" : undefined}
3819 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
3820 title={`Preview · ${previewStatusLabel(preview.status)}`}
3821 >
3822 <span class="preview-prpill-dot" aria-hidden="true"></span>
3823 <span>Preview: </span>
3824 <span>{previewStatusLabel(preview.status)}</span>
3825 </a>
3826 )}
b078860Claude3827 {canManage && pr.state === "open" && pr.isDraft && (
3828 <form
3829 method="post"
3830 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3831 class="prs-inline-form prs-detail-actions"
3832 >
3833 <button type="submit" class="prs-merge-ready-btn">
3834 Ready for review
3835 </button>
3836 </form>
3837 )}
3838 </div>
3839 </div>
3c03977Claude3840 <script
3841 dangerouslySetInnerHTML={{
3842 __html: LIVE_COEDIT_SCRIPT(pr.id),
3843 }}
3844 />
829a046Claude3845 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude3846 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude3847 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude3848
b078860Claude3849 <nav class="prs-detail-tabs" aria-label="Pull request sections">
3850 <a
3851 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
3852 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
3853 >
3854 Conversation
3855 <span class="prs-detail-tab-count">{commentCount}</span>
3856 </a>
b558f23Claude3857 <a
3858 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
3859 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
3860 >
3861 Commits
3862 {branchAhead > 0 && (
3863 <span class="prs-detail-tab-count">{branchAhead}</span>
3864 )}
3865 </a>
b078860Claude3866 <a
3867 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
3868 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3869 >
3870 Files changed
3871 {diffFiles.length > 0 && (
3872 <span class="prs-detail-tab-count">{diffFiles.length}</span>
3873 )}
3874 </a>
3875 </nav>
3876
b558f23Claude3877 {tab === "commits" ? (
3878 <div class="prs-commits-list">
3879 {prCommits.length === 0 ? (
3880 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
3881 ) : (
3882 prCommits.map((commit) => (
3883 <div class="prs-commit-row">
3884 <span class="prs-commit-dot" aria-hidden="true"></span>
3885 <div class="prs-commit-body">
3886 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
3887 <div class="prs-commit-meta">
3888 <strong>{commit.author}</strong> committed{" "}
3889 {formatRelative(new Date(commit.date))}
3890 </div>
3891 </div>
3892 <a
3893 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
3894 class="prs-commit-sha"
3895 title="View commit"
3896 >
3897 {commit.sha.slice(0, 7)}
3898 </a>
3899 </div>
3900 ))
3901 )}
3902 </div>
3903 ) : tab === "files" ? (
ea9ed4cClaude3904 <DiffView
3905 raw={diffRaw}
3906 files={diffFiles}
3907 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
47a7a0aClaude3908 inlineComments={diffInlineComments}
3909 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
b5dd694Claude3910 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
ea9ed4cClaude3911 />
b078860Claude3912 ) : (
3913 <>
3914 {pr.body && (
3915 <CommentBox
3916 author={author?.username ?? "unknown"}
3917 date={pr.createdAt}
3918 body={renderMarkdown(pr.body)}
3919 />
3920 )}
3921
422a2d4Claude3922 {/* Block H — AI trio review (security/correctness/style). When
3923 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
3924 hoisted into a 3-column card grid above the normal comment
3925 stream so reviewers see verdicts at a glance. Disagreements
3926 are surfaced as a yellow callout. */}
3927 <TrioReviewGrid
3928 comments={comments.map(({ comment }) => comment)}
3929 />
3930
15db0e0Claude3931 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude3932 // Skip trio comments — already rendered in TrioReviewGrid above.
3933 if (isTrioComment(comment.body)) return null;
15db0e0Claude3934 const slashCmd = detectSlashCmdComment(comment.body);
3935 if (slashCmd) {
3936 const visible = stripSlashCmdMarker(comment.body);
3937 return (
3938 <div class={`slash-pill slash-cmd-${slashCmd}`}>
3939 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
3940 <span class="slash-pill-actor">
3941 <strong>{commentAuthor.username}</strong>
3942 {" ran "}
3943 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude3944 </span>
15db0e0Claude3945 <span class="slash-pill-time">
3946 {formatRelative(comment.createdAt)}
3947 </span>
3948 <div class="slash-pill-body">
3949 <MarkdownContent html={renderMarkdown(visible)} />
3950 </div>
3951 </div>
3952 );
3953 }
cb5a796Claude3954 const isPending = comment.moderationStatus === "pending";
15db0e0Claude3955 return (
cb5a796Claude3956 <div
3957 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
3958 >
15db0e0Claude3959 <div class="prs-comment-head">
3960 <strong>{commentAuthor.username}</strong>
a7460bfClaude3961 {commentAuthor.username === BOT_USERNAME && (
3962 <span class="prs-bot-badge">&#x1F916; bot</span>
3963 )}
15db0e0Claude3964 {comment.isAiReview && (
3965 <span class="prs-ai-badge">AI Review</span>
3966 )}
cb5a796Claude3967 {isPending && (
3968 <span
3969 class="modq-pending-badge"
3970 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
3971 >
3972 Awaiting approval
3973 </span>
3974 )}
15db0e0Claude3975 <span class="prs-comment-time">
3976 commented {formatRelative(comment.createdAt)}
3977 </span>
3978 {comment.filePath && (
3979 <span class="prs-comment-loc">
3980 {comment.filePath}
3981 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
3982 </span>
3983 )}
3984 </div>
3985 <div class="prs-comment-body">
3986 <MarkdownContent html={renderMarkdown(comment.body)} />
3987 </div>
0074234Claude3988 </div>
15db0e0Claude3989 );
3990 })}
0074234Claude3991
b078860Claude3992 {/* Quick link to the Files changed tab when there's a diff to look at. */}
3993 {pr.state !== "merged" && (
3994 <a
3995 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3996 class="prs-files-card"
3997 >
3998 <span class="prs-files-card-icon" aria-hidden="true">
3999 {"▤"}
4000 </span>
4001 <div class="prs-files-card-text">
4002 <p class="prs-files-card-title">Files changed</p>
4003 <p class="prs-files-card-sub">
4004 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
4005 </p>
e883329Claude4006 </div>
b078860Claude4007 <span class="prs-files-card-cta">View diff {"→"}</span>
4008 </a>
4009 )}
4010
6d1bbc2Claude4011 {linkedIssues.length > 0 && (
4012 <div class="prs-linked-issues">
4013 <div class="prs-linked-issues-head">
4014 <span>Closing issues</span>
4015 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
4016 </div>
4017 {linkedIssues.map((issue) => (
4018 <a
4019 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
4020 class="prs-linked-issue-row"
4021 >
4022 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
4023 {issue.state === "open" ? "○" : "✓"}
4024 </span>
4025 <span class="prs-linked-issue-title">{issue.title}</span>
4026 <span class="prs-linked-issue-num">#{issue.number}</span>
4027 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
4028 {issue.state}
4029 </span>
4030 </a>
4031 ))}
4032 </div>
4033 )}
4034
b078860Claude4035 {error && (
4036 <div
4037 class="auth-error"
4038 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)"
4039 >
4040 {decodeURIComponent(error)}
4041 </div>
4042 )}
4043
4044 {info && (
4045 <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)">
4046 {decodeURIComponent(info)}
4047 </div>
4048 )}
e883329Claude4049
b078860Claude4050 {pr.state === "open" && (prRisk || prRiskCalculating) && (
4051 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
4052 )}
4053
0a67773Claude4054 {/* ─── Review summary ─────────────────────────────────── */}
4055 {(approvals.length > 0 || changesRequested.length > 0) && (
4056 <div class="prs-review-summary">
4057 {approvals.length > 0 && (
4058 <div class="prs-review-row prs-review-approved">
4059 <span class="prs-review-icon">✓</span>
4060 <span>
4061 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4062 approved this pull request
4063 </span>
4064 </div>
4065 )}
4066 {changesRequested.length > 0 && (
4067 <div class="prs-review-row prs-review-changes">
4068 <span class="prs-review-icon">✗</span>
4069 <span>
4070 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4071 requested changes
4072 </span>
4073 </div>
4074 )}
4075 </div>
4076 )}
4077
ace34efClaude4078 {/* Suggested reviewers */}
4079 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
4080 <div class="prs-review-summary" style="margin-top:12px">
4081 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
4082 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
4083 Suggested reviewers
4084 </span>
4085 {reviewerSuggestions.map((r) => (
4086 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
4087 style="display:flex;align-items:center;gap:8px;width:100%">
4088 <input type="hidden" name="reviewerId" value={r.userId} />
4089 <span class="prs-reviewer-avatar">
4090 {r.username.slice(0, 1).toUpperCase()}
4091 </span>
4092 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
4093 {r.username}
4094 </a>
4095 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
4096 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
4097 Request
4098 </button>
4099 </form>
4100 ))}
4101 </div>
4102 </div>
4103 )}
4104
b078860Claude4105 {pr.state === "open" && gateChecks.length > 0 && (
4106 <div class="prs-gate-card">
4107 <div class="prs-gate-head">
4108 <h3>Gate checks</h3>
4109 <span class="prs-gate-summary">
4110 {gatesAllPassed
4111 ? `All ${gateChecks.length} checks passed`
4112 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
4113 </span>
c3e0c07Claude4114 </div>
b078860Claude4115 {gateChecks.map((check) => {
4116 const isAi = /ai.*review/i.test(check.name);
4117 const isSkip = check.skipped === true;
4118 const statusClass = isSkip
4119 ? "is-skip"
4120 : check.passed
4121 ? "is-pass"
4122 : "is-fail";
4123 const statusGlyph = isSkip
4124 ? "—"
4125 : check.passed
4126 ? "✓"
4127 : "✗";
4128 const statusLabel = isSkip
4129 ? "Skipped"
4130 : check.passed
4131 ? "Passed"
4132 : "Failing";
4133 return (
4134 <div
4135 class="prs-gate-row"
4136 style={
4137 isAi
4138 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
4139 : ""
4140 }
4141 >
4142 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
4143 {statusGlyph}
4144 </span>
4145 <span class="prs-gate-name">
4146 {check.name}
4147 {isAi && (
4148 <span
4149 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"
4150 >
4151 AI
4152 </span>
4153 )}
4154 </span>
4155 <span class="prs-gate-details">{check.details}</span>
4156 <span class={`prs-gate-pill ${statusClass}`}>
4157 {statusLabel}
e883329Claude4158 </span>
4159 </div>
b078860Claude4160 );
4161 })}
4162 <div class="prs-gate-footer">
4163 {gatesAllPassed
4164 ? "All checks passed — ready to merge."
4165 : gateChecks.some(
4166 (c) => !c.passed && c.name === "Merge check"
4167 )
4168 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
4169 : "Some checks failed — resolve issues before merging."}
4170 {aiReviewCount > 0 && (
4171 <>
4172 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
4173 </>
4174 )}
4175 </div>
4176 </div>
4177 )}
4178
240c477Claude4179 {pr.state === "open" && ciStatuses.length > 0 && (
4180 <div class="prs-ci-card">
4181 <div class="prs-ci-head">
4182 <h3>CI checks</h3>
4183 <span class="prs-ci-summary">
4184 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
4185 </span>
4186 </div>
4187 {ciStatuses.map((status) => {
4188 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
4189 return (
4190 <div class="prs-ci-row">
4191 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
4192 <span class="prs-ci-context">{status.context}</span>
4193 {status.description && (
4194 <span class="prs-ci-desc">{status.description}</span>
4195 )}
4196 <span class={`prs-ci-pill is-${status.state}`}>
4197 {status.state}
4198 </span>
4199 {status.targetUrl && (
4200 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
4201 )}
4202 </div>
4203 );
4204 })}
4205 </div>
4206 )}
4207
b078860Claude4208 {/* ─── Merge area / state-aware action card ─────────────── */}
4209 {user && pr.state === "open" && (
4210 <div
4211 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
4212 >
4213 <div class="prs-merge-head">
4214 <strong>
4215 {pr.isDraft
4216 ? "Draft — ready for review?"
4217 : mergeBlocked
4218 ? "Merge blocked"
4219 : "Ready to merge"}
4220 </strong>
e883329Claude4221 </div>
b078860Claude4222 <p class="prs-merge-sub">
4223 {pr.isDraft
4224 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
4225 : mergeBlocked
4226 ? "Resolve the failing gate checks above before this PR can land."
4227 : gateChecks.length > 0
4228 ? gatesAllPassed
4229 ? "All gates green. Merge will fast-forward into the base branch."
4230 : "Conflicts will be auto-resolved by GlueCron AI on merge."
4231 : "Run gate checks by refreshing once your branch has a recent commit."}
4232 </p>
4233 <Form
4234 method="post"
4235 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
4236 >
4237 <FormGroup>
3c03977Claude4238 <div class="live-cursor-host" style="position:relative">
4239 <textarea
4240 name="body"
4241 id="pr-comment-body"
4242 data-live-field="comment_new"
6cd2f0eClaude4243 data-md-preview=""
3c03977Claude4244 rows={5}
4245 required
4246 placeholder="Leave a comment... (Markdown supported)"
4247 style="font-family:var(--font-mono);font-size:13px;width:100%"
4248 ></textarea>
4249 </div>
15db0e0Claude4250 <span class="slash-hint" title="Type a slash-command as the first line">
4251 Type <code>/</code> for commands —{" "}
4252 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
4253 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
4254 </span>
b078860Claude4255 </FormGroup>
4256 <div class="prs-merge-actions">
4257 <Button type="submit" variant="primary">
4258 Comment
4259 </Button>
0a67773Claude4260 {user && user.id !== pr.authorId && pr.state === "open" && (
4261 <>
4262 <button
4263 type="submit"
4264 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4265 name="review_state"
4266 value="approved"
4267 class="prs-review-approve-btn"
4268 title="Approve this pull request"
4269 >
4270 ✓ Approve
4271 </button>
4272 <button
4273 type="submit"
4274 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4275 name="review_state"
4276 value="changes_requested"
4277 class="prs-review-changes-btn"
4278 title="Request changes before merging"
4279 >
4280 ✗ Request changes
4281 </button>
4282 </>
4283 )}
b078860Claude4284 {canManage && (
4285 <>
4286 {pr.isDraft ? (
4287 <button
4288 type="submit"
4289 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4290 formnovalidate
4291 class="prs-merge-ready-btn"
4292 >
4293 Ready for review
4294 </button>
4295 ) : (
a164a6dClaude4296 <>
4297 <div class="prs-merge-strategy-wrap">
4298 <span class="prs-merge-strategy-label">Strategy</span>
4299 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
4300 <option value="merge">Merge commit</option>
4301 <option value="squash">Squash and merge</option>
4302 <option value="ff">Fast-forward</option>
4303 </select>
4304 </div>
4305 <button
4306 type="submit"
4307 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
4308 formnovalidate
4309 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
4310 title={
4311 mergeBlocked
4312 ? "Failing gate checks must be resolved before this PR can merge."
4313 : "Merge pull request"
4314 }
4315 >
4316 {"✔"} Merge pull request
4317 </button>
4318 </>
b078860Claude4319 )}
4320 {!pr.isDraft && (
4321 <button
0074234Claude4322 type="submit"
b078860Claude4323 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
4324 formnovalidate
4325 class="prs-merge-back-draft"
4326 title="Convert back to draft"
0074234Claude4327 >
b078860Claude4328 Convert to draft
4329 </button>
4330 )}
4331 {isAiReviewEnabled() && (
4332 <button
4333 type="submit"
4334 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
4335 formnovalidate
4336 class="btn"
4337 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
4338 >
4339 Re-run AI review
4340 </button>
4341 )}
1d4ff60Claude4342 {isAiReviewEnabled() && !hasAiTestsMarker && (
4343 <button
4344 type="submit"
4345 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
4346 formnovalidate
4347 class="btn"
4348 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."
4349 >
4350 Generate tests with AI
4351 </button>
4352 )}
b078860Claude4353 <Button
4354 type="submit"
4355 variant="danger"
4356 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
4357 >
4358 Close
4359 </Button>
4360 </>
4361 )}
4362 </div>
4363 </Form>
4364 </div>
4365 )}
4366
4367 {/* Read-only footers for non-open states. */}
4368 {pr.state === "merged" && (
4369 <div class="prs-merge-card is-merged">
4370 <div class="prs-merge-head">
4371 <strong>{"⮌"} Merged</strong>
0074234Claude4372 </div>
b078860Claude4373 <p class="prs-merge-sub">
4374 This pull request was merged into{" "}
4375 <code>{pr.baseBranch}</code>.
4376 </p>
4377 </div>
4378 )}
4379 {pr.state === "closed" && (
4380 <div class="prs-merge-card is-closed">
4381 <div class="prs-merge-head">
4382 <strong>{"✕"} Closed without merging</strong>
4383 </div>
4384 <p class="prs-merge-sub">
4385 This pull request was closed and not merged.
4386 </p>
4387 </div>
4388 )}
4389 </>
4390 )}
0074234Claude4391 </Layout>
4392 );
4393});
4394
6d1bbc2Claude4395// Update branch — merge base into head so the PR branch is up to date.
4396// Uses a git worktree so the bare repo stays clean. Write access required.
4397pulls.post(
4398 "/:owner/:repo/pulls/:number/update-branch",
4399 softAuth,
4400 requireAuth,
4401 requireRepoAccess("write"),
4402 async (c) => {
4403 const { owner: ownerName, repo: repoName } = c.req.param();
4404 const prNum = parseInt(c.req.param("number"), 10);
4405 const user = c.get("user")!;
4406 const resolved = await resolveRepo(ownerName, repoName);
4407 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4408
4409 const [pr] = await db
4410 .select()
4411 .from(pullRequests)
4412 .where(and(
4413 eq(pullRequests.repositoryId, resolved.repo.id),
4414 eq(pullRequests.number, prNum),
4415 ))
4416 .limit(1);
4417 if (!pr || pr.state !== "open") {
4418 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4419 }
4420
4421 const repoDir = getRepoPath(ownerName, repoName);
4422 const wt = `${repoDir}/_update_wt_${Date.now()}`;
4423 const gitEnv = {
4424 ...process.env,
4425 GIT_AUTHOR_NAME: user.displayName || user.username,
4426 GIT_AUTHOR_EMAIL: user.email,
4427 GIT_COMMITTER_NAME: user.displayName || user.username,
4428 GIT_COMMITTER_EMAIL: user.email,
4429 };
4430
4431 const addWt = Bun.spawn(
4432 ["git", "worktree", "add", wt, pr.headBranch],
4433 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4434 );
4435 if (await addWt.exited !== 0) {
4436 return c.redirect(
4437 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
4438 );
4439 }
4440
4441 let ok = false;
4442 try {
4443 const mergeProc = Bun.spawn(
4444 ["git", "merge", "--no-edit", pr.baseBranch],
4445 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
4446 );
4447 if (await mergeProc.exited === 0) {
4448 ok = true;
4449 } else {
4450 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4451 }
4452 } catch {
4453 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4454 }
4455
4456 await Bun.spawn(
4457 ["git", "worktree", "remove", "--force", wt],
4458 { cwd: repoDir }
4459 ).exited.catch(() => {});
4460
4461 if (ok) {
4462 return c.redirect(
4463 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
4464 );
4465 }
4466 return c.redirect(
4467 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
4468 );
4469 }
4470);
4471
b558f23Claude4472// Edit PR title (and optionally body). Owner or author only.
4473pulls.post(
4474 "/:owner/:repo/pulls/:number/edit",
4475 softAuth,
4476 requireAuth,
4477 requireRepoAccess("write"),
4478 async (c) => {
4479 const { owner: ownerName, repo: repoName } = c.req.param();
4480 const prNum = parseInt(c.req.param("number"), 10);
4481 const user = c.get("user")!;
4482 const resolved = await resolveRepo(ownerName, repoName);
4483 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4484
4485 const [pr] = await db
4486 .select()
4487 .from(pullRequests)
4488 .where(and(
4489 eq(pullRequests.repositoryId, resolved.repo.id),
4490 eq(pullRequests.number, prNum),
4491 ))
4492 .limit(1);
4493 if (!pr || pr.state !== "open") {
4494 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4495 }
4496 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
4497 if (!canEdit) {
4498 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4499 }
4500
4501 const body = await c.req.parseBody();
4502 const newTitle = String(body.title || "").trim().slice(0, 256);
4503 if (!newTitle) {
4504 return c.redirect(
4505 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
4506 );
4507 }
4508
4509 await db
4510 .update(pullRequests)
4511 .set({ title: newTitle, updatedAt: new Date() })
4512 .where(eq(pullRequests.id, pr.id));
4513
4514 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
4515 }
4516);
4517
cb5a796Claude4518// Add comment to PR.
4519//
4520// Permission model mirrors `issues.tsx`: any logged-in user with read
4521// access can submit; `decideInitialStatus` routes non-collaborators
4522// through the moderation queue. Slash commands only fire when the
4523// comment is auto-approved — we don't want a banned/pending comment to
4524// silently trigger AI work on the PR.
0074234Claude4525pulls.post(
4526 "/:owner/:repo/pulls/:number/comment",
4527 softAuth,
4528 requireAuth,
cb5a796Claude4529 requireRepoAccess("read"),
0074234Claude4530 async (c) => {
4531 const { owner: ownerName, repo: repoName } = c.req.param();
4532 const prNum = parseInt(c.req.param("number"), 10);
4533 const user = c.get("user")!;
4534 const body = await c.req.parseBody();
4535 const commentBody = String(body.body || "").trim();
47a7a0aClaude4536 const filePathRaw = String(body.file_path || "").trim();
4537 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
4538 const inlineFilePath = filePathRaw || undefined;
4539 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude4540
4541 if (!commentBody) {
4542 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4543 }
4544
4545 const resolved = await resolveRepo(ownerName, repoName);
4546 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4547
4548 const [pr] = await db
4549 .select()
4550 .from(pullRequests)
4551 .where(
4552 and(
4553 eq(pullRequests.repositoryId, resolved.repo.id),
4554 eq(pullRequests.number, prNum)
4555 )
4556 )
4557 .limit(1);
4558
4559 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4560
cb5a796Claude4561 const decision = await decideInitialStatus({
4562 commenterUserId: user.id,
4563 repositoryId: resolved.repo.id,
4564 kind: "pr",
4565 threadId: pr.id,
4566 });
4567
d4ac5c3Claude4568 const [inserted] = await db
4569 .insert(prComments)
4570 .values({
4571 pullRequestId: pr.id,
4572 authorId: user.id,
4573 body: commentBody,
cb5a796Claude4574 moderationStatus: decision.status,
47a7a0aClaude4575 filePath: inlineFilePath,
4576 lineNumber: inlineLineNumber,
d4ac5c3Claude4577 })
4578 .returning();
4579
cb5a796Claude4580 // Live update: only when the comment is actually visible.
4581 if (inserted && decision.status === "approved") {
d4ac5c3Claude4582 try {
4583 const { publish } = await import("../lib/sse");
4584 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
4585 event: "pr-comment",
4586 data: {
4587 pullRequestId: pr.id,
4588 commentId: inserted.id,
4589 authorId: user.id,
4590 authorUsername: user.username,
4591 },
4592 });
4593 } catch {
4594 /* SSE is best-effort */
4595 }
4596 }
0074234Claude4597
cb5a796Claude4598 if (decision.status === "pending") {
4599 void notifyOwnerOfPendingComment({
4600 repositoryId: resolved.repo.id,
4601 commenterUsername: user.username,
4602 kind: "pr",
4603 threadNumber: prNum,
4604 ownerUsername: ownerName,
4605 repoName,
4606 });
4607 return c.redirect(
4608 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4609 );
4610 }
4611 if (decision.status === "rejected") {
4612 // Silent ban path — same UX as 'pending' so we don't leak the gate.
4613 return c.redirect(
4614 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4615 );
4616 }
4617
15db0e0Claude4618 // Slash-command handoff. We always store the original comment above
4619 // first so free-form text that happens to start with `/` is preserved
4620 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude4621 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude4622 const parsed = parseSlashCommand(commentBody);
4623 if (parsed) {
4624 try {
4625 const result = await executeSlashCommand({
4626 command: parsed.command,
4627 args: parsed.args,
4628 prId: pr.id,
4629 userId: user.id,
4630 repositoryId: resolved.repo.id,
4631 });
4632 await db.insert(prComments).values({
4633 pullRequestId: pr.id,
4634 authorId: user.id,
4635 body: result.body,
4636 });
4637 } catch (err) {
4638 // Defence-in-depth — executeSlashCommand promises not to throw,
4639 // but if it ever does we want the PR thread to know.
4640 await db
4641 .insert(prComments)
4642 .values({
4643 pullRequestId: pr.id,
4644 authorId: user.id,
4645 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
4646 })
4647 .catch(() => {});
4648 }
4649 }
4650
47a7a0aClaude4651 // Inline comments go back to the files tab; conversation comments to the conversation tab
4652 const redirectTab = inlineFilePath ? "?tab=files" : "";
4653 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude4654 }
4655);
4656
b5dd694Claude4657// Apply a suggestion from a PR comment — commits the suggested code to the
4658// head branch on behalf of the logged-in user.
4659pulls.post(
4660 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
4661 softAuth,
4662 requireAuth,
4663 requireRepoAccess("read"),
4664 async (c) => {
4665 const { owner: ownerName, repo: repoName } = c.req.param();
4666 const prNum = parseInt(c.req.param("number"), 10);
4667 const commentId = c.req.param("commentId"); // UUID
4668 const user = c.get("user")!;
4669
4670 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
4671
4672 const resolved = await resolveRepo(ownerName, repoName);
4673 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4674
4675 const [pr] = await db
4676 .select()
4677 .from(pullRequests)
4678 .where(
4679 and(
4680 eq(pullRequests.repositoryId, resolved.repo.id),
4681 eq(pullRequests.number, prNum)
4682 )
4683 )
4684 .limit(1);
4685
4686 if (!pr || pr.state !== "open") {
4687 return c.redirect(`${backUrl}&error=pr_not_open`);
4688 }
4689
4690 // Only PR author or repo owner may apply suggestions.
4691 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
4692 return c.redirect(`${backUrl}&error=forbidden`);
4693 }
4694
4695 // Load the comment.
4696 const [comment] = await db
4697 .select()
4698 .from(prComments)
4699 .where(
4700 and(
4701 eq(prComments.id, commentId),
4702 eq(prComments.pullRequestId, pr.id)
4703 )
4704 )
4705 .limit(1);
4706
4707 if (!comment) {
4708 return c.redirect(`${backUrl}&error=comment_not_found`);
4709 }
4710
4711 // Parse suggestion block from comment body.
4712 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
4713 if (!m) {
4714 return c.redirect(`${backUrl}&error=no_suggestion`);
4715 }
4716 const suggestionCode = m[1];
4717
4718 // Get the commenter's details for the commit message co-author line.
4719 const [commenter] = await db
4720 .select()
4721 .from(users)
4722 .where(eq(users.id, comment.authorId))
4723 .limit(1);
4724
4725 // Fetch current file content from head branch.
4726 if (!comment.filePath) {
4727 return c.redirect(`${backUrl}&error=file_not_found`);
4728 }
4729 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
4730 if (!blob) {
4731 return c.redirect(`${backUrl}&error=file_not_found`);
4732 }
4733
4734 // Apply the patch — replace the target line(s) with suggestion lines.
4735 const lines = blob.content.split('\n');
4736 const lineIdx = (comment.lineNumber ?? 1) - 1;
4737 if (lineIdx < 0 || lineIdx >= lines.length) {
4738 return c.redirect(`${backUrl}&error=line_out_of_range`);
4739 }
4740 const suggestionLines = suggestionCode.split('\n');
4741 lines.splice(lineIdx, 1, ...suggestionLines);
4742 const newContent = lines.join('\n');
4743
4744 // Commit the change.
4745 const coAuthorLine = commenter
4746 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
4747 : "";
4748 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
4749
4750 const result = await createOrUpdateFileOnBranch({
4751 owner: ownerName,
4752 name: repoName,
4753 branch: pr.headBranch,
4754 filePath: comment.filePath,
4755 bytes: new TextEncoder().encode(newContent),
4756 message: commitMessage,
4757 authorName: user.username,
4758 authorEmail: `${user.username}@users.noreply.gluecron.com`,
4759 });
4760
4761 if ("error" in result) {
4762 return c.redirect(`${backUrl}&error=apply_failed`);
4763 }
4764
4765 // Post a follow-up comment noting the suggestion was applied.
4766 await db.insert(prComments).values({
4767 pullRequestId: pr.id,
4768 authorId: user.id,
4769 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
4770 });
4771
4772 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
4773 }
4774);
4775
0a67773Claude4776// Formal review — Approve / Request Changes / Comment
4777pulls.post(
4778 "/:owner/:repo/pulls/:number/review",
4779 softAuth,
4780 requireAuth,
4781 requireRepoAccess("read"),
4782 async (c) => {
4783 const { owner: ownerName, repo: repoName } = c.req.param();
4784 const prNum = parseInt(c.req.param("number"), 10);
4785 const user = c.get("user")!;
4786 const body = await c.req.parseBody();
4787 const reviewBody = String(body.body || "").trim();
4788 const reviewState = String(body.review_state || "commented");
4789
4790 const validStates = ["approved", "changes_requested", "commented"];
4791 if (!validStates.includes(reviewState)) {
4792 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4793 }
4794
4795 const resolved = await resolveRepo(ownerName, repoName);
4796 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4797
4798 const [pr] = await db
4799 .select()
4800 .from(pullRequests)
4801 .where(
4802 and(
4803 eq(pullRequests.repositoryId, resolved.repo.id),
4804 eq(pullRequests.number, prNum)
4805 )
4806 )
4807 .limit(1);
4808 if (!pr || pr.state !== "open") {
4809 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4810 }
4811 // Authors can't review their own PR
4812 if (pr.authorId === user.id) {
4813 return c.redirect(
4814 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
4815 );
4816 }
4817
4818 await db.insert(prReviews).values({
4819 pullRequestId: pr.id,
4820 reviewerId: user.id,
4821 state: reviewState,
4822 body: reviewBody || null,
4823 });
4824
4825 const stateLabel =
4826 reviewState === "approved" ? "Approved"
4827 : reviewState === "changes_requested" ? "Changes requested"
4828 : "Commented";
4829 return c.redirect(
4830 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
4831 );
4832 }
4833);
4834
e883329Claude4835// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude4836// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
4837// but we keep it at "write" for v1 so trusted collaborators can ship.
4838// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
4839// surface. Branch-protection rules (evaluated below) are the current mechanism
4840// for locking down merges further on specific branches.
0074234Claude4841pulls.post(
4842 "/:owner/:repo/pulls/:number/merge",
4843 softAuth,
4844 requireAuth,
04f6b7fClaude4845 requireRepoAccess("write"),
0074234Claude4846 async (c) => {
4847 const { owner: ownerName, repo: repoName } = c.req.param();
4848 const prNum = parseInt(c.req.param("number"), 10);
4849 const user = c.get("user")!;
4850
a164a6dClaude4851 // Read merge strategy from form (default: merge commit)
4852 let mergeStrategy = "merge";
4853 try {
4854 const body = await c.req.parseBody();
4855 const s = body.merge_strategy;
4856 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
4857 } catch { /* ignore parse errors — default to merge commit */ }
4858
0074234Claude4859 const resolved = await resolveRepo(ownerName, repoName);
4860 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4861
4862 const [pr] = await db
4863 .select()
4864 .from(pullRequests)
4865 .where(
4866 and(
4867 eq(pullRequests.repositoryId, resolved.repo.id),
4868 eq(pullRequests.number, prNum)
4869 )
4870 )
4871 .limit(1);
4872
4873 if (!pr || pr.state !== "open") {
4874 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4875 }
4876
6fc53bdClaude4877 // Draft PRs cannot be merged — must be marked ready first.
4878 if (pr.isDraft) {
4879 return c.redirect(
4880 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4881 "This PR is a draft. Mark it as ready for review before merging."
4882 )}`
4883 );
4884 }
4885
e883329Claude4886 // Resolve head SHA
4887 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4888 if (!headSha) {
4889 return c.redirect(
4890 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
4891 );
4892 }
4893
4894 // Check if AI review approved this PR
4895 const aiComments = await db
4896 .select()
4897 .from(prComments)
4898 .where(
4899 and(
4900 eq(prComments.pullRequestId, pr.id),
4901 eq(prComments.isAiReview, true)
4902 )
4903 );
4904 const aiApproved = aiComments.length === 0 || aiComments.some(
4905 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude4906 );
e883329Claude4907
4908 // Run all green gate checks (GateTest + mergeability + AI review)
4909 const gateResult = await runAllGateChecks(
4910 ownerName,
4911 repoName,
4912 pr.baseBranch,
4913 pr.headBranch,
4914 headSha,
4915 aiApproved
0074234Claude4916 );
4917
e883329Claude4918 // If GateTest or AI review failed (hard blocks), reject the merge
4919 const hardFailures = gateResult.checks.filter(
4920 (check) => !check.passed && check.name !== "Merge check"
4921 );
4922 if (hardFailures.length > 0) {
4923 const errorMsg = hardFailures
4924 .map((f) => `${f.name}: ${f.details}`)
4925 .join("; ");
0074234Claude4926 return c.redirect(
e883329Claude4927 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude4928 );
4929 }
4930
1e162a8Claude4931 // D5 — Branch-protection enforcement. Looks up the matching rule for the
4932 // base branch and blocks the merge if requireAiApproval / requireGreenGates
4933 // / requireHumanReview / requiredApprovals are not satisfied. Independent
4934 // of repo-global settings, so owners can lock specific branches down
4935 // further than the repo default.
4936 const protectionRule = await matchProtection(
4937 resolved.repo.id,
4938 pr.baseBranch
4939 );
4940 if (protectionRule) {
4941 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude4942 const required = await listRequiredChecks(protectionRule.id);
4943 const passingNames = required.length > 0
4944 ? await passingCheckNames(resolved.repo.id, headSha)
4945 : [];
4946 const decision = evaluateProtection(
4947 protectionRule,
4948 {
4949 aiApproved,
4950 humanApprovalCount: humanApprovals,
4951 gateResultGreen: hardFailures.length === 0,
4952 hasFailedGates: hardFailures.length > 0,
4953 passingCheckNames: passingNames,
4954 },
4955 required.map((r) => r.checkName)
4956 );
1e162a8Claude4957 if (!decision.allowed) {
4958 return c.redirect(
4959 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4960 decision.reasons.join(" ")
4961 )}`
4962 );
4963 }
4964 }
4965
e883329Claude4966 // Attempt the merge — with auto conflict resolution if needed
4967 const repoDir = getRepoPath(ownerName, repoName);
4968 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
4969 const hasConflicts = mergeCheck && !mergeCheck.passed;
4970
4971 if (hasConflicts && isAiReviewEnabled()) {
4972 // Use Claude to auto-resolve conflicts
4973 const mergeResult = await mergeWithAutoResolve(
4974 ownerName,
4975 repoName,
4976 pr.baseBranch,
4977 pr.headBranch,
4978 `Merge pull request #${pr.number}: ${pr.title}`
4979 );
4980
4981 if (!mergeResult.success) {
4982 return c.redirect(
4983 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
4984 );
4985 }
4986
4987 // Post a comment about the auto-resolution
4988 if (mergeResult.resolvedFiles.length > 0) {
4989 await db.insert(prComments).values({
4990 pullRequestId: pr.id,
4991 authorId: user.id,
4992 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
4993 isAiReview: true,
4994 });
4995 }
4996 } else {
a164a6dClaude4997 // Worktree-based merge: supports merge-commit, squash, and fast-forward
4998 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
4999 const gitEnv = {
5000 ...process.env,
5001 GIT_AUTHOR_NAME: user.displayName || user.username,
5002 GIT_AUTHOR_EMAIL: user.email,
5003 GIT_COMMITTER_NAME: user.displayName || user.username,
5004 GIT_COMMITTER_EMAIL: user.email,
5005 };
5006
5007 // Create linked worktree on the base branch
5008 const addWt = Bun.spawn(
5009 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude5010 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5011 );
a164a6dClaude5012 if (await addWt.exited !== 0) {
5013 return c.redirect(
5014 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
5015 );
5016 }
5017
5018 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
5019 let mergeOk = false;
5020
5021 try {
5022 if (mergeStrategy === "squash") {
5023 // Squash: stage all changes without committing
5024 const squashProc = Bun.spawn(
5025 ["git", "merge", "--squash", headSha],
5026 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5027 );
5028 if (await squashProc.exited !== 0) {
5029 const errTxt = await new Response(squashProc.stderr).text();
5030 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
5031 }
5032 // Commit the squashed changes
5033 const commitProc = Bun.spawn(
5034 ["git", "commit", "-m", commitMsg],
5035 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5036 );
5037 if (await commitProc.exited !== 0) {
5038 const errTxt = await new Response(commitProc.stderr).text();
5039 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
5040 }
5041 mergeOk = true;
5042 } else if (mergeStrategy === "ff") {
5043 // Fast-forward only — fail if FF is not possible
5044 const ffProc = Bun.spawn(
5045 ["git", "merge", "--ff-only", headSha],
5046 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5047 );
5048 if (await ffProc.exited !== 0) {
5049 const errTxt = await new Response(ffProc.stderr).text();
5050 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
5051 }
5052 mergeOk = true;
5053 } else {
5054 // Default: merge commit (--no-ff always creates a merge commit)
5055 const mergeProc = Bun.spawn(
5056 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
5057 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5058 );
5059 if (await mergeProc.exited !== 0) {
5060 const errTxt = await new Response(mergeProc.stderr).text();
5061 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
5062 }
5063 mergeOk = true;
5064 }
5065 } catch (err) {
5066 // Always clean up the worktree before redirecting
5067 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
5068 const msg = err instanceof Error ? err.message : "Merge failed";
5069 return c.redirect(
5070 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
5071 );
5072 }
5073
5074 // Clean up worktree (changes are now in the bare repo via linked worktree)
5075 await Bun.spawn(
5076 ["git", "worktree", "remove", "--force", wt],
5077 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5078 ).exited.catch(() => {});
e883329Claude5079
a164a6dClaude5080 if (!mergeOk) {
e883329Claude5081 return c.redirect(
a164a6dClaude5082 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude5083 );
5084 }
5085 }
5086
0074234Claude5087 await db
5088 .update(pullRequests)
5089 .set({
5090 state: "merged",
5091 mergedAt: new Date(),
5092 mergedBy: user.id,
5093 updatedAt: new Date(),
5094 })
5095 .where(eq(pullRequests.id, pr.id));
5096
8809b87Claude5097 // Chat notifier — fan out merge event to Slack/Discord/Teams.
5098 import("../lib/chat-notifier")
5099 .then((m) =>
5100 m.notifyChatChannels({
5101 ownerUserId: resolved.repo.ownerId,
5102 repositoryId: resolved.repo.id,
5103 event: {
5104 event: "pr.merged",
5105 repo: `${ownerName}/${repoName}`,
5106 title: `#${pr.number} ${pr.title}`,
5107 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
5108 actor: user.username,
5109 },
5110 })
5111 )
5112 .catch((err) =>
5113 console.warn(`[chat-notifier] PR merge notify failed:`, err)
5114 );
5115
d62fb36Claude5116 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
5117 // and auto-close each matching open issue with a back-link comment. Bounded
5118 // to the same repo for v1 (cross-repo refs ignored). Failures never block
5119 // the merge redirect.
5120 try {
5121 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
5122 const refs = extractClosingRefsMulti([pr.title, pr.body]);
5123 for (const n of refs) {
5124 const [issue] = await db
5125 .select()
5126 .from(issues)
5127 .where(
5128 and(
5129 eq(issues.repositoryId, resolved.repo.id),
5130 eq(issues.number, n)
5131 )
5132 )
5133 .limit(1);
5134 if (!issue || issue.state !== "open") continue;
5135 await db
5136 .update(issues)
5137 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
5138 .where(eq(issues.id, issue.id));
5139 await db.insert(issueComments).values({
5140 issueId: issue.id,
5141 authorId: user.id,
5142 body: `Closed by pull request #${pr.number}.`,
5143 });
5144 }
5145 } catch {
5146 // Never block the merge on close-keyword failures.
5147 }
5148
0074234Claude5149 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5150 }
5151);
5152
6fc53bdClaude5153// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
5154// hasn't run yet on this PR.
5155pulls.post(
5156 "/:owner/:repo/pulls/:number/ready",
5157 softAuth,
5158 requireAuth,
04f6b7fClaude5159 requireRepoAccess("write"),
6fc53bdClaude5160 async (c) => {
5161 const { owner: ownerName, repo: repoName } = c.req.param();
5162 const prNum = parseInt(c.req.param("number"), 10);
5163 const user = c.get("user")!;
5164
5165 const resolved = await resolveRepo(ownerName, repoName);
5166 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5167
5168 const [pr] = await db
5169 .select()
5170 .from(pullRequests)
5171 .where(
5172 and(
5173 eq(pullRequests.repositoryId, resolved.repo.id),
5174 eq(pullRequests.number, prNum)
5175 )
5176 )
5177 .limit(1);
5178 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5179
5180 // Only the author or repo owner can toggle draft state.
5181 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5182 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5183 }
5184
5185 if (pr.state === "open" && pr.isDraft) {
5186 await db
5187 .update(pullRequests)
5188 .set({ isDraft: false, updatedAt: new Date() })
5189 .where(eq(pullRequests.id, pr.id));
5190
5191 if (isAiReviewEnabled()) {
5192 triggerAiReview(
5193 ownerName,
5194 repoName,
5195 pr.id,
5196 pr.title,
0316dbbClaude5197 pr.body || "",
6fc53bdClaude5198 pr.baseBranch,
5199 pr.headBranch
5200 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
5201 }
5202 }
5203
5204 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5205 }
5206);
5207
5208// Convert a PR back to draft.
5209pulls.post(
5210 "/:owner/:repo/pulls/:number/draft",
5211 softAuth,
5212 requireAuth,
04f6b7fClaude5213 requireRepoAccess("write"),
6fc53bdClaude5214 async (c) => {
5215 const { owner: ownerName, repo: repoName } = c.req.param();
5216 const prNum = parseInt(c.req.param("number"), 10);
5217 const user = c.get("user")!;
5218
5219 const resolved = await resolveRepo(ownerName, repoName);
5220 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5221
5222 const [pr] = await db
5223 .select()
5224 .from(pullRequests)
5225 .where(
5226 and(
5227 eq(pullRequests.repositoryId, resolved.repo.id),
5228 eq(pullRequests.number, prNum)
5229 )
5230 )
5231 .limit(1);
5232 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5233
5234 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5235 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5236 }
5237
5238 if (pr.state === "open" && !pr.isDraft) {
5239 await db
5240 .update(pullRequests)
5241 .set({ isDraft: true, updatedAt: new Date() })
5242 .where(eq(pullRequests.id, pr.id));
5243 }
5244
5245 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5246 }
5247);
5248
0074234Claude5249// Close PR
5250pulls.post(
5251 "/:owner/:repo/pulls/:number/close",
5252 softAuth,
5253 requireAuth,
04f6b7fClaude5254 requireRepoAccess("write"),
0074234Claude5255 async (c) => {
5256 const { owner: ownerName, repo: repoName } = c.req.param();
5257 const prNum = parseInt(c.req.param("number"), 10);
5258
5259 const resolved = await resolveRepo(ownerName, repoName);
5260 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5261
5262 await db
5263 .update(pullRequests)
5264 .set({
5265 state: "closed",
5266 closedAt: new Date(),
5267 updatedAt: new Date(),
5268 })
5269 .where(
5270 and(
5271 eq(pullRequests.repositoryId, resolved.repo.id),
5272 eq(pullRequests.number, prNum)
5273 )
5274 );
5275
5276 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5277 }
5278);
5279
c3e0c07Claude5280// Re-run AI review on demand (e.g. after a force-push). Bypasses the
5281// idempotency marker via { force: true }. Write-access only.
5282pulls.post(
5283 "/:owner/:repo/pulls/:number/ai-rereview",
5284 softAuth,
5285 requireAuth,
5286 requireRepoAccess("write"),
5287 async (c) => {
5288 const { owner: ownerName, repo: repoName } = c.req.param();
5289 const prNum = parseInt(c.req.param("number"), 10);
5290 const resolved = await resolveRepo(ownerName, repoName);
5291 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5292
5293 const [pr] = await db
5294 .select()
5295 .from(pullRequests)
5296 .where(
5297 and(
5298 eq(pullRequests.repositoryId, resolved.repo.id),
5299 eq(pullRequests.number, prNum)
5300 )
5301 )
5302 .limit(1);
5303 if (!pr) {
5304 return c.redirect(`/${ownerName}/${repoName}/pulls`);
5305 }
5306
5307 if (!isAiReviewEnabled()) {
5308 return c.redirect(
5309 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5310 "AI review is not configured (ANTHROPIC_API_KEY)."
5311 )}`
5312 );
5313 }
5314
5315 // Fire-and-forget but with { force: true } to bypass the
5316 // already-reviewed marker. The function still never throws.
5317 triggerAiReview(
5318 ownerName,
5319 repoName,
5320 pr.id,
5321 pr.title || "",
5322 pr.body || "",
5323 pr.baseBranch,
5324 pr.headBranch,
5325 { force: true }
a28cedeClaude5326 ).catch((err) => {
5327 console.warn(
5328 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
5329 err instanceof Error ? err.message : err
5330 );
5331 });
c3e0c07Claude5332
5333 return c.redirect(
5334 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5335 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
5336 )}`
5337 );
5338 }
5339);
5340
1d4ff60Claude5341// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
5342// the PR's head branch carrying just the new test files. Write-access only.
5343// Idempotent — if `ai:added-tests` was previously applied we redirect with
5344// an `info` banner instead of re-firing.
5345pulls.post(
5346 "/:owner/:repo/pulls/:number/generate-tests",
5347 softAuth,
5348 requireAuth,
5349 requireRepoAccess("write"),
5350 async (c) => {
5351 const { owner: ownerName, repo: repoName } = c.req.param();
5352 const prNum = parseInt(c.req.param("number"), 10);
5353 const resolved = await resolveRepo(ownerName, repoName);
5354 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5355
5356 const [pr] = await db
5357 .select()
5358 .from(pullRequests)
5359 .where(
5360 and(
5361 eq(pullRequests.repositoryId, resolved.repo.id),
5362 eq(pullRequests.number, prNum)
5363 )
5364 )
5365 .limit(1);
5366 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5367
5368 if (!isAiReviewEnabled()) {
5369 return c.redirect(
5370 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5371 "AI test generation is not configured (ANTHROPIC_API_KEY)."
5372 )}`
5373 );
5374 }
5375
5376 // Fire-and-forget. The lib never throws.
5377 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
5378 .then((res) => {
5379 if (!res.ok) {
5380 console.warn(
5381 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
5382 );
5383 }
5384 })
5385 .catch((err) => {
5386 console.warn(
5387 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
5388 err instanceof Error ? err.message : err
5389 );
5390 });
5391
5392 return c.redirect(
5393 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5394 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
5395 )}`
5396 );
5397 }
5398);
5399
ace34efClaude5400// ─── Request review ───────────────────────────────────────────────────────────
5401pulls.post(
5402 "/:owner/:repo/pulls/:number/request-review",
5403 softAuth,
5404 requireAuth,
5405 requireRepoAccess("write"),
5406 async (c) => {
5407 const { owner: ownerName, repo: repoName } = c.req.param();
5408 const prNum = parseInt(c.req.param("number"), 10);
5409 const user = c.get("user")!;
5410
5411 const resolved = await resolveRepo(ownerName, repoName);
5412 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5413
5414 const [pr] = await db
5415 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
5416 .from(pullRequests)
5417 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5418 .limit(1);
5419
5420 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5421
5422 const body = await c.req.formData().catch(() => null);
5423 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
5424
5425 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
5426 return c.redirect(
5427 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
5428 );
5429 }
5430
f4abb8eClaude5431 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
5432 // requesting reviews from arbitrary user IDs outside this repository.
5433 const isOwner = reviewerId === resolved.owner.id;
5434 if (!isOwner) {
5435 const [collab] = await db
5436 .select({ id: repoCollaborators.id })
5437 .from(repoCollaborators)
5438 .where(
5439 and(
5440 eq(repoCollaborators.repositoryId, resolved.repo.id),
5441 eq(repoCollaborators.userId, reviewerId),
5442 isNotNull(repoCollaborators.acceptedAt)
5443 )
5444 )
5445 .limit(1);
5446 if (!collab) {
5447 return c.redirect(
5448 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
5449 );
5450 }
5451 }
5452
ace34efClaude5453 const { requestReview } = await import("../lib/reviewer-suggest");
5454 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
5455
5456 const msg = result.ok
5457 ? "Review requested successfully."
5458 : `Failed to request review: ${result.error ?? "unknown error"}`;
5459
5460 return c.redirect(
5461 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
5462 );
5463 }
5464);
5465
0074234Claude5466export default pulls;