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