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.tsxBlame6356 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";
34e63b9Claude70import { getPatternWarning, type Pattern } from "../lib/pattern-detector";
cc34156Claude71import { getReviewContext, recordPrVisit, type ReviewContext } from "../lib/review-context";
534f04aClaude72import {
73 computePrRiskForPullRequest,
74 getCachedPrRisk,
75 type PrRiskScore,
76} from "../lib/pr-risk";
0316dbbClaude77import { runAllGateChecks } from "../lib/gate";
78import type { GateCheckResult } from "../lib/gate";
79import {
80 matchProtection,
81 countHumanApprovals,
82 listRequiredChecks,
83 passingCheckNames,
84 evaluateProtection,
85} from "../lib/branch-protection";
86import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude87import {
88 listBranches,
89 getRepoPath,
e883329Claude90 resolveRef,
b5dd694Claude91 getBlob,
92 createOrUpdateFileOnBranch,
b558f23Claude93 commitsBetween,
0074234Claude94} from "../git/repository";
b558f23Claude95import type { GitDiffFile, GitCommit } from "../git/repository";
240c477Claude96import { listStatuses } from "../lib/commit-statuses";
97import type { CommitStatus } from "../db/schema";
0074234Claude98import { html } from "hono/html";
4bbacbeClaude99import {
100 getPreviewForBranch,
101 previewStatusLabel,
102} from "../lib/branch-previews";
1e162a8Claude103import {
bb0f894Claude104 Flex,
105 Container,
106 Badge,
107 Button,
108 LinkButton,
109 Form,
110 FormGroup,
111 Input,
112 TextArea,
113 Select,
114 EmptyState,
115 FilterTabs,
116 TabNav,
117 List,
118 ListItem,
119 Text,
120 Alert,
121 MarkdownContent,
122 CommentBox,
123 formatRelative,
124} from "../views/ui";
0074234Claude125
ace34efClaude126import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
74d8c4dClaude127import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
a7460bfClaude128import { BOT_USERNAME } from "../lib/bot-user";
3033f70Claude129import { analyzeImpact, type ImpactAnalysis } from "../lib/pr-impact";
130import { getPreviewDir, isPreviewExpired } from "../lib/pr-stage";
ace34efClaude131
0074234Claude132const pulls = new Hono<AuthEnv>();
133
b078860Claude134/* ──────────────────────────────────────────────────────────────────────
135 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
136 * into the issue tracker or any other route. Tokens come from layout.tsx
137 * `:root` so light/dark stays consistent if/when light mode lands.
138 * ──────────────────────────────────────────────────────────────────── */
139const PRS_LIST_STYLES = `
140 .prs-hero {
141 position: relative;
142 margin: 0 0 var(--space-5);
143 padding: 22px 26px 24px;
144 background: var(--bg-elevated);
145 border: 1px solid var(--border);
146 border-radius: 16px;
147 overflow: hidden;
148 }
149 .prs-hero::before {
150 content: '';
151 position: absolute; top: 0; left: 0; right: 0;
152 height: 2px;
153 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
154 opacity: 0.7;
155 pointer-events: none;
156 }
157 .prs-hero-inner {
158 position: relative;
159 display: flex;
160 justify-content: space-between;
161 align-items: flex-end;
162 gap: 20px;
163 flex-wrap: wrap;
164 }
165 .prs-hero-text { flex: 1; min-width: 280px; }
166 .prs-hero-eyebrow {
167 font-size: 12px;
168 color: var(--text-muted);
169 text-transform: uppercase;
170 letter-spacing: 0.08em;
171 font-weight: 600;
172 margin-bottom: 8px;
173 }
174 .prs-hero-title {
175 font-family: var(--font-display);
176 font-size: clamp(26px, 3.4vw, 34px);
177 font-weight: 800;
178 letter-spacing: -0.025em;
179 line-height: 1.06;
180 margin: 0 0 8px;
181 color: var(--text-strong);
182 }
183 .prs-hero-title .gradient-text {
184 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
185 -webkit-background-clip: text;
186 background-clip: text;
187 -webkit-text-fill-color: transparent;
188 color: transparent;
189 }
190 .prs-hero-sub {
191 font-size: 14.5px;
192 color: var(--text-muted);
193 margin: 0;
194 line-height: 1.5;
195 max-width: 620px;
196 }
197 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
198 .prs-cta {
199 display: inline-flex; align-items: center; gap: 6px;
200 padding: 10px 16px;
201 border-radius: 10px;
202 font-size: 13.5px;
203 font-weight: 600;
204 color: #fff;
205 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
206 border: 1px solid rgba(140,109,255,0.55);
207 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
208 text-decoration: none;
209 transition: transform 120ms ease, box-shadow 160ms ease;
210 }
211 .prs-cta:hover {
212 transform: translateY(-1px);
213 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
214 color: #fff;
215 }
216
217 .prs-tabs {
218 display: flex; flex-wrap: wrap; gap: 6px;
219 margin: 0 0 18px;
220 padding: 6px;
221 background: var(--bg-secondary);
222 border: 1px solid var(--border);
223 border-radius: 12px;
224 }
225 .prs-tab {
226 display: inline-flex; align-items: center; gap: 8px;
227 padding: 7px 13px;
228 font-size: 13px;
229 font-weight: 500;
230 color: var(--text-muted);
231 border-radius: 8px;
232 text-decoration: none;
233 transition: background 120ms ease, color 120ms ease;
234 }
235 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
236 .prs-tab.is-active {
237 background: var(--bg-elevated);
238 color: var(--text-strong);
239 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
240 }
241 .prs-tab-count {
242 display: inline-flex; align-items: center; justify-content: center;
243 min-width: 22px; padding: 2px 7px;
244 font-size: 11.5px;
245 font-weight: 600;
246 border-radius: 9999px;
247 background: var(--bg-tertiary);
248 color: var(--text-muted);
249 }
250 .prs-tab.is-active .prs-tab-count {
251 background: rgba(140,109,255,0.18);
252 color: var(--text-link);
253 }
254
255 .prs-list { display: flex; flex-direction: column; gap: 10px; }
256 .prs-row {
257 position: relative;
258 display: flex; align-items: flex-start; gap: 14px;
259 padding: 14px 16px;
260 background: var(--bg-elevated);
261 border: 1px solid var(--border);
262 border-radius: 12px;
263 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
264 }
265 .prs-row:hover {
266 transform: translateY(-1px);
267 border-color: var(--border-strong);
268 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
269 }
270 .prs-row-icon {
271 flex: 0 0 auto;
272 width: 26px; height: 26px;
273 display: inline-flex; align-items: center; justify-content: center;
274 border-radius: 9999px;
275 font-size: 13px;
276 margin-top: 2px;
277 }
278 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
279 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
280 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
281 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
282 .prs-row-body { flex: 1; min-width: 0; }
283 .prs-row-title {
284 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
285 font-size: 15px; font-weight: 600;
286 color: var(--text-strong);
287 line-height: 1.35;
288 margin: 0 0 6px;
289 }
290 .prs-row-number {
291 color: var(--text-muted);
292 font-weight: 400;
293 font-size: 14px;
294 }
295 .prs-row-meta {
296 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
297 font-size: 12.5px;
298 color: var(--text-muted);
299 }
300 .prs-branch-chips {
301 display: inline-flex; align-items: center; gap: 6px;
302 font-family: var(--font-mono);
303 font-size: 11.5px;
304 }
305 .prs-branch-chip {
306 padding: 2px 8px;
307 border-radius: 9999px;
308 background: var(--bg-tertiary);
309 border: 1px solid var(--border);
310 color: var(--text);
311 }
312 .prs-branch-arrow {
313 color: var(--text-faint);
314 font-size: 11px;
315 }
316 .prs-row-tags {
317 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
318 margin-left: auto;
319 }
320 .prs-tag {
321 display: inline-flex; align-items: center; gap: 4px;
322 padding: 2px 8px;
323 font-size: 11px;
324 font-weight: 600;
325 border-radius: 9999px;
326 border: 1px solid var(--border);
327 background: var(--bg-secondary);
328 color: var(--text-muted);
329 line-height: 1.6;
330 }
331 .prs-tag.is-draft {
332 color: var(--text-muted);
333 border-color: var(--border-strong);
334 }
335 .prs-tag.is-merged {
336 color: var(--text-link);
337 border-color: rgba(140,109,255,0.45);
338 background: rgba(140,109,255,0.10);
339 }
1aef949Claude340 .prs-tag.is-approved {
341 color: #34d399;
342 border-color: rgba(52,211,153,0.40);
343 background: rgba(52,211,153,0.08);
344 }
345 .prs-tag.is-changes {
346 color: #f87171;
347 border-color: rgba(248,113,113,0.40);
348 background: rgba(248,113,113,0.08);
349 }
b078860Claude350
351 .prs-empty {
ea9ed4cClaude352 position: relative;
353 padding: 56px 32px;
b078860Claude354 text-align: center;
355 border: 1px dashed var(--border);
ea9ed4cClaude356 border-radius: 16px;
357 background: var(--bg-elevated);
b078860Claude358 color: var(--text-muted);
ea9ed4cClaude359 overflow: hidden;
b078860Claude360 }
ea9ed4cClaude361 .prs-empty::before {
362 content: '';
363 position: absolute;
364 inset: -40% -20% auto auto;
365 width: 320px; height: 320px;
366 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
367 filter: blur(70px);
368 opacity: 0.55;
369 pointer-events: none;
370 animation: prsEmptyOrb 16s ease-in-out infinite;
371 }
372 @keyframes prsEmptyOrb {
373 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
374 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
375 }
376 @media (prefers-reduced-motion: reduce) {
377 .prs-empty::before { animation: none; }
378 }
379 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude380 .prs-empty strong {
381 display: block;
382 color: var(--text-strong);
ea9ed4cClaude383 font-family: var(--font-display);
384 font-size: 22px;
385 font-weight: 700;
386 letter-spacing: -0.018em;
387 margin-bottom: 2px;
388 }
389 .prs-empty-sub {
390 font-size: 14.5px;
391 color: var(--text-muted);
392 line-height: 1.55;
393 max-width: 460px;
394 margin: 0 0 18px;
b078860Claude395 }
ea9ed4cClaude396 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude397
398 @media (max-width: 720px) {
399 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
400 .prs-hero-actions { width: 100%; }
401 .prs-row-tags { margin-left: 0; }
402 }
f1dc7c7Claude403
404 /* Additional mobile rules. Additive only. */
405 @media (max-width: 720px) {
406 .prs-hero { padding: 18px 18px 20px; }
407 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
408 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
409 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
410 .prs-row { padding: 12px 14px; gap: 10px; }
411 .prs-row-icon { width: 24px; height: 24px; }
412 }
f5b9ef5Claude413
414 /* ─── Sort controls (PR list) ─── */
415 .prs-sort-row {
416 display: flex;
417 align-items: center;
418 gap: 6px;
419 margin: 0 0 12px;
420 flex-wrap: wrap;
421 }
422 .prs-sort-label {
423 font-size: 12.5px;
424 color: var(--text-muted);
425 font-weight: 600;
426 margin-right: 2px;
427 }
428 .prs-sort-opt {
429 font-size: 12.5px;
430 color: var(--text-muted);
431 text-decoration: none;
432 padding: 3px 10px;
433 border-radius: 9999px;
434 border: 1px solid transparent;
435 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
436 }
437 .prs-sort-opt:hover {
438 background: var(--bg-hover);
439 color: var(--text);
440 }
441 .prs-sort-opt.is-active {
442 background: rgba(140,109,255,0.12);
443 color: var(--text-link);
444 border-color: rgba(140,109,255,0.35);
445 font-weight: 600;
446 }
b078860Claude447`;
448
449/* ──────────────────────────────────────────────────────────────────────
450 * Inline CSS for the detail page. Same `.prs-*` namespace.
451 * ──────────────────────────────────────────────────────────────────── */
452const PRS_DETAIL_STYLES = `
453 .prs-detail-hero {
454 position: relative;
455 margin: 0 0 var(--space-4);
456 padding: 24px 26px;
457 background: var(--bg-elevated);
458 border: 1px solid var(--border);
459 border-radius: 16px;
460 overflow: hidden;
461 }
462 .prs-detail-hero::before {
463 content: '';
464 position: absolute; top: 0; left: 0; right: 0;
465 height: 2px;
466 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
467 opacity: 0.7;
468 pointer-events: none;
469 }
470 .prs-detail-title {
471 font-family: var(--font-display);
472 font-size: clamp(22px, 2.6vw, 28px);
473 font-weight: 700;
474 letter-spacing: -0.022em;
475 line-height: 1.2;
476 color: var(--text-strong);
477 margin: 0 0 12px;
478 }
479 .prs-detail-num {
480 color: var(--text-muted);
481 font-weight: 400;
482 }
483 .prs-state-pill {
484 display: inline-flex; align-items: center; gap: 6px;
485 padding: 6px 12px;
486 border-radius: 9999px;
487 font-size: 12.5px;
488 font-weight: 600;
489 line-height: 1;
490 border: 1px solid transparent;
491 }
492 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
493 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
494 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
495 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
496
74d8c4dClaude497 .prs-size-badge {
498 display: inline-flex;
499 align-items: center;
500 padding: 2px 8px;
501 border-radius: 20px;
502 font-size: 11px;
503 font-weight: 700;
504 letter-spacing: 0.04em;
505 border: 1px solid currentColor;
506 opacity: 0.85;
507 }
508
b078860Claude509 .prs-detail-meta {
510 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
511 font-size: 13px;
512 color: var(--text-muted);
513 }
514 .prs-detail-meta strong { color: var(--text); }
515 .prs-detail-branches {
516 display: inline-flex; align-items: center; gap: 6px;
517 font-family: var(--font-mono);
518 font-size: 12px;
519 }
520 .prs-branch-pill {
521 padding: 3px 9px;
522 border-radius: 9999px;
523 background: var(--bg-tertiary);
524 border: 1px solid var(--border);
525 color: var(--text);
526 }
527 .prs-branch-pill.is-head { color: var(--text-strong); }
528 .prs-branch-arrow-lg {
529 color: var(--accent);
530 font-size: 14px;
531 font-weight: 700;
532 }
0369e77Claude533 .prs-branch-sync {
534 display: inline-flex; align-items: center; gap: 4px;
535 font-size: 11.5px; font-weight: 600;
536 padding: 2px 8px;
537 border-radius: 9999px;
538 border: 1px solid var(--border);
539 background: var(--bg-secondary);
540 color: var(--text-muted);
541 cursor: default;
542 }
543 .prs-branch-sync.is-behind {
544 color: #f87171;
545 border-color: rgba(248,113,113,0.35);
546 background: rgba(248,113,113,0.07);
547 }
548 .prs-branch-sync.is-synced {
549 color: #34d399;
550 border-color: rgba(52,211,153,0.35);
551 background: rgba(52,211,153,0.07);
552 }
b078860Claude553
554 .prs-detail-actions {
555 display: inline-flex; gap: 8px; margin-left: auto;
556 }
557
558 .prs-detail-tabs {
559 display: flex; gap: 4px;
560 margin: 0 0 16px;
561 border-bottom: 1px solid var(--border);
562 }
563 .prs-detail-tab {
564 padding: 10px 14px;
565 font-size: 13.5px;
566 font-weight: 500;
567 color: var(--text-muted);
568 text-decoration: none;
569 border-bottom: 2px solid transparent;
570 transition: color 120ms ease, border-color 120ms ease;
571 margin-bottom: -1px;
572 }
573 .prs-detail-tab:hover { color: var(--text); }
574 .prs-detail-tab.is-active {
575 color: var(--text-strong);
576 border-bottom-color: var(--accent);
577 }
578 .prs-detail-tab-count {
579 display: inline-flex; align-items: center; justify-content: center;
580 min-width: 20px; padding: 0 6px; margin-left: 6px;
581 height: 18px;
582 font-size: 11px;
583 font-weight: 600;
584 border-radius: 9999px;
585 background: var(--bg-tertiary);
586 color: var(--text-muted);
587 }
588
589 /* Gate / check status section */
590 .prs-gate-card {
591 margin-top: 20px;
592 background: var(--bg-elevated);
593 border: 1px solid var(--border);
594 border-radius: 14px;
595 overflow: hidden;
596 }
597 .prs-gate-head {
598 display: flex; align-items: center; gap: 10px;
599 padding: 14px 18px;
600 border-bottom: 1px solid var(--border);
601 }
602 .prs-gate-head h3 {
603 margin: 0;
604 font-size: 14px;
605 font-weight: 600;
606 color: var(--text-strong);
607 }
608 .prs-gate-summary {
609 margin-left: auto;
610 font-size: 12px;
611 color: var(--text-muted);
612 }
613 .prs-gate-row {
614 display: flex; align-items: center; gap: 12px;
615 padding: 12px 18px;
616 border-bottom: 1px solid var(--border-subtle);
617 }
618 .prs-gate-row:last-child { border-bottom: 0; }
619 .prs-gate-icon {
620 flex: 0 0 auto;
621 width: 22px; height: 22px;
622 display: inline-flex; align-items: center; justify-content: center;
623 border-radius: 9999px;
624 font-size: 12px;
625 font-weight: 700;
626 }
627 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
628 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
629 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
630 .prs-gate-name {
631 font-size: 13px;
632 font-weight: 600;
633 color: var(--text);
634 min-width: 140px;
635 }
636 .prs-gate-details {
637 flex: 1; min-width: 0;
638 font-size: 12.5px;
639 color: var(--text-muted);
640 }
641 .prs-gate-pill {
642 flex: 0 0 auto;
643 padding: 3px 10px;
644 border-radius: 9999px;
645 font-size: 11px;
646 font-weight: 600;
647 line-height: 1.5;
648 border: 1px solid transparent;
649 }
650 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
651 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
652 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
653 .prs-gate-footer {
654 padding: 12px 18px;
655 background: var(--bg-secondary);
656 font-size: 12px;
657 color: var(--text-muted);
658 }
659
660 /* Comment cards */
661 .prs-comment {
662 margin-top: 14px;
663 background: var(--bg-elevated);
664 border: 1px solid var(--border);
665 border-radius: 12px;
666 overflow: hidden;
667 }
668 .prs-comment-head {
669 display: flex; align-items: center; gap: 10px;
670 padding: 10px 14px;
671 background: var(--bg-secondary);
672 border-bottom: 1px solid var(--border);
673 font-size: 13px;
674 flex-wrap: wrap;
675 }
676 .prs-comment-head strong { color: var(--text-strong); }
677 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
678 .prs-comment-loc {
679 font-family: var(--font-mono);
680 font-size: 11.5px;
681 color: var(--text-muted);
682 background: var(--bg-tertiary);
683 padding: 2px 8px;
684 border-radius: 6px;
685 }
686 .prs-comment-body { padding: 14px 18px; }
687 .prs-comment.is-ai {
688 border-color: rgba(140,109,255,0.45);
689 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
690 }
691 .prs-comment.is-ai .prs-comment-head {
692 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
693 border-bottom-color: rgba(140,109,255,0.30);
694 }
695 .prs-ai-badge {
696 display: inline-flex; align-items: center; gap: 4px;
697 padding: 2px 9px;
698 font-size: 10.5px;
699 font-weight: 700;
700 letter-spacing: 0.04em;
701 text-transform: uppercase;
702 color: #fff;
703 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
704 border-radius: 9999px;
705 }
a7460bfClaude706 .prs-bot-badge {
707 display: inline-flex; align-items: center; gap: 3px;
708 padding: 1px 7px;
709 font-size: 10px;
710 font-weight: 600;
711 color: var(--fg-muted);
712 background: var(--bg-elevated);
713 border: 1px solid var(--border);
714 border-radius: 9999px;
715 }
b078860Claude716
717 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
718 .prs-files-card {
719 margin-top: 18px;
720 padding: 14px 18px;
721 display: flex; align-items: center; gap: 14px;
722 background: var(--bg-elevated);
723 border: 1px solid var(--border);
724 border-radius: 12px;
725 text-decoration: none;
726 color: inherit;
727 transition: border-color 120ms ease, transform 140ms ease;
728 }
729 .prs-files-card:hover {
730 border-color: rgba(140,109,255,0.45);
731 transform: translateY(-1px);
732 }
733 .prs-files-card-icon {
734 width: 36px; height: 36px;
735 display: inline-flex; align-items: center; justify-content: center;
736 border-radius: 10px;
737 background: rgba(140,109,255,0.12);
738 color: var(--text-link);
739 font-size: 18px;
740 }
741 .prs-files-card-text { flex: 1; min-width: 0; }
742 .prs-files-card-title {
743 font-size: 14px;
744 font-weight: 600;
745 color: var(--text-strong);
746 margin: 0 0 2px;
747 }
748 .prs-files-card-sub {
749 font-size: 12.5px;
750 color: var(--text-muted);
751 margin: 0;
752 }
753 .prs-files-card-cta {
754 font-size: 12.5px;
755 color: var(--text-link);
756 font-weight: 600;
757 }
758
759 /* Merge area */
760 .prs-merge-card {
761 position: relative;
762 margin-top: 22px;
763 padding: 18px;
764 background: var(--bg-elevated);
765 border-radius: 14px;
766 overflow: hidden;
767 }
768 .prs-merge-card::before {
769 content: '';
770 position: absolute; inset: 0;
771 padding: 1px;
772 border-radius: 14px;
773 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
774 -webkit-mask:
775 linear-gradient(#000 0 0) content-box,
776 linear-gradient(#000 0 0);
777 -webkit-mask-composite: xor;
778 mask-composite: exclude;
779 pointer-events: none;
780 }
781 .prs-merge-card.is-closed::before { background: var(--border-strong); }
782 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
783 .prs-merge-head {
784 display: flex; align-items: center; gap: 12px;
785 margin-bottom: 12px;
786 }
787 .prs-merge-head strong {
788 font-family: var(--font-display);
789 font-size: 15px;
790 color: var(--text-strong);
791 font-weight: 700;
792 }
793 .prs-merge-sub {
794 font-size: 13px;
795 color: var(--text-muted);
796 margin: 0 0 12px;
797 }
798 .prs-merge-actions {
799 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
800 }
801 .prs-merge-btn {
802 display: inline-flex; align-items: center; gap: 6px;
803 padding: 9px 16px;
804 border-radius: 10px;
805 font-size: 13.5px;
806 font-weight: 600;
807 color: #fff;
808 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
809 border: 1px solid rgba(52,211,153,0.55);
810 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
811 cursor: pointer;
812 transition: transform 120ms ease, box-shadow 160ms ease;
813 }
814 .prs-merge-btn:hover {
815 transform: translateY(-1px);
816 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
817 }
818 .prs-merge-btn[disabled],
819 .prs-merge-btn.is-disabled {
820 opacity: 0.55;
821 cursor: not-allowed;
822 transform: none;
823 box-shadow: none;
824 }
825 .prs-merge-ready-btn {
826 display: inline-flex; align-items: center; gap: 6px;
827 padding: 9px 16px;
828 border-radius: 10px;
829 font-size: 13.5px;
830 font-weight: 600;
831 color: #fff;
832 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
833 border: 1px solid rgba(140,109,255,0.55);
834 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
835 cursor: pointer;
836 transition: transform 120ms ease, box-shadow 160ms ease;
837 }
838 .prs-merge-ready-btn:hover {
839 transform: translateY(-1px);
840 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
841 }
842 .prs-merge-back-draft {
843 background: none; border: 1px solid var(--border-strong);
844 color: var(--text-muted);
845 padding: 9px 14px; border-radius: 10px;
846 font-size: 13px; cursor: pointer;
847 }
848 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
849
a164a6dClaude850 /* Merge strategy selector */
851 .prs-merge-strategy-wrap {
852 display: inline-flex; align-items: center;
853 background: var(--bg-elevated);
854 border: 1px solid var(--border);
855 border-radius: 10px;
856 overflow: hidden;
857 }
858 .prs-merge-strategy-label {
859 font-size: 11.5px; font-weight: 600;
860 color: var(--text-muted);
861 padding: 0 10px 0 12px;
862 white-space: nowrap;
863 }
864 .prs-merge-strategy-select {
865 background: transparent;
866 border: none;
867 color: var(--text);
868 font-size: 13px;
869 padding: 7px 10px 7px 4px;
870 cursor: pointer;
871 outline: none;
872 appearance: auto;
873 }
874 .prs-merge-strategy-select:focus { outline: 2px solid rgba(140,109,255,0.45); }
875
0a67773Claude876 /* Review summary banner */
877 .prs-review-summary {
878 display: flex; flex-direction: column; gap: 6px;
879 padding: 12px 16px;
880 background: var(--bg-elevated);
881 border: 1px solid var(--border);
882 border-radius: var(--r-md, 8px);
883 margin-bottom: 12px;
884 }
885 .prs-review-row {
886 display: flex; align-items: center; gap: 10px;
887 font-size: 13px;
888 }
889 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
890 .prs-review-approved .prs-review-icon { color: #34d399; }
891 .prs-review-changes .prs-review-icon { color: #f87171; }
ace34efClaude892 .prs-reviewer-avatar {
893 width: 24px; height: 24px; border-radius: 50%;
894 background: var(--accent); color: #fff;
895 display: flex; align-items: center; justify-content: center;
896 font-size: 11px; font-weight: 700; flex-shrink: 0;
897 }
0a67773Claude898
899 /* Review action buttons */
900 .prs-review-approve-btn {
901 display: inline-flex; align-items: center; gap: 5px;
902 padding: 8px 14px; border-radius: 8px; font-size: 13px;
903 font-weight: 600; cursor: pointer;
904 background: rgba(52,211,153,0.12);
905 color: #34d399;
906 border: 1px solid rgba(52,211,153,0.35);
907 transition: background 120ms;
908 }
909 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
910 .prs-review-changes-btn {
911 display: inline-flex; align-items: center; gap: 5px;
912 padding: 8px 14px; border-radius: 8px; font-size: 13px;
913 font-weight: 600; cursor: pointer;
914 background: rgba(248,113,113,0.10);
915 color: #f87171;
916 border: 1px solid rgba(248,113,113,0.30);
917 transition: background 120ms;
918 }
919 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
920
b078860Claude921 /* Inline form helpers */
922 .prs-inline-form { display: inline-flex; }
923
924 /* Comment composer */
925 .prs-composer { margin-top: 22px; }
926 .prs-composer textarea {
927 border-radius: 12px;
928 }
929
930 @media (max-width: 720px) {
931 .prs-detail-actions { margin-left: 0; }
932 .prs-merge-actions { width: 100%; }
933 .prs-merge-actions > * { flex: 1; min-width: 0; }
934 }
f1dc7c7Claude935
936 /* Additional mobile rules. Additive only. */
937 @media (max-width: 720px) {
938 .prs-detail-hero { padding: 18px; }
939 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
940 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
941 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
942 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
943 .prs-gate-name { min-width: 0; }
944 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
945 .prs-gate-summary { margin-left: 0; }
946 .prs-merge-btn,
947 .prs-merge-ready-btn,
948 .prs-merge-back-draft { min-height: 44px; }
949 .prs-comment-body { padding: 12px 14px; }
950 .prs-comment-head { padding: 10px 12px; }
951 .prs-files-card { padding: 12px 14px; }
952 }
3c03977Claude953
954 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
955 .live-pill {
956 display: inline-flex;
957 align-items: center;
958 gap: 8px;
959 padding: 4px 10px 4px 8px;
960 margin-left: 6px;
961 background: var(--bg-elevated);
962 border: 1px solid var(--border);
963 border-radius: 9999px;
964 font-size: 12px;
965 color: var(--text-muted);
966 line-height: 1;
967 vertical-align: middle;
968 }
969 .live-pill.is-busy { color: var(--text); }
970 .live-pill-dot {
971 width: 8px; height: 8px;
972 border-radius: 9999px;
973 background: #34d399;
974 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
975 animation: live-pulse 1.6s ease-in-out infinite;
976 }
977 @keyframes live-pulse {
978 0%, 100% { opacity: 1; }
979 50% { opacity: 0.55; }
980 }
981 .live-avatars {
982 display: inline-flex;
983 margin-left: 2px;
984 }
985 .live-avatar {
986 display: inline-flex;
987 align-items: center;
988 justify-content: center;
989 width: 22px; height: 22px;
990 border-radius: 9999px;
991 font-size: 10px;
992 font-weight: 700;
993 color: #0b1020;
994 margin-left: -6px;
995 border: 2px solid var(--bg-elevated);
996 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
997 }
998 .live-avatar:first-child { margin-left: 0; }
999 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
1000 .live-cursor-host {
1001 position: relative;
1002 }
1003 .live-cursor-overlay {
1004 position: absolute;
1005 inset: 0;
1006 pointer-events: none;
1007 overflow: hidden;
1008 border-radius: inherit;
1009 }
1010 .live-cursor {
1011 position: absolute;
1012 width: 2px;
1013 height: 18px;
1014 border-radius: 2px;
1015 transform: translate(-1px, 0);
1016 transition: transform 80ms linear, opacity 200ms ease;
1017 }
1018 .live-cursor::after {
1019 content: attr(data-label);
1020 position: absolute;
1021 top: -16px;
1022 left: -2px;
1023 font-size: 10px;
1024 line-height: 1;
1025 color: #0b1020;
1026 background: inherit;
1027 padding: 2px 5px;
1028 border-radius: 4px 4px 4px 0;
1029 white-space: nowrap;
1030 font-weight: 600;
1031 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
1032 }
1033 .live-cursor.is-idle { opacity: 0.4; }
1034 .live-edit-tag {
1035 display: inline-block;
1036 margin-left: 6px;
1037 padding: 1px 6px;
1038 font-size: 10px;
1039 font-weight: 600;
1040 letter-spacing: 0.02em;
1041 color: #0b1020;
1042 border-radius: 9999px;
1043 }
15db0e0Claude1044
1045 /* ─── Slash-command pill + composer hint ─── */
1046 .slash-hint {
1047 display: inline-flex;
1048 align-items: center;
1049 gap: 6px;
1050 margin-top: 6px;
1051 padding: 3px 9px;
1052 font-size: 11.5px;
1053 color: var(--text-muted);
1054 background: var(--bg-elevated);
1055 border: 1px dashed var(--border);
1056 border-radius: 9999px;
1057 width: fit-content;
1058 }
1059 .slash-hint code {
1060 background: rgba(110, 168, 255, 0.12);
1061 color: var(--text-strong);
1062 padding: 0 5px;
1063 border-radius: 4px;
1064 font-size: 11px;
1065 }
1066 .slash-pill {
1067 display: grid;
1068 grid-template-columns: auto 1fr auto;
1069 align-items: center;
1070 column-gap: 10px;
1071 row-gap: 6px;
1072 margin: 10px 0;
1073 padding: 10px 14px;
1074 background: linear-gradient(
1075 135deg,
1076 rgba(110, 168, 255, 0.08),
1077 rgba(163, 113, 247, 0.06)
1078 );
1079 border: 1px solid rgba(110, 168, 255, 0.32);
1080 border-left: 3px solid var(--accent, #6ea8ff);
1081 border-radius: var(--radius);
1082 font-size: 13px;
1083 color: var(--text);
1084 }
1085 .slash-pill-icon {
1086 font-size: 14px;
1087 line-height: 1;
1088 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
1089 }
1090 .slash-pill-actor { color: var(--text-muted); }
1091 .slash-pill-actor strong { color: var(--text-strong); }
1092 .slash-pill-cmd {
1093 background: rgba(110, 168, 255, 0.16);
1094 color: var(--text-strong);
1095 padding: 1px 6px;
1096 border-radius: 4px;
1097 font-size: 12.5px;
1098 }
1099 .slash-pill-time {
1100 color: var(--text-muted);
1101 font-size: 12px;
1102 justify-self: end;
1103 }
1104 .slash-pill-body {
1105 grid-column: 1 / -1;
1106 color: var(--text);
1107 font-size: 13px;
1108 line-height: 1.55;
1109 }
1110 .slash-pill-body p:first-child { margin-top: 0; }
1111 .slash-pill-body p:last-child { margin-bottom: 0; }
1112 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
1113 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1114 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
1115 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
3033f70Claude1116 .slash-pill.slash-cmd-stage { border-left-color: #36c5d6; }
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 }
3033f70Claude1478
1479 /* ─── Merge Impact Analysis panel (.impact-*) ─── */
1480 .impact-panel {
1481 margin-top: 20px;
1482 border: 1px solid var(--border);
1483 border-radius: 12px;
1484 overflow: hidden;
1485 background: var(--bg-elevated);
1486 }
1487 .impact-header {
1488 display: flex;
1489 align-items: center;
1490 gap: 10px;
1491 padding: 12px 16px;
1492 background: var(--bg-elevated);
1493 border-bottom: 1px solid var(--border);
1494 cursor: pointer;
1495 user-select: none;
1496 }
1497 .impact-header:hover { background: var(--bg-hover); }
1498 .impact-score {
1499 display: inline-flex;
1500 align-items: center;
1501 justify-content: center;
1502 min-width: 34px;
1503 height: 24px;
1504 border-radius: 9999px;
1505 font-size: 11.5px;
1506 font-weight: 800;
1507 padding: 0 8px;
1508 letter-spacing: 0.01em;
1509 border: 1.5px solid transparent;
1510 }
1511 .impact-score.score-low {
1512 color: #34d399;
1513 background: rgba(52,211,153,0.12);
1514 border-color: rgba(52,211,153,0.35);
1515 }
1516 .impact-score.score-medium {
1517 color: #fbbf24;
1518 background: rgba(251,191,36,0.12);
1519 border-color: rgba(251,191,36,0.35);
1520 }
1521 .impact-score.score-high {
1522 color: #f87171;
1523 background: rgba(248,113,113,0.12);
1524 border-color: rgba(248,113,113,0.35);
1525 }
1526 .impact-header strong {
1527 font-size: 13.5px;
1528 color: var(--text-strong);
1529 font-weight: 700;
1530 }
1531 .impact-summary {
1532 font-size: 12.5px;
1533 color: var(--text-muted);
1534 flex: 1;
1535 }
1536 .impact-toggle {
1537 background: none;
1538 border: none;
1539 color: var(--text-muted);
1540 font-size: 11px;
1541 cursor: pointer;
1542 padding: 2px 6px;
1543 border-radius: 4px;
1544 transition: color 120ms;
1545 line-height: 1;
1546 }
1547 .impact-toggle:hover { color: var(--text); }
1548 .impact-toggle.is-open { transform: rotate(180deg); }
1549 .impact-body {
1550 padding: 14px 16px;
1551 display: flex;
1552 flex-direction: column;
1553 gap: 14px;
1554 }
1555 .impact-body[hidden] { display: none; }
1556 .impact-section h4 {
1557 margin: 0 0 8px;
1558 font-size: 12.5px;
1559 font-weight: 600;
1560 color: var(--text-muted);
1561 text-transform: uppercase;
1562 letter-spacing: 0.06em;
1563 }
1564 .impact-file-list {
1565 display: flex;
1566 flex-direction: column;
1567 gap: 3px;
1568 margin: 0;
1569 padding: 0;
1570 list-style: none;
1571 }
1572 .impact-file-list li {
1573 font-family: var(--font-mono);
1574 font-size: 12px;
1575 color: var(--text);
1576 padding: 3px 8px;
1577 background: var(--bg-secondary);
1578 border-radius: 5px;
1579 overflow: hidden;
1580 text-overflow: ellipsis;
1581 white-space: nowrap;
1582 }
1583 .impact-downstream .impact-file-list li {
1584 background: rgba(248,113,113,0.06);
1585 border: 1px solid rgba(248,113,113,0.15);
1586 }
1587 .impact-downstream h4 {
1588 color: #f87171;
1589 }
1590 .impact-empty {
1591 font-size: 12.5px;
1592 color: var(--text-muted);
1593 font-style: italic;
1594 }
b078860Claude1595`;
1596
3033f70Claude1597
1598
81c73c1Claude1599/**
1600 * Tiny inline JS that drives the "Suggest description with AI" button.
1601 * On click, gathers form values, POSTs JSON to the given endpoint, and
1602 * pipes the response into the #pr-body textarea. All DOM lookups are
1603 * defensive — element absence is a silent no-op.
1604 *
1605 * Built as a string template so it lives next to its server-side caller
1606 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1607 * to avoid </script> breakouts.
1608 */
1609function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1610 const url = JSON.stringify(endpointUrl)
1611 .split("<").join("\\u003C")
1612 .split(">").join("\\u003E")
1613 .split("&").join("\\u0026");
1614 return (
1615 "(function(){try{" +
1616 "var btn=document.getElementById('ai-suggest-desc');" +
1617 "var status=document.getElementById('ai-suggest-status');" +
1618 "var body=document.getElementById('pr-body');" +
1619 "var form=btn&&btn.closest&&btn.closest('form');" +
1620 "if(!btn||!body||!form)return;" +
1621 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1622 "var fd=new FormData(form);" +
1623 "var title=String(fd.get('title')||'').trim();" +
1624 "var base=String(fd.get('base')||'').trim();" +
1625 "var head=String(fd.get('head')||'').trim();" +
1626 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1627 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1628 "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'})" +
1629 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1630 ".then(function(j){btn.disabled=false;" +
1631 "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;}}" +
1632 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1633 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1634 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1635 "});" +
1636 "}catch(e){}})();"
1637 );
1638}
1639
3c03977Claude1640/**
1641 * Live co-editing client. Connects to the per-PR SSE feed and:
1642 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1643 * status colour per user).
1644 * - Renders tinted cursor caret overlays inside #pr-body and every
1645 * `[data-live-field]` element.
1646 * - Broadcasts the local user's cursor position (selectionStart /
1647 * selectionEnd) debounced at 100ms.
1648 * - Broadcasts content patches (`replace` of the whole textarea —
1649 * last-write-wins v1) debounced at 250ms.
1650 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1651 * to the matching local field if untouched.
1652 *
1653 * All endpoint URLs are JSON-escaped via safe replacements so they
1654 * can't break out of the <script> tag.
1655 */
25b1ff7Claude1656
1657/**
1658 * Figma-style collaborative PR presence client (WebSocket).
1659 *
1660 * Connects to `GET /:owner/:repo/pulls/:number/presence` (WebSocket upgrade).
1661 * On connect the server sends `{type:"init", users:[...]}` so the bar renders
1662 * immediately. Subsequent messages from the server drive the presence bar and
1663 * per-line cursor pills in the diff.
1664 *
1665 * Outbound messages:
1666 * {type:"cursor", line: N} — user hovered a diff line
1667 * {type:"typing", line: N, typing: bool} — textarea focus/blur in diff
1668 * {type:"ping"} — keep-alive every 10s
1669 *
1670 * Inbound messages:
1671 * {type:"init", users:[{sessionId,username,colour,line,typing}]}
1672 * {type:"join", user:{sessionId,username,colour,line,typing}}
1673 * {type:"leave", sessionId}
1674 * {type:"cursor", sessionId, username, colour, line}
1675 * {type:"typing", sessionId, username, colour, line, typing}
1676 */
1677function PR_PRESENCE_SCRIPT(owner: string, repo: string, prNum: number): string {
1678 const wsPath = JSON.stringify(`/${owner}/${repo}/pulls/${prNum}/presence`)
1679 .split("<").join("\\u003C")
1680 .split(">").join("\\u003E")
1681 .split("&").join("\\u0026");
1682 return `(function(){
1683try{
1684var wsPath=${wsPath};
1685var proto=location.protocol==='https:'?'wss:':'ws:';
1686var url=proto+'//'+location.host+wsPath;
1687var mySessionId=null;
1688// sessionId -> {username, colour, line, typing}
1689var peers={};
1690var ws=null;
1691var pingTimer=null;
1692var reconnectDelay=1500;
1693var reconnectTimer=null;
1694
1695function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
1696
1697// ── Toast ──────────────────────────────────────────────────────────────
1698var toastWrap=document.getElementById('presence-toasts');
1699function toast(msg){
1700 if(!toastWrap)return;
1701 var t=document.createElement('div');
1702 t.className='presence-toast';
1703 t.textContent=msg;
1704 toastWrap.appendChild(t);
1705 setTimeout(function(){t.classList.add('fading');setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},420);},2500);
1706}
1707
1708// ── Presence bar ───────────────────────────────────────────────────────
1709var avEl=document.getElementById('presence-avatars');
1710var countEl=document.getElementById('presence-count');
1711function renderBar(){
1712 if(!avEl)return;
1713 var ids=Object.keys(peers);
1714 var html='';
1715 for(var i=0;i<ids.length&&i<8;i++){
1716 var p=peers[ids[i]];
1717 var initials=(p.username||'?').slice(0,2).toUpperCase();
1718 html+='<span class="presence-avatar" style="background:'+esc(p.colour)+'" title="'+esc(p.username)+'">';
1719 html+='<span class="presence-avatar-dot">'+esc(initials)+'</span>';
1720 html+=esc(p.username);
1721 html+='</span>';
1722 }
1723 avEl.innerHTML=html;
1724 if(countEl){
1725 var n=ids.length;
1726 countEl.textContent=n===0?'No other reviewers':n===1?'1 reviewer online':n+' reviewers online';
1727 }
1728}
1729
1730// ── Diff cursor pills ──────────────────────────────────────────────────
1731// data-line value is like "12:x:5" or "12:5:x" — pull numeric line only
1732function lineNumFromKey(key){var m=String(key).match(/(\d+)/);return m?parseInt(m[1],10):null;}
1733function findDiffRow(line){return document.querySelector('[data-line]') &&
1734 (function(){var rows=document.querySelectorAll('[data-line]');
1735 for(var i=0;i<rows.length;i++){var n=lineNumFromKey(rows[i].getAttribute('data-line')||'');if(n===line)return rows[i];}
1736 return null;
1737 })();}
1738function removePill(sessionId){var old=document.querySelector('[data-presence-sid="'+sessionId+'"]');if(old&&old.parentNode)old.parentNode.removeChild(old);}
1739function placePill(sessionId,username,colour,line,typing){
1740 removePill(sessionId);
1741 if(line==null)return;
1742 var row=findDiffRow(line);if(!row)return;
1743 var pill=document.createElement('span');
1744 pill.className='presence-line-pill'+(typing?' is-typing':'');
1745 pill.setAttribute('data-presence-sid',sessionId);
1746 pill.style.background=colour||'#8c6dff';
1747 pill.textContent=(username||'?').slice(0,12)+(typing?' typing':'');
1748 row.appendChild(pill);
1749}
1750function clearPeer(sessionId){removePill(sessionId);delete peers[sessionId];}
1751
1752// ── Inbound message handler ────────────────────────────────────────────
1753function onMsg(raw){
1754 var d;try{d=JSON.parse(raw);}catch(e){return;}
1755 if(!d||!d.type)return;
1756 if(d.type==='init'){
1757 mySessionId=d.sessionId||null;
1758 peers={};
1759 (d.users||[]).forEach(function(u){
1760 if(u.sessionId===mySessionId)return;
1761 peers[u.sessionId]={username:u.username,colour:u.colour,line:u.line,typing:u.typing};
1762 placePill(u.sessionId,u.username,u.colour,u.line,u.typing);
1763 });
1764 renderBar();
1765 } else if(d.type==='join'){
1766 if(d.user&&d.user.sessionId!==mySessionId){
1767 peers[d.user.sessionId]={username:d.user.username,colour:d.user.colour,line:d.user.line,typing:d.user.typing};
1768 renderBar();
1769 toast(esc(d.user.username)+' joined the review');
1770 }
1771 } else if(d.type==='leave'){
1772 if(d.sessionId&&d.sessionId!==mySessionId){
1773 var name=peers[d.sessionId]&&peers[d.sessionId].username;
1774 clearPeer(d.sessionId);
1775 renderBar();
1776 if(name)toast(esc(name)+' left the review');
1777 }
1778 } else if(d.type==='cursor'){
1779 if(d.sessionId&&d.sessionId!==mySessionId){
1780 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=false;}
1781 placePill(d.sessionId,d.username,d.colour,d.line,false);
1782 }
1783 } else if(d.type==='typing'){
1784 if(d.sessionId&&d.sessionId!==mySessionId){
1785 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=d.typing;}
1786 placePill(d.sessionId,d.username,d.colour,d.line,d.typing);
1787 }
1788 }
1789}
1790
1791// ── Outbound helpers ───────────────────────────────────────────────────
1792function send(obj){try{if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}catch(e){}}
1793
1794// ── Mouse hover on diff rows ───────────────────────────────────────────
1795var hoverTimer=null;
1796document.addEventListener('mouseover',function(ev){
1797 var row=ev.target&&ev.target.closest&&ev.target.closest('[data-line]');
1798 if(!row)return;
1799 if(hoverTimer)clearTimeout(hoverTimer);
1800 hoverTimer=setTimeout(function(){
1801 var key=row.getAttribute('data-line')||'';
1802 var line=lineNumFromKey(key);
1803 if(line!=null)send({type:'cursor',line:line});
1804 },80);
1805});
1806
1807// ── Typing detection in diff comment textareas ─────────────────────────
1808document.addEventListener('focusin',function(ev){
1809 var ta=ev.target;
1810 if(!ta||ta.tagName!=='TEXTAREA')return;
1811 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1812 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1813 if(line!=null)send({type:'typing',line:line,typing:true});
1814});
1815document.addEventListener('focusout',function(ev){
1816 var ta=ev.target;
1817 if(!ta||ta.tagName!=='TEXTAREA')return;
1818 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1819 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1820 if(line!=null)send({type:'typing',line:line,typing:false});
1821});
1822
1823// ── WebSocket lifecycle ────────────────────────────────────────────────
1824function connect(){
1825 if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=null;}
1826 try{ws=new WebSocket(url);}catch(e){scheduleReconnect();return;}
1827 ws.onopen=function(){
1828 reconnectDelay=1500;
1829 pingTimer=setInterval(function(){send({type:'ping'});},10000);
1830 };
1831 ws.onmessage=function(ev){onMsg(ev.data);};
1832 ws.onclose=function(){
1833 if(pingTimer){clearInterval(pingTimer);pingTimer=null;}
1834 scheduleReconnect();
1835 };
1836 ws.onerror=function(){try{ws.close();}catch(e){}};
1837}
1838function scheduleReconnect(){
1839 reconnectTimer=setTimeout(function(){connect();},reconnectDelay);
1840 reconnectDelay=Math.min(reconnectDelay*2,30000);
1841}
1842
1843connect();
1844}catch(e){}})();`;
1845}
1846
3c03977Claude1847function LIVE_COEDIT_SCRIPT(prId: string): string {
1848 const idJson = JSON.stringify(prId)
1849 .split("<").join("\\u003C")
1850 .split(">").join("\\u003E")
1851 .split("&").join("\\u0026");
1852 return (
1853 "(function(){try{" +
1854 "if(typeof EventSource==='undefined')return;" +
1855 "var prId=" + idJson + ";" +
1856 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1857 "var pill=document.getElementById('live-pill');" +
1858 "var avEl=document.getElementById('live-avatars');" +
1859 "var countEl=document.getElementById('live-count');" +
1860 "var sessionId=null;var myColor=null;" +
1861 "var presence={};" + // sessionId -> {color,status,userId,initials}
1862 "var lastApplied={};" + // field -> last server value (for echo suppression)
1863 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1864 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1865 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1866 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1867 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1868 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1869 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1870 "avEl.innerHTML=html;}}" +
1871 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1872 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1873 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1874 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1875 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1876 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1877 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1878 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1879 "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);}" +
1880 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1881 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1882 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1883 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1884 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1885 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1886 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1887 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1888 "}catch(e){}" +
1889 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1890 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1891 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1892 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1893 "var es;var delay=1000;" +
1894 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1895 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1896 "(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){}});" +
1897 "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){}});" +
1898 "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){}});" +
1899 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1900 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1901 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1902 "var patch=d.patch;if(!patch||!patch.field)return;" +
1903 "var ta=fieldEl(patch.field);if(!ta)return;" +
1904 "if(document.activeElement===ta)return;" + // don't trample local typing
1905 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1906 "}catch(e){}});" +
1907 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1908 "}connect();" +
1909 "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){}}" +
1910 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1911 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1912 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1913 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1914 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1915 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1916 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1917 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1918 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1919 "}" +
1920 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1921 "var live=document.querySelectorAll('[data-live-field]');" +
1922 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1923 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1924 "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){}});" +
1925 "}catch(e){}})();"
1926 );
1927}
1928
0074234Claude1929async function resolveRepo(ownerName: string, repoName: string) {
1930 const [owner] = await db
1931 .select()
1932 .from(users)
1933 .where(eq(users.username, ownerName))
1934 .limit(1);
1935 if (!owner) return null;
1936 const [repo] = await db
1937 .select()
1938 .from(repositories)
1939 .where(
1940 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1941 )
1942 .limit(1);
1943 if (!repo) return null;
1944 return { owner, repo };
1945}
1946
1947// PR Nav helper
1948const PrNav = ({
1949 owner,
1950 repo,
1951 active,
1952}: {
1953 owner: string;
1954 repo: string;
1955 active: "code" | "issues" | "pulls" | "commits";
1956}) => (
bb0f894Claude1957 <TabNav
1958 tabs={[
1959 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1960 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1961 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1962 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1963 ]}
1964 />
0074234Claude1965);
1966
534f04aClaude1967/**
1968 * Block M3 — pre-merge risk score card. Pure presentational helper.
1969 * Rendered in the conversation tab above the gate checks block. Hidden
1970 * entirely when the PR is closed/merged or there is nothing cached and
1971 * nothing in-flight.
1972 */
1973function PrRiskCard({
1974 risk,
1975 calculating,
1976}: {
1977 risk: PrRiskScore | null;
1978 calculating: boolean;
1979}) {
1980 if (!risk) {
1981 return (
1982 <div
1983 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1984 >
1985 <strong style="font-size: 13px; color: var(--text)">
1986 Risk score: calculating…
1987 </strong>
1988 <div style="font-size: 12px; margin-top: 4px">
1989 Refresh in a moment to see the pre-merge risk score for this PR.
1990 </div>
1991 </div>
1992 );
1993 }
1994
1995 const palette = riskBandPalette(risk.band);
1996 const label = riskBandLabel(risk.band);
1997
1998 return (
1999 <div
2000 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
2001 >
2002 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
2003 <strong>Risk score:</strong>
2004 <span style={`color:${palette.border};font-weight:600`}>
2005 {palette.icon} {label} ({risk.score}/10)
2006 </span>
2007 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
2008 {risk.commitSha.slice(0, 7)}
2009 </span>
2010 </div>
2011 {risk.aiSummary && (
2012 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
2013 {risk.aiSummary}
2014 </div>
2015 )}
2016 <details style="margin-top:10px">
2017 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
2018 See full signal breakdown
2019 </summary>
2020 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
2021 <li>files changed: {risk.signals.filesChanged}</li>
2022 <li>
2023 lines added/removed: {risk.signals.linesAdded} /{" "}
2024 {risk.signals.linesRemoved}
2025 </li>
2026 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
2027 <li>
2028 schema migration touched:{" "}
2029 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
2030 </li>
2031 <li>
2032 locked / sensitive path touched:{" "}
2033 {risk.signals.lockedPathTouched ? "yes" : "no"}
2034 </li>
2035 <li>
2036 adds new dependency:{" "}
2037 {risk.signals.addsNewDependency ? "yes" : "no"}
2038 </li>
2039 <li>
2040 bumps major dependency:{" "}
2041 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
2042 </li>
2043 <li>
2044 tests added for new code:{" "}
2045 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
2046 </li>
2047 <li>
2048 diff-minus-test ratio:{" "}
2049 {risk.signals.diffMinusTestRatio.toFixed(2)}
2050 </li>
2051 </ul>
2052 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2053 How is this calculated? The score is a transparent sum of
2054 weighted signals — see <code>src/lib/pr-risk.ts</code>
2055 {" "}<code>computePrRiskScore</code>.
2056 </div>
2057 </details>
2058 {calculating && (
2059 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2060 (recomputing for the latest commit — refresh to update)
2061 </div>
2062 )}
2063 </div>
2064 );
2065}
2066
2067function riskBandPalette(band: PrRiskScore["band"]): {
2068 border: string;
2069 icon: string;
2070} {
2071 switch (band) {
2072 case "low":
2073 return { border: "var(--green)", icon: "" };
2074 case "medium":
2075 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
2076 case "high":
2077 return { border: "var(--orange, #db6d28)", icon: "⚠" };
2078 case "critical":
2079 return { border: "var(--red)", icon: "\u{1F6D1}" };
2080 }
2081}
2082
2083function riskBandLabel(band: PrRiskScore["band"]): string {
2084 switch (band) {
2085 case "low":
2086 return "LOW";
2087 case "medium":
2088 return "MEDIUM";
2089 case "high":
2090 return "HIGH";
2091 case "critical":
2092 return "CRITICAL";
2093 }
2094}
2095
3033f70Claude2096// ---------------------------------------------------------------------------
2097// Merge Impact Analysis — collapsible panel showing affected files and
2098// downstream repos. Shown on open PRs for users with write access.
2099// ---------------------------------------------------------------------------
2100
2101function ImpactPanel({ analysis, owner }: { analysis: ImpactAnalysis; owner: string }) {
2102 const score = analysis.riskScore;
2103 const band = score <= 30 ? "low" : score <= 60 ? "medium" : "high";
2104 const hasAny =
2105 analysis.affectedTestFiles.length > 0 ||
2106 analysis.affectedFiles.length > 0 ||
2107 analysis.downstreamRepos.length > 0;
2108
2109 return (
2110 <div class="impact-panel">
2111 <div
2112 class="impact-header"
2113 onclick="(function(h){var b=h.parentElement.querySelector('.impact-body');var t=h.querySelector('.impact-toggle');if(!b)return;var hidden=b.hasAttribute('hidden');if(hidden){b.removeAttribute('hidden');t&&t.classList.add('is-open');}else{b.setAttribute('hidden','');t&&t.classList.remove('is-open');}})(this)"
2114 >
2115 <span class={`impact-score score-${band}`}>{score}</span>
2116 <strong>Merge Impact</strong>
2117 <span class="impact-summary">{analysis.riskSummary}</span>
2118 <button class="impact-toggle" type="button" aria-label="Toggle impact panel">
2119
2120 </button>
2121 </div>
2122 <div class="impact-body" hidden>
2123 <div class="impact-section">
2124 <h4>Affected test files ({analysis.affectedTestFiles.length})</h4>
2125 {analysis.affectedTestFiles.length === 0 ? (
2126 <span class="impact-empty">No test files reference the changed files.</span>
2127 ) : (
2128 <ul class="impact-file-list">
2129 {analysis.affectedTestFiles.map((f) => (
2130 <li title={f}>{f}</li>
2131 ))}
2132 </ul>
2133 )}
2134 </div>
2135 <div class="impact-section">
2136 <h4>Affected source files ({analysis.affectedFiles.length})</h4>
2137 {analysis.affectedFiles.length === 0 ? (
2138 <span class="impact-empty">No other source files import the changed files.</span>
2139 ) : (
2140 <ul class="impact-file-list">
2141 {analysis.affectedFiles.map((f) => (
2142 <li title={f}>{f}</li>
2143 ))}
2144 </ul>
2145 )}
2146 </div>
2147 {analysis.downstreamRepos.length > 0 && (
2148 <div class="impact-section impact-downstream">
2149 <h4>Downstream repos ({analysis.downstreamRepos.length})</h4>
2150 <ul class="impact-file-list">
2151 {analysis.downstreamRepos.map((r) => (
2152 <li>
2153 <a
2154 href={`/${r.owner}/${r.repo}`}
2155 style="color:var(--text-link);text-decoration:none"
2156 >
2157 {r.owner}/{r.repo}
2158 </a>
2159 {" "}
2160 <span style="color:var(--text-muted);font-size:11px">
2161 via {r.matchedDependency}
2162 </span>
2163 </li>
2164 ))}
2165 </ul>
2166 </div>
2167 )}
2168 </div>
2169 </div>
2170 );
2171}
2172
422a2d4Claude2173// ---------------------------------------------------------------------------
2174// AI Trio Review — 3-column card grid + disagreement callout.
2175//
2176// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
2177// per run: one per persona (security/correctness/style) plus a top-level
2178// summary. We surface them here as a single grid above the normal
2179// comment stream so reviewers see the verdicts at a glance.
2180// ---------------------------------------------------------------------------
2181
2182const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
2183
2184interface TrioCommentLike {
2185 body: string;
2186}
2187
2188function isTrioComment(body: string | null | undefined): boolean {
2189 if (!body) return false;
2190 return (
2191 body.includes(TRIO_SUMMARY_MARKER) ||
2192 body.includes(TRIO_COMMENT_MARKER.security) ||
2193 body.includes(TRIO_COMMENT_MARKER.correctness) ||
2194 body.includes(TRIO_COMMENT_MARKER.style)
2195 );
2196}
2197
2198function trioPersonaOfComment(body: string): TrioPersona | null {
2199 for (const p of TRIO_PERSONAS) {
2200 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
2201 }
2202 return null;
2203}
2204
2205/**
2206 * Best-effort verdict parse from a persona comment body. The body shape
2207 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
2208 * we only need the "Pass" / "Fail" word from the H2 heading.
2209 */
2210function trioVerdictOfBody(body: string): "pass" | "fail" | null {
2211 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
2212 if (!m) return null;
2213 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
2214}
2215
2216/**
2217 * Parse the disagreement bullet list out of the summary comment so we
2218 * can render it as a polished callout strip. Returns [] when nothing
2219 * matches — the comment author may have edited the marker out.
2220 */
2221function parseDisagreements(summaryBody: string): Array<{
2222 file: string;
2223 failing: string;
2224 passing: string;
2225}> {
2226 const out: Array<{ file: string; failing: string; passing: string }> = [];
2227 // Each disagreement line looks like:
2228 // - `path:42` — security, style say ✗, correctness say ✓
2229 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
2230 let m: RegExpExecArray | null;
2231 while ((m = re.exec(summaryBody)) !== null) {
2232 out.push({
2233 file: m[1].trim(),
2234 failing: m[2].trim().replace(/[,\s]+$/g, ""),
2235 passing: m[3].trim().replace(/[,\s]+$/g, ""),
2236 });
2237 }
2238 return out;
2239}
2240
2241function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
2242 // Find the most recent persona comments + summary. We iterate from
2243 // the end so re-reviews (multiple runs on the same PR) display the
2244 // freshest verdict.
2245 const latest: Partial<Record<TrioPersona, string>> = {};
2246 let summaryBody: string | null = null;
2247 for (let i = comments.length - 1; i >= 0; i--) {
2248 const body = comments[i].body || "";
2249 if (!isTrioComment(body)) continue;
2250 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
2251 summaryBody = body;
2252 continue;
2253 }
2254 const persona = trioPersonaOfComment(body);
2255 if (persona && !latest[persona]) latest[persona] = body;
2256 }
2257 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2258 if (!anyPersona && !summaryBody) return null;
2259
2260 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
2261
2262 return (
67dc4e1Claude2263 <div class="trio-wrap" id="trio-review-section">
422a2d4Claude2264 <div class="trio-header">
2265 <span class="trio-header-dot" aria-hidden="true"></span>
2266 <strong>AI Trio Review</strong>
2267 <span class="trio-header-sub">
2268 Three independent reviewers ran in parallel.
2269 </span>
2270 </div>
2271 <div class="trio-grid">
2272 {TRIO_PERSONAS.map((persona) => {
2273 const body = latest[persona];
2274 const verdict = body ? trioVerdictOfBody(body) : null;
2275 const stateClass =
2276 verdict === "fail"
2277 ? "is-fail"
2278 : verdict === "pass"
2279 ? "is-pass"
2280 : "is-pending";
2281 return (
2282 <div class={`trio-card trio-${persona} ${stateClass}`}>
2283 <div class="trio-card-head">
2284 <span class="trio-card-icon" aria-hidden="true">
2285 {persona === "security"
2286 ? "🛡"
2287 : persona === "correctness"
2288 ? "✓"
2289 : "✎"}
2290 </span>
2291 <strong class="trio-card-title">
2292 {persona[0].toUpperCase() + persona.slice(1)}
2293 </strong>
2294 <span class="trio-card-verdict">
2295 {verdict === "pass"
2296 ? "Pass"
2297 : verdict === "fail"
2298 ? "Fail"
2299 : "Pending"}
2300 </span>
2301 </div>
2302 <div class="trio-card-body">
2303 {body ? (
2304 <MarkdownContent
2305 html={renderMarkdown(stripTrioHeading(body))}
2306 />
2307 ) : (
2308 <span class="trio-card-empty">
2309 Awaiting reviewer output.
2310 </span>
2311 )}
2312 </div>
2313 </div>
2314 );
2315 })}
2316 </div>
2317 {disagreements.length > 0 && (
2318 <div class="trio-disagreement-strip" role="note">
2319 <span class="trio-disagreement-icon" aria-hidden="true">
2320
2321 </span>
2322 <div class="trio-disagreement-body">
2323 <strong>Reviewers disagree — review carefully.</strong>
2324 <ul class="trio-disagreement-list">
2325 {disagreements.map((d) => (
2326 <li>
2327 <code>{d.file}</code> — {d.failing} says ✗,{" "}
2328 {d.passing} says ✓
2329 </li>
2330 ))}
2331 </ul>
2332 </div>
2333 </div>
2334 )}
2335 </div>
2336 );
2337}
2338
2339/**
2340 * Strip the marker comment + first H2 heading from a persona body so
2341 * the card body shows just the findings list (verdict is already in
2342 * the card head). Best-effort — malformed bodies render whole.
2343 */
2344function stripTrioHeading(body: string): string {
2345 return body
2346 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
2347 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
2348 .trim();
2349}
2350
67dc4e1Claude2351/**
2352 * Three small verdict pills rendered inline in the PR header. Each pill
2353 * links to the `#trio-review-section` anchor so clicking scrolls to the
2354 * full card grid. Only shown when `AI_TRIO_REVIEW_ENABLED=1` and at
2355 * least one persona comment exists.
2356 */
2357function TrioVerdictPills({
2358 comments,
2359}: {
2360 comments: TrioCommentLike[];
2361}) {
2362 if (!isTrioReviewEnabled()) return null;
2363
2364 // Find latest persona verdicts (same logic as TrioReviewGrid).
2365 const latest: Partial<Record<TrioPersona, string>> = {};
2366 for (let i = comments.length - 1; i >= 0; i--) {
2367 const body = comments[i].body || "";
2368 if (!isTrioComment(body)) continue;
2369 if (body.includes(TRIO_SUMMARY_MARKER)) continue;
2370 const persona = trioPersonaOfComment(body);
2371 if (persona && !latest[persona]) latest[persona] = body;
2372 }
2373
2374 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2375 if (!anyPersona) return null;
2376
2377 const PERSONA_LABEL: Record<TrioPersona, string> = {
2378 security: "Security",
2379 correctness: "Correctness",
2380 style: "Style",
2381 };
2382 const PERSONA_ICON: Record<TrioPersona, string> = {
2383 security: "🛡",
2384 correctness: "✓",
2385 style: "✎",
2386 };
2387
2388 return (
2389 <span class="trio-pills-wrap" aria-label="AI Trio Review verdicts">
2390 {TRIO_PERSONAS.map((persona) => {
2391 const body = latest[persona];
2392 const verdict = body ? trioVerdictOfBody(body) : null;
2393 const stateClass =
2394 verdict === "pass"
2395 ? "is-pass"
2396 : verdict === "fail"
2397 ? "is-fail"
2398 : "is-pending";
2399 const glyph =
2400 verdict === "pass" ? "✓" : verdict === "fail" ? "✗" : "⟳";
2401 return (
2402 <a
2403 href="#trio-review-section"
2404 class={`trio-pill ${stateClass}`}
2405 title={`AI ${PERSONA_LABEL[persona]} Review — ${verdict === "pass" ? "Pass" : verdict === "fail" ? "Fail" : "Pending"}`}
2406 >
2407 <span aria-hidden="true">{PERSONA_ICON[persona]}</span>
2408 {PERSONA_LABEL[persona]} {glyph}
2409 </a>
2410 );
2411 })}
2412 </span>
2413 );
2414}
2415
0074234Claude2416// List PRs
04f6b7fClaude2417pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2418 const { owner: ownerName, repo: repoName } = c.req.param();
2419 const user = c.get("user");
2420 const state = c.req.query("state") || "open";
d790b49Claude2421 const searchQ = c.req.query("q")?.trim() || "";
80bd7c8Claude2422 const authorFilter = c.req.query("author")?.trim() || "";
f5b9ef5Claude2423 const sortPr = (c.req.query("sort") || "newest").trim();
0074234Claude2424
ea9ed4cClaude2425 // ── Loading skeleton (flag-gated) ──
2426 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
2427 // the user see the page structure before counts + select resolve.
2428 // Behind a flag for now — we don't ship flashes.
2429 if (c.req.query("skeleton") === "1") {
2430 return c.html(
2431 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2432 <RepoHeader owner={ownerName} repo={repoName} />
2433 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2434 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2435 <style
2436 dangerouslySetInnerHTML={{
2437 __html: `
404b398Claude2438 .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; }
2439 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude2440 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
2441 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
2442 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
2443 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
2444 .prs-skel-row { height: 76px; border-radius: 12px; }
2445 `,
2446 }}
2447 />
2448 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
2449 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
2450 <div class="prs-skel-list" aria-hidden="true">
2451 {Array.from({ length: 6 }).map(() => (
2452 <div class="prs-skel prs-skel-row" />
2453 ))}
2454 </div>
2455 <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">
2456 Loading pull requests for {ownerName}/{repoName}…
2457 </span>
2458 </Layout>
2459 );
2460 }
2461
0074234Claude2462 const resolved = await resolveRepo(ownerName, repoName);
2463 if (!resolved) return c.notFound();
2464
6fc53bdClaude2465 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
2466 const stateFilter =
2467 state === "draft"
2468 ? and(
2469 eq(pullRequests.state, "open"),
2470 eq(pullRequests.isDraft, true)
2471 )
2472 : eq(pullRequests.state, state);
2473
0074234Claude2474 const prList = await db
2475 .select({
2476 pr: pullRequests,
2477 author: { username: users.username },
2478 })
2479 .from(pullRequests)
2480 .innerJoin(users, eq(pullRequests.authorId, users.id))
2481 .where(
d790b49Claude2482 and(
2483 eq(pullRequests.repositoryId, resolved.repo.id),
2484 stateFilter,
2485 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
80bd7c8Claude2486 authorFilter ? eq(users.username, authorFilter) : undefined,
d790b49Claude2487 )
0074234Claude2488 )
f5b9ef5Claude2489 .orderBy(
2490 sortPr === "oldest" ? asc(pullRequests.createdAt)
2491 : sortPr === "updated" ? desc(pullRequests.updatedAt)
2492 : desc(pullRequests.createdAt) // newest (default)
2493 );
0074234Claude2494
0369e77Claude2495 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude2496 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude2497 const commentCountMap = new Map<string, number>();
1aef949Claude2498 if (prList.length > 0) {
2499 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude2500 const [reviewRows, commentRows] = await Promise.all([
2501 db
2502 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
2503 .from(prReviews)
2504 .where(inArray(prReviews.pullRequestId, prIds)),
2505 db
2506 .select({
2507 prId: prComments.pullRequestId,
2508 cnt: sql<number>`count(*)::int`,
2509 })
2510 .from(prComments)
2511 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
2512 .groupBy(prComments.pullRequestId),
2513 ]);
1aef949Claude2514 for (const r of reviewRows) {
2515 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
2516 if (r.state === "approved") entry.approved = true;
2517 if (r.state === "changes_requested") entry.changesRequested = true;
2518 reviewMap.set(r.prId, entry);
2519 }
0369e77Claude2520 for (const r of commentRows) {
2521 commentCountMap.set(r.prId, Number(r.cnt));
2522 }
1aef949Claude2523 }
2524
0074234Claude2525 const [counts] = await db
2526 .select({
2527 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude2528 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude2529 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
2530 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
2531 })
2532 .from(pullRequests)
2533 .where(eq(pullRequests.repositoryId, resolved.repo.id));
2534
b078860Claude2535 const openCount = counts?.open ?? 0;
2536 const mergedCount = counts?.merged ?? 0;
2537 const closedCount = counts?.closed ?? 0;
2538 const draftCount = counts?.draft ?? 0;
2539 const allCount = openCount + mergedCount + closedCount;
2540
2541 // "All" is presentational only — the DB query for state='all' matches
2542 // nothing, so we render a friendlier empty state when picked. We do NOT
2543 // change the query logic to keep this commit purely visual.
80bd7c8Claude2544 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude2545 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude2546 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
2547 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
2548 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
2549 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
2550 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude2551 ];
2552 const isAllState = state === "all";
cb5a796Claude2553 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
2554 const prListPendingCount = viewerIsOwnerOnPrList
2555 ? await countPendingForRepo(resolved.repo.id)
2556 : 0;
b078860Claude2557
0074234Claude2558 return c.html(
2559 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2560 <RepoHeader owner={ownerName} repo={repoName} />
2561 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2562 <PendingCommentsBanner
2563 owner={ownerName}
2564 repo={repoName}
2565 count={prListPendingCount}
2566 />
b078860Claude2567 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2568
2569 <div class="prs-hero">
2570 <div class="prs-hero-inner">
2571 <div class="prs-hero-text">
2572 <div class="prs-hero-eyebrow">Pull requests</div>
2573 <h1 class="prs-hero-title">
2574 Review, <span class="gradient-text">merge with AI</span>.
2575 </h1>
2576 <p class="prs-hero-sub">
2577 {openCount === 0 && allCount === 0
2578 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2579 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
2580 </p>
2581 </div>
7a28902Claude2582 <div class="prs-hero-actions">
2583 <a
2584 href={`/${ownerName}/${repoName}/pulls/insights`}
2585 class="prs-cta"
2586 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
2587 >
2588 Insights
2589 </a>
2590 {user && (
b078860Claude2591 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
2592 + New pull request
2593 </a>
7a28902Claude2594 )}
2595 </div>
b078860Claude2596 </div>
2597 </div>
2598
2599 <nav class="prs-tabs" aria-label="Pull request filters">
2600 {tabPills.map((t) => {
2601 const isActive =
2602 state === t.key ||
2603 (t.key === "open" &&
2604 state !== "merged" &&
2605 state !== "closed" &&
2606 state !== "all" &&
2607 state !== "draft");
2608 return (
2609 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2610 <span>{t.label}</span>
2611 <span class="prs-tab-count">{t.count}</span>
2612 </a>
2613 );
2614 })}
2615 </nav>
2616
d790b49Claude2617 <form
2618 method="get"
2619 action={`/${ownerName}/${repoName}/pulls`}
2620 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2621 >
2622 <input type="hidden" name="state" value={state} />
2623 <input
2624 type="search"
2625 name="q"
2626 value={searchQ}
2627 placeholder="Search pull requests…"
2628 class="issues-search-input"
2629 style="flex:1;max-width:380px"
2630 />
80bd7c8Claude2631 <input
2632 type="text"
2633 name="author"
2634 value={authorFilter}
2635 placeholder="Filter by author…"
2636 class="issues-search-input"
2637 style="max-width:200px"
2638 />
d790b49Claude2639 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2640 {(searchQ || authorFilter) && (
d790b49Claude2641 <a
2642 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2643 class="issues-filter-clear"
2644 >
2645 Clear
2646 </a>
2647 )}
2648 </form>
f5b9ef5Claude2649
2650 <div class="prs-sort-row">
2651 <span class="prs-sort-label">Sort:</span>
2652 {(["newest", "oldest", "updated"] as const).map((s) => (
2653 <a
2654 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2655 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2656 >
2657 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2658 </a>
2659 ))}
2660 </div>
2661
0074234Claude2662 {prList.length === 0 ? (
b078860Claude2663 <div class="prs-empty">
ea9ed4cClaude2664 <div class="prs-empty-inner">
2665 <strong>
80bd7c8Claude2666 {searchQ || authorFilter
2667 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2668 : isAllState
2669 ? "Pick a filter above to browse PRs."
2670 : `No ${state} pull requests.`}
ea9ed4cClaude2671 </strong>
2672 <p class="prs-empty-sub">
80bd7c8Claude2673 {searchQ || authorFilter
2674 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2675 : state === "open"
2676 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2677 : isAllState
2678 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2679 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2680 </p>
2681 <div class="prs-empty-cta">
80bd7c8Claude2682 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2683 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2684 + New pull request
2685 </a>
2686 )}
80bd7c8Claude2687 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2688 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2689 View open PRs
2690 </a>
2691 )}
2692 <a href={`/${ownerName}/${repoName}`} class="btn">
2693 Back to code
2694 </a>
2695 </div>
2696 </div>
b078860Claude2697 </div>
0074234Claude2698 ) : (
b078860Claude2699 <div class="prs-list">
2700 {prList.map(({ pr, author }) => {
2701 const stateClass =
2702 pr.state === "open"
2703 ? pr.isDraft
2704 ? "state-draft"
2705 : "state-open"
2706 : pr.state === "merged"
2707 ? "state-merged"
2708 : "state-closed";
2709 const icon =
2710 pr.state === "open"
2711 ? pr.isDraft
2712 ? "◌"
2713 : "○"
2714 : pr.state === "merged"
2715 ? "⮌"
2716 : "✓";
1aef949Claude2717 const rv = reviewMap.get(pr.id);
b078860Claude2718 return (
2719 <a
2720 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2721 class="prs-row"
2722 style="text-decoration:none;color:inherit"
0074234Claude2723 >
b078860Claude2724 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2725 {icon}
0074234Claude2726 </div>
b078860Claude2727 <div class="prs-row-body">
2728 <h3 class="prs-row-title">
2729 <span>{pr.title}</span>
2730 <span class="prs-row-number">#{pr.number}</span>
2731 </h3>
2732 <div class="prs-row-meta">
2733 <span
2734 class="prs-branch-chips"
2735 title={`${pr.headBranch} into ${pr.baseBranch}`}
2736 >
2737 <span class="prs-branch-chip">{pr.headBranch}</span>
2738 <span class="prs-branch-arrow">{"→"}</span>
2739 <span class="prs-branch-chip">{pr.baseBranch}</span>
2740 </span>
2741 <span>
2742 by{" "}
2743 <strong style="color:var(--text)">
2744 {author.username}
2745 </strong>{" "}
2746 {formatRelative(pr.createdAt)}
2747 </span>
2748 <span class="prs-row-tags">
2749 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2750 {pr.state === "merged" && (
2751 <span class="prs-tag is-merged">Merged</span>
2752 )}
1aef949Claude2753 {rv?.approved && !rv.changesRequested && (
2754 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
2755 )}
2756 {rv?.changesRequested && (
2757 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
2758 )}
0369e77Claude2759 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
2760 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
2761 💬 {commentCountMap.get(pr.id)}
2762 </span>
2763 )}
b078860Claude2764 </span>
2765 </div>
0074234Claude2766 </div>
b078860Claude2767 </a>
2768 );
2769 })}
2770 </div>
0074234Claude2771 )}
2772 </Layout>
2773 );
2774});
2775
7a28902Claude2776/* ─────────────────────────────────────────────────────────────────────────
2777 * PR Insights — 90-day analytics for the pull request activity of a repo.
2778 * Route: GET /:owner/:repo/pulls/insights
2779 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
2780 * "insights" is not swallowed by the :number param.
2781 * ───────────────────────────────────────────────────────────────────────── */
2782
2783/** Format a millisecond duration as human-readable string. */
2784function formatMsDuration(ms: number): string {
2785 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
2786 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
2787 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
2788 return `${Math.round(ms / 86_400_000)}d`;
2789}
2790
2791/** Format an ISO week string as "Jan 15". */
2792function formatWeekLabel(isoWeek: string): string {
2793 try {
2794 const d = new Date(isoWeek);
2795 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2796 } catch {
2797 return isoWeek.slice(5, 10);
2798 }
2799}
2800
2801const PR_INSIGHTS_STYLES = `
2802 .pri-page { padding-bottom: 48px; }
2803 .pri-hero {
2804 position: relative;
2805 margin: 0 0 var(--space-5);
2806 padding: 22px 26px 24px;
2807 background: var(--bg-elevated);
2808 border: 1px solid var(--border);
2809 border-radius: 16px;
2810 overflow: hidden;
2811 }
2812 .pri-hero::before {
2813 content: '';
2814 position: absolute; top: 0; left: 0; right: 0;
2815 height: 2px;
2816 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2817 opacity: 0.7;
2818 pointer-events: none;
2819 }
2820 .pri-hero-eyebrow {
2821 font-size: 12px;
2822 color: var(--text-muted);
2823 text-transform: uppercase;
2824 letter-spacing: 0.08em;
2825 font-weight: 600;
2826 margin-bottom: 8px;
2827 }
2828 .pri-hero-title {
2829 font-family: var(--font-display);
2830 font-size: clamp(26px, 3.4vw, 34px);
2831 font-weight: 800;
2832 letter-spacing: -0.025em;
2833 line-height: 1.06;
2834 margin: 0 0 8px;
2835 color: var(--text-strong);
2836 }
2837 .pri-hero-title .gradient-text {
2838 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2839 -webkit-background-clip: text;
2840 background-clip: text;
2841 -webkit-text-fill-color: transparent;
2842 color: transparent;
2843 }
2844 .pri-hero-sub {
2845 font-size: 14.5px;
2846 color: var(--text-muted);
2847 margin: 0;
2848 line-height: 1.5;
2849 }
2850 .pri-section { margin-bottom: 32px; }
2851 .pri-section-title {
2852 font-size: 13px;
2853 font-weight: 700;
2854 text-transform: uppercase;
2855 letter-spacing: 0.06em;
2856 color: var(--text-muted);
2857 margin: 0 0 14px;
2858 }
2859 .pri-cards {
2860 display: grid;
2861 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
2862 gap: 12px;
2863 }
2864 .pri-card {
2865 padding: 16px 18px;
2866 background: var(--bg-elevated);
2867 border: 1px solid var(--border);
2868 border-radius: 12px;
2869 }
2870 .pri-card-label {
2871 font-size: 12px;
2872 font-weight: 600;
2873 color: var(--text-muted);
2874 text-transform: uppercase;
2875 letter-spacing: 0.05em;
2876 margin-bottom: 6px;
2877 }
2878 .pri-card-value {
2879 font-size: 28px;
2880 font-weight: 800;
2881 letter-spacing: -0.04em;
2882 color: var(--text-strong);
2883 line-height: 1;
2884 }
2885 .pri-card-sub {
2886 font-size: 12px;
2887 color: var(--text-muted);
2888 margin-top: 4px;
2889 }
2890 .pri-chart {
2891 display: flex;
2892 align-items: flex-end;
2893 gap: 6px;
2894 height: 120px;
2895 background: var(--bg-elevated);
2896 border: 1px solid var(--border);
2897 border-radius: 12px;
2898 padding: 16px 16px 0;
2899 }
2900 .pri-bar-col {
2901 flex: 1;
2902 display: flex;
2903 flex-direction: column;
2904 align-items: center;
2905 justify-content: flex-end;
2906 height: 100%;
2907 gap: 4px;
2908 }
2909 .pri-bar {
2910 width: 100%;
2911 min-height: 4px;
2912 border-radius: 4px 4px 0 0;
2913 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 100%);
2914 transition: opacity 140ms;
2915 }
2916 .pri-bar:hover { opacity: 0.8; }
2917 .pri-bar-label {
2918 font-size: 10px;
2919 color: var(--text-muted);
2920 text-align: center;
2921 padding-bottom: 8px;
2922 white-space: nowrap;
2923 overflow: hidden;
2924 text-overflow: ellipsis;
2925 max-width: 100%;
2926 }
2927 .pri-table {
2928 width: 100%;
2929 border-collapse: collapse;
2930 font-size: 13.5px;
2931 }
2932 .pri-table th {
2933 text-align: left;
2934 font-size: 12px;
2935 font-weight: 600;
2936 text-transform: uppercase;
2937 letter-spacing: 0.05em;
2938 color: var(--text-muted);
2939 padding: 8px 12px;
2940 border-bottom: 1px solid var(--border);
2941 }
2942 .pri-table td {
2943 padding: 10px 12px;
2944 border-bottom: 1px solid var(--border);
2945 color: var(--text);
2946 }
2947 .pri-table tr:last-child td { border-bottom: none; }
2948 .pri-table-wrap {
2949 background: var(--bg-elevated);
2950 border: 1px solid var(--border);
2951 border-radius: 12px;
2952 overflow: hidden;
2953 }
2954 .pri-age-row {
2955 display: flex;
2956 align-items: center;
2957 gap: 12px;
2958 padding: 10px 0;
2959 border-bottom: 1px solid var(--border);
2960 font-size: 13.5px;
2961 }
2962 .pri-age-row:last-child { border-bottom: none; }
2963 .pri-age-label {
2964 flex: 0 0 80px;
2965 color: var(--text-muted);
2966 font-size: 12.5px;
2967 font-weight: 600;
2968 }
2969 .pri-age-bar-wrap {
2970 flex: 1;
2971 height: 8px;
2972 background: var(--bg-secondary);
2973 border-radius: 9999px;
2974 overflow: hidden;
2975 }
2976 .pri-age-bar {
2977 height: 100%;
2978 border-radius: 9999px;
2979 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
2980 min-width: 4px;
2981 }
2982 .pri-age-count {
2983 flex: 0 0 32px;
2984 text-align: right;
2985 font-weight: 600;
2986 color: var(--text-strong);
2987 font-size: 13px;
2988 }
2989 .pri-sparkline {
2990 display: flex;
2991 align-items: flex-end;
2992 gap: 3px;
2993 height: 40px;
2994 }
2995 .pri-spark-bar {
2996 flex: 1;
2997 min-height: 2px;
2998 border-radius: 2px 2px 0 0;
2999 background: var(--accent, #8c6dff);
3000 opacity: 0.7;
3001 }
3002 .pri-empty {
3003 color: var(--text-muted);
3004 font-size: 14px;
3005 padding: 24px 0;
3006 text-align: center;
3007 }
3008 @media (max-width: 600px) {
3009 .pri-cards { grid-template-columns: repeat(2, 1fr); }
3010 .pri-hero { padding: 18px 18px 20px; }
3011 }
3012`;
3013
3014pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
3015 const { owner: ownerName, repo: repoName } = c.req.param();
3016 const user = c.get("user");
3017
3018 const resolved = await resolveRepo(ownerName, repoName);
3019 if (!resolved) return c.notFound();
3020
3021 const repoId = resolved.repo.id;
3022 const now = Date.now();
3023
3024 // 1. Merged PRs in last 90 days (avg merge time)
3025 const mergedPRs = await db
3026 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
3027 .from(pullRequests)
3028 .where(and(
3029 eq(pullRequests.repositoryId, repoId),
3030 eq(pullRequests.state, "merged"),
3031 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3032 ));
3033
3034 const avgMergeMs = mergedPRs.length > 0
3035 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
3036 : null;
3037
3038 // 2. PR throughput (last 8 weeks)
3039 const weeklyPRs = await db
3040 .select({
3041 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
3042 count: sql<number>`count(*)::int`,
3043 })
3044 .from(pullRequests)
3045 .where(and(
3046 eq(pullRequests.repositoryId, repoId),
3047 sql`${pullRequests.createdAt} > now() - interval '56 days'`
3048 ))
3049 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
3050 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
3051
3052 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
3053
3054 // 3. PR merge rate (last 90 days)
3055 const [rateCounts] = await db
3056 .select({
3057 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
3058 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
3059 })
3060 .from(pullRequests)
3061 .where(and(
3062 eq(pullRequests.repositoryId, repoId),
3063 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3064 ));
3065
3066 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
3067 const mergeRate = totalResolved > 0
3068 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
3069 : null;
3070
3071 // 4. Top reviewers (last 90 days)
3072 const reviewerCounts = await db
3073 .select({
3074 userId: prReviews.reviewerId,
3075 username: users.username,
3076 count: sql<number>`count(*)::int`,
3077 })
3078 .from(prReviews)
3079 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3080 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
3081 .where(and(
3082 eq(pullRequests.repositoryId, repoId),
3083 sql`${prReviews.createdAt} > now() - interval '90 days'`
3084 ))
3085 .groupBy(prReviews.reviewerId, users.username)
3086 .orderBy(desc(sql`count(*)`))
3087 .limit(5);
3088
3089 // 5. Average reviews per merged PR
3090 const [avgReviewRow] = await db
3091 .select({
3092 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
3093 })
3094 .from(pullRequests)
3095 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3096 .where(and(
3097 eq(pullRequests.repositoryId, repoId),
3098 eq(pullRequests.state, "merged"),
3099 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3100 ));
3101
3102 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
3103 ? Math.round(avgReviewRow.avgReviews * 10) / 10
3104 : null;
3105
3106 // 6. Review turnaround — avg time from PR open to first review
3107 const prsWithReviews = await db
3108 .select({
3109 createdAt: pullRequests.createdAt,
3110 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
3111 })
3112 .from(pullRequests)
3113 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3114 .where(and(
3115 eq(pullRequests.repositoryId, repoId),
3116 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3117 ))
3118 .groupBy(pullRequests.id, pullRequests.createdAt);
3119
3120 const avgReviewTurnaroundMs = prsWithReviews.length > 0
3121 ? prsWithReviews.reduce((s, row) => {
3122 const firstMs = new Date(row.firstReview).getTime();
3123 return s + Math.max(0, firstMs - row.createdAt.getTime());
3124 }, 0) / prsWithReviews.length
3125 : null;
3126
3127 // 7. Open PRs by age bucket
3128 const openPRs = await db
3129 .select({ createdAt: pullRequests.createdAt })
3130 .from(pullRequests)
3131 .where(and(
3132 eq(pullRequests.repositoryId, repoId),
3133 eq(pullRequests.state, "open")
3134 ));
3135
3136 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
3137 for (const { createdAt } of openPRs) {
3138 const ageDays = (now - createdAt.getTime()) / 86_400_000;
3139 if (ageDays < 1) ageBuckets.lt1d++;
3140 else if (ageDays < 3) ageBuckets.d1to3++;
3141 else if (ageDays < 7) ageBuckets.d3to7++;
3142 else if (ageDays < 30) ageBuckets.d7to30++;
3143 else ageBuckets.gt30d++;
3144 }
3145 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
3146
3147 // 8. 7-day merge sparkline
3148 const sparklineRows = await db
3149 .select({
3150 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
3151 count: sql<number>`count(*)::int`,
3152 })
3153 .from(pullRequests)
3154 .where(and(
3155 eq(pullRequests.repositoryId, repoId),
3156 eq(pullRequests.state, "merged"),
3157 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
3158 ))
3159 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
3160 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
3161
3162 const sparkMap = new Map<string, number>();
3163 for (const row of sparklineRows) {
3164 sparkMap.set(row.day.slice(0, 10), row.count);
3165 }
3166 const sparkline: number[] = [];
3167 for (let i = 6; i >= 0; i--) {
3168 const d = new Date(now - i * 86_400_000);
3169 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
3170 }
3171 const maxSpark = Math.max(1, ...sparkline);
3172
3173 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
3174 { label: "< 1 day", key: "lt1d" },
3175 { label: "1–3 days", key: "d1to3" },
3176 { label: "3–7 days", key: "d3to7" },
3177 { label: "7–30 days", key: "d7to30" },
3178 { label: "> 30 days", key: "gt30d" },
3179 ];
3180
3181 return c.html(
3182 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
3183 <RepoHeader owner={ownerName} repo={repoName} />
3184 <PrNav owner={ownerName} repo={repoName} active="pulls" />
3185 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
3186
3187 <div class="pri-page">
3188 {/* Hero */}
3189 <div class="pri-hero">
3190 <div class="pri-hero-eyebrow">Pull requests</div>
3191 <h1 class="pri-hero-title">
3192 PR <span class="gradient-text">Insights</span>
3193 </h1>
3194 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
3195 </div>
3196
3197 {/* Stat cards */}
3198 <div class="pri-section">
3199 <div class="pri-section-title">At a glance</div>
3200 <div class="pri-cards">
3201 <div class="pri-card">
3202 <div class="pri-card-label">Avg merge time</div>
3203 <div class="pri-card-value">
3204 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
3205 </div>
3206 <div class="pri-card-sub">last 90 days</div>
3207 </div>
3208 <div class="pri-card">
3209 <div class="pri-card-label">Total merged</div>
3210 <div class="pri-card-value">{mergedPRs.length}</div>
3211 <div class="pri-card-sub">last 90 days</div>
3212 </div>
3213 <div class="pri-card">
3214 <div class="pri-card-label">Open PRs</div>
3215 <div class="pri-card-value">{openPRs.length}</div>
3216 <div class="pri-card-sub">right now</div>
3217 </div>
3218 <div class="pri-card">
3219 <div class="pri-card-label">Merge rate</div>
3220 <div class="pri-card-value">
3221 {mergeRate != null ? `${mergeRate}%` : "—"}
3222 </div>
3223 <div class="pri-card-sub">merged vs closed</div>
3224 </div>
3225 <div class="pri-card">
3226 <div class="pri-card-label">Avg reviews / PR</div>
3227 <div class="pri-card-value">
3228 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
3229 </div>
3230 <div class="pri-card-sub">merged PRs, 90d</div>
3231 </div>
3232 <div class="pri-card">
3233 <div class="pri-card-label">Top reviewer</div>
3234 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
3235 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
3236 </div>
3237 <div class="pri-card-sub">
3238 {reviewerCounts.length > 0
3239 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
3240 : "no reviews yet"}
3241 </div>
3242 </div>
3243 </div>
3244 </div>
3245
3246 {/* Review turnaround */}
3247 <div class="pri-section">
3248 <div class="pri-section-title">Review turnaround</div>
3249 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
3250 <div class="pri-card">
3251 <div class="pri-card-label">Avg time to first review</div>
3252 <div class="pri-card-value">
3253 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
3254 </div>
3255 <div class="pri-card-sub">
3256 {prsWithReviews.length > 0
3257 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
3258 : "no reviewed PRs in 90d"}
3259 </div>
3260 </div>
3261 </div>
3262 </div>
3263
3264 {/* Weekly throughput bar chart */}
3265 <div class="pri-section">
3266 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
3267 {weeklyPRs.length === 0 ? (
3268 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
3269 ) : (
3270 <div class="pri-chart">
3271 {weeklyPRs.map((w) => (
3272 <div class="pri-bar-col">
3273 <div
3274 class="pri-bar"
3275 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
3276 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
3277 />
3278 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
3279 </div>
3280 ))}
3281 </div>
3282 )}
3283 </div>
3284
3285 {/* 7-day merge sparkline */}
3286 <div class="pri-section">
3287 <div class="pri-section-title">Merges this week (daily)</div>
3288 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
3289 <div class="pri-sparkline">
3290 {sparkline.map((v) => (
3291 <div
3292 class="pri-spark-bar"
3293 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
3294 title={`${v} merge${v === 1 ? "" : "s"}`}
3295 />
3296 ))}
3297 </div>
3298 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
3299 <span>7 days ago</span>
3300 <span>Today</span>
3301 </div>
3302 </div>
3303 </div>
3304
3305 {/* Top reviewers table */}
3306 <div class="pri-section">
3307 <div class="pri-section-title">Top reviewers (last 90 days)</div>
3308 {reviewerCounts.length === 0 ? (
3309 <div class="pri-empty">No reviews posted in the last 90 days.</div>
3310 ) : (
3311 <div class="pri-table-wrap">
3312 <table class="pri-table">
3313 <thead>
3314 <tr>
3315 <th>#</th>
3316 <th>Reviewer</th>
3317 <th>Reviews</th>
3318 </tr>
3319 </thead>
3320 <tbody>
3321 {reviewerCounts.map((r, i) => (
3322 <tr>
3323 <td style="color:var(--text-muted)">{i + 1}</td>
3324 <td>
3325 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
3326 {r.username}
3327 </a>
3328 </td>
3329 <td style="font-weight:600">{r.count}</td>
3330 </tr>
3331 ))}
3332 </tbody>
3333 </table>
3334 </div>
3335 )}
3336 </div>
3337
3338 {/* Open PRs by age */}
3339 <div class="pri-section">
3340 <div class="pri-section-title">Open PRs by age</div>
3341 {openPRs.length === 0 ? (
3342 <div class="pri-empty">No open pull requests.</div>
3343 ) : (
3344 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
3345 {ageBucketDefs.map(({ label, key }) => (
3346 <div class="pri-age-row">
3347 <span class="pri-age-label">{label}</span>
3348 <div class="pri-age-bar-wrap">
3349 <div
3350 class="pri-age-bar"
3351 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
3352 />
3353 </div>
3354 <span class="pri-age-count">{ageBuckets[key]}</span>
3355 </div>
3356 ))}
3357 </div>
3358 )}
3359 </div>
3360
3361 {/* Back link */}
3362 <div>
3363 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
3364 {"←"} Back to pull requests
3365 </a>
3366 </div>
3367 </div>
3368 </Layout>
3369 );
3370});
3371
0074234Claude3372// New PR form
3373pulls.get(
3374 "/:owner/:repo/pulls/new",
3375 softAuth,
3376 requireAuth,
04f6b7fClaude3377 requireRepoAccess("write"),
0074234Claude3378 async (c) => {
3379 const { owner: ownerName, repo: repoName } = c.req.param();
3380 const user = c.get("user")!;
3381 const branches = await listBranches(ownerName, repoName);
3382 const error = c.req.query("error");
3383 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude3384 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude3385
3386 return c.html(
3387 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
3388 <RepoHeader owner={ownerName} repo={repoName} />
3389 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude3390 <Container maxWidth={800}>
3391 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude3392 {error && (
bb0f894Claude3393 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude3394 )}
0316dbbClaude3395 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
3396 <Flex gap={12} align="center" style="margin-bottom: 16px">
3397 <Select name="base">
0074234Claude3398 {branches.map((b) => (
3399 <option value={b} selected={b === defaultBase}>
3400 {b}
3401 </option>
3402 ))}
bb0f894Claude3403 </Select>
3404 <Text muted>&larr;</Text>
3405 <Select name="head">
0074234Claude3406 {branches
3407 .filter((b) => b !== defaultBase)
3408 .concat(defaultBase === branches[0] ? [] : [branches[0]])
3409 .map((b) => (
3410 <option value={b}>{b}</option>
3411 ))}
bb0f894Claude3412 </Select>
3413 </Flex>
3414 <FormGroup>
3415 <Input
0074234Claude3416 name="title"
3417 required
3418 placeholder="Title"
bb0f894Claude3419 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]3420 aria-label="Pull request title"
0074234Claude3421 />
bb0f894Claude3422 </FormGroup>
3423 <FormGroup>
3424 <TextArea
0074234Claude3425 name="body"
81c73c1Claude3426 id="pr-body"
0074234Claude3427 rows={8}
3428 placeholder="Description (Markdown supported)"
bb0f894Claude3429 mono
0074234Claude3430 />
bb0f894Claude3431 </FormGroup>
81c73c1Claude3432 <Flex gap={8} align="center">
3433 <Button type="submit" variant="primary">
3434 Create pull request
3435 </Button>
3436 <button
3437 type="button"
3438 id="ai-suggest-desc"
3439 class="btn"
3440 style="font-weight:500"
3441 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
3442 >
3443 Suggest description with AI
3444 </button>
3445 <span
3446 id="ai-suggest-status"
3447 style="color:var(--text-muted);font-size:13px"
3448 />
3449 </Flex>
bb0f894Claude3450 </Form>
81c73c1Claude3451 <script
3452 dangerouslySetInnerHTML={{
3453 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
3454 }}
3455 />
bb0f894Claude3456 </Container>
0074234Claude3457 </Layout>
3458 );
3459 }
3460);
3461
81c73c1Claude3462// AI-suggested PR description — JSON endpoint driven by the form button.
3463// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
3464// 200; the inline script reads `ok` to decide what to do.
3465pulls.post(
3466 "/:owner/:repo/ai/pr-description",
3467 softAuth,
3468 requireAuth,
3469 requireRepoAccess("write"),
3470 async (c) => {
3471 const { owner: ownerName, repo: repoName } = c.req.param();
3472 if (!isAiAvailable()) {
3473 return c.json({
3474 ok: false,
3475 error: "AI is not available — set ANTHROPIC_API_KEY.",
3476 });
3477 }
3478 const body = await c.req.parseBody();
3479 const title = String(body.title || "").trim();
3480 const baseBranch = String(body.base || "").trim();
3481 const headBranch = String(body.head || "").trim();
3482 if (!baseBranch || !headBranch) {
3483 return c.json({ ok: false, error: "Pick base + head branches first." });
3484 }
3485 if (baseBranch === headBranch) {
3486 return c.json({ ok: false, error: "Base and head must differ." });
3487 }
3488
3489 let diff = "";
3490 try {
3491 const cwd = getRepoPath(ownerName, repoName);
3492 const proc = Bun.spawn(
3493 [
3494 "git",
3495 "diff",
3496 `${baseBranch}...${headBranch}`,
3497 "--",
3498 ],
3499 { cwd, stdout: "pipe", stderr: "pipe" }
3500 );
6ea2109Claude3501 // 30s ceiling — without this a pathological diff (huge binary or
3502 // a corrupt ref) hangs the request indefinitely.
3503 const killer = setTimeout(() => proc.kill(), 30_000);
3504 try {
3505 diff = await new Response(proc.stdout).text();
3506 await proc.exited;
3507 } finally {
3508 clearTimeout(killer);
3509 }
81c73c1Claude3510 } catch {
3511 diff = "";
3512 }
3513 if (!diff.trim()) {
3514 return c.json({
3515 ok: false,
3516 error: "No diff between branches — nothing to summarise.",
3517 });
3518 }
3519
3520 let summary = "";
3521 try {
3522 summary = await generatePrSummary(title || "(untitled)", diff);
3523 } catch (err) {
3524 const msg = err instanceof Error ? err.message : "AI request failed.";
3525 return c.json({ ok: false, error: msg });
3526 }
3527 if (!summary.trim()) {
3528 return c.json({ ok: false, error: "AI returned an empty draft." });
3529 }
3530 return c.json({ ok: true, body: summary });
3531 }
3532);
3533
0074234Claude3534// Create PR
3535pulls.post(
3536 "/:owner/:repo/pulls/new",
3537 softAuth,
3538 requireAuth,
04f6b7fClaude3539 requireRepoAccess("write"),
0074234Claude3540 async (c) => {
3541 const { owner: ownerName, repo: repoName } = c.req.param();
3542 const user = c.get("user")!;
3543 const body = await c.req.parseBody();
3544 const title = String(body.title || "").trim();
3545 const prBody = String(body.body || "").trim();
3546 const baseBranch = String(body.base || "main");
3547 const headBranch = String(body.head || "");
3548
3549 if (!title || !headBranch) {
3550 return c.redirect(
3551 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
3552 );
3553 }
3554
3555 if (baseBranch === headBranch) {
3556 return c.redirect(
3557 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
3558 );
3559 }
3560
3561 const resolved = await resolveRepo(ownerName, repoName);
3562 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3563
6fc53bdClaude3564 const isDraft = String(body.draft || "") === "1";
3565
0074234Claude3566 const [pr] = await db
3567 .insert(pullRequests)
3568 .values({
3569 repositoryId: resolved.repo.id,
3570 authorId: user.id,
3571 title,
3572 body: prBody || null,
3573 baseBranch,
3574 headBranch,
6fc53bdClaude3575 isDraft,
0074234Claude3576 })
3577 .returning();
3578
ec9e3e3Claude3579 // CODEOWNERS — auto-request reviewers based on changed files.
3580 // Fire-and-forget; errors never block PR creation.
3581 (async () => {
3582 try {
3583 const repoDir = getRepoPath(ownerName, repoName);
3584 // Get list of changed files between base and head
3585 const diffProc = Bun.spawn(
3586 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
3587 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3588 );
3589 const rawDiff = await new Response(diffProc.stdout).text();
3590 await diffProc.exited;
3591 const changedFiles = rawDiff.trim().split("\n").filter(Boolean);
3592
3593 if (changedFiles.length > 0) {
3594 // Get CODEOWNERS from the default branch of the repo
3595 const rules = await getCodeownersForRepo(
3596 ownerName,
3597 repoName,
3598 resolved.repo.defaultBranch
3599 );
3600 if (rules.length > 0) {
3601 const ownerUsernames = await reviewersForChangedFiles(
3602 resolved.repo.id,
3603 changedFiles
3604 );
3605 // Filter out the PR author
3606 const filteredOwners = ownerUsernames.filter(
3607 (u) => u !== resolved.owner.username
3608 );
3609
3610 if (filteredOwners.length > 0) {
3611 // Look up user IDs for the owner usernames
3612 const reviewerUsers = await db
3613 .select({ id: users.id, username: users.username })
3614 .from(users)
3615 .where(
3616 inArray(
3617 users.username,
3618 filteredOwners
3619 )
3620 );
3621
3622 // Create review request rows (UNIQUE constraint prevents dupes)
3623 if (reviewerUsers.length > 0) {
3624 await db
3625 .insert(prReviewRequests)
3626 .values(
3627 reviewerUsers.map((u) => ({
3628 prId: pr.id,
3629 reviewerId: u.id,
3630 requestedBy: null as string | null,
3631 }))
3632 )
3633 .onConflictDoNothing();
3634
3635 // Add a PR comment announcing the auto-assigned reviewers
3636 const mentionList = reviewerUsers
3637 .map((u) => `@${u.username}`)
3638 .join(", ");
3639 await db.insert(prComments).values({
3640 pullRequestId: pr.id,
3641 authorId: user.id,
3642 body: `AI: Requested review from ${mentionList} based on CODEOWNERS`,
3643 isAiReview: true,
3644 });
3645 }
3646 }
3647 }
3648 }
3649 } catch (err) {
3650 console.warn("[codeowners] auto-assign failed:", err instanceof Error ? err.message : err);
3651 }
3652 })();
3653
6fc53bdClaude3654 // Skip AI review on drafts — it runs again when the PR is marked ready.
3655 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3656 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3657 (err) => console.error("[ai-review] Failed:", err)
3658 );
3659 }
3660
3cbe3d6Claude3661 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3662 triggerPrTriage({
3663 ownerName,
3664 repoName,
3665 repositoryId: resolved.repo.id,
3666 prId: pr.id,
3667 prAuthorId: user.id,
3668 title,
3669 body: prBody,
3670 baseBranch,
3671 headBranch,
3672 }).catch((err) => console.error("[pr-triage] Failed:", err));
3673
1d4ff60Claude3674 // Chat notifier — fan out to Slack/Discord/Teams.
3675 import("../lib/chat-notifier")
3676 .then((m) =>
3677 m.notifyChatChannels({
3678 ownerUserId: resolved.repo.ownerId,
3679 repositoryId: resolved.repo.id,
3680 event: {
3681 event: "pr.opened",
3682 repo: `${ownerName}/${repoName}`,
3683 title: `#${pr.number} ${title}`,
3684 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3685 body: prBody || undefined,
3686 actor: user.username,
3687 },
3688 })
3689 )
3690 .catch((err) =>
3691 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3692 );
3693
9dd96b9Test User3694 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude3695 import("../lib/auto-merge")
3696 .then((m) => m.tryAutoMergeNow(pr.id))
3697 .catch((err) => {
3698 console.warn(
3699 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
3700 err instanceof Error ? err.message : err
3701 );
3702 });
9dd96b9Test User3703
1df50d5Claude3704 // Migration 0077 — PR preview build. Fire-and-forget; skips when
3705 // PREVIEW_DOMAIN is unset or the repo has no preview_build_command.
3706 // Resolve head SHA asynchronously so we don't block the redirect.
3707 resolveRef(ownerName, repoName, headBranch)
3708 .then((headSha) => {
3709 if (!headSha) return;
3710 return import("../lib/preview-builder").then((m) =>
3711 m.buildPreview(pr.id, resolved.repo.id, headSha)
3712 );
3713 })
3714 .catch(() => {});
3715
0074234Claude3716 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
3717 }
3718);
3719
3720// View single PR
04f6b7fClaude3721pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude3722 const { owner: ownerName, repo: repoName } = c.req.param();
3723 const prNum = parseInt(c.req.param("number"), 10);
3724 const user = c.get("user");
3725 const tab = c.req.query("tab") || "conversation";
3726
3727 const resolved = await resolveRepo(ownerName, repoName);
3728 if (!resolved) return c.notFound();
3729
3730 const [pr] = await db
3731 .select()
3732 .from(pullRequests)
3733 .where(
3734 and(
3735 eq(pullRequests.repositoryId, resolved.repo.id),
3736 eq(pullRequests.number, prNum)
3737 )
3738 )
3739 .limit(1);
3740
3741 if (!pr) return c.notFound();
3742
3743 const [author] = await db
3744 .select()
3745 .from(users)
3746 .where(eq(users.id, pr.authorId))
3747 .limit(1);
3748
cb5a796Claude3749 const allCommentsRaw = await db
0074234Claude3750 .select({
3751 comment: prComments,
cb5a796Claude3752 author: { id: users.id, username: users.username },
0074234Claude3753 })
3754 .from(prComments)
3755 .innerJoin(users, eq(prComments.authorId, users.id))
3756 .where(eq(prComments.pullRequestId, pr.id))
3757 .orderBy(asc(prComments.createdAt));
3758
cb5a796Claude3759 // Filter pending/rejected/spam for non-owner, non-author viewers.
3760 // Owner always sees everything; comment author sees their own pending
3761 // with an "Awaiting approval" badge in the render below.
3762 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
3763 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
3764 if (viewerIsRepoOwner) return true;
3765 if (comment.moderationStatus === "approved") return true;
3766 if (
3767 user &&
3768 cAuthor.id === user.id &&
3769 comment.moderationStatus === "pending"
3770 ) {
3771 return true;
3772 }
3773 return false;
3774 });
3775 const prPendingCount = viewerIsRepoOwner
3776 ? await countPendingForRepo(resolved.repo.id)
3777 : 0;
3778
6fc53bdClaude3779 // Reactions for the PR body + each comment, in parallel.
3780 const [prReactions, ...prCommentReactions] = await Promise.all([
3781 summariseReactions("pr", pr.id, user?.id),
3782 ...comments.map((row) =>
3783 summariseReactions("pr_comment", row.comment.id, user?.id)
3784 ),
3785 ]);
3786
0a67773Claude3787 // Formal reviews (Approve / Request Changes)
3788 const reviewRows = await db
3789 .select({
3790 id: prReviews.id,
3791 state: prReviews.state,
3792 body: prReviews.body,
3793 isAi: prReviews.isAi,
3794 createdAt: prReviews.createdAt,
3795 reviewerUsername: users.username,
3796 reviewerId: prReviews.reviewerId,
3797 })
3798 .from(prReviews)
3799 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3800 .where(eq(prReviews.pullRequestId, pr.id))
3801 .orderBy(asc(prReviews.createdAt));
3802 // Most recent review per reviewer determines the current state
3803 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
3804 for (const r of reviewRows) {
3805 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
3806 }
3807 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
3808 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
3809 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
3810
ec9e3e3Claude3811 // Requested reviewers from CODEOWNERS auto-assign (migration 0077).
3812 const requestedReviewerRows = await db
3813 .select({
3814 reviewerUsername: users.username,
3815 reviewerId: prReviewRequests.reviewerId,
3816 createdAt: prReviewRequests.createdAt,
3817 })
3818 .from(prReviewRequests)
3819 .innerJoin(users, eq(prReviewRequests.reviewerId, users.id))
3820 .where(eq(prReviewRequests.prId, pr.id))
3821 .orderBy(asc(prReviewRequests.createdAt))
3822 .catch(() => [] as { reviewerUsername: string; reviewerId: string; createdAt: Date }[]);
3823
ace34efClaude3824 // Suggested reviewers — best-effort, never throws
3825 let reviewerSuggestions: ReviewerCandidate[] = [];
3826 try {
3827 if (user) {
3828 reviewerSuggestions = await suggestReviewers(
3829 ownerName, repoName, pr.headBranch, pr.baseBranch,
3830 pr.authorId, resolved.repo.id
3831 );
3832 }
3833 } catch {
3834 // silent degradation
3835 }
3836
0074234Claude3837 const canManage =
3838 user &&
3839 (user.id === resolved.owner.id || user.id === pr.authorId);
3840
1d4ff60Claude3841 // Has any previous AI-test-generator run already tagged this PR? Used
3842 // both to hide the "Generate tests with AI" button and to short-circuit
3843 // the explicit POST handler.
3844 const hasAiTestsMarker = comments.some(({ comment }) =>
3845 (comment.body || "").includes(AI_TESTS_MARKER)
3846 );
3847
e883329Claude3848 const error = c.req.query("error");
c3e0c07Claude3849 const info = c.req.query("info");
e883329Claude3850
3851 // Get gate check status for open PRs
3852 let gateChecks: GateCheckResult[] = [];
240c477Claude3853 let ciStatuses: CommitStatus[] = [];
e883329Claude3854 if (pr.state === "open") {
3855 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3856 if (headSha) {
3857 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
3858 const aiApproved = aiComments.length === 0 || aiComments.some(
3859 ({ comment }) => comment.body.includes("**Approved**")
3860 );
240c477Claude3861 const [gateResult, fetchedCiStatuses] = await Promise.all([
3862 runAllGateChecks(
3863 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
3864 ),
3865 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
3866 ]);
e883329Claude3867 gateChecks = gateResult.checks;
240c477Claude3868 ciStatuses = fetchedCiStatuses;
e883329Claude3869 }
3870 }
3871
534f04aClaude3872 // Block M3 — pre-merge risk score. Cache-only on the request path so
3873 // the page never waits on Haiku. On a cache miss for an open PR we
3874 // kick off the computation fire-and-forget; the next refresh shows it.
3875 let prRisk: PrRiskScore | null = null;
3876 let prRiskCalculating = false;
3877 if (pr.state === "open") {
3878 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
3879 if (!prRisk) {
3880 prRiskCalculating = true;
a28cedeClaude3881 void computePrRiskForPullRequest(pr.id).catch((err) => {
3882 console.warn(
3883 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
3884 err instanceof Error ? err.message : err
3885 );
3886 });
534f04aClaude3887 }
3888 }
3889
4bbacbeClaude3890 // Migration 0062 — per-branch preview URL. The head branch always
3891 // has a preview row (unless it's the default branch, which never
3892 // happens for an open PR) once it has been pushed at least once.
3893 const preview = await getPreviewForBranch(
3894 (resolved.repo as { id: string }).id,
3895 pr.headBranch
3896 );
3897
0369e77Claude3898 // Branch ahead/behind counts — how many commits head is ahead of base and
3899 // how many commits base has advanced since head branched off.
3900 let branchAhead = 0;
3901 let branchBehind = 0;
3902 if (pr.state === "open") {
3903 try {
3904 const repoDir = getRepoPath(ownerName, repoName);
3905 const [aheadProc, behindProc] = [
3906 Bun.spawn(
3907 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
3908 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3909 ),
3910 Bun.spawn(
3911 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
3912 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3913 ),
3914 ];
3915 const [aheadTxt, behindTxt] = await Promise.all([
3916 new Response(aheadProc.stdout).text(),
3917 new Response(behindProc.stdout).text(),
3918 ]);
3919 await Promise.all([aheadProc.exited, behindProc.exited]);
3920 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
3921 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
3922 } catch { /* non-blocking */ }
3923 }
3924
6d1bbc2Claude3925 // Linked issues — parse closing keywords from PR title+body, look up issues
3926 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
3927 try {
3928 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3929 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3930 if (refs.length > 0) {
3931 linkedIssues = await db
3932 .select({ number: issues.number, title: issues.title, state: issues.state })
3933 .from(issues)
3934 .where(and(
3935 eq(issues.repositoryId, resolved.repo.id),
3936 inArray(issues.number, refs),
3937 ));
3938 }
3939 } catch { /* non-blocking */ }
3940
3941 // Task list progress — count markdown checkboxes in PR body
3942 let taskTotal = 0;
3943 let taskChecked = 0;
3944 if (pr.body) {
3945 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
3946 taskTotal++;
3947 if (m[1].trim() !== "") taskChecked++;
3948 }
3949 }
3950
74d8c4dClaude3951 // M15 — PR size badge (best-effort, non-blocking)
3952 let prSizeInfo: PrSizeInfo | null = null;
3953 try {
3954 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
3955 } catch { /* swallow — purely cosmetic */ }
3956
3033f70Claude3957 // Merge impact analysis — only for open PRs with write access (cached, fast)
3958 let impactAnalysis: ImpactAnalysis | null = null;
3959 if (pr.state === "open" && canManage) {
3960 try {
3961 impactAnalysis = await analyzeImpact(resolved.repo.id, pr.id);
3962 } catch { /* non-blocking */ }
3963 }
3964
47a7a0aClaude3965 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude3966 let diffRaw = "";
3967 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude3968 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude3969 if (tab === "files") {
3970 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude3971 // Run the two git diffs in parallel — they're independent reads of
3972 // the same range. Previously sequential, doubling the wall time on
3973 // big PRs (100+ files = 10-30s for no reason).
0074234Claude3974 const proc = Bun.spawn(
3975 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
3976 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3977 );
3978 const statProc = Bun.spawn(
3979 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
3980 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3981 );
6ea2109Claude3982 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
3983 // would otherwise hang the whole request.
3984 const killer = setTimeout(() => {
3985 proc.kill();
3986 statProc.kill();
3987 }, 30_000);
3988 let stat = "";
3989 try {
3990 [diffRaw, stat] = await Promise.all([
3991 new Response(proc.stdout).text(),
3992 new Response(statProc.stdout).text(),
3993 ]);
3994 await Promise.all([proc.exited, statProc.exited]);
3995 } finally {
3996 clearTimeout(killer);
3997 }
0074234Claude3998
3999 diffFiles = stat
4000 .trim()
4001 .split("\n")
4002 .filter(Boolean)
4003 .map((line) => {
4004 const [add, del, filePath] = line.split("\t");
4005 return {
4006 path: filePath,
4007 status: "modified",
4008 additions: add === "-" ? 0 : parseInt(add, 10),
4009 deletions: del === "-" ? 0 : parseInt(del, 10),
4010 patch: "",
4011 };
4012 });
47a7a0aClaude4013
4014 // Fetch inline comments (file+line anchored) for the files tab
4015 const inlineRows = await db
4016 .select({
4017 id: prComments.id,
4018 filePath: prComments.filePath,
4019 lineNumber: prComments.lineNumber,
4020 body: prComments.body,
4021 isAiReview: prComments.isAiReview,
4022 createdAt: prComments.createdAt,
4023 authorUsername: users.username,
4024 })
4025 .from(prComments)
4026 .innerJoin(users, eq(prComments.authorId, users.id))
4027 .where(
4028 and(
4029 eq(prComments.pullRequestId, pr.id),
4030 eq(prComments.moderationStatus, "approved"),
4031 )
4032 )
4033 .orderBy(asc(prComments.createdAt));
4034
4035 diffInlineComments = inlineRows
4036 .filter(r => r.filePath != null && r.lineNumber != null)
4037 .map(r => ({
4038 id: r.id,
4039 filePath: r.filePath!,
4040 lineNumber: r.lineNumber!,
4041 authorUsername: r.authorUsername,
4042 body: renderMarkdown(r.body),
4043 isAiReview: r.isAiReview,
4044 createdAt: r.createdAt.toISOString(),
4045 }));
0074234Claude4046 }
4047
34e63b9Claude4048 // Proactive pattern warning — get changed file paths and check for recurring
4049 // bug patterns. Fire-and-forget safe; returns null on any error or cache miss.
4050 let patternWarning: Pattern | null = null;
4051 try {
4052 const repoDir = getRepoPath(ownerName, repoName);
4053 const nameOnlyProc = Bun.spawn(
4054 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4055 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4056 );
4057 const nameOnlyRaw = await new Response(nameOnlyProc.stdout).text();
4058 await nameOnlyProc.exited;
4059 const prChangedFiles = nameOnlyRaw.trim().split("\n").filter(Boolean);
4060 if (prChangedFiles.length > 0) {
4061 patternWarning = await getPatternWarning(resolved.repo.id, prChangedFiles);
4062 }
4063 } catch {
4064 // Non-blocking — swallow
4065 }
4066
b078860Claude4067 // ─── Derived visual state ───
4068 const stateKey =
4069 pr.state === "open"
4070 ? pr.isDraft
4071 ? "draft"
4072 : "open"
4073 : pr.state;
4074 const stateLabel =
4075 stateKey === "open"
4076 ? "Open"
4077 : stateKey === "draft"
4078 ? "Draft"
4079 : stateKey === "merged"
4080 ? "Merged"
4081 : "Closed";
4082 const stateIcon =
4083 stateKey === "open"
4084 ? "○"
4085 : stateKey === "draft"
4086 ? "◌"
4087 : stateKey === "merged"
4088 ? "⮌"
4089 : "✓";
4090 const commentCount = comments.length;
4091 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
4092 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
4093 const mergeBlocked =
4094 gateChecks.length > 0 &&
4095 gateChecks.some(
4096 (c) => !c.passed && c.name !== "Merge check"
4097 );
4098
b558f23Claude4099 // Commits tab — list commits included in this PR (base..head range)
4100 let prCommits: GitCommit[] = [];
4101 if (tab === "commits") {
4102 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
4103 }
4104
cc34156Claude4105 // Review context restore — compute BEFORE recording the visit so the
4106 // previous timestamp is available for the delta calculation.
4107 let reviewCtx: ReviewContext | null = null;
4108 if (user) {
4109 reviewCtx = await getReviewContext(pr.id, user.id, {
4110 ownerName,
4111 repoName,
4112 baseBranch: pr.baseBranch,
4113 headBranch: pr.headBranch,
4114 });
4115 // Fire-and-forget: record the visit AFTER computing context
4116 void recordPrVisit(pr.id, user.id);
4117 }
4118
0074234Claude4119 return c.html(
4120 <Layout
4121 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
4122 user={user}
4123 >
4124 <RepoHeader owner={ownerName} repo={repoName} />
4125 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude4126 <PendingCommentsBanner
4127 owner={ownerName}
4128 repo={repoName}
4129 count={prPendingCount}
4130 />
b078860Claude4131 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude4132 <div
4133 id="live-comment-banner"
4134 class="alert"
4135 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
4136 >
4137 <strong class="js-live-count">0</strong> new comment(s) —{" "}
4138 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
4139 reload to view
4140 </a>
4141 </div>
4142 <script
4143 dangerouslySetInnerHTML={{
4144 __html: liveCommentBannerScript({
4145 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
4146 bannerElementId: "live-comment-banner",
4147 }),
4148 }}
4149 />
b078860Claude4150
cc34156Claude4151 {/* Review context restore banner — shown when returning after changes */}
4152 {reviewCtx && (
4153 <div
4154 class="context-restore-banner"
4155 id="review-context"
4156 style="display:flex;align-items:flex-start;gap:12px;padding:12px 16px;margin:0 0 12px;background:var(--bg-elevated,#f8f9fa);border:1px solid var(--border,#e1e4e8);border-left:3px solid var(--accent,#0070f3);border-radius:10px"
4157 >
4158 <span class="context-icon" style="font-size:18px;flex-shrink:0;margin-top:2px" aria-hidden="true">{"↩"}</span>
4159 <div style="flex:1;min-width:0">
4160 <strong style="font-size:13.5px;color:var(--text-strong,#111)">Welcome back</strong>
4161 <p style="margin:4px 0 0;font-size:13px;color:var(--text,#333);line-height:1.5">{reviewCtx.summary}</p>
4162 <small style="font-size:11.5px;color:var(--text-muted,#777)">
4163 Last visited {formatRelative(new Date(reviewCtx.lastVisitedAt))}
4164 {reviewCtx.commitsSince > 0 && ` · ${reviewCtx.commitsSince} new commit${reviewCtx.commitsSince === 1 ? "" : "s"}`}
4165 {reviewCtx.newComments > 0 && ` · ${reviewCtx.newComments} new comment${reviewCtx.newComments === 1 ? "" : "s"}`}
4166 </small>
4167 {reviewCtx.suggestedStartLine && (
4168 <p style="margin:6px 0 0;font-size:12px;color:var(--accent,#0070f3)">
4169 Start at: <code style="font-size:11px">{reviewCtx.suggestedStartLine}</code>
4170 </p>
4171 )}
4172 </div>
4173 <button
4174 type="button"
4175 onclick="this.closest('.context-restore-banner').remove()"
4176 style="flex-shrink:0;background:none;border:none;cursor:pointer;font-size:18px;color:var(--text-muted,#777);padding:0;line-height:1"
4177 aria-label="Dismiss"
4178 >
4179 {"×"}
4180 </button>
4181 </div>
4182 )}
4183
b078860Claude4184 <div class="prs-detail-hero">
b558f23Claude4185 <div class="prs-edit-title-wrap">
4186 <h1 class="prs-detail-title" id="pr-title-display">
4187 {pr.title}{" "}
4188 <span class="prs-detail-num">#{pr.number}</span>
4189 </h1>
4190 {canManage && pr.state === "open" && (
4191 <button
4192 type="button"
4193 class="prs-edit-btn"
4194 id="pr-edit-toggle"
4195 onclick={`
4196 document.getElementById('pr-title-display').style.display='none';
4197 document.getElementById('pr-edit-toggle').style.display='none';
4198 document.getElementById('pr-edit-form').style.display='flex';
4199 document.getElementById('pr-title-input').focus();
4200 `}
4201 >
4202 Edit
4203 </button>
4204 )}
4205 </div>
4206 {canManage && pr.state === "open" && (
4207 <form
4208 id="pr-edit-form"
4209 method="post"
4210 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
4211 class="prs-edit-form"
4212 style="display:none"
4213 >
4214 <input
4215 id="pr-title-input"
4216 type="text"
4217 name="title"
4218 value={pr.title}
4219 required
4220 maxlength={256}
4221 placeholder="Pull request title"
4222 />
4223 <div class="prs-edit-actions">
4224 <button type="submit" class="prs-edit-save-btn">Save</button>
4225 <button
4226 type="button"
4227 class="prs-edit-cancel-btn"
4228 onclick={`
4229 document.getElementById('pr-edit-form').style.display='none';
4230 document.getElementById('pr-title-display').style.display='';
4231 document.getElementById('pr-edit-toggle').style.display='';
4232 `}
4233 >
4234 Cancel
4235 </button>
4236 </div>
4237 </form>
4238 )}
b078860Claude4239 <div class="prs-detail-meta">
4240 <span class={`prs-state-pill state-${stateKey}`}>
4241 <span aria-hidden="true">{stateIcon}</span>
4242 <span>{stateLabel}</span>
4243 </span>
74d8c4dClaude4244 {prSizeInfo && (
4245 <span
4246 class="prs-size-badge"
4247 style={`color:${prSizeInfo.color};background:${prSizeInfo.bgColor}`}
4248 title={`${prSizeInfo.linesChanged} lines changed (+${prSizeInfo.added} −${prSizeInfo.deleted})`}
4249 >
4250 {prSizeInfo.label}
4251 </span>
4252 )}
67dc4e1Claude4253 <TrioVerdictPills
4254 comments={comments.map(({ comment }) => comment)}
4255 />
b078860Claude4256 <span>
4257 <strong>{author?.username}</strong> wants to merge
4258 </span>
4259 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
4260 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
4261 <span class="prs-branch-arrow-lg">{"→"}</span>
4262 <span class="prs-branch-pill">{pr.baseBranch}</span>
4263 </span>
0369e77Claude4264 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
4265 <span
4266 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
4267 title={branchBehind > 0
4268 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
4269 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
4270 >
4271 {branchAhead > 0 ? `↑${branchAhead}` : ""}
4272 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
4273 {branchBehind > 0 ? `↓${branchBehind}` : ""}
4274 </span>
4275 )}
b078860Claude4276 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude4277 {taskTotal > 0 && (
4278 <span
4279 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
4280 title={`${taskChecked} of ${taskTotal} tasks completed`}
4281 >
4282 <span class="prs-tasks-progress" aria-hidden="true">
4283 <span
4284 class="prs-tasks-progress-bar"
4285 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
4286 ></span>
4287 </span>
4288 {taskChecked}/{taskTotal} tasks
4289 </span>
4290 )}
4291 {canManage && pr.state === "open" && branchBehind > 0 && (
4292 <form
4293 method="post"
4294 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
4295 class="prs-inline-form"
4296 >
4297 <button
4298 type="submit"
4299 class="prs-update-branch-btn"
4300 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
4301 >
4302 ↑ Update branch
4303 </button>
4304 </form>
4305 )}
3c03977Claude4306 <span
4307 id="live-pill"
4308 class="live-pill"
4309 title="People editing this PR right now"
4310 >
4311 <span class="live-pill-dot" aria-hidden="true"></span>
4312 <span>
4313 Live: <strong id="live-count">0</strong> editing
4314 </span>
4315 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
4316 </span>
4bbacbeClaude4317 {preview && (
4318 <a
4319 class={`preview-prpill is-${preview.status}`}
4320 href={
4321 preview.status === "ready"
4322 ? preview.previewUrl
4323 : `/${ownerName}/${repoName}/previews`
4324 }
4325 target={preview.status === "ready" ? "_blank" : undefined}
4326 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
4327 title={`Preview · ${previewStatusLabel(preview.status)}`}
4328 >
4329 <span class="preview-prpill-dot" aria-hidden="true"></span>
4330 <span>Preview: </span>
4331 <span>{previewStatusLabel(preview.status)}</span>
4332 </a>
4333 )}
b078860Claude4334 {canManage && pr.state === "open" && pr.isDraft && (
4335 <form
4336 method="post"
4337 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4338 class="prs-inline-form prs-detail-actions"
4339 >
4340 <button type="submit" class="prs-merge-ready-btn">
4341 Ready for review
4342 </button>
4343 </form>
4344 )}
4345 </div>
4346 </div>
3c03977Claude4347 <script
4348 dangerouslySetInnerHTML={{
4349 __html: LIVE_COEDIT_SCRIPT(pr.id),
4350 }}
4351 />
829a046Claude4352 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude4353 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude4354 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude4355
25b1ff7Claude4356 {/* Presence styles + bar (shown only on the files tab so cursor pills work) */}
4357 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES }} />
4358 {/* Toast container — always present for join/leave toasts */}
4359 <div id="presence-toasts" class="presence-toast-wrap" aria-live="polite" />
4360 {user && (
4361 <>
4362 <div class="presence-bar" id="presence-bar">
4363 <span class="presence-bar-label">Live reviewers</span>
4364 <div class="presence-avatars" id="presence-avatars" />
4365 <span class="presence-count" id="presence-count">Loading…</span>
4366 </div>
4367 <script
4368 dangerouslySetInnerHTML={{
4369 __html: PR_PRESENCE_SCRIPT(ownerName, repoName, pr.number),
4370 }}
4371 />
4372 </>
4373 )}
4374
b078860Claude4375 <nav class="prs-detail-tabs" aria-label="Pull request sections">
4376 <a
4377 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
4378 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
4379 >
4380 Conversation
4381 <span class="prs-detail-tab-count">{commentCount}</span>
4382 </a>
b558f23Claude4383 <a
4384 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
4385 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
4386 >
4387 Commits
4388 {branchAhead > 0 && (
4389 <span class="prs-detail-tab-count">{branchAhead}</span>
4390 )}
4391 </a>
b078860Claude4392 <a
4393 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
4394 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4395 >
4396 Files changed
4397 {diffFiles.length > 0 && (
4398 <span class="prs-detail-tab-count">{diffFiles.length}</span>
4399 )}
4400 </a>
4401 </nav>
4402
34e63b9Claude4403 {/* Proactive pattern warning — shown when a known recurring bug pattern
4404 overlaps with the files changed in this PR. */}
4405 {patternWarning && (
4406 <div class="pattern-warning" style="margin:0 0 16px;padding:12px 16px;border-radius:8px;background:var(--bg-elevated);border:1px solid #f59e0b;border-left:4px solid #f59e0b;font-size:13px;line-height:1.5">
4407 <span style="font-size:15px;margin-right:6px" aria-hidden="true">⚠️</span>
4408 <strong>Recurring pattern detected: {patternWarning.title}</strong>
4409 <span style="color:var(--fg-muted)">
4410 {" — "}
4411 This area has had {patternWarning.occurrences} similar fix
4412 {patternWarning.occurrences === 1 ? "" : "es"}.
4413 {patternWarning.rootCauseHypothesis && (
4414 <> Root cause may be in <code style="font-size:12px">{patternWarning.suggestedFile}</code>.</>
4415 )}
4416 </span>
4417 </div>
4418 )}
4419
b558f23Claude4420 {tab === "commits" ? (
4421 <div class="prs-commits-list">
4422 {prCommits.length === 0 ? (
4423 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
4424 ) : (
4425 prCommits.map((commit) => (
4426 <div class="prs-commit-row">
4427 <span class="prs-commit-dot" aria-hidden="true"></span>
4428 <div class="prs-commit-body">
4429 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
4430 <div class="prs-commit-meta">
4431 <strong>{commit.author}</strong> committed{" "}
4432 {formatRelative(new Date(commit.date))}
4433 </div>
4434 </div>
4435 <a
4436 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
4437 class="prs-commit-sha"
4438 title="View commit"
4439 >
4440 {commit.sha.slice(0, 7)}
4441 </a>
4442 </div>
4443 ))
4444 )}
4445 </div>
4446 ) : tab === "files" ? (
1d6db4dClaude4447 <>
4448 {/* PR Split Suggestion — shown when PR has >400 changed lines */}
4449 {splitSuggestion && (
4450 <div class="split-suggestion" id="pr-split-banner">
4451 <div class="split-header">
4452 <span class="split-icon" aria-hidden="true">✂️</span>
4453 <strong>This PR may be too large to review effectively</strong>
4454 <span class="split-stat">
4455 {splitSuggestion.totalLines} lines · {splitSuggestion.totalFiles} files
4456 </span>
4457 <button
4458 class="split-toggle"
4459 type="button"
4460 onclick="const b=document.getElementById('pr-split-body');const hidden=b.hasAttribute('hidden');b.toggleAttribute('hidden');this.textContent=hidden?'Hide split suggestion':'Show split suggestion';"
4461 >
4462 Show split suggestion
4463 </button>
4464 </div>
4465 <div class="split-body" id="pr-split-body" hidden>
4466 <p class="split-intro">
4467 AI suggests splitting into {splitSuggestion.suggestedPrs.length} PRs:
4468 </p>
4469 {splitSuggestion.suggestedPrs.map((sp, i) => (
4470 <div class="split-pr">
4471 <div class="split-pr-num">{i + 1}</div>
4472 <div class="split-pr-body">
4473 <strong>{sp.title}</strong>
4474 <p>{sp.rationale}</p>
4475 <code>{sp.files.join(", ")}</code>
4476 <span class="split-lines">~{sp.estimatedLines} lines</span>
4477 </div>
4478 </div>
4479 ))}
4480 {splitSuggestion.mergeOrder.length > 0 && (
4481 <p class="split-order">
4482 Suggested merge order:{" "}
4483 <strong>{splitSuggestion.mergeOrder.join(" → ")}</strong>
4484 </p>
4485 )}
4486 </div>
4487 </div>
4488 )}
4489
4490 {/* Bus Factor Warning — shown when changed files overlap at-risk files */}
4491 {busRiskFiles.length > 0 && (() => {
4492 const topRisk = busRiskFiles.some((f) => f.risk === "critical")
4493 ? "critical"
4494 : busRiskFiles.some((f) => f.risk === "high")
4495 ? "high"
4496 : "medium";
4497 return (
4498 <div class={`busfactor-panel busfactor-${topRisk}`}>
4499 <span class="busfactor-icon" aria-hidden="true">⚠️</span>
4500 <div class="busfactor-body">
4501 <strong>Knowledge concentration warning</strong>
4502 <p>
4503 {busRiskFiles.length} file{busRiskFiles.length !== 1 ? "s" : ""} in
4504 this PR {busRiskFiles.length !== 1 ? "are" : "is"} primarily
4505 maintained by one person. Consider pairing on this review.
4506 </p>
4507 <ul>
4508 {busRiskFiles.map((f) => (
4509 <li>
4510 <code>{f.path}</code> —{" "}
4511 <strong>{f.primaryAuthorPct}%</strong> by {f.primaryAuthor}
4512 </li>
4513 ))}
4514 </ul>
4515 </div>
4516 </div>
4517 );
4518 })()}
4519
4520 <DiffView
4521 raw={diffRaw}
4522 files={diffFiles}
4523 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
4524 inlineComments={diffInlineComments}
4525 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
4526 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
4527 />
4528 </>
b078860Claude4529 ) : (
4530 <>
4531 {pr.body && (
4532 <CommentBox
4533 author={author?.username ?? "unknown"}
4534 date={pr.createdAt}
4535 body={renderMarkdown(pr.body)}
4536 />
4537 )}
4538
422a2d4Claude4539 {/* Block H — AI trio review (security/correctness/style). When
4540 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
4541 hoisted into a 3-column card grid above the normal comment
4542 stream so reviewers see verdicts at a glance. Disagreements
4543 are surfaced as a yellow callout. */}
4544 <TrioReviewGrid
4545 comments={comments.map(({ comment }) => comment)}
4546 />
4547
15db0e0Claude4548 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude4549 // Skip trio comments — already rendered in TrioReviewGrid above.
4550 if (isTrioComment(comment.body)) return null;
15db0e0Claude4551 const slashCmd = detectSlashCmdComment(comment.body);
4552 if (slashCmd) {
4553 const visible = stripSlashCmdMarker(comment.body);
4554 return (
4555 <div class={`slash-pill slash-cmd-${slashCmd}`}>
4556 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
4557 <span class="slash-pill-actor">
4558 <strong>{commentAuthor.username}</strong>
4559 {" ran "}
4560 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude4561 </span>
15db0e0Claude4562 <span class="slash-pill-time">
4563 {formatRelative(comment.createdAt)}
4564 </span>
4565 <div class="slash-pill-body">
4566 <MarkdownContent html={renderMarkdown(visible)} />
4567 </div>
4568 </div>
4569 );
4570 }
cb5a796Claude4571 const isPending = comment.moderationStatus === "pending";
15db0e0Claude4572 return (
cb5a796Claude4573 <div
4574 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
4575 >
15db0e0Claude4576 <div class="prs-comment-head">
4577 <strong>{commentAuthor.username}</strong>
a7460bfClaude4578 {commentAuthor.username === BOT_USERNAME && (
4579 <span class="prs-bot-badge">&#x1F916; bot</span>
4580 )}
15db0e0Claude4581 {comment.isAiReview && (
4582 <span class="prs-ai-badge">AI Review</span>
4583 )}
cb5a796Claude4584 {isPending && (
4585 <span
4586 class="modq-pending-badge"
4587 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
4588 >
4589 Awaiting approval
4590 </span>
4591 )}
15db0e0Claude4592 <span class="prs-comment-time">
4593 commented {formatRelative(comment.createdAt)}
4594 </span>
4595 {comment.filePath && (
4596 <span class="prs-comment-loc">
4597 {comment.filePath}
4598 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
4599 </span>
4600 )}
4601 </div>
4602 <div class="prs-comment-body">
4603 <MarkdownContent html={renderMarkdown(comment.body)} />
4604 </div>
0074234Claude4605 </div>
15db0e0Claude4606 );
4607 })}
0074234Claude4608
b078860Claude4609 {/* Quick link to the Files changed tab when there's a diff to look at. */}
4610 {pr.state !== "merged" && (
4611 <a
4612 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4613 class="prs-files-card"
4614 >
4615 <span class="prs-files-card-icon" aria-hidden="true">
4616 {"▤"}
4617 </span>
4618 <div class="prs-files-card-text">
4619 <p class="prs-files-card-title">Files changed</p>
4620 <p class="prs-files-card-sub">
4621 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
4622 </p>
e883329Claude4623 </div>
b078860Claude4624 <span class="prs-files-card-cta">View diff {"→"}</span>
4625 </a>
4626 )}
4627
6d1bbc2Claude4628 {linkedIssues.length > 0 && (
4629 <div class="prs-linked-issues">
4630 <div class="prs-linked-issues-head">
4631 <span>Closing issues</span>
4632 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
4633 </div>
4634 {linkedIssues.map((issue) => (
4635 <a
4636 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
4637 class="prs-linked-issue-row"
4638 >
4639 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
4640 {issue.state === "open" ? "○" : "✓"}
4641 </span>
4642 <span class="prs-linked-issue-title">{issue.title}</span>
4643 <span class="prs-linked-issue-num">#{issue.number}</span>
4644 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
4645 {issue.state}
4646 </span>
4647 </a>
4648 ))}
4649 </div>
4650 )}
4651
b078860Claude4652 {error && (
4653 <div
4654 class="auth-error"
4655 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)"
4656 >
4657 {decodeURIComponent(error)}
4658 </div>
4659 )}
4660
4661 {info && (
4662 <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)">
4663 {decodeURIComponent(info)}
4664 </div>
4665 )}
e883329Claude4666
b078860Claude4667 {pr.state === "open" && (prRisk || prRiskCalculating) && (
4668 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
4669 )}
4670
3033f70Claude4671 {/* ─── Merge Impact Analysis panel ─────────────────────── */}
4672 {impactAnalysis && pr.state === "open" && (
4673 <ImpactPanel analysis={impactAnalysis} owner={ownerName} />
4674 )}
4675
0a67773Claude4676 {/* ─── Review summary ─────────────────────────────────── */}
4677 {(approvals.length > 0 || changesRequested.length > 0) && (
4678 <div class="prs-review-summary">
4679 {approvals.length > 0 && (
4680 <div class="prs-review-row prs-review-approved">
4681 <span class="prs-review-icon">✓</span>
4682 <span>
4683 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4684 approved this pull request
4685 </span>
4686 </div>
4687 )}
4688 {changesRequested.length > 0 && (
4689 <div class="prs-review-row prs-review-changes">
4690 <span class="prs-review-icon">✗</span>
4691 <span>
4692 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4693 requested changes
4694 </span>
4695 </div>
4696 )}
4697 </div>
4698 )}
4699
ace34efClaude4700 {/* Suggested reviewers */}
4701 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
4702 <div class="prs-review-summary" style="margin-top:12px">
4703 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
4704 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
4705 Suggested reviewers
4706 </span>
4707 {reviewerSuggestions.map((r) => (
4708 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
4709 style="display:flex;align-items:center;gap:8px;width:100%">
4710 <input type="hidden" name="reviewerId" value={r.userId} />
4711 <span class="prs-reviewer-avatar">
4712 {r.username.slice(0, 1).toUpperCase()}
4713 </span>
4714 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
4715 {r.username}
4716 </a>
4717 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
4718 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
4719 Request
4720 </button>
4721 </form>
4722 ))}
4723 </div>
4724 </div>
4725 )}
4726
b078860Claude4727 {pr.state === "open" && gateChecks.length > 0 && (
4728 <div class="prs-gate-card">
4729 <div class="prs-gate-head">
4730 <h3>Gate checks</h3>
4731 <span class="prs-gate-summary">
4732 {gatesAllPassed
4733 ? `All ${gateChecks.length} checks passed`
4734 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
4735 </span>
c3e0c07Claude4736 </div>
b078860Claude4737 {gateChecks.map((check) => {
4738 const isAi = /ai.*review/i.test(check.name);
4739 const isSkip = check.skipped === true;
4740 const statusClass = isSkip
4741 ? "is-skip"
4742 : check.passed
4743 ? "is-pass"
4744 : "is-fail";
4745 const statusGlyph = isSkip
4746 ? "—"
4747 : check.passed
4748 ? "✓"
4749 : "✗";
4750 const statusLabel = isSkip
4751 ? "Skipped"
4752 : check.passed
4753 ? "Passed"
4754 : "Failing";
4755 return (
4756 <div
4757 class="prs-gate-row"
4758 style={
4759 isAi
4760 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
4761 : ""
4762 }
4763 >
4764 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
4765 {statusGlyph}
4766 </span>
4767 <span class="prs-gate-name">
4768 {check.name}
4769 {isAi && (
4770 <span
4771 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"
4772 >
4773 AI
4774 </span>
4775 )}
4776 </span>
4777 <span class="prs-gate-details">{check.details}</span>
4778 <span class={`prs-gate-pill ${statusClass}`}>
4779 {statusLabel}
e883329Claude4780 </span>
4781 </div>
b078860Claude4782 );
4783 })}
4784 <div class="prs-gate-footer">
4785 {gatesAllPassed
4786 ? "All checks passed — ready to merge."
4787 : gateChecks.some(
4788 (c) => !c.passed && c.name === "Merge check"
4789 )
4790 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
4791 : "Some checks failed — resolve issues before merging."}
4792 {aiReviewCount > 0 && (
4793 <>
4794 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
4795 </>
4796 )}
4797 </div>
4798 </div>
4799 )}
4800
240c477Claude4801 {pr.state === "open" && ciStatuses.length > 0 && (
4802 <div class="prs-ci-card">
4803 <div class="prs-ci-head">
4804 <h3>CI checks</h3>
4805 <span class="prs-ci-summary">
4806 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
4807 </span>
4808 </div>
4809 {ciStatuses.map((status) => {
4810 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
4811 return (
4812 <div class="prs-ci-row">
4813 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
4814 <span class="prs-ci-context">{status.context}</span>
4815 {status.description && (
4816 <span class="prs-ci-desc">{status.description}</span>
4817 )}
4818 <span class={`prs-ci-pill is-${status.state}`}>
4819 {status.state}
4820 </span>
4821 {status.targetUrl && (
4822 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
4823 )}
4824 </div>
4825 );
4826 })}
4827 </div>
4828 )}
4829
b078860Claude4830 {/* ─── Merge area / state-aware action card ─────────────── */}
4831 {user && pr.state === "open" && (
4832 <div
4833 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
4834 >
4835 <div class="prs-merge-head">
4836 <strong>
4837 {pr.isDraft
4838 ? "Draft — ready for review?"
4839 : mergeBlocked
4840 ? "Merge blocked"
4841 : "Ready to merge"}
4842 </strong>
e883329Claude4843 </div>
b078860Claude4844 <p class="prs-merge-sub">
4845 {pr.isDraft
4846 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
4847 : mergeBlocked
4848 ? "Resolve the failing gate checks above before this PR can land."
4849 : gateChecks.length > 0
4850 ? gatesAllPassed
4851 ? "All gates green. Merge will fast-forward into the base branch."
4852 : "Conflicts will be auto-resolved by GlueCron AI on merge."
4853 : "Run gate checks by refreshing once your branch has a recent commit."}
4854 </p>
4855 <Form
4856 method="post"
4857 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
4858 >
4859 <FormGroup>
3c03977Claude4860 <div class="live-cursor-host" style="position:relative">
4861 <textarea
4862 name="body"
4863 id="pr-comment-body"
4864 data-live-field="comment_new"
6cd2f0eClaude4865 data-md-preview=""
3c03977Claude4866 rows={5}
4867 required
4868 placeholder="Leave a comment... (Markdown supported)"
4869 style="font-family:var(--font-mono);font-size:13px;width:100%"
4870 ></textarea>
4871 </div>
15db0e0Claude4872 <span class="slash-hint" title="Type a slash-command as the first line">
4873 Type <code>/</code> for commands —{" "}
4874 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
3033f70Claude4875 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>,{" "}
4876 <code>/stage</code>
15db0e0Claude4877 </span>
b078860Claude4878 </FormGroup>
4879 <div class="prs-merge-actions">
4880 <Button type="submit" variant="primary">
4881 Comment
4882 </Button>
0a67773Claude4883 {user && user.id !== pr.authorId && pr.state === "open" && (
4884 <>
4885 <button
4886 type="submit"
4887 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4888 name="review_state"
4889 value="approved"
4890 class="prs-review-approve-btn"
4891 title="Approve this pull request"
4892 >
4893 ✓ Approve
4894 </button>
4895 <button
4896 type="submit"
4897 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4898 name="review_state"
4899 value="changes_requested"
4900 class="prs-review-changes-btn"
4901 title="Request changes before merging"
4902 >
4903 ✗ Request changes
4904 </button>
4905 </>
4906 )}
b078860Claude4907 {canManage && (
4908 <>
4909 {pr.isDraft ? (
4910 <button
4911 type="submit"
4912 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4913 formnovalidate
4914 class="prs-merge-ready-btn"
4915 >
4916 Ready for review
4917 </button>
4918 ) : (
a164a6dClaude4919 <>
4920 <div class="prs-merge-strategy-wrap">
4921 <span class="prs-merge-strategy-label">Strategy</span>
4922 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
4923 <option value="merge">Merge commit</option>
4924 <option value="squash">Squash and merge</option>
4925 <option value="ff">Fast-forward</option>
4926 </select>
4927 </div>
4928 <button
4929 type="submit"
4930 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
4931 formnovalidate
4932 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
4933 title={
4934 mergeBlocked
4935 ? "Failing gate checks must be resolved before this PR can merge."
4936 : "Merge pull request"
4937 }
4938 >
4939 {"✔"} Merge pull request
4940 </button>
4941 </>
b078860Claude4942 )}
4943 {!pr.isDraft && (
4944 <button
0074234Claude4945 type="submit"
b078860Claude4946 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
4947 formnovalidate
4948 class="prs-merge-back-draft"
4949 title="Convert back to draft"
0074234Claude4950 >
b078860Claude4951 Convert to draft
4952 </button>
4953 )}
4954 {isAiReviewEnabled() && (
4955 <button
4956 type="submit"
4957 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
4958 formnovalidate
4959 class="btn"
4960 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
4961 >
4962 Re-run AI review
4963 </button>
4964 )}
1d4ff60Claude4965 {isAiReviewEnabled() && !hasAiTestsMarker && (
4966 <button
4967 type="submit"
4968 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
4969 formnovalidate
4970 class="btn"
4971 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."
4972 >
4973 Generate tests with AI
4974 </button>
4975 )}
b078860Claude4976 <Button
4977 type="submit"
4978 variant="danger"
4979 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
4980 >
4981 Close
4982 </Button>
4983 </>
4984 )}
4985 </div>
4986 </Form>
4987 </div>
4988 )}
4989
4990 {/* Read-only footers for non-open states. */}
4991 {pr.state === "merged" && (
4992 <div class="prs-merge-card is-merged">
4993 <div class="prs-merge-head">
4994 <strong>{"⮌"} Merged</strong>
0074234Claude4995 </div>
b078860Claude4996 <p class="prs-merge-sub">
4997 This pull request was merged into{" "}
4998 <code>{pr.baseBranch}</code>.
4999 </p>
5000 </div>
5001 )}
5002 {pr.state === "closed" && (
5003 <div class="prs-merge-card is-closed">
5004 <div class="prs-merge-head">
5005 <strong>{"✕"} Closed without merging</strong>
5006 </div>
5007 <p class="prs-merge-sub">
5008 This pull request was closed and not merged.
5009 </p>
5010 </div>
5011 )}
5012 </>
5013 )}
641aa42Claude5014 {/* Keyboard hint bar — shown at the bottom of PR pages */}
5015 <div class="kbd-hints" aria-label="Keyboard shortcuts for this pull request">
5016 <kbd>c</kbd> comment &middot; <kbd>e</kbd> edit title &middot; <kbd>m</kbd> merge &middot; <kbd>a</kbd> approve &middot; <kbd>r</kbd> request changes &middot; <kbd>?</kbd> shortcuts
5017 </div>
5018 <style dangerouslySetInnerHTML={{ __html: `
5019 .kbd-hints {
5020 position: fixed;
5021 bottom: 0;
5022 left: 0;
5023 right: 0;
5024 z-index: 90;
5025 padding: 6px 24px;
5026 background: var(--bg-secondary);
5027 border-top: 1px solid var(--border);
5028 font-size: 12px;
5029 color: var(--text-muted);
5030 display: flex;
5031 align-items: center;
5032 gap: 8px;
5033 flex-wrap: wrap;
5034 }
5035 .kbd-hints kbd {
5036 font-family: var(--font-mono);
5037 font-size: 10px;
5038 background: var(--bg-elevated);
5039 border: 1px solid var(--border);
5040 border-bottom-width: 2px;
5041 border-radius: 4px;
5042 padding: 1px 5px;
5043 color: var(--text);
5044 line-height: 1.5;
5045 }
5046 /* Padding so the page footer doesn't overlap the hint bar */
5047 main { padding-bottom: 40px; }
5048 ` }} />
5049 {/* Repo context commands for command palette */}
5050 <script
5051 id="cmdk-repo-context"
5052 dangerouslySetInnerHTML={{
5053 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
5054 { label: `New issue in ${repoName}`, href: `/${ownerName}/${repoName}/issues/new`, kw: "create add bug" },
5055 { label: `New pull request in ${repoName}`, href: `/${ownerName}/${repoName}/pulls/new`, kw: "pr branch merge" },
5056 { label: `Browse code — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}`, kw: "files tree" },
5057 { label: `View commits — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/commits`, kw: "history log" },
5058 { label: `Issues — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/issues`, kw: "bugs tasks" },
5059 { label: `Pull requests — ${ownerName}/${repoName}`, href: `/${ownerName}/${repoName}/pulls`, kw: "prs reviews" },
5060 ])};`,
5061 }}
5062 />
5063 {/* PR keyboard shortcuts script */}
5064 <script dangerouslySetInnerHTML={{ __html: `
5065 (function(){
5066 var commentBox = document.querySelector('textarea[name="body"]');
5067 var mergeBtn = document.querySelector('[data-merge-btn], .prs-merge-btn, button[form*="merge"], form[action*="/merge"] button[type="submit"]');
5068 var editBtn = document.getElementById('pr-edit-toggle');
5069 var approveUrl = ${JSON.stringify(`/${ownerName}/${repoName}/pulls/${pr.number}/review`)};
5070
5071 function isTyping(t){
5072 t = t || {};
5073 var tag = (t.tagName || '').toLowerCase();
5074 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
5075 }
5076
5077 document.addEventListener('keydown', function(e){
5078 if (isTyping(e.target)) return;
5079 if (e.metaKey || e.ctrlKey || e.altKey) return;
5080 if (e.key === 'c') {
5081 e.preventDefault();
5082 if (commentBox) { commentBox.focus(); commentBox.scrollIntoView({block:'center'}); }
5083 }
5084 if (e.key === 'e') {
5085 e.preventDefault();
5086 if (editBtn) { editBtn.click(); }
5087 }
5088 if (e.key === 'm') {
5089 e.preventDefault();
5090 var mBtn = document.querySelector('.prs-merge-btn, form[action*="/merge"] button[type="submit"]');
5091 if (mBtn) { mBtn.focus(); mBtn.scrollIntoView({block:'center'}); }
5092 }
5093 if (e.key === 'a') {
5094 e.preventDefault();
5095 // Navigate to approve review page
5096 window.location.href = approveUrl + '?action=approve';
5097 }
5098 if (e.key === 'r') {
5099 e.preventDefault();
5100 window.location.href = approveUrl + '?action=request_changes';
5101 }
5102 if (e.key === 'Escape') {
5103 var focused = document.activeElement;
5104 if (focused) focused.blur();
5105 }
5106 });
5107 })();
5108 ` }} />
0074234Claude5109 </Layout>
5110 );
5111});
5112
6d1bbc2Claude5113// Update branch — merge base into head so the PR branch is up to date.
5114// Uses a git worktree so the bare repo stays clean. Write access required.
5115pulls.post(
5116 "/:owner/:repo/pulls/:number/update-branch",
5117 softAuth,
5118 requireAuth,
5119 requireRepoAccess("write"),
5120 async (c) => {
5121 const { owner: ownerName, repo: repoName } = c.req.param();
5122 const prNum = parseInt(c.req.param("number"), 10);
5123 const user = c.get("user")!;
5124 const resolved = await resolveRepo(ownerName, repoName);
5125 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5126
5127 const [pr] = await db
5128 .select()
5129 .from(pullRequests)
5130 .where(and(
5131 eq(pullRequests.repositoryId, resolved.repo.id),
5132 eq(pullRequests.number, prNum),
5133 ))
5134 .limit(1);
5135 if (!pr || pr.state !== "open") {
5136 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5137 }
5138
5139 const repoDir = getRepoPath(ownerName, repoName);
5140 const wt = `${repoDir}/_update_wt_${Date.now()}`;
5141 const gitEnv = {
5142 ...process.env,
5143 GIT_AUTHOR_NAME: user.displayName || user.username,
5144 GIT_AUTHOR_EMAIL: user.email,
5145 GIT_COMMITTER_NAME: user.displayName || user.username,
5146 GIT_COMMITTER_EMAIL: user.email,
5147 };
5148
5149 const addWt = Bun.spawn(
5150 ["git", "worktree", "add", wt, pr.headBranch],
5151 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5152 );
5153 if (await addWt.exited !== 0) {
5154 return c.redirect(
5155 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
5156 );
5157 }
5158
5159 let ok = false;
5160 try {
5161 const mergeProc = Bun.spawn(
5162 ["git", "merge", "--no-edit", pr.baseBranch],
5163 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
5164 );
5165 if (await mergeProc.exited === 0) {
5166 ok = true;
5167 } else {
5168 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5169 }
5170 } catch {
5171 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
5172 }
5173
5174 await Bun.spawn(
5175 ["git", "worktree", "remove", "--force", wt],
5176 { cwd: repoDir }
5177 ).exited.catch(() => {});
5178
5179 if (ok) {
5180 return c.redirect(
5181 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
5182 );
5183 }
5184 return c.redirect(
5185 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
5186 );
5187 }
5188);
5189
b558f23Claude5190// Edit PR title (and optionally body). Owner or author only.
5191pulls.post(
5192 "/:owner/:repo/pulls/:number/edit",
5193 softAuth,
5194 requireAuth,
5195 requireRepoAccess("write"),
5196 async (c) => {
5197 const { owner: ownerName, repo: repoName } = c.req.param();
5198 const prNum = parseInt(c.req.param("number"), 10);
5199 const user = c.get("user")!;
5200 const resolved = await resolveRepo(ownerName, repoName);
5201 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5202
5203 const [pr] = await db
5204 .select()
5205 .from(pullRequests)
5206 .where(and(
5207 eq(pullRequests.repositoryId, resolved.repo.id),
5208 eq(pullRequests.number, prNum),
5209 ))
5210 .limit(1);
5211 if (!pr || pr.state !== "open") {
5212 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5213 }
5214 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
5215 if (!canEdit) {
5216 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5217 }
5218
5219 const body = await c.req.parseBody();
5220 const newTitle = String(body.title || "").trim().slice(0, 256);
5221 if (!newTitle) {
5222 return c.redirect(
5223 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
5224 );
5225 }
5226
5227 await db
5228 .update(pullRequests)
5229 .set({ title: newTitle, updatedAt: new Date() })
5230 .where(eq(pullRequests.id, pr.id));
5231
5232 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
5233 }
5234);
5235
cb5a796Claude5236// Add comment to PR.
5237//
5238// Permission model mirrors `issues.tsx`: any logged-in user with read
5239// access can submit; `decideInitialStatus` routes non-collaborators
5240// through the moderation queue. Slash commands only fire when the
5241// comment is auto-approved — we don't want a banned/pending comment to
5242// silently trigger AI work on the PR.
0074234Claude5243pulls.post(
5244 "/:owner/:repo/pulls/:number/comment",
5245 softAuth,
5246 requireAuth,
cb5a796Claude5247 requireRepoAccess("read"),
0074234Claude5248 async (c) => {
5249 const { owner: ownerName, repo: repoName } = c.req.param();
5250 const prNum = parseInt(c.req.param("number"), 10);
5251 const user = c.get("user")!;
5252 const body = await c.req.parseBody();
5253 const commentBody = String(body.body || "").trim();
47a7a0aClaude5254 const filePathRaw = String(body.file_path || "").trim();
5255 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
5256 const inlineFilePath = filePathRaw || undefined;
5257 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude5258
5259 if (!commentBody) {
5260 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5261 }
5262
5263 const resolved = await resolveRepo(ownerName, repoName);
5264 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5265
5266 const [pr] = await db
5267 .select()
5268 .from(pullRequests)
5269 .where(
5270 and(
5271 eq(pullRequests.repositoryId, resolved.repo.id),
5272 eq(pullRequests.number, prNum)
5273 )
5274 )
5275 .limit(1);
5276
5277 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5278
cb5a796Claude5279 const decision = await decideInitialStatus({
5280 commenterUserId: user.id,
5281 repositoryId: resolved.repo.id,
5282 kind: "pr",
5283 threadId: pr.id,
5284 });
5285
d4ac5c3Claude5286 const [inserted] = await db
5287 .insert(prComments)
5288 .values({
5289 pullRequestId: pr.id,
5290 authorId: user.id,
5291 body: commentBody,
cb5a796Claude5292 moderationStatus: decision.status,
47a7a0aClaude5293 filePath: inlineFilePath,
5294 lineNumber: inlineLineNumber,
d4ac5c3Claude5295 })
5296 .returning();
5297
cb5a796Claude5298 // Live update: only when the comment is actually visible.
5299 if (inserted && decision.status === "approved") {
d4ac5c3Claude5300 try {
5301 const { publish } = await import("../lib/sse");
5302 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
5303 event: "pr-comment",
5304 data: {
5305 pullRequestId: pr.id,
5306 commentId: inserted.id,
5307 authorId: user.id,
5308 authorUsername: user.username,
5309 },
5310 });
5311 } catch {
5312 /* SSE is best-effort */
5313 }
b7ecb14Claude5314 // Notify the PR author — fire-and-forget, never blocks the response.
5315 if (pr.authorId && pr.authorId !== user.id) {
5316 void import("../lib/notify").then(({ createNotification }) =>
5317 createNotification({
5318 userId: pr.authorId,
5319 type: "pr_comment",
5320 title: `New comment on "${pr.title}"`,
5321 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
5322 url: `/${ownerName}/${repoName}/pulls/${prNum}`,
5323 repoId: resolved.repo.id,
5324 })
5325 ).catch(() => { /* never block the response */ });
5326 }
d4ac5c3Claude5327 }
0074234Claude5328
cb5a796Claude5329 if (decision.status === "pending") {
5330 void notifyOwnerOfPendingComment({
5331 repositoryId: resolved.repo.id,
5332 commenterUsername: user.username,
5333 kind: "pr",
5334 threadNumber: prNum,
5335 ownerUsername: ownerName,
5336 repoName,
5337 });
5338 return c.redirect(
5339 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5340 );
5341 }
5342 if (decision.status === "rejected") {
5343 // Silent ban path — same UX as 'pending' so we don't leak the gate.
5344 return c.redirect(
5345 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5346 );
5347 }
5348
15db0e0Claude5349 // Slash-command handoff. We always store the original comment above
5350 // first so free-form text that happens to start with `/` is preserved
5351 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude5352 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude5353 const parsed = parseSlashCommand(commentBody);
5354 if (parsed) {
5355 try {
5356 const result = await executeSlashCommand({
5357 command: parsed.command,
5358 args: parsed.args,
5359 prId: pr.id,
5360 userId: user.id,
5361 repositoryId: resolved.repo.id,
5362 });
5363 await db.insert(prComments).values({
5364 pullRequestId: pr.id,
5365 authorId: user.id,
5366 body: result.body,
5367 });
5368 } catch (err) {
5369 // Defence-in-depth — executeSlashCommand promises not to throw,
5370 // but if it ever does we want the PR thread to know.
5371 await db
5372 .insert(prComments)
5373 .values({
5374 pullRequestId: pr.id,
5375 authorId: user.id,
5376 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
5377 })
5378 .catch(() => {});
5379 }
5380 }
5381
47a7a0aClaude5382 // Inline comments go back to the files tab; conversation comments to the conversation tab
5383 const redirectTab = inlineFilePath ? "?tab=files" : "";
5384 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude5385 }
5386);
5387
b5dd694Claude5388// Apply a suggestion from a PR comment — commits the suggested code to the
5389// head branch on behalf of the logged-in user.
5390pulls.post(
5391 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
5392 softAuth,
5393 requireAuth,
5394 requireRepoAccess("read"),
5395 async (c) => {
5396 const { owner: ownerName, repo: repoName } = c.req.param();
5397 const prNum = parseInt(c.req.param("number"), 10);
5398 const commentId = c.req.param("commentId"); // UUID
5399 const user = c.get("user")!;
5400
5401 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
5402
5403 const resolved = await resolveRepo(ownerName, repoName);
5404 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5405
5406 const [pr] = await db
5407 .select()
5408 .from(pullRequests)
5409 .where(
5410 and(
5411 eq(pullRequests.repositoryId, resolved.repo.id),
5412 eq(pullRequests.number, prNum)
5413 )
5414 )
5415 .limit(1);
5416
5417 if (!pr || pr.state !== "open") {
5418 return c.redirect(`${backUrl}&error=pr_not_open`);
5419 }
5420
5421 // Only PR author or repo owner may apply suggestions.
5422 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
5423 return c.redirect(`${backUrl}&error=forbidden`);
5424 }
5425
5426 // Load the comment.
5427 const [comment] = await db
5428 .select()
5429 .from(prComments)
5430 .where(
5431 and(
5432 eq(prComments.id, commentId),
5433 eq(prComments.pullRequestId, pr.id)
5434 )
5435 )
5436 .limit(1);
5437
5438 if (!comment) {
5439 return c.redirect(`${backUrl}&error=comment_not_found`);
5440 }
5441
5442 // Parse suggestion block from comment body.
5443 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
5444 if (!m) {
5445 return c.redirect(`${backUrl}&error=no_suggestion`);
5446 }
5447 const suggestionCode = m[1];
5448
5449 // Get the commenter's details for the commit message co-author line.
5450 const [commenter] = await db
5451 .select()
5452 .from(users)
5453 .where(eq(users.id, comment.authorId))
5454 .limit(1);
5455
5456 // Fetch current file content from head branch.
5457 if (!comment.filePath) {
5458 return c.redirect(`${backUrl}&error=file_not_found`);
5459 }
5460 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
5461 if (!blob) {
5462 return c.redirect(`${backUrl}&error=file_not_found`);
5463 }
5464
5465 // Apply the patch — replace the target line(s) with suggestion lines.
5466 const lines = blob.content.split('\n');
5467 const lineIdx = (comment.lineNumber ?? 1) - 1;
5468 if (lineIdx < 0 || lineIdx >= lines.length) {
5469 return c.redirect(`${backUrl}&error=line_out_of_range`);
5470 }
5471 const suggestionLines = suggestionCode.split('\n');
5472 lines.splice(lineIdx, 1, ...suggestionLines);
5473 const newContent = lines.join('\n');
5474
5475 // Commit the change.
5476 const coAuthorLine = commenter
5477 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
5478 : "";
5479 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
5480
5481 const result = await createOrUpdateFileOnBranch({
5482 owner: ownerName,
5483 name: repoName,
5484 branch: pr.headBranch,
5485 filePath: comment.filePath,
5486 bytes: new TextEncoder().encode(newContent),
5487 message: commitMessage,
5488 authorName: user.username,
5489 authorEmail: `${user.username}@users.noreply.gluecron.com`,
5490 });
5491
5492 if ("error" in result) {
5493 return c.redirect(`${backUrl}&error=apply_failed`);
5494 }
5495
5496 // Post a follow-up comment noting the suggestion was applied.
5497 await db.insert(prComments).values({
5498 pullRequestId: pr.id,
5499 authorId: user.id,
5500 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
5501 });
5502
5503 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5504 }
5505);
5506
0a67773Claude5507// Formal review — Approve / Request Changes / Comment
5508pulls.post(
5509 "/:owner/:repo/pulls/:number/review",
5510 softAuth,
5511 requireAuth,
5512 requireRepoAccess("read"),
5513 async (c) => {
5514 const { owner: ownerName, repo: repoName } = c.req.param();
5515 const prNum = parseInt(c.req.param("number"), 10);
5516 const user = c.get("user")!;
5517 const body = await c.req.parseBody();
5518 const reviewBody = String(body.body || "").trim();
5519 const reviewState = String(body.review_state || "commented");
5520
5521 const validStates = ["approved", "changes_requested", "commented"];
5522 if (!validStates.includes(reviewState)) {
5523 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5524 }
5525
5526 const resolved = await resolveRepo(ownerName, repoName);
5527 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5528
5529 const [pr] = await db
5530 .select()
5531 .from(pullRequests)
5532 .where(
5533 and(
5534 eq(pullRequests.repositoryId, resolved.repo.id),
5535 eq(pullRequests.number, prNum)
5536 )
5537 )
5538 .limit(1);
5539 if (!pr || pr.state !== "open") {
5540 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5541 }
5542 // Authors can't review their own PR
5543 if (pr.authorId === user.id) {
5544 return c.redirect(
5545 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
5546 );
5547 }
5548
5549 await db.insert(prReviews).values({
5550 pullRequestId: pr.id,
5551 reviewerId: user.id,
5552 state: reviewState,
5553 body: reviewBody || null,
5554 });
5555
5556 const stateLabel =
5557 reviewState === "approved" ? "Approved"
5558 : reviewState === "changes_requested" ? "Changes requested"
5559 : "Commented";
5560 return c.redirect(
5561 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
5562 );
5563 }
5564);
5565
e883329Claude5566// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude5567// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
5568// but we keep it at "write" for v1 so trusted collaborators can ship.
5569// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
5570// surface. Branch-protection rules (evaluated below) are the current mechanism
5571// for locking down merges further on specific branches.
0074234Claude5572pulls.post(
5573 "/:owner/:repo/pulls/:number/merge",
5574 softAuth,
5575 requireAuth,
04f6b7fClaude5576 requireRepoAccess("write"),
0074234Claude5577 async (c) => {
5578 const { owner: ownerName, repo: repoName } = c.req.param();
5579 const prNum = parseInt(c.req.param("number"), 10);
5580 const user = c.get("user")!;
5581
a164a6dClaude5582 // Read merge strategy from form (default: merge commit)
5583 let mergeStrategy = "merge";
5584 try {
5585 const body = await c.req.parseBody();
5586 const s = body.merge_strategy;
5587 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
5588 } catch { /* ignore parse errors — default to merge commit */ }
5589
0074234Claude5590 const resolved = await resolveRepo(ownerName, repoName);
5591 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5592
5593 const [pr] = await db
5594 .select()
5595 .from(pullRequests)
5596 .where(
5597 and(
5598 eq(pullRequests.repositoryId, resolved.repo.id),
5599 eq(pullRequests.number, prNum)
5600 )
5601 )
5602 .limit(1);
5603
5604 if (!pr || pr.state !== "open") {
5605 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5606 }
5607
6fc53bdClaude5608 // Draft PRs cannot be merged — must be marked ready first.
5609 if (pr.isDraft) {
5610 return c.redirect(
5611 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5612 "This PR is a draft. Mark it as ready for review before merging."
5613 )}`
5614 );
5615 }
5616
ec9e3e3Claude5617 // Required reviews check — branch-protection `required_approvals` gate.
5618 // Evaluated before running expensive gate checks so the feedback is fast.
5619 {
5620 const eligibility = await checkMergeEligible(pr.id, resolved.repo.id, pr.baseBranch);
5621 if (!eligibility.eligible && eligibility.reason) {
5622 return c.redirect(
5623 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(eligibility.reason)}`
5624 );
5625 }
5626 }
5627
e883329Claude5628 // Resolve head SHA
5629 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
5630 if (!headSha) {
5631 return c.redirect(
5632 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
5633 );
5634 }
5635
5636 // Check if AI review approved this PR
5637 const aiComments = await db
5638 .select()
5639 .from(prComments)
5640 .where(
5641 and(
5642 eq(prComments.pullRequestId, pr.id),
5643 eq(prComments.isAiReview, true)
5644 )
5645 );
5646 const aiApproved = aiComments.length === 0 || aiComments.some(
5647 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude5648 );
e883329Claude5649
5650 // Run all green gate checks (GateTest + mergeability + AI review)
5651 const gateResult = await runAllGateChecks(
5652 ownerName,
5653 repoName,
5654 pr.baseBranch,
5655 pr.headBranch,
5656 headSha,
5657 aiApproved
0074234Claude5658 );
5659
e883329Claude5660 // If GateTest or AI review failed (hard blocks), reject the merge
5661 const hardFailures = gateResult.checks.filter(
5662 (check) => !check.passed && check.name !== "Merge check"
5663 );
5664 if (hardFailures.length > 0) {
5665 const errorMsg = hardFailures
5666 .map((f) => `${f.name}: ${f.details}`)
5667 .join("; ");
0074234Claude5668 return c.redirect(
e883329Claude5669 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude5670 );
5671 }
5672
1e162a8Claude5673 // D5 — Branch-protection enforcement. Looks up the matching rule for the
5674 // base branch and blocks the merge if requireAiApproval / requireGreenGates
5675 // / requireHumanReview / requiredApprovals are not satisfied. Independent
5676 // of repo-global settings, so owners can lock specific branches down
5677 // further than the repo default.
5678 const protectionRule = await matchProtection(
5679 resolved.repo.id,
5680 pr.baseBranch
5681 );
5682 if (protectionRule) {
5683 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude5684 const required = await listRequiredChecks(protectionRule.id);
5685 const passingNames = required.length > 0
5686 ? await passingCheckNames(resolved.repo.id, headSha)
5687 : [];
5688 const decision = evaluateProtection(
5689 protectionRule,
5690 {
5691 aiApproved,
5692 humanApprovalCount: humanApprovals,
5693 gateResultGreen: hardFailures.length === 0,
5694 hasFailedGates: hardFailures.length > 0,
5695 passingCheckNames: passingNames,
5696 },
5697 required.map((r) => r.checkName)
5698 );
1e162a8Claude5699 if (!decision.allowed) {
5700 return c.redirect(
5701 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5702 decision.reasons.join(" ")
5703 )}`
5704 );
5705 }
5706 }
5707
e883329Claude5708 // Attempt the merge — with auto conflict resolution if needed
5709 const repoDir = getRepoPath(ownerName, repoName);
5710 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
5711 const hasConflicts = mergeCheck && !mergeCheck.passed;
5712
5713 if (hasConflicts && isAiReviewEnabled()) {
5714 // Use Claude to auto-resolve conflicts
5715 const mergeResult = await mergeWithAutoResolve(
5716 ownerName,
5717 repoName,
5718 pr.baseBranch,
5719 pr.headBranch,
5720 `Merge pull request #${pr.number}: ${pr.title}`
5721 );
5722
5723 if (!mergeResult.success) {
5724 return c.redirect(
5725 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
5726 );
5727 }
5728
5729 // Post a comment about the auto-resolution
5730 if (mergeResult.resolvedFiles.length > 0) {
5731 await db.insert(prComments).values({
5732 pullRequestId: pr.id,
5733 authorId: user.id,
5734 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
5735 isAiReview: true,
5736 });
5737 }
5738 } else {
a164a6dClaude5739 // Worktree-based merge: supports merge-commit, squash, and fast-forward
5740 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
5741 const gitEnv = {
5742 ...process.env,
5743 GIT_AUTHOR_NAME: user.displayName || user.username,
5744 GIT_AUTHOR_EMAIL: user.email,
5745 GIT_COMMITTER_NAME: user.displayName || user.username,
5746 GIT_COMMITTER_EMAIL: user.email,
5747 };
5748
5749 // Create linked worktree on the base branch
5750 const addWt = Bun.spawn(
5751 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude5752 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5753 );
a164a6dClaude5754 if (await addWt.exited !== 0) {
5755 return c.redirect(
5756 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
5757 );
5758 }
5759
5760 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
5761 let mergeOk = false;
5762
5763 try {
5764 if (mergeStrategy === "squash") {
5765 // Squash: stage all changes without committing
5766 const squashProc = Bun.spawn(
5767 ["git", "merge", "--squash", headSha],
5768 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5769 );
5770 if (await squashProc.exited !== 0) {
5771 const errTxt = await new Response(squashProc.stderr).text();
5772 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
5773 }
5774 // Commit the squashed changes
5775 const commitProc = Bun.spawn(
5776 ["git", "commit", "-m", commitMsg],
5777 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5778 );
5779 if (await commitProc.exited !== 0) {
5780 const errTxt = await new Response(commitProc.stderr).text();
5781 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
5782 }
5783 mergeOk = true;
5784 } else if (mergeStrategy === "ff") {
5785 // Fast-forward only — fail if FF is not possible
5786 const ffProc = Bun.spawn(
5787 ["git", "merge", "--ff-only", headSha],
5788 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5789 );
5790 if (await ffProc.exited !== 0) {
5791 const errTxt = await new Response(ffProc.stderr).text();
5792 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
5793 }
5794 mergeOk = true;
5795 } else {
5796 // Default: merge commit (--no-ff always creates a merge commit)
5797 const mergeProc = Bun.spawn(
5798 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
5799 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5800 );
5801 if (await mergeProc.exited !== 0) {
5802 const errTxt = await new Response(mergeProc.stderr).text();
5803 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
5804 }
5805 mergeOk = true;
5806 }
5807 } catch (err) {
5808 // Always clean up the worktree before redirecting
5809 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
5810 const msg = err instanceof Error ? err.message : "Merge failed";
5811 return c.redirect(
5812 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
5813 );
5814 }
5815
5816 // Clean up worktree (changes are now in the bare repo via linked worktree)
5817 await Bun.spawn(
5818 ["git", "worktree", "remove", "--force", wt],
5819 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5820 ).exited.catch(() => {});
e883329Claude5821
a164a6dClaude5822 if (!mergeOk) {
e883329Claude5823 return c.redirect(
a164a6dClaude5824 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude5825 );
5826 }
5827 }
5828
0074234Claude5829 await db
5830 .update(pullRequests)
5831 .set({
5832 state: "merged",
5833 mergedAt: new Date(),
5834 mergedBy: user.id,
5835 updatedAt: new Date(),
5836 })
5837 .where(eq(pullRequests.id, pr.id));
5838
8809b87Claude5839 // Chat notifier — fan out merge event to Slack/Discord/Teams.
5840 import("../lib/chat-notifier")
5841 .then((m) =>
5842 m.notifyChatChannels({
5843 ownerUserId: resolved.repo.ownerId,
5844 repositoryId: resolved.repo.id,
5845 event: {
5846 event: "pr.merged",
5847 repo: `${ownerName}/${repoName}`,
5848 title: `#${pr.number} ${pr.title}`,
5849 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
5850 actor: user.username,
5851 },
5852 })
5853 )
5854 .catch((err) =>
5855 console.warn(`[chat-notifier] PR merge notify failed:`, err)
5856 );
5857
d62fb36Claude5858 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
5859 // and auto-close each matching open issue with a back-link comment. Bounded
5860 // to the same repo for v1 (cross-repo refs ignored). Failures never block
5861 // the merge redirect.
5862 try {
5863 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
5864 const refs = extractClosingRefsMulti([pr.title, pr.body]);
5865 for (const n of refs) {
5866 const [issue] = await db
5867 .select()
5868 .from(issues)
5869 .where(
5870 and(
5871 eq(issues.repositoryId, resolved.repo.id),
5872 eq(issues.number, n)
5873 )
5874 )
5875 .limit(1);
5876 if (!issue || issue.state !== "open") continue;
5877 await db
5878 .update(issues)
5879 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
5880 .where(eq(issues.id, issue.id));
5881 await db.insert(issueComments).values({
5882 issueId: issue.id,
5883 authorId: user.id,
5884 body: `Closed by pull request #${pr.number}.`,
5885 });
5886 }
5887 } catch {
5888 // Never block the merge on close-keyword failures.
5889 }
5890
0074234Claude5891 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5892 }
5893);
5894
6fc53bdClaude5895// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
5896// hasn't run yet on this PR.
5897pulls.post(
5898 "/:owner/:repo/pulls/:number/ready",
5899 softAuth,
5900 requireAuth,
04f6b7fClaude5901 requireRepoAccess("write"),
6fc53bdClaude5902 async (c) => {
5903 const { owner: ownerName, repo: repoName } = c.req.param();
5904 const prNum = parseInt(c.req.param("number"), 10);
5905 const user = c.get("user")!;
5906
5907 const resolved = await resolveRepo(ownerName, repoName);
5908 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5909
5910 const [pr] = await db
5911 .select()
5912 .from(pullRequests)
5913 .where(
5914 and(
5915 eq(pullRequests.repositoryId, resolved.repo.id),
5916 eq(pullRequests.number, prNum)
5917 )
5918 )
5919 .limit(1);
5920 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5921
5922 // Only the author or repo owner can toggle draft state.
5923 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5924 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5925 }
5926
5927 if (pr.state === "open" && pr.isDraft) {
5928 await db
5929 .update(pullRequests)
5930 .set({ isDraft: false, updatedAt: new Date() })
5931 .where(eq(pullRequests.id, pr.id));
5932
5933 if (isAiReviewEnabled()) {
5934 triggerAiReview(
5935 ownerName,
5936 repoName,
5937 pr.id,
5938 pr.title,
0316dbbClaude5939 pr.body || "",
6fc53bdClaude5940 pr.baseBranch,
5941 pr.headBranch
5942 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
5943 }
5944 }
5945
5946 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5947 }
5948);
5949
5950// Convert a PR back to draft.
5951pulls.post(
5952 "/:owner/:repo/pulls/:number/draft",
5953 softAuth,
5954 requireAuth,
04f6b7fClaude5955 requireRepoAccess("write"),
6fc53bdClaude5956 async (c) => {
5957 const { owner: ownerName, repo: repoName } = c.req.param();
5958 const prNum = parseInt(c.req.param("number"), 10);
5959 const user = c.get("user")!;
5960
5961 const resolved = await resolveRepo(ownerName, repoName);
5962 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5963
5964 const [pr] = await db
5965 .select()
5966 .from(pullRequests)
5967 .where(
5968 and(
5969 eq(pullRequests.repositoryId, resolved.repo.id),
5970 eq(pullRequests.number, prNum)
5971 )
5972 )
5973 .limit(1);
5974 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5975
5976 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5977 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5978 }
5979
5980 if (pr.state === "open" && !pr.isDraft) {
5981 await db
5982 .update(pullRequests)
5983 .set({ isDraft: true, updatedAt: new Date() })
5984 .where(eq(pullRequests.id, pr.id));
5985 }
5986
5987 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5988 }
5989);
5990
0074234Claude5991// Close PR
5992pulls.post(
5993 "/:owner/:repo/pulls/:number/close",
5994 softAuth,
5995 requireAuth,
04f6b7fClaude5996 requireRepoAccess("write"),
0074234Claude5997 async (c) => {
5998 const { owner: ownerName, repo: repoName } = c.req.param();
5999 const prNum = parseInt(c.req.param("number"), 10);
6000
6001 const resolved = await resolveRepo(ownerName, repoName);
6002 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6003
6004 await db
6005 .update(pullRequests)
6006 .set({
6007 state: "closed",
6008 closedAt: new Date(),
6009 updatedAt: new Date(),
6010 })
6011 .where(
6012 and(
6013 eq(pullRequests.repositoryId, resolved.repo.id),
6014 eq(pullRequests.number, prNum)
6015 )
6016 );
6017
6018 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
6019 }
6020);
6021
c3e0c07Claude6022// Re-run AI review on demand (e.g. after a force-push). Bypasses the
6023// idempotency marker via { force: true }. Write-access only.
6024pulls.post(
6025 "/:owner/:repo/pulls/:number/ai-rereview",
6026 softAuth,
6027 requireAuth,
6028 requireRepoAccess("write"),
6029 async (c) => {
6030 const { owner: ownerName, repo: repoName } = c.req.param();
6031 const prNum = parseInt(c.req.param("number"), 10);
6032 const resolved = await resolveRepo(ownerName, repoName);
6033 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6034
6035 const [pr] = await db
6036 .select()
6037 .from(pullRequests)
6038 .where(
6039 and(
6040 eq(pullRequests.repositoryId, resolved.repo.id),
6041 eq(pullRequests.number, prNum)
6042 )
6043 )
6044 .limit(1);
6045 if (!pr) {
6046 return c.redirect(`/${ownerName}/${repoName}/pulls`);
6047 }
6048
6049 if (!isAiReviewEnabled()) {
6050 return c.redirect(
6051 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6052 "AI review is not configured (ANTHROPIC_API_KEY)."
6053 )}`
6054 );
6055 }
6056
6057 // Fire-and-forget but with { force: true } to bypass the
6058 // already-reviewed marker. The function still never throws.
6059 triggerAiReview(
6060 ownerName,
6061 repoName,
6062 pr.id,
6063 pr.title || "",
6064 pr.body || "",
6065 pr.baseBranch,
6066 pr.headBranch,
6067 { force: true }
a28cedeClaude6068 ).catch((err) => {
6069 console.warn(
6070 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
6071 err instanceof Error ? err.message : err
6072 );
6073 });
c3e0c07Claude6074
6075 return c.redirect(
6076 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6077 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
6078 )}`
6079 );
6080 }
6081);
6082
1d4ff60Claude6083// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
6084// the PR's head branch carrying just the new test files. Write-access only.
6085// Idempotent — if `ai:added-tests` was previously applied we redirect with
6086// an `info` banner instead of re-firing.
6087pulls.post(
6088 "/:owner/:repo/pulls/:number/generate-tests",
6089 softAuth,
6090 requireAuth,
6091 requireRepoAccess("write"),
6092 async (c) => {
6093 const { owner: ownerName, repo: repoName } = c.req.param();
6094 const prNum = parseInt(c.req.param("number"), 10);
6095 const resolved = await resolveRepo(ownerName, repoName);
6096 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
6097
6098 const [pr] = await db
6099 .select()
6100 .from(pullRequests)
6101 .where(
6102 and(
6103 eq(pullRequests.repositoryId, resolved.repo.id),
6104 eq(pullRequests.number, prNum)
6105 )
6106 )
6107 .limit(1);
6108 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6109
6110 if (!isAiReviewEnabled()) {
6111 return c.redirect(
6112 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
6113 "AI test generation is not configured (ANTHROPIC_API_KEY)."
6114 )}`
6115 );
6116 }
6117
6118 // Fire-and-forget. The lib never throws.
6119 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
6120 .then((res) => {
6121 if (!res.ok) {
6122 console.warn(
6123 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
6124 );
6125 }
6126 })
6127 .catch((err) => {
6128 console.warn(
6129 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
6130 err instanceof Error ? err.message : err
6131 );
6132 });
6133
6134 return c.redirect(
6135 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
6136 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
6137 )}`
6138 );
6139 }
6140);
6141
ace34efClaude6142// ─── Request review ───────────────────────────────────────────────────────────
6143pulls.post(
6144 "/:owner/:repo/pulls/:number/request-review",
6145 softAuth,
6146 requireAuth,
6147 requireRepoAccess("write"),
6148 async (c) => {
6149 const { owner: ownerName, repo: repoName } = c.req.param();
6150 const prNum = parseInt(c.req.param("number"), 10);
6151 const user = c.get("user")!;
6152
6153 const resolved = await resolveRepo(ownerName, repoName);
6154 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6155
6156 const [pr] = await db
6157 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
6158 .from(pullRequests)
6159 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
6160 .limit(1);
6161
6162 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
6163
6164 const body = await c.req.formData().catch(() => null);
6165 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
6166
6167 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
6168 return c.redirect(
6169 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
6170 );
6171 }
6172
f4abb8eClaude6173 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
6174 // requesting reviews from arbitrary user IDs outside this repository.
6175 const isOwner = reviewerId === resolved.owner.id;
6176 if (!isOwner) {
6177 const [collab] = await db
6178 .select({ id: repoCollaborators.id })
6179 .from(repoCollaborators)
6180 .where(
6181 and(
6182 eq(repoCollaborators.repositoryId, resolved.repo.id),
6183 eq(repoCollaborators.userId, reviewerId),
6184 isNotNull(repoCollaborators.acceptedAt)
6185 )
6186 )
6187 .limit(1);
6188 if (!collab) {
6189 return c.redirect(
6190 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
6191 );
6192 }
6193 }
6194
ace34efClaude6195 const { requestReview } = await import("../lib/reviewer-suggest");
6196 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
6197
6198 const msg = result.ok
6199 ? "Review requested successfully."
6200 : `Failed to request review: ${result.error ?? "unknown error"}`;
6201
6202 return c.redirect(
6203 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
6204 );
6205 }
6206);
6207
25b1ff7Claude6208// ─── WebSocket presence endpoint ─────────────────────────────────────────────
6209//
6210// GET /:owner/:repo/pulls/:number/presence (WebSocket upgrade)
6211//
6212// Unauthenticated connections are rejected with 401. On connect:
6213// → server sends {type:"init", sessionId, users:[...]}
6214// → server broadcasts {type:"join", user} to all other sessions in the room
6215//
6216// Accepted client messages:
6217// {type:"cursor", line: number} — user hovering a diff line
6218// {type:"typing", line: number, typing: bool} — textarea focus/blur
6219// {type:"ping"} — keep-alive (updates lastSeen)
6220//
6221// The WS `data` payload we store on each socket carries everything needed in
6222// the event handlers so no closure tricks are required.
6223
6224pulls.get(
6225 "/:owner/:repo/pulls/:number/presence",
6226 softAuth,
6227 upgradeWebSocket(async (c) => {
6228 const { owner: ownerName, repo: repoName, number: prNumStr } = c.req.param();
6229 const prNum = parseInt(prNumStr ?? "0", 10);
6230 const user = c.get("user");
6231
6232 // Auth check — no anonymous presence
6233 if (!user) {
6234 // upgradeWebSocket doesn't support returning a non-101 directly;
6235 // we return a dummy handler that immediately closes with 4001.
6236 return {
6237 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6238 ws.close(4001, "Unauthorized");
6239 },
6240 onMessage() {},
6241 onClose() {},
6242 };
6243 }
6244
6245 // Resolve repo to get its numeric id for the room key
6246 const resolved = await resolveRepo(ownerName, repoName);
6247 if (!resolved || isNaN(prNum)) {
6248 return {
6249 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6250 ws.close(4004, "Not found");
6251 },
6252 onMessage() {},
6253 onClose() {},
6254 };
6255 }
6256
6257 const prId = `${resolved.repo.id}:${prNum}`;
6258 const sessionId = `${user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
6259
6260 return {
6261 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
6262 // Register and join room
6263 registerSocket(prId, sessionId, {
6264 send: (data: string) => ws.send(data),
6265 readyState: ws.readyState,
6266 });
6267 const presenceUser = joinRoom(prId, sessionId, {
6268 userId: user.id,
6269 username: user.username,
6270 });
6271
6272 // Send init snapshot to the new joiner
6273 const currentUsers = getRoomUsers(prId);
6274 ws.send(
6275 JSON.stringify({
6276 type: "init",
6277 sessionId,
6278 users: currentUsers,
6279 })
6280 );
6281
6282 // Broadcast join to all OTHER sessions
6283 broadcastToRoom(
6284 prId,
6285 {
6286 type: "join",
6287 user: { ...presenceUser, sessionId },
6288 },
6289 sessionId
6290 );
6291 },
6292
6293 onMessage(evt: MessageEvent, _ws: import("hono/ws").WSContext) {
6294 let msg: { type: string; line?: number; typing?: boolean };
6295 try {
6296 msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
6297 } catch {
6298 return;
6299 }
6300
6301 if (msg.type === "ping") {
6302 pingSession(prId, sessionId);
6303 return;
6304 }
6305
6306 if (msg.type === "cursor") {
6307 const line = typeof msg.line === "number" ? msg.line : null;
6308 const updated = updatePresence(prId, sessionId, line, false);
6309 if (updated) {
6310 broadcastToRoom(
6311 prId,
6312 {
6313 type: "cursor",
6314 sessionId,
6315 username: updated.username,
6316 colour: updated.colour,
6317 line,
6318 },
6319 sessionId
6320 );
6321 }
6322 return;
6323 }
6324
6325 if (msg.type === "typing") {
6326 const line = typeof msg.line === "number" ? msg.line : null;
6327 const typing = !!msg.typing;
6328 const updated = updatePresence(prId, sessionId, line, typing);
6329 if (updated) {
6330 broadcastToRoom(
6331 prId,
6332 {
6333 type: "typing",
6334 sessionId,
6335 username: updated.username,
6336 colour: updated.colour,
6337 line,
6338 typing,
6339 },
6340 sessionId
6341 );
6342 }
6343 return;
6344 }
6345 },
6346
6347 onClose() {
6348 leaveRoom(prId, sessionId);
6349 unregisterSocket(prId, sessionId);
6350 broadcastToRoom(prId, { type: "leave", sessionId });
6351 },
6352 };
6353 })
6354);
6355
0074234Claude6356export default pulls;