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