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.tsxBlame6055 lines · 3 contributors
0074234Claude1/**
2 * Pull request routes — create, list, view, merge, close, comment.
b078860Claude3 *
4 * The list view (`GET /:owner/:repo/pulls`) and detail view
5 * (`GET /:owner/:repo/pulls/:number`) carry the 2026 polish: hero with
6 * gradient title + hairline strip, pill-style state tabs, soft-lift
7 * row cards, conversation thread with AI-review accent border, distinct
8 * gate-check rows, and a gradient-bordered "Merge pull request" button.
9 *
10 * All visual styling is scoped via `.prs-*` class prefixes inside inline
11 * <style> blocks so other surfaces are untouched. No business logic was
12 * changed in this polish pass — AI review triggers, auto-merge wiring,
13 * gate evaluation, and the merge handler are preserved exactly.
0074234Claude14 */
15
16import { Hono } from "hono";
f4abb8eClaude17import { eq, and, desc, asc, sql, inArray, ilike, ne, isNotNull } from "drizzle-orm";
0074234Claude18import { db } from "../db";
19import {
20 pullRequests,
21 prComments,
0a67773Claude22 prReviews,
ec9e3e3Claude23 prReviewRequests,
0074234Claude24 repositories,
25 users,
d62fb36Claude26 issues,
27 issueComments,
f4abb8eClaude28 repoCollaborators,
0074234Claude29} from "../db/schema";
30import { Layout } from "../views/layout";
ea9ed4cClaude31import { RepoHeader } from "../views/components";
cb5a796Claude32import { PendingCommentsBanner } from "../views/pending-comments-banner";
47a7a0aClaude33import { DiffView, type InlineDiffComment } from "../views/diff-view";
6fc53bdClaude34import { ReactionsBar } from "../views/reactions";
35import { summariseReactions } from "../lib/reactions";
24cf2caClaude36import { loadPrTemplate } from "../lib/templates";
0074234Claude37import { renderMarkdown } from "../lib/markdown";
15db0e0Claude38import {
39 parseSlashCommand,
40 executeSlashCommand,
41 detectSlashCmdComment,
42 stripSlashCmdMarker,
43} from "../lib/pr-slash-commands";
b584e52Claude44import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude45import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude46import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude47import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
0074234Claude48import { softAuth, requireAuth } from "../middleware/auth";
49import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude50import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude51import {
52 decideInitialStatus,
53 notifyOwnerOfPendingComment,
54 countPendingForRepo,
55} from "../lib/comment-moderation";
0316dbbClaude56import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
79ed944Claude57import {
58 TRIO_COMMENT_MARKER,
59 TRIO_SUMMARY_MARKER,
67dc4e1Claude60 isTrioReviewEnabled,
79ed944Claude61 type TrioPersona,
62} from "../lib/ai-review-trio";
1d4ff60Claude63import {
64 generateTestsForPr,
65 AI_TESTS_MARKER,
66} from "../lib/ai-test-generator";
0316dbbClaude67import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude68import { generatePrSummary } from "../lib/ai-generators";
69import { isAiAvailable } from "../lib/ai-client";
534f04aClaude70import {
71 computePrRiskForPullRequest,
72 getCachedPrRisk,
73 type PrRiskScore,
74} from "../lib/pr-risk";
0316dbbClaude75import { runAllGateChecks } from "../lib/gate";
76import type { GateCheckResult } from "../lib/gate";
77import {
78 matchProtection,
79 countHumanApprovals,
80 listRequiredChecks,
81 passingCheckNames,
82 evaluateProtection,
83} from "../lib/branch-protection";
84import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude85import {
86 listBranches,
87 getRepoPath,
e883329Claude88 resolveRef,
b5dd694Claude89 getBlob,
90 createOrUpdateFileOnBranch,
b558f23Claude91 commitsBetween,
0074234Claude92} from "../git/repository";
b558f23Claude93import type { GitDiffFile, GitCommit } from "../git/repository";
240c477Claude94import { listStatuses } from "../lib/commit-statuses";
95import type { CommitStatus } from "../db/schema";
0074234Claude96import { html } from "hono/html";
4bbacbeClaude97import {
98 getPreviewForBranch,
99 previewStatusLabel,
100} from "../lib/branch-previews";
1e162a8Claude101import {
bb0f894Claude102 Flex,
103 Container,
104 Badge,
105 Button,
106 LinkButton,
107 Form,
108 FormGroup,
109 Input,
110 TextArea,
111 Select,
112 EmptyState,
113 FilterTabs,
114 TabNav,
115 List,
116 ListItem,
117 Text,
118 Alert,
119 MarkdownContent,
120 CommentBox,
121 formatRelative,
122} from "../views/ui";
0074234Claude123
ace34efClaude124import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
74d8c4dClaude125import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
a7460bfClaude126import { BOT_USERNAME } from "../lib/bot-user";
ec9e3e3Claude127import {
128 getCodeownersForRepo,
129 reviewersForChangedFiles,
130} from "../lib/codeowners";
131import { checkMergeEligible } from "../lib/branch-rules";
25b1ff7Claude132import {
133 joinRoom,
134 leaveRoom,
135 updatePresence,
136 pingSession,
137 getRoomUsers,
138 broadcastToRoom,
139 registerSocket,
140 unregisterSocket,
141} from "../lib/pr-presence";
142import { upgradeWebSocket, websocket as presenceWebsocket } from "hono/bun";
143
144export { presenceWebsocket };
ace34efClaude145
0074234Claude146const pulls = new Hono<AuthEnv>();
147
b078860Claude148/* ──────────────────────────────────────────────────────────────────────
149 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
150 * into the issue tracker or any other route. Tokens come from layout.tsx
151 * `:root` so light/dark stays consistent if/when light mode lands.
152 * ──────────────────────────────────────────────────────────────────── */
153const PRS_LIST_STYLES = `
154 .prs-hero {
155 position: relative;
156 margin: 0 0 var(--space-5);
157 padding: 22px 26px 24px;
158 background: var(--bg-elevated);
159 border: 1px solid var(--border);
160 border-radius: 16px;
161 overflow: hidden;
162 }
163 .prs-hero::before {
164 content: '';
165 position: absolute; top: 0; left: 0; right: 0;
166 height: 2px;
167 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
168 opacity: 0.7;
169 pointer-events: none;
170 }
171 .prs-hero-inner {
172 position: relative;
173 display: flex;
174 justify-content: space-between;
175 align-items: flex-end;
176 gap: 20px;
177 flex-wrap: wrap;
178 }
179 .prs-hero-text { flex: 1; min-width: 280px; }
180 .prs-hero-eyebrow {
181 font-size: 12px;
182 color: var(--text-muted);
183 text-transform: uppercase;
184 letter-spacing: 0.08em;
185 font-weight: 600;
186 margin-bottom: 8px;
187 }
188 .prs-hero-title {
189 font-family: var(--font-display);
190 font-size: clamp(26px, 3.4vw, 34px);
191 font-weight: 800;
192 letter-spacing: -0.025em;
193 line-height: 1.06;
194 margin: 0 0 8px;
195 color: var(--text-strong);
196 }
197 .prs-hero-title .gradient-text {
198 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
199 -webkit-background-clip: text;
200 background-clip: text;
201 -webkit-text-fill-color: transparent;
202 color: transparent;
203 }
204 .prs-hero-sub {
205 font-size: 14.5px;
206 color: var(--text-muted);
207 margin: 0;
208 line-height: 1.5;
209 max-width: 620px;
210 }
211 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
212 .prs-cta {
213 display: inline-flex; align-items: center; gap: 6px;
214 padding: 10px 16px;
215 border-radius: 10px;
216 font-size: 13.5px;
217 font-weight: 600;
218 color: #fff;
219 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
220 border: 1px solid rgba(140,109,255,0.55);
221 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
222 text-decoration: none;
223 transition: transform 120ms ease, box-shadow 160ms ease;
224 }
225 .prs-cta:hover {
226 transform: translateY(-1px);
227 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
228 color: #fff;
229 }
230
231 .prs-tabs {
232 display: flex; flex-wrap: wrap; gap: 6px;
233 margin: 0 0 18px;
234 padding: 6px;
235 background: var(--bg-secondary);
236 border: 1px solid var(--border);
237 border-radius: 12px;
238 }
239 .prs-tab {
240 display: inline-flex; align-items: center; gap: 8px;
241 padding: 7px 13px;
242 font-size: 13px;
243 font-weight: 500;
244 color: var(--text-muted);
245 border-radius: 8px;
246 text-decoration: none;
247 transition: background 120ms ease, color 120ms ease;
248 }
249 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
250 .prs-tab.is-active {
251 background: var(--bg-elevated);
252 color: var(--text-strong);
253 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
254 }
255 .prs-tab-count {
256 display: inline-flex; align-items: center; justify-content: center;
257 min-width: 22px; padding: 2px 7px;
258 font-size: 11.5px;
259 font-weight: 600;
260 border-radius: 9999px;
261 background: var(--bg-tertiary);
262 color: var(--text-muted);
263 }
264 .prs-tab.is-active .prs-tab-count {
265 background: rgba(140,109,255,0.18);
266 color: var(--text-link);
267 }
268
269 .prs-list { display: flex; flex-direction: column; gap: 10px; }
270 .prs-row {
271 position: relative;
272 display: flex; align-items: flex-start; gap: 14px;
273 padding: 14px 16px;
274 background: var(--bg-elevated);
275 border: 1px solid var(--border);
276 border-radius: 12px;
277 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
278 }
279 .prs-row:hover {
280 transform: translateY(-1px);
281 border-color: var(--border-strong);
282 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
283 }
284 .prs-row-icon {
285 flex: 0 0 auto;
286 width: 26px; height: 26px;
287 display: inline-flex; align-items: center; justify-content: center;
288 border-radius: 9999px;
289 font-size: 13px;
290 margin-top: 2px;
291 }
292 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
293 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
294 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
295 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
296 .prs-row-body { flex: 1; min-width: 0; }
297 .prs-row-title {
298 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
299 font-size: 15px; font-weight: 600;
300 color: var(--text-strong);
301 line-height: 1.35;
302 margin: 0 0 6px;
303 }
304 .prs-row-number {
305 color: var(--text-muted);
306 font-weight: 400;
307 font-size: 14px;
308 }
309 .prs-row-meta {
310 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
311 font-size: 12.5px;
312 color: var(--text-muted);
313 }
314 .prs-branch-chips {
315 display: inline-flex; align-items: center; gap: 6px;
316 font-family: var(--font-mono);
317 font-size: 11.5px;
318 }
319 .prs-branch-chip {
320 padding: 2px 8px;
321 border-radius: 9999px;
322 background: var(--bg-tertiary);
323 border: 1px solid var(--border);
324 color: var(--text);
325 }
326 .prs-branch-arrow {
327 color: var(--text-faint);
328 font-size: 11px;
329 }
330 .prs-row-tags {
331 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
332 margin-left: auto;
333 }
334 .prs-tag {
335 display: inline-flex; align-items: center; gap: 4px;
336 padding: 2px 8px;
337 font-size: 11px;
338 font-weight: 600;
339 border-radius: 9999px;
340 border: 1px solid var(--border);
341 background: var(--bg-secondary);
342 color: var(--text-muted);
343 line-height: 1.6;
344 }
345 .prs-tag.is-draft {
346 color: var(--text-muted);
347 border-color: var(--border-strong);
348 }
349 .prs-tag.is-merged {
350 color: var(--text-link);
351 border-color: rgba(140,109,255,0.45);
352 background: rgba(140,109,255,0.10);
353 }
1aef949Claude354 .prs-tag.is-approved {
355 color: #34d399;
356 border-color: rgba(52,211,153,0.40);
357 background: rgba(52,211,153,0.08);
358 }
359 .prs-tag.is-changes {
360 color: #f87171;
361 border-color: rgba(248,113,113,0.40);
362 background: rgba(248,113,113,0.08);
363 }
b078860Claude364
365 .prs-empty {
ea9ed4cClaude366 position: relative;
367 padding: 56px 32px;
b078860Claude368 text-align: center;
369 border: 1px dashed var(--border);
ea9ed4cClaude370 border-radius: 16px;
371 background: var(--bg-elevated);
b078860Claude372 color: var(--text-muted);
ea9ed4cClaude373 overflow: hidden;
b078860Claude374 }
ea9ed4cClaude375 .prs-empty::before {
376 content: '';
377 position: absolute;
378 inset: -40% -20% auto auto;
379 width: 320px; height: 320px;
380 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
381 filter: blur(70px);
382 opacity: 0.55;
383 pointer-events: none;
384 animation: prsEmptyOrb 16s ease-in-out infinite;
385 }
386 @keyframes prsEmptyOrb {
387 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
388 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
389 }
390 @media (prefers-reduced-motion: reduce) {
391 .prs-empty::before { animation: none; }
392 }
393 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude394 .prs-empty strong {
395 display: block;
396 color: var(--text-strong);
ea9ed4cClaude397 font-family: var(--font-display);
398 font-size: 22px;
399 font-weight: 700;
400 letter-spacing: -0.018em;
401 margin-bottom: 2px;
402 }
403 .prs-empty-sub {
404 font-size: 14.5px;
405 color: var(--text-muted);
406 line-height: 1.55;
407 max-width: 460px;
408 margin: 0 0 18px;
b078860Claude409 }
ea9ed4cClaude410 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude411
412 @media (max-width: 720px) {
413 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
414 .prs-hero-actions { width: 100%; }
415 .prs-row-tags { margin-left: 0; }
416 }
f1dc7c7Claude417
418 /* Additional mobile rules. Additive only. */
419 @media (max-width: 720px) {
420 .prs-hero { padding: 18px 18px 20px; }
421 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
422 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
423 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
424 .prs-row { padding: 12px 14px; gap: 10px; }
425 .prs-row-icon { width: 24px; height: 24px; }
426 }
f5b9ef5Claude427
428 /* ─── Sort controls (PR list) ─── */
429 .prs-sort-row {
430 display: flex;
431 align-items: center;
432 gap: 6px;
433 margin: 0 0 12px;
434 flex-wrap: wrap;
435 }
436 .prs-sort-label {
437 font-size: 12.5px;
438 color: var(--text-muted);
439 font-weight: 600;
440 margin-right: 2px;
441 }
442 .prs-sort-opt {
443 font-size: 12.5px;
444 color: var(--text-muted);
445 text-decoration: none;
446 padding: 3px 10px;
447 border-radius: 9999px;
448 border: 1px solid transparent;
449 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
450 }
451 .prs-sort-opt:hover {
452 background: var(--bg-hover);
453 color: var(--text);
454 }
455 .prs-sort-opt.is-active {
456 background: rgba(140,109,255,0.12);
457 color: var(--text-link);
458 border-color: rgba(140,109,255,0.35);
459 font-weight: 600;
460 }
b078860Claude461`;
462
463/* ──────────────────────────────────────────────────────────────────────
464 * Inline CSS for the detail page. Same `.prs-*` namespace.
465 * ──────────────────────────────────────────────────────────────────── */
466const PRS_DETAIL_STYLES = `
467 .prs-detail-hero {
468 position: relative;
469 margin: 0 0 var(--space-4);
470 padding: 24px 26px;
471 background: var(--bg-elevated);
472 border: 1px solid var(--border);
473 border-radius: 16px;
474 overflow: hidden;
475 }
476 .prs-detail-hero::before {
477 content: '';
478 position: absolute; top: 0; left: 0; right: 0;
479 height: 2px;
480 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
481 opacity: 0.7;
482 pointer-events: none;
483 }
484 .prs-detail-title {
485 font-family: var(--font-display);
486 font-size: clamp(22px, 2.6vw, 28px);
487 font-weight: 700;
488 letter-spacing: -0.022em;
489 line-height: 1.2;
490 color: var(--text-strong);
491 margin: 0 0 12px;
492 }
493 .prs-detail-num {
494 color: var(--text-muted);
495 font-weight: 400;
496 }
497 .prs-state-pill {
498 display: inline-flex; align-items: center; gap: 6px;
499 padding: 6px 12px;
500 border-radius: 9999px;
501 font-size: 12.5px;
502 font-weight: 600;
503 line-height: 1;
504 border: 1px solid transparent;
505 }
506 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
507 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
508 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
509 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
510
74d8c4dClaude511 .prs-size-badge {
512 display: inline-flex;
513 align-items: center;
514 padding: 2px 8px;
515 border-radius: 20px;
516 font-size: 11px;
517 font-weight: 700;
518 letter-spacing: 0.04em;
519 border: 1px solid currentColor;
520 opacity: 0.85;
521 }
522
b078860Claude523 .prs-detail-meta {
524 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
525 font-size: 13px;
526 color: var(--text-muted);
527 }
528 .prs-detail-meta strong { color: var(--text); }
529 .prs-detail-branches {
530 display: inline-flex; align-items: center; gap: 6px;
531 font-family: var(--font-mono);
532 font-size: 12px;
533 }
534 .prs-branch-pill {
535 padding: 3px 9px;
536 border-radius: 9999px;
537 background: var(--bg-tertiary);
538 border: 1px solid var(--border);
539 color: var(--text);
540 }
541 .prs-branch-pill.is-head { color: var(--text-strong); }
542 .prs-branch-arrow-lg {
543 color: var(--accent);
544 font-size: 14px;
545 font-weight: 700;
546 }
0369e77Claude547 .prs-branch-sync {
548 display: inline-flex; align-items: center; gap: 4px;
549 font-size: 11.5px; font-weight: 600;
550 padding: 2px 8px;
551 border-radius: 9999px;
552 border: 1px solid var(--border);
553 background: var(--bg-secondary);
554 color: var(--text-muted);
555 cursor: default;
556 }
557 .prs-branch-sync.is-behind {
558 color: #f87171;
559 border-color: rgba(248,113,113,0.35);
560 background: rgba(248,113,113,0.07);
561 }
562 .prs-branch-sync.is-synced {
563 color: #34d399;
564 border-color: rgba(52,211,153,0.35);
565 background: rgba(52,211,153,0.07);
566 }
b078860Claude567
568 .prs-detail-actions {
569 display: inline-flex; gap: 8px; margin-left: auto;
570 }
571
572 .prs-detail-tabs {
573 display: flex; gap: 4px;
574 margin: 0 0 16px;
575 border-bottom: 1px solid var(--border);
576 }
577 .prs-detail-tab {
578 padding: 10px 14px;
579 font-size: 13.5px;
580 font-weight: 500;
581 color: var(--text-muted);
582 text-decoration: none;
583 border-bottom: 2px solid transparent;
584 transition: color 120ms ease, border-color 120ms ease;
585 margin-bottom: -1px;
586 }
587 .prs-detail-tab:hover { color: var(--text); }
588 .prs-detail-tab.is-active {
589 color: var(--text-strong);
590 border-bottom-color: var(--accent);
591 }
592 .prs-detail-tab-count {
593 display: inline-flex; align-items: center; justify-content: center;
594 min-width: 20px; padding: 0 6px; margin-left: 6px;
595 height: 18px;
596 font-size: 11px;
597 font-weight: 600;
598 border-radius: 9999px;
599 background: var(--bg-tertiary);
600 color: var(--text-muted);
601 }
602
603 /* Gate / check status section */
604 .prs-gate-card {
605 margin-top: 20px;
606 background: var(--bg-elevated);
607 border: 1px solid var(--border);
608 border-radius: 14px;
609 overflow: hidden;
610 }
611 .prs-gate-head {
612 display: flex; align-items: center; gap: 10px;
613 padding: 14px 18px;
614 border-bottom: 1px solid var(--border);
615 }
616 .prs-gate-head h3 {
617 margin: 0;
618 font-size: 14px;
619 font-weight: 600;
620 color: var(--text-strong);
621 }
622 .prs-gate-summary {
623 margin-left: auto;
624 font-size: 12px;
625 color: var(--text-muted);
626 }
627 .prs-gate-row {
628 display: flex; align-items: center; gap: 12px;
629 padding: 12px 18px;
630 border-bottom: 1px solid var(--border-subtle);
631 }
632 .prs-gate-row:last-child { border-bottom: 0; }
633 .prs-gate-icon {
634 flex: 0 0 auto;
635 width: 22px; height: 22px;
636 display: inline-flex; align-items: center; justify-content: center;
637 border-radius: 9999px;
638 font-size: 12px;
639 font-weight: 700;
640 }
641 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
642 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
643 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
644 .prs-gate-name {
645 font-size: 13px;
646 font-weight: 600;
647 color: var(--text);
648 min-width: 140px;
649 }
650 .prs-gate-details {
651 flex: 1; min-width: 0;
652 font-size: 12.5px;
653 color: var(--text-muted);
654 }
655 .prs-gate-pill {
656 flex: 0 0 auto;
657 padding: 3px 10px;
658 border-radius: 9999px;
659 font-size: 11px;
660 font-weight: 600;
661 line-height: 1.5;
662 border: 1px solid transparent;
663 }
664 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
665 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
666 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
667 .prs-gate-footer {
668 padding: 12px 18px;
669 background: var(--bg-secondary);
670 font-size: 12px;
671 color: var(--text-muted);
672 }
673
674 /* Comment cards */
675 .prs-comment {
676 margin-top: 14px;
677 background: var(--bg-elevated);
678 border: 1px solid var(--border);
679 border-radius: 12px;
680 overflow: hidden;
681 }
682 .prs-comment-head {
683 display: flex; align-items: center; gap: 10px;
684 padding: 10px 14px;
685 background: var(--bg-secondary);
686 border-bottom: 1px solid var(--border);
687 font-size: 13px;
688 flex-wrap: wrap;
689 }
690 .prs-comment-head strong { color: var(--text-strong); }
691 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
692 .prs-comment-loc {
693 font-family: var(--font-mono);
694 font-size: 11.5px;
695 color: var(--text-muted);
696 background: var(--bg-tertiary);
697 padding: 2px 8px;
698 border-radius: 6px;
699 }
700 .prs-comment-body { padding: 14px 18px; }
701 .prs-comment.is-ai {
702 border-color: rgba(140,109,255,0.45);
703 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
704 }
705 .prs-comment.is-ai .prs-comment-head {
706 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
707 border-bottom-color: rgba(140,109,255,0.30);
708 }
709 .prs-ai-badge {
710 display: inline-flex; align-items: center; gap: 4px;
711 padding: 2px 9px;
712 font-size: 10.5px;
713 font-weight: 700;
714 letter-spacing: 0.04em;
715 text-transform: uppercase;
716 color: #fff;
717 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
718 border-radius: 9999px;
719 }
a7460bfClaude720 .prs-bot-badge {
721 display: inline-flex; align-items: center; gap: 3px;
722 padding: 1px 7px;
723 font-size: 10px;
724 font-weight: 600;
725 color: var(--fg-muted);
726 background: var(--bg-elevated);
727 border: 1px solid var(--border);
728 border-radius: 9999px;
729 }
b078860Claude730
731 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
732 .prs-files-card {
733 margin-top: 18px;
734 padding: 14px 18px;
735 display: flex; align-items: center; gap: 14px;
736 background: var(--bg-elevated);
737 border: 1px solid var(--border);
738 border-radius: 12px;
739 text-decoration: none;
740 color: inherit;
741 transition: border-color 120ms ease, transform 140ms ease;
742 }
743 .prs-files-card:hover {
744 border-color: rgba(140,109,255,0.45);
745 transform: translateY(-1px);
746 }
747 .prs-files-card-icon {
748 width: 36px; height: 36px;
749 display: inline-flex; align-items: center; justify-content: center;
750 border-radius: 10px;
751 background: rgba(140,109,255,0.12);
752 color: var(--text-link);
753 font-size: 18px;
754 }
755 .prs-files-card-text { flex: 1; min-width: 0; }
756 .prs-files-card-title {
757 font-size: 14px;
758 font-weight: 600;
759 color: var(--text-strong);
760 margin: 0 0 2px;
761 }
762 .prs-files-card-sub {
763 font-size: 12.5px;
764 color: var(--text-muted);
765 margin: 0;
766 }
767 .prs-files-card-cta {
768 font-size: 12.5px;
769 color: var(--text-link);
770 font-weight: 600;
771 }
772
773 /* Merge area */
774 .prs-merge-card {
775 position: relative;
776 margin-top: 22px;
777 padding: 18px;
778 background: var(--bg-elevated);
779 border-radius: 14px;
780 overflow: hidden;
781 }
782 .prs-merge-card::before {
783 content: '';
784 position: absolute; inset: 0;
785 padding: 1px;
786 border-radius: 14px;
787 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
788 -webkit-mask:
789 linear-gradient(#000 0 0) content-box,
790 linear-gradient(#000 0 0);
791 -webkit-mask-composite: xor;
792 mask-composite: exclude;
793 pointer-events: none;
794 }
795 .prs-merge-card.is-closed::before { background: var(--border-strong); }
796 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
797 .prs-merge-head {
798 display: flex; align-items: center; gap: 12px;
799 margin-bottom: 12px;
800 }
801 .prs-merge-head strong {
802 font-family: var(--font-display);
803 font-size: 15px;
804 color: var(--text-strong);
805 font-weight: 700;
806 }
807 .prs-merge-sub {
808 font-size: 13px;
809 color: var(--text-muted);
810 margin: 0 0 12px;
811 }
812 .prs-merge-actions {
813 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
814 }
815 .prs-merge-btn {
816 display: inline-flex; align-items: center; gap: 6px;
817 padding: 9px 16px;
818 border-radius: 10px;
819 font-size: 13.5px;
820 font-weight: 600;
821 color: #fff;
822 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
823 border: 1px solid rgba(52,211,153,0.55);
824 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
825 cursor: pointer;
826 transition: transform 120ms ease, box-shadow 160ms ease;
827 }
828 .prs-merge-btn:hover {
829 transform: translateY(-1px);
830 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
831 }
832 .prs-merge-btn[disabled],
833 .prs-merge-btn.is-disabled {
834 opacity: 0.55;
835 cursor: not-allowed;
836 transform: none;
837 box-shadow: none;
838 }
839 .prs-merge-ready-btn {
840 display: inline-flex; align-items: center; gap: 6px;
841 padding: 9px 16px;
842 border-radius: 10px;
843 font-size: 13.5px;
844 font-weight: 600;
845 color: #fff;
846 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
847 border: 1px solid rgba(140,109,255,0.55);
848 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
849 cursor: pointer;
850 transition: transform 120ms ease, box-shadow 160ms ease;
851 }
852 .prs-merge-ready-btn:hover {
853 transform: translateY(-1px);
854 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
855 }
856 .prs-merge-back-draft {
857 background: none; border: 1px solid var(--border-strong);
858 color: var(--text-muted);
859 padding: 9px 14px; border-radius: 10px;
860 font-size: 13px; cursor: pointer;
861 }
862 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
863
a164a6dClaude864 /* Merge strategy selector */
865 .prs-merge-strategy-wrap {
866 display: inline-flex; align-items: center;
867 background: var(--bg-elevated);
868 border: 1px solid var(--border);
869 border-radius: 10px;
870 overflow: hidden;
871 }
872 .prs-merge-strategy-label {
873 font-size: 11.5px; font-weight: 600;
874 color: var(--text-muted);
875 padding: 0 10px 0 12px;
876 white-space: nowrap;
877 }
878 .prs-merge-strategy-select {
879 background: transparent;
880 border: none;
881 color: var(--text);
882 font-size: 13px;
883 padding: 7px 10px 7px 4px;
884 cursor: pointer;
885 outline: none;
886 appearance: auto;
887 }
888 .prs-merge-strategy-select:focus { outline: 2px solid rgba(140,109,255,0.45); }
889
0a67773Claude890 /* Review summary banner */
891 .prs-review-summary {
892 display: flex; flex-direction: column; gap: 6px;
893 padding: 12px 16px;
894 background: var(--bg-elevated);
895 border: 1px solid var(--border);
896 border-radius: var(--r-md, 8px);
897 margin-bottom: 12px;
898 }
899 .prs-review-row {
900 display: flex; align-items: center; gap: 10px;
901 font-size: 13px;
902 }
903 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
904 .prs-review-approved .prs-review-icon { color: #34d399; }
905 .prs-review-changes .prs-review-icon { color: #f87171; }
ace34efClaude906 .prs-reviewer-avatar {
907 width: 24px; height: 24px; border-radius: 50%;
908 background: var(--accent); color: #fff;
909 display: flex; align-items: center; justify-content: center;
910 font-size: 11px; font-weight: 700; flex-shrink: 0;
911 }
0a67773Claude912
913 /* Review action buttons */
914 .prs-review-approve-btn {
915 display: inline-flex; align-items: center; gap: 5px;
916 padding: 8px 14px; border-radius: 8px; font-size: 13px;
917 font-weight: 600; cursor: pointer;
918 background: rgba(52,211,153,0.12);
919 color: #34d399;
920 border: 1px solid rgba(52,211,153,0.35);
921 transition: background 120ms;
922 }
923 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
924 .prs-review-changes-btn {
925 display: inline-flex; align-items: center; gap: 5px;
926 padding: 8px 14px; border-radius: 8px; font-size: 13px;
927 font-weight: 600; cursor: pointer;
928 background: rgba(248,113,113,0.10);
929 color: #f87171;
930 border: 1px solid rgba(248,113,113,0.30);
931 transition: background 120ms;
932 }
933 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
934
b078860Claude935 /* Inline form helpers */
936 .prs-inline-form { display: inline-flex; }
937
938 /* Comment composer */
939 .prs-composer { margin-top: 22px; }
940 .prs-composer textarea {
941 border-radius: 12px;
942 }
943
944 @media (max-width: 720px) {
945 .prs-detail-actions { margin-left: 0; }
946 .prs-merge-actions { width: 100%; }
947 .prs-merge-actions > * { flex: 1; min-width: 0; }
948 }
f1dc7c7Claude949
950 /* Additional mobile rules. Additive only. */
951 @media (max-width: 720px) {
952 .prs-detail-hero { padding: 18px; }
953 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
954 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
955 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
956 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
957 .prs-gate-name { min-width: 0; }
958 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
959 .prs-gate-summary { margin-left: 0; }
960 .prs-merge-btn,
961 .prs-merge-ready-btn,
962 .prs-merge-back-draft { min-height: 44px; }
963 .prs-comment-body { padding: 12px 14px; }
964 .prs-comment-head { padding: 10px 12px; }
965 .prs-files-card { padding: 12px 14px; }
966 }
3c03977Claude967
968 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
969 .live-pill {
970 display: inline-flex;
971 align-items: center;
972 gap: 8px;
973 padding: 4px 10px 4px 8px;
974 margin-left: 6px;
975 background: var(--bg-elevated);
976 border: 1px solid var(--border);
977 border-radius: 9999px;
978 font-size: 12px;
979 color: var(--text-muted);
980 line-height: 1;
981 vertical-align: middle;
982 }
983 .live-pill.is-busy { color: var(--text); }
984 .live-pill-dot {
985 width: 8px; height: 8px;
986 border-radius: 9999px;
987 background: #34d399;
988 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
989 animation: live-pulse 1.6s ease-in-out infinite;
990 }
991 @keyframes live-pulse {
992 0%, 100% { opacity: 1; }
993 50% { opacity: 0.55; }
994 }
995 .live-avatars {
996 display: inline-flex;
997 margin-left: 2px;
998 }
999 .live-avatar {
1000 display: inline-flex;
1001 align-items: center;
1002 justify-content: center;
1003 width: 22px; height: 22px;
1004 border-radius: 9999px;
1005 font-size: 10px;
1006 font-weight: 700;
1007 color: #0b1020;
1008 margin-left: -6px;
1009 border: 2px solid var(--bg-elevated);
1010 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
1011 }
1012 .live-avatar:first-child { margin-left: 0; }
1013 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
1014 .live-cursor-host {
1015 position: relative;
1016 }
1017 .live-cursor-overlay {
1018 position: absolute;
1019 inset: 0;
1020 pointer-events: none;
1021 overflow: hidden;
1022 border-radius: inherit;
1023 }
1024 .live-cursor {
1025 position: absolute;
1026 width: 2px;
1027 height: 18px;
1028 border-radius: 2px;
1029 transform: translate(-1px, 0);
1030 transition: transform 80ms linear, opacity 200ms ease;
1031 }
1032 .live-cursor::after {
1033 content: attr(data-label);
1034 position: absolute;
1035 top: -16px;
1036 left: -2px;
1037 font-size: 10px;
1038 line-height: 1;
1039 color: #0b1020;
1040 background: inherit;
1041 padding: 2px 5px;
1042 border-radius: 4px 4px 4px 0;
1043 white-space: nowrap;
1044 font-weight: 600;
1045 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
1046 }
1047 .live-cursor.is-idle { opacity: 0.4; }
1048 .live-edit-tag {
1049 display: inline-block;
1050 margin-left: 6px;
1051 padding: 1px 6px;
1052 font-size: 10px;
1053 font-weight: 600;
1054 letter-spacing: 0.02em;
1055 color: #0b1020;
1056 border-radius: 9999px;
1057 }
15db0e0Claude1058
1059 /* ─── Slash-command pill + composer hint ─── */
1060 .slash-hint {
1061 display: inline-flex;
1062 align-items: center;
1063 gap: 6px;
1064 margin-top: 6px;
1065 padding: 3px 9px;
1066 font-size: 11.5px;
1067 color: var(--text-muted);
1068 background: var(--bg-elevated);
1069 border: 1px dashed var(--border);
1070 border-radius: 9999px;
1071 width: fit-content;
1072 }
1073 .slash-hint code {
1074 background: rgba(110, 168, 255, 0.12);
1075 color: var(--text-strong);
1076 padding: 0 5px;
1077 border-radius: 4px;
1078 font-size: 11px;
1079 }
1080 .slash-pill {
1081 display: grid;
1082 grid-template-columns: auto 1fr auto;
1083 align-items: center;
1084 column-gap: 10px;
1085 row-gap: 6px;
1086 margin: 10px 0;
1087 padding: 10px 14px;
1088 background: linear-gradient(
1089 135deg,
1090 rgba(110, 168, 255, 0.08),
1091 rgba(163, 113, 247, 0.06)
1092 );
1093 border: 1px solid rgba(110, 168, 255, 0.32);
1094 border-left: 3px solid var(--accent, #6ea8ff);
1095 border-radius: var(--radius);
1096 font-size: 13px;
1097 color: var(--text);
1098 }
1099 .slash-pill-icon {
1100 font-size: 14px;
1101 line-height: 1;
1102 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
1103 }
1104 .slash-pill-actor { color: var(--text-muted); }
1105 .slash-pill-actor strong { color: var(--text-strong); }
1106 .slash-pill-cmd {
1107 background: rgba(110, 168, 255, 0.16);
1108 color: var(--text-strong);
1109 padding: 1px 6px;
1110 border-radius: 4px;
1111 font-size: 12.5px;
1112 }
1113 .slash-pill-time {
1114 color: var(--text-muted);
1115 font-size: 12px;
1116 justify-self: end;
1117 }
1118 .slash-pill-body {
1119 grid-column: 1 / -1;
1120 color: var(--text);
1121 font-size: 13px;
1122 line-height: 1.55;
1123 }
1124 .slash-pill-body p:first-child { margin-top: 0; }
1125 .slash-pill-body p:last-child { margin-bottom: 0; }
1126 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
1127 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1128 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
1129 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude1130
1131 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
1132 .preview-prpill {
1133 display: inline-flex; align-items: center; gap: 6px;
1134 padding: 3px 10px;
1135 border-radius: 9999px;
1136 font-family: var(--font-mono);
1137 font-size: 11.5px;
1138 font-weight: 600;
1139 background: rgba(255,255,255,0.04);
1140 color: var(--text-muted);
1141 text-decoration: none;
1142 border: 1px solid var(--border);
1143 }
1144 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
1145 .preview-prpill .preview-prpill-dot {
1146 width: 7px; height: 7px;
1147 border-radius: 9999px;
1148 background: currentColor;
1149 }
1150 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1151 .preview-prpill.is-building .preview-prpill-dot {
1152 animation: previewPrPulse 1.4s ease-in-out infinite;
1153 }
1154 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1155 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1156 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1157 @keyframes previewPrPulse {
1158 0%, 100% { opacity: 1; }
1159 50% { opacity: 0.4; }
1160 }
79ed944Claude1161
1162 /* ─── AI Trio Review — 3-column verdict cards ─── */
1163 .trio-wrap {
1164 margin-top: 18px;
1165 padding: 16px;
1166 background: var(--bg-elevated);
1167 border: 1px solid var(--border);
1168 border-radius: 14px;
1169 }
1170 .trio-header {
1171 display: flex; align-items: center; gap: 10px;
1172 margin: 0 0 12px;
1173 font-size: 13.5px;
1174 color: var(--text);
1175 }
1176 .trio-header strong { color: var(--text-strong); }
1177 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1178 .trio-header-dot {
1179 width: 8px; height: 8px; border-radius: 9999px;
1180 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1181 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1182 }
1183 .trio-grid {
1184 display: grid;
1185 grid-template-columns: repeat(3, minmax(0, 1fr));
1186 gap: 12px;
1187 }
1188 .trio-card {
1189 background: var(--bg-secondary);
1190 border: 1px solid var(--border);
1191 border-radius: 12px;
1192 overflow: hidden;
1193 display: flex; flex-direction: column;
1194 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1195 }
1196 .trio-card-head {
1197 display: flex; align-items: center; gap: 8px;
1198 padding: 10px 12px;
1199 border-bottom: 1px solid var(--border);
1200 background: rgba(255,255,255,0.02);
1201 font-size: 13px;
1202 }
1203 .trio-card-icon {
1204 display: inline-flex; align-items: center; justify-content: center;
1205 width: 22px; height: 22px;
1206 border-radius: 9999px;
1207 font-size: 12px;
1208 background: rgba(255,255,255,0.05);
1209 }
1210 .trio-card-title {
1211 color: var(--text-strong);
1212 font-weight: 600;
1213 letter-spacing: 0.01em;
1214 }
1215 .trio-card-verdict {
1216 margin-left: auto;
1217 font-size: 11px;
1218 font-weight: 700;
1219 letter-spacing: 0.06em;
1220 text-transform: uppercase;
1221 padding: 3px 9px;
1222 border-radius: 9999px;
1223 background: var(--bg-tertiary);
1224 color: var(--text-muted);
1225 border: 1px solid var(--border-strong);
1226 }
1227 .trio-card-body {
1228 padding: 12px 14px;
1229 font-size: 13px;
1230 color: var(--text);
1231 flex: 1;
1232 min-height: 64px;
1233 line-height: 1.55;
1234 }
1235 .trio-card-body p { margin: 0 0 8px; }
1236 .trio-card-body p:last-child { margin-bottom: 0; }
1237 .trio-card-body ul { margin: 0; padding-left: 18px; }
1238 .trio-card-body code {
1239 font-family: var(--font-mono);
1240 font-size: 12px;
1241 background: var(--bg-tertiary);
1242 padding: 1px 6px;
1243 border-radius: 5px;
1244 }
1245 .trio-card-empty {
1246 color: var(--text-muted);
1247 font-style: italic;
1248 font-size: 12.5px;
1249 }
1250
1251 /* Pass state — neutral, no accent. */
1252 .trio-card.is-pass .trio-card-verdict {
1253 color: var(--green);
1254 border-color: rgba(52,211,153,0.35);
1255 background: rgba(52,211,153,0.12);
1256 }
1257
1258 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1259 .trio-card.trio-security.is-fail {
1260 border-color: rgba(248,113,113,0.55);
1261 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1262 }
1263 .trio-card.trio-security.is-fail .trio-card-head {
1264 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1265 border-bottom-color: rgba(248,113,113,0.30);
1266 }
1267 .trio-card.trio-security.is-fail .trio-card-verdict {
1268 color: #fecaca;
1269 border-color: rgba(248,113,113,0.55);
1270 background: rgba(248,113,113,0.20);
1271 }
1272
1273 .trio-card.trio-correctness.is-fail {
1274 border-color: rgba(251,191,36,0.55);
1275 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1276 }
1277 .trio-card.trio-correctness.is-fail .trio-card-head {
1278 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1279 border-bottom-color: rgba(251,191,36,0.30);
1280 }
1281 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1282 color: #fde68a;
1283 border-color: rgba(251,191,36,0.55);
1284 background: rgba(251,191,36,0.20);
1285 }
1286
1287 .trio-card.trio-style.is-fail {
1288 border-color: rgba(96,165,250,0.55);
1289 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1290 }
1291 .trio-card.trio-style.is-fail .trio-card-head {
1292 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1293 border-bottom-color: rgba(96,165,250,0.30);
1294 }
1295 .trio-card.trio-style.is-fail .trio-card-verdict {
1296 color: #bfdbfe;
1297 border-color: rgba(96,165,250,0.55);
1298 background: rgba(96,165,250,0.20);
1299 }
1300
1301 /* Disagreement callout strip — yellow, prominent. */
1302 .trio-disagreement-strip {
1303 display: flex;
1304 gap: 12px;
1305 margin-top: 14px;
1306 padding: 12px 14px;
1307 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1308 border: 1px solid rgba(251,191,36,0.45);
1309 border-radius: 10px;
1310 color: var(--text);
1311 font-size: 13px;
1312 }
1313 .trio-disagreement-icon {
1314 flex: 0 0 auto;
1315 width: 26px; height: 26px;
1316 display: inline-flex; align-items: center; justify-content: center;
1317 border-radius: 9999px;
1318 background: rgba(251,191,36,0.25);
1319 color: #fde68a;
1320 font-size: 14px;
1321 }
1322 .trio-disagreement-body strong {
1323 display: block;
1324 color: #fde68a;
1325 margin: 0 0 4px;
1326 font-weight: 700;
1327 }
1328 .trio-disagreement-list {
1329 margin: 0;
1330 padding-left: 18px;
1331 color: var(--text);
1332 font-size: 12.5px;
1333 line-height: 1.55;
1334 }
1335 .trio-disagreement-list code {
1336 font-family: var(--font-mono);
1337 font-size: 11.5px;
1338 background: var(--bg-tertiary);
1339 padding: 1px 5px;
1340 border-radius: 4px;
1341 }
1342
1343 @media (max-width: 720px) {
1344 .trio-grid { grid-template-columns: 1fr; }
1345 .trio-wrap { padding: 12px; }
1346 }
6d1bbc2Claude1347
1348 /* ─── Task list progress pill ─── */
1349 .prs-tasks-pill {
1350 display: inline-flex; align-items: center; gap: 5px;
1351 font-size: 11.5px; font-weight: 600;
1352 padding: 2px 9px; border-radius: 9999px;
1353 border: 1px solid var(--border);
1354 background: var(--bg-elevated);
1355 color: var(--text-muted);
1356 }
1357 .prs-tasks-pill.is-complete {
1358 color: #34d399;
1359 border-color: rgba(52,211,153,0.40);
1360 background: rgba(52,211,153,0.08);
1361 }
1362 .prs-tasks-progress { display: inline-block; width: 36px; height: 4px; border-radius: 9999px; background: var(--border); overflow: hidden; vertical-align: middle; }
1363 .prs-tasks-progress-bar { height: 100%; border-radius: 9999px; background: #34d399; }
1364
1365 /* ─── Update branch button ─── */
1366 .prs-update-branch-btn {
1367 display: inline-flex; align-items: center; gap: 5px;
1368 padding: 4px 12px; border-radius: 8px; font-size: 12.5px;
1369 font-weight: 600; cursor: pointer;
1370 background: rgba(96,165,250,0.10);
1371 color: #60a5fa;
1372 border: 1px solid rgba(96,165,250,0.30);
1373 transition: background 120ms;
1374 }
1375 .prs-update-branch-btn:hover { background: rgba(96,165,250,0.20); }
1376
1377 /* ─── Linked issues panel ─── */
1378 .prs-linked-issues {
1379 margin-top: 16px;
1380 border: 1px solid var(--border);
1381 border-radius: 12px;
1382 overflow: hidden;
1383 }
1384 .prs-linked-issues-head {
1385 display: flex; align-items: center; justify-content: space-between;
1386 padding: 10px 16px;
1387 background: var(--bg-elevated);
1388 border-bottom: 1px solid var(--border);
1389 font-size: 13px; font-weight: 600; color: var(--text);
1390 }
1391 .prs-linked-issues-count {
1392 font-size: 11px; font-weight: 700;
1393 padding: 1px 7px; border-radius: 9999px;
1394 background: var(--bg-tertiary);
1395 color: var(--text-muted);
1396 }
1397 .prs-linked-issue-row {
1398 display: flex; align-items: center; gap: 10px;
1399 padding: 9px 16px;
1400 border-bottom: 1px solid var(--border);
1401 font-size: 13px;
1402 text-decoration: none; color: inherit;
1403 }
1404 .prs-linked-issue-row:last-child { border-bottom: none; }
1405 .prs-linked-issue-row:hover { background: var(--bg-hover); }
1406 .prs-linked-issue-icon { flex: 0 0 auto; font-size: 14px; }
1407 .prs-linked-issue-icon.is-open { color: #34d399; }
1408 .prs-linked-issue-icon.is-closed { color: #8b949e; }
1409 .prs-linked-issue-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1410 .prs-linked-issue-num { color: var(--text-muted); font-size: 12px; }
1411 .prs-linked-issue-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
1412 .prs-linked-issue-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
1413 .prs-linked-issue-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
b558f23Claude1414
1415 /* ─── Commits tab ─── */
1416 .prs-commits-list { display: flex; flex-direction: column; gap: 0; margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1417 .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; }
1418 .prs-commit-row:last-child { border-bottom: none; }
1419 .prs-commit-row:hover { background: var(--bg-hover); }
1420 .prs-commit-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-top: 6px; }
1421 .prs-commit-body { flex: 1 1 auto; min-width: 0; }
1422 .prs-commit-msg { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
1423 .prs-commit-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
1424 .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; }
1425 .prs-commit-sha:hover { color: var(--accent); }
1426 .prs-commits-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13.5px; }
1427
1428 /* ─── Edit PR title/body ─── */
1429 .prs-edit-title-wrap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1430 .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; }
1431 .prs-edit-btn:hover { color: var(--text); border-color: var(--text-muted); }
1432 .prs-edit-form { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
1433 .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; }
1434 .prs-edit-actions { display: flex; gap: 8px; }
1435 .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; }
1436 .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; }
240c477Claude1437
1438 /* ─── CI status checks ─── */
1439 .prs-ci-card { margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1440 .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); }
1441 .prs-ci-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text); }
1442 .prs-ci-summary { font-size: 12px; color: var(--text-muted); }
1443 .prs-ci-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
1444 .prs-ci-row:last-child { border-bottom: none; }
1445 .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; }
1446 .prs-ci-icon.is-success { background: rgba(52,211,153,0.20); color: #34d399; }
1447 .prs-ci-icon.is-pending { background: rgba(251,191,36,0.20); color: #fbbf24; }
1448 .prs-ci-icon.is-failure, .prs-ci-icon.is-error { background: rgba(248,113,113,0.20); color: #f87171; }
1449 .prs-ci-context { flex: 1 1 auto; font-size: 13px; font-weight: 500; color: var(--text); }
1450 .prs-ci-desc { font-size: 12px; color: var(--text-muted); }
1451 .prs-ci-pill { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 9999px; }
1452 .prs-ci-pill.is-success { color: #34d399; background: rgba(52,211,153,0.10); }
1453 .prs-ci-pill.is-pending { color: #fbbf24; background: rgba(251,191,36,0.10); }
1454 .prs-ci-pill.is-failure, .prs-ci-pill.is-error { color: #f87171; background: rgba(248,113,113,0.10); }
1455 .prs-ci-link { font-size: 12px; color: var(--accent); text-decoration: none; }
1456 .prs-ci-link:hover { text-decoration: underline; }
67dc4e1Claude1457
1458 /* ─── AI Trio verdict pills (header summary) ─── */
1459 .trio-pill {
1460 display: inline-flex; align-items: center; gap: 4px;
1461 padding: 2px 8px;
1462 font-size: 11px;
1463 font-weight: 700;
1464 border-radius: 9999px;
1465 border: 1px solid transparent;
1466 text-decoration: none;
1467 line-height: 1.6;
1468 letter-spacing: 0.01em;
1469 cursor: pointer;
1470 transition: opacity 120ms ease;
1471 }
1472 .trio-pill:hover { opacity: 0.8; }
1473 .trio-pill.is-pass {
1474 color: #34d399;
1475 background: rgba(52,211,153,0.10);
1476 border-color: rgba(52,211,153,0.35);
1477 }
1478 .trio-pill.is-fail {
1479 color: #f87171;
1480 background: rgba(248,113,113,0.10);
1481 border-color: rgba(248,113,113,0.35);
1482 }
1483 .trio-pill.is-pending {
1484 color: var(--text-muted);
1485 background: rgba(255,255,255,0.04);
1486 border-color: var(--border-strong);
1487 }
1488 .trio-pills-wrap {
1489 display: inline-flex; align-items: center; gap: 4px;
1490 }
b078860Claude1491`;
1492
25b1ff7Claude1493/* ──────────────────────────────────────────────────────────────────────
1494 * Figma-style collaborative PR presence — styles for the presence bar
1495 * above the diff and the per-line reviewer cursor pills. All scoped
1496 * with `.presence-*` prefix so they never bleed into other views.
1497 * ──────────────────────────────────────────────────────────────────── */
1498const PRESENCE_STYLES = `
1499 .presence-bar {
1500 display: flex;
1501 align-items: center;
1502 gap: 10px;
1503 padding: 8px 14px;
1504 margin: 0 0 10px;
1505 background: var(--bg-elevated);
1506 border: 1px solid var(--border);
1507 border-radius: 10px;
1508 font-size: 12.5px;
1509 color: var(--text-muted);
1510 min-height: 38px;
1511 }
1512 .presence-bar-label {
1513 font-weight: 600;
1514 color: var(--text-muted);
1515 flex-shrink: 0;
1516 }
1517 .presence-avatars {
1518 display: flex;
1519 align-items: center;
1520 gap: 6px;
1521 flex: 1;
1522 flex-wrap: wrap;
1523 }
1524 .presence-avatar {
1525 display: inline-flex;
1526 align-items: center;
1527 gap: 5px;
1528 padding: 3px 8px 3px 4px;
1529 border-radius: 9999px;
1530 font-size: 12px;
1531 font-weight: 600;
1532 color: #fff;
1533 opacity: 0.92;
1534 transition: opacity 200ms;
1535 }
1536 .presence-avatar-dot {
1537 width: 20px; height: 20px;
1538 border-radius: 9999px;
1539 background: rgba(255,255,255,0.22);
1540 display: inline-flex;
1541 align-items: center;
1542 justify-content: center;
1543 font-size: 10px;
1544 font-weight: 700;
1545 flex-shrink: 0;
1546 }
1547 .presence-count {
1548 font-size: 12px;
1549 color: var(--text-faint);
1550 flex-shrink: 0;
1551 }
1552 /* Per-line reviewer cursor pill — injected by JS into .diff-row */
1553 .presence-line-pill {
1554 position: absolute;
1555 right: 6px;
1556 top: 50%;
1557 transform: translateY(-50%);
1558 display: inline-flex;
1559 align-items: center;
1560 gap: 4px;
1561 padding: 1px 7px;
1562 border-radius: 9999px;
1563 font-size: 10.5px;
1564 font-weight: 600;
1565 color: #fff;
1566 pointer-events: none;
1567 white-space: nowrap;
1568 z-index: 10;
1569 opacity: 0.88;
1570 animation: presence-in 160ms ease;
1571 }
1572 @keyframes presence-in {
1573 from { opacity: 0; transform: translateY(-50%) scale(0.85); }
1574 to { opacity: 0.88; transform: translateY(-50%) scale(1); }
1575 }
1576 .presence-line-pill.is-typing::after {
1577 content: '…';
1578 opacity: 0.7;
1579 }
1580 /* diff rows with a cursor pill need relative positioning */
1581 .diff-row { position: relative; }
1582 /* Toast for join/leave events */
1583 .presence-toast-wrap {
1584 position: fixed;
1585 bottom: 24px;
1586 right: 24px;
1587 display: flex;
1588 flex-direction: column;
1589 gap: 8px;
1590 z-index: 9999;
1591 pointer-events: none;
1592 }
1593 .presence-toast {
1594 padding: 8px 14px;
1595 border-radius: 8px;
1596 background: var(--bg-elevated);
1597 border: 1px solid var(--border);
1598 box-shadow: 0 6px 20px -8px rgba(0,0,0,0.55);
1599 font-size: 13px;
1600 color: var(--text);
1601 opacity: 1;
1602 transition: opacity 400ms;
1603 }
1604 .presence-toast.fading { opacity: 0; }
1605`;
1606
1607
81c73c1Claude1608/**
1609 * Tiny inline JS that drives the "Suggest description with AI" button.
1610 * On click, gathers form values, POSTs JSON to the given endpoint, and
1611 * pipes the response into the #pr-body textarea. All DOM lookups are
1612 * defensive — element absence is a silent no-op.
1613 *
1614 * Built as a string template so it lives next to its server-side caller
1615 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1616 * to avoid </script> breakouts.
1617 */
1618function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1619 const url = JSON.stringify(endpointUrl)
1620 .split("<").join("\\u003C")
1621 .split(">").join("\\u003E")
1622 .split("&").join("\\u0026");
1623 return (
1624 "(function(){try{" +
1625 "var btn=document.getElementById('ai-suggest-desc');" +
1626 "var status=document.getElementById('ai-suggest-status');" +
1627 "var body=document.getElementById('pr-body');" +
1628 "var form=btn&&btn.closest&&btn.closest('form');" +
1629 "if(!btn||!body||!form)return;" +
1630 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1631 "var fd=new FormData(form);" +
1632 "var title=String(fd.get('title')||'').trim();" +
1633 "var base=String(fd.get('base')||'').trim();" +
1634 "var head=String(fd.get('head')||'').trim();" +
1635 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1636 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1637 "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'})" +
1638 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1639 ".then(function(j){btn.disabled=false;" +
1640 "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;}}" +
1641 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1642 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1643 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1644 "});" +
1645 "}catch(e){}})();"
1646 );
1647}
1648
3c03977Claude1649/**
1650 * Live co-editing client. Connects to the per-PR SSE feed and:
1651 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1652 * status colour per user).
1653 * - Renders tinted cursor caret overlays inside #pr-body and every
1654 * `[data-live-field]` element.
1655 * - Broadcasts the local user's cursor position (selectionStart /
1656 * selectionEnd) debounced at 100ms.
1657 * - Broadcasts content patches (`replace` of the whole textarea —
1658 * last-write-wins v1) debounced at 250ms.
1659 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1660 * to the matching local field if untouched.
1661 *
1662 * All endpoint URLs are JSON-escaped via safe replacements so they
1663 * can't break out of the <script> tag.
1664 */
25b1ff7Claude1665
1666/**
1667 * Figma-style collaborative PR presence client (WebSocket).
1668 *
1669 * Connects to `GET /:owner/:repo/pulls/:number/presence` (WebSocket upgrade).
1670 * On connect the server sends `{type:"init", users:[...]}` so the bar renders
1671 * immediately. Subsequent messages from the server drive the presence bar and
1672 * per-line cursor pills in the diff.
1673 *
1674 * Outbound messages:
1675 * {type:"cursor", line: N} — user hovered a diff line
1676 * {type:"typing", line: N, typing: bool} — textarea focus/blur in diff
1677 * {type:"ping"} — keep-alive every 10s
1678 *
1679 * Inbound messages:
1680 * {type:"init", users:[{sessionId,username,colour,line,typing}]}
1681 * {type:"join", user:{sessionId,username,colour,line,typing}}
1682 * {type:"leave", sessionId}
1683 * {type:"cursor", sessionId, username, colour, line}
1684 * {type:"typing", sessionId, username, colour, line, typing}
1685 */
1686function PR_PRESENCE_SCRIPT(owner: string, repo: string, prNum: number): string {
1687 const wsPath = JSON.stringify(`/${owner}/${repo}/pulls/${prNum}/presence`)
1688 .split("<").join("\\u003C")
1689 .split(">").join("\\u003E")
1690 .split("&").join("\\u0026");
1691 return `(function(){
1692try{
1693var wsPath=${wsPath};
1694var proto=location.protocol==='https:'?'wss:':'ws:';
1695var url=proto+'//'+location.host+wsPath;
1696var mySessionId=null;
1697// sessionId -> {username, colour, line, typing}
1698var peers={};
1699var ws=null;
1700var pingTimer=null;
1701var reconnectDelay=1500;
1702var reconnectTimer=null;
1703
1704function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
1705
1706// ── Toast ──────────────────────────────────────────────────────────────
1707var toastWrap=document.getElementById('presence-toasts');
1708function toast(msg){
1709 if(!toastWrap)return;
1710 var t=document.createElement('div');
1711 t.className='presence-toast';
1712 t.textContent=msg;
1713 toastWrap.appendChild(t);
1714 setTimeout(function(){t.classList.add('fading');setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},420);},2500);
1715}
1716
1717// ── Presence bar ───────────────────────────────────────────────────────
1718var avEl=document.getElementById('presence-avatars');
1719var countEl=document.getElementById('presence-count');
1720function renderBar(){
1721 if(!avEl)return;
1722 var ids=Object.keys(peers);
1723 var html='';
1724 for(var i=0;i<ids.length&&i<8;i++){
1725 var p=peers[ids[i]];
1726 var initials=(p.username||'?').slice(0,2).toUpperCase();
1727 html+='<span class="presence-avatar" style="background:'+esc(p.colour)+'" title="'+esc(p.username)+'">';
1728 html+='<span class="presence-avatar-dot">'+esc(initials)+'</span>';
1729 html+=esc(p.username);
1730 html+='</span>';
1731 }
1732 avEl.innerHTML=html;
1733 if(countEl){
1734 var n=ids.length;
1735 countEl.textContent=n===0?'No other reviewers':n===1?'1 reviewer online':n+' reviewers online';
1736 }
1737}
1738
1739// ── Diff cursor pills ──────────────────────────────────────────────────
1740// data-line value is like "12:x:5" or "12:5:x" — pull numeric line only
1741function lineNumFromKey(key){var m=String(key).match(/(\d+)/);return m?parseInt(m[1],10):null;}
1742function findDiffRow(line){return document.querySelector('[data-line]') &&
1743 (function(){var rows=document.querySelectorAll('[data-line]');
1744 for(var i=0;i<rows.length;i++){var n=lineNumFromKey(rows[i].getAttribute('data-line')||'');if(n===line)return rows[i];}
1745 return null;
1746 })();}
1747function removePill(sessionId){var old=document.querySelector('[data-presence-sid="'+sessionId+'"]');if(old&&old.parentNode)old.parentNode.removeChild(old);}
1748function placePill(sessionId,username,colour,line,typing){
1749 removePill(sessionId);
1750 if(line==null)return;
1751 var row=findDiffRow(line);if(!row)return;
1752 var pill=document.createElement('span');
1753 pill.className='presence-line-pill'+(typing?' is-typing':'');
1754 pill.setAttribute('data-presence-sid',sessionId);
1755 pill.style.background=colour||'#8c6dff';
1756 pill.textContent=(username||'?').slice(0,12)+(typing?' typing':'');
1757 row.appendChild(pill);
1758}
1759function clearPeer(sessionId){removePill(sessionId);delete peers[sessionId];}
1760
1761// ── Inbound message handler ────────────────────────────────────────────
1762function onMsg(raw){
1763 var d;try{d=JSON.parse(raw);}catch(e){return;}
1764 if(!d||!d.type)return;
1765 if(d.type==='init'){
1766 mySessionId=d.sessionId||null;
1767 peers={};
1768 (d.users||[]).forEach(function(u){
1769 if(u.sessionId===mySessionId)return;
1770 peers[u.sessionId]={username:u.username,colour:u.colour,line:u.line,typing:u.typing};
1771 placePill(u.sessionId,u.username,u.colour,u.line,u.typing);
1772 });
1773 renderBar();
1774 } else if(d.type==='join'){
1775 if(d.user&&d.user.sessionId!==mySessionId){
1776 peers[d.user.sessionId]={username:d.user.username,colour:d.user.colour,line:d.user.line,typing:d.user.typing};
1777 renderBar();
1778 toast(esc(d.user.username)+' joined the review');
1779 }
1780 } else if(d.type==='leave'){
1781 if(d.sessionId&&d.sessionId!==mySessionId){
1782 var name=peers[d.sessionId]&&peers[d.sessionId].username;
1783 clearPeer(d.sessionId);
1784 renderBar();
1785 if(name)toast(esc(name)+' left the review');
1786 }
1787 } else if(d.type==='cursor'){
1788 if(d.sessionId&&d.sessionId!==mySessionId){
1789 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=false;}
1790 placePill(d.sessionId,d.username,d.colour,d.line,false);
1791 }
1792 } else if(d.type==='typing'){
1793 if(d.sessionId&&d.sessionId!==mySessionId){
1794 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=d.typing;}
1795 placePill(d.sessionId,d.username,d.colour,d.line,d.typing);
1796 }
1797 }
1798}
1799
1800// ── Outbound helpers ───────────────────────────────────────────────────
1801function send(obj){try{if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}catch(e){}}
1802
1803// ── Mouse hover on diff rows ───────────────────────────────────────────
1804var hoverTimer=null;
1805document.addEventListener('mouseover',function(ev){
1806 var row=ev.target&&ev.target.closest&&ev.target.closest('[data-line]');
1807 if(!row)return;
1808 if(hoverTimer)clearTimeout(hoverTimer);
1809 hoverTimer=setTimeout(function(){
1810 var key=row.getAttribute('data-line')||'';
1811 var line=lineNumFromKey(key);
1812 if(line!=null)send({type:'cursor',line:line});
1813 },80);
1814});
1815
1816// ── Typing detection in diff comment textareas ─────────────────────────
1817document.addEventListener('focusin',function(ev){
1818 var ta=ev.target;
1819 if(!ta||ta.tagName!=='TEXTAREA')return;
1820 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1821 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1822 if(line!=null)send({type:'typing',line:line,typing:true});
1823});
1824document.addEventListener('focusout',function(ev){
1825 var ta=ev.target;
1826 if(!ta||ta.tagName!=='TEXTAREA')return;
1827 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1828 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1829 if(line!=null)send({type:'typing',line:line,typing:false});
1830});
1831
1832// ── WebSocket lifecycle ────────────────────────────────────────────────
1833function connect(){
1834 if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=null;}
1835 try{ws=new WebSocket(url);}catch(e){scheduleReconnect();return;}
1836 ws.onopen=function(){
1837 reconnectDelay=1500;
1838 pingTimer=setInterval(function(){send({type:'ping'});},10000);
1839 };
1840 ws.onmessage=function(ev){onMsg(ev.data);};
1841 ws.onclose=function(){
1842 if(pingTimer){clearInterval(pingTimer);pingTimer=null;}
1843 scheduleReconnect();
1844 };
1845 ws.onerror=function(){try{ws.close();}catch(e){}};
1846}
1847function scheduleReconnect(){
1848 reconnectTimer=setTimeout(function(){connect();},reconnectDelay);
1849 reconnectDelay=Math.min(reconnectDelay*2,30000);
1850}
1851
1852connect();
1853}catch(e){}})();`;
1854}
1855
3c03977Claude1856function LIVE_COEDIT_SCRIPT(prId: string): string {
1857 const idJson = JSON.stringify(prId)
1858 .split("<").join("\\u003C")
1859 .split(">").join("\\u003E")
1860 .split("&").join("\\u0026");
1861 return (
1862 "(function(){try{" +
1863 "if(typeof EventSource==='undefined')return;" +
1864 "var prId=" + idJson + ";" +
1865 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1866 "var pill=document.getElementById('live-pill');" +
1867 "var avEl=document.getElementById('live-avatars');" +
1868 "var countEl=document.getElementById('live-count');" +
1869 "var sessionId=null;var myColor=null;" +
1870 "var presence={};" + // sessionId -> {color,status,userId,initials}
1871 "var lastApplied={};" + // field -> last server value (for echo suppression)
1872 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1873 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1874 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1875 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1876 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1877 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1878 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1879 "avEl.innerHTML=html;}}" +
1880 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1881 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1882 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1883 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1884 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1885 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1886 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1887 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1888 "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);}" +
1889 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1890 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1891 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1892 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1893 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1894 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1895 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1896 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1897 "}catch(e){}" +
1898 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1899 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1900 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1901 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1902 "var es;var delay=1000;" +
1903 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1904 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1905 "(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){}});" +
1906 "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){}});" +
1907 "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){}});" +
1908 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1909 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1910 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1911 "var patch=d.patch;if(!patch||!patch.field)return;" +
1912 "var ta=fieldEl(patch.field);if(!ta)return;" +
1913 "if(document.activeElement===ta)return;" + // don't trample local typing
1914 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1915 "}catch(e){}});" +
1916 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1917 "}connect();" +
1918 "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){}}" +
1919 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1920 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1921 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1922 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1923 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1924 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1925 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1926 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1927 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1928 "}" +
1929 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1930 "var live=document.querySelectorAll('[data-live-field]');" +
1931 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1932 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1933 "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){}});" +
1934 "}catch(e){}})();"
1935 );
1936}
1937
0074234Claude1938async function resolveRepo(ownerName: string, repoName: string) {
1939 const [owner] = await db
1940 .select()
1941 .from(users)
1942 .where(eq(users.username, ownerName))
1943 .limit(1);
1944 if (!owner) return null;
1945 const [repo] = await db
1946 .select()
1947 .from(repositories)
1948 .where(
1949 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1950 )
1951 .limit(1);
1952 if (!repo) return null;
1953 return { owner, repo };
1954}
1955
1956// PR Nav helper
1957const PrNav = ({
1958 owner,
1959 repo,
1960 active,
1961}: {
1962 owner: string;
1963 repo: string;
1964 active: "code" | "issues" | "pulls" | "commits";
1965}) => (
bb0f894Claude1966 <TabNav
1967 tabs={[
1968 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1969 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1970 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1971 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1972 ]}
1973 />
0074234Claude1974);
1975
534f04aClaude1976/**
1977 * Block M3 — pre-merge risk score card. Pure presentational helper.
1978 * Rendered in the conversation tab above the gate checks block. Hidden
1979 * entirely when the PR is closed/merged or there is nothing cached and
1980 * nothing in-flight.
1981 */
1982function PrRiskCard({
1983 risk,
1984 calculating,
1985}: {
1986 risk: PrRiskScore | null;
1987 calculating: boolean;
1988}) {
1989 if (!risk) {
1990 return (
1991 <div
1992 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1993 >
1994 <strong style="font-size: 13px; color: var(--text)">
1995 Risk score: calculating…
1996 </strong>
1997 <div style="font-size: 12px; margin-top: 4px">
1998 Refresh in a moment to see the pre-merge risk score for this PR.
1999 </div>
2000 </div>
2001 );
2002 }
2003
2004 const palette = riskBandPalette(risk.band);
2005 const label = riskBandLabel(risk.band);
2006
2007 return (
2008 <div
2009 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
2010 >
2011 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
2012 <strong>Risk score:</strong>
2013 <span style={`color:${palette.border};font-weight:600`}>
2014 {palette.icon} {label} ({risk.score}/10)
2015 </span>
2016 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
2017 {risk.commitSha.slice(0, 7)}
2018 </span>
2019 </div>
2020 {risk.aiSummary && (
2021 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
2022 {risk.aiSummary}
2023 </div>
2024 )}
2025 <details style="margin-top:10px">
2026 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
2027 See full signal breakdown
2028 </summary>
2029 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
2030 <li>files changed: {risk.signals.filesChanged}</li>
2031 <li>
2032 lines added/removed: {risk.signals.linesAdded} /{" "}
2033 {risk.signals.linesRemoved}
2034 </li>
2035 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
2036 <li>
2037 schema migration touched:{" "}
2038 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
2039 </li>
2040 <li>
2041 locked / sensitive path touched:{" "}
2042 {risk.signals.lockedPathTouched ? "yes" : "no"}
2043 </li>
2044 <li>
2045 adds new dependency:{" "}
2046 {risk.signals.addsNewDependency ? "yes" : "no"}
2047 </li>
2048 <li>
2049 bumps major dependency:{" "}
2050 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
2051 </li>
2052 <li>
2053 tests added for new code:{" "}
2054 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
2055 </li>
2056 <li>
2057 diff-minus-test ratio:{" "}
2058 {risk.signals.diffMinusTestRatio.toFixed(2)}
2059 </li>
2060 </ul>
2061 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2062 How is this calculated? The score is a transparent sum of
2063 weighted signals — see <code>src/lib/pr-risk.ts</code>
2064 {" "}<code>computePrRiskScore</code>.
2065 </div>
2066 </details>
2067 {calculating && (
2068 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
2069 (recomputing for the latest commit — refresh to update)
2070 </div>
2071 )}
2072 </div>
2073 );
2074}
2075
2076function riskBandPalette(band: PrRiskScore["band"]): {
2077 border: string;
2078 icon: string;
2079} {
2080 switch (band) {
2081 case "low":
2082 return { border: "var(--green)", icon: "" };
2083 case "medium":
2084 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
2085 case "high":
2086 return { border: "var(--orange, #db6d28)", icon: "⚠" };
2087 case "critical":
2088 return { border: "var(--red)", icon: "\u{1F6D1}" };
2089 }
2090}
2091
2092function riskBandLabel(band: PrRiskScore["band"]): string {
2093 switch (band) {
2094 case "low":
2095 return "LOW";
2096 case "medium":
2097 return "MEDIUM";
2098 case "high":
2099 return "HIGH";
2100 case "critical":
2101 return "CRITICAL";
2102 }
2103}
2104
422a2d4Claude2105// ---------------------------------------------------------------------------
2106// AI Trio Review — 3-column card grid + disagreement callout.
2107//
2108// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
2109// per run: one per persona (security/correctness/style) plus a top-level
2110// summary. We surface them here as a single grid above the normal
2111// comment stream so reviewers see the verdicts at a glance.
2112// ---------------------------------------------------------------------------
2113
2114const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
2115
2116interface TrioCommentLike {
2117 body: string;
2118}
2119
2120function isTrioComment(body: string | null | undefined): boolean {
2121 if (!body) return false;
2122 return (
2123 body.includes(TRIO_SUMMARY_MARKER) ||
2124 body.includes(TRIO_COMMENT_MARKER.security) ||
2125 body.includes(TRIO_COMMENT_MARKER.correctness) ||
2126 body.includes(TRIO_COMMENT_MARKER.style)
2127 );
2128}
2129
2130function trioPersonaOfComment(body: string): TrioPersona | null {
2131 for (const p of TRIO_PERSONAS) {
2132 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
2133 }
2134 return null;
2135}
2136
2137/**
2138 * Best-effort verdict parse from a persona comment body. The body shape
2139 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
2140 * we only need the "Pass" / "Fail" word from the H2 heading.
2141 */
2142function trioVerdictOfBody(body: string): "pass" | "fail" | null {
2143 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
2144 if (!m) return null;
2145 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
2146}
2147
2148/**
2149 * Parse the disagreement bullet list out of the summary comment so we
2150 * can render it as a polished callout strip. Returns [] when nothing
2151 * matches — the comment author may have edited the marker out.
2152 */
2153function parseDisagreements(summaryBody: string): Array<{
2154 file: string;
2155 failing: string;
2156 passing: string;
2157}> {
2158 const out: Array<{ file: string; failing: string; passing: string }> = [];
2159 // Each disagreement line looks like:
2160 // - `path:42` — security, style say ✗, correctness say ✓
2161 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
2162 let m: RegExpExecArray | null;
2163 while ((m = re.exec(summaryBody)) !== null) {
2164 out.push({
2165 file: m[1].trim(),
2166 failing: m[2].trim().replace(/[,\s]+$/g, ""),
2167 passing: m[3].trim().replace(/[,\s]+$/g, ""),
2168 });
2169 }
2170 return out;
2171}
2172
2173function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
2174 // Find the most recent persona comments + summary. We iterate from
2175 // the end so re-reviews (multiple runs on the same PR) display the
2176 // freshest verdict.
2177 const latest: Partial<Record<TrioPersona, string>> = {};
2178 let summaryBody: string | null = null;
2179 for (let i = comments.length - 1; i >= 0; i--) {
2180 const body = comments[i].body || "";
2181 if (!isTrioComment(body)) continue;
2182 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
2183 summaryBody = body;
2184 continue;
2185 }
2186 const persona = trioPersonaOfComment(body);
2187 if (persona && !latest[persona]) latest[persona] = body;
2188 }
2189 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2190 if (!anyPersona && !summaryBody) return null;
2191
2192 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
2193
2194 return (
67dc4e1Claude2195 <div class="trio-wrap" id="trio-review-section">
422a2d4Claude2196 <div class="trio-header">
2197 <span class="trio-header-dot" aria-hidden="true"></span>
2198 <strong>AI Trio Review</strong>
2199 <span class="trio-header-sub">
2200 Three independent reviewers ran in parallel.
2201 </span>
2202 </div>
2203 <div class="trio-grid">
2204 {TRIO_PERSONAS.map((persona) => {
2205 const body = latest[persona];
2206 const verdict = body ? trioVerdictOfBody(body) : null;
2207 const stateClass =
2208 verdict === "fail"
2209 ? "is-fail"
2210 : verdict === "pass"
2211 ? "is-pass"
2212 : "is-pending";
2213 return (
2214 <div class={`trio-card trio-${persona} ${stateClass}`}>
2215 <div class="trio-card-head">
2216 <span class="trio-card-icon" aria-hidden="true">
2217 {persona === "security"
2218 ? "🛡"
2219 : persona === "correctness"
2220 ? "✓"
2221 : "✎"}
2222 </span>
2223 <strong class="trio-card-title">
2224 {persona[0].toUpperCase() + persona.slice(1)}
2225 </strong>
2226 <span class="trio-card-verdict">
2227 {verdict === "pass"
2228 ? "Pass"
2229 : verdict === "fail"
2230 ? "Fail"
2231 : "Pending"}
2232 </span>
2233 </div>
2234 <div class="trio-card-body">
2235 {body ? (
2236 <MarkdownContent
2237 html={renderMarkdown(stripTrioHeading(body))}
2238 />
2239 ) : (
2240 <span class="trio-card-empty">
2241 Awaiting reviewer output.
2242 </span>
2243 )}
2244 </div>
2245 </div>
2246 );
2247 })}
2248 </div>
2249 {disagreements.length > 0 && (
2250 <div class="trio-disagreement-strip" role="note">
2251 <span class="trio-disagreement-icon" aria-hidden="true">
2252
2253 </span>
2254 <div class="trio-disagreement-body">
2255 <strong>Reviewers disagree — review carefully.</strong>
2256 <ul class="trio-disagreement-list">
2257 {disagreements.map((d) => (
2258 <li>
2259 <code>{d.file}</code> — {d.failing} says ✗,{" "}
2260 {d.passing} says ✓
2261 </li>
2262 ))}
2263 </ul>
2264 </div>
2265 </div>
2266 )}
2267 </div>
2268 );
2269}
2270
2271/**
2272 * Strip the marker comment + first H2 heading from a persona body so
2273 * the card body shows just the findings list (verdict is already in
2274 * the card head). Best-effort — malformed bodies render whole.
2275 */
2276function stripTrioHeading(body: string): string {
2277 return body
2278 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
2279 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
2280 .trim();
2281}
2282
67dc4e1Claude2283/**
2284 * Three small verdict pills rendered inline in the PR header. Each pill
2285 * links to the `#trio-review-section` anchor so clicking scrolls to the
2286 * full card grid. Only shown when `AI_TRIO_REVIEW_ENABLED=1` and at
2287 * least one persona comment exists.
2288 */
2289function TrioVerdictPills({
2290 comments,
2291}: {
2292 comments: TrioCommentLike[];
2293}) {
2294 if (!isTrioReviewEnabled()) return null;
2295
2296 // Find latest persona verdicts (same logic as TrioReviewGrid).
2297 const latest: Partial<Record<TrioPersona, string>> = {};
2298 for (let i = comments.length - 1; i >= 0; i--) {
2299 const body = comments[i].body || "";
2300 if (!isTrioComment(body)) continue;
2301 if (body.includes(TRIO_SUMMARY_MARKER)) continue;
2302 const persona = trioPersonaOfComment(body);
2303 if (persona && !latest[persona]) latest[persona] = body;
2304 }
2305
2306 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
2307 if (!anyPersona) return null;
2308
2309 const PERSONA_LABEL: Record<TrioPersona, string> = {
2310 security: "Security",
2311 correctness: "Correctness",
2312 style: "Style",
2313 };
2314 const PERSONA_ICON: Record<TrioPersona, string> = {
2315 security: "🛡",
2316 correctness: "✓",
2317 style: "✎",
2318 };
2319
2320 return (
2321 <span class="trio-pills-wrap" aria-label="AI Trio Review verdicts">
2322 {TRIO_PERSONAS.map((persona) => {
2323 const body = latest[persona];
2324 const verdict = body ? trioVerdictOfBody(body) : null;
2325 const stateClass =
2326 verdict === "pass"
2327 ? "is-pass"
2328 : verdict === "fail"
2329 ? "is-fail"
2330 : "is-pending";
2331 const glyph =
2332 verdict === "pass" ? "✓" : verdict === "fail" ? "✗" : "⟳";
2333 return (
2334 <a
2335 href="#trio-review-section"
2336 class={`trio-pill ${stateClass}`}
2337 title={`AI ${PERSONA_LABEL[persona]} Review — ${verdict === "pass" ? "Pass" : verdict === "fail" ? "Fail" : "Pending"}`}
2338 >
2339 <span aria-hidden="true">{PERSONA_ICON[persona]}</span>
2340 {PERSONA_LABEL[persona]} {glyph}
2341 </a>
2342 );
2343 })}
2344 </span>
2345 );
2346}
2347
0074234Claude2348// List PRs
04f6b7fClaude2349pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude2350 const { owner: ownerName, repo: repoName } = c.req.param();
2351 const user = c.get("user");
2352 const state = c.req.query("state") || "open";
d790b49Claude2353 const searchQ = c.req.query("q")?.trim() || "";
80bd7c8Claude2354 const authorFilter = c.req.query("author")?.trim() || "";
f5b9ef5Claude2355 const sortPr = (c.req.query("sort") || "newest").trim();
0074234Claude2356
ea9ed4cClaude2357 // ── Loading skeleton (flag-gated) ──
2358 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
2359 // the user see the page structure before counts + select resolve.
2360 // Behind a flag for now — we don't ship flashes.
2361 if (c.req.query("skeleton") === "1") {
2362 return c.html(
2363 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2364 <RepoHeader owner={ownerName} repo={repoName} />
2365 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2366 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2367 <style
2368 dangerouslySetInnerHTML={{
2369 __html: `
404b398Claude2370 .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; }
2371 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude2372 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
2373 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
2374 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
2375 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
2376 .prs-skel-row { height: 76px; border-radius: 12px; }
2377 `,
2378 }}
2379 />
2380 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
2381 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
2382 <div class="prs-skel-list" aria-hidden="true">
2383 {Array.from({ length: 6 }).map(() => (
2384 <div class="prs-skel prs-skel-row" />
2385 ))}
2386 </div>
2387 <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">
2388 Loading pull requests for {ownerName}/{repoName}…
2389 </span>
2390 </Layout>
2391 );
2392 }
2393
0074234Claude2394 const resolved = await resolveRepo(ownerName, repoName);
2395 if (!resolved) return c.notFound();
2396
6fc53bdClaude2397 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
2398 const stateFilter =
2399 state === "draft"
2400 ? and(
2401 eq(pullRequests.state, "open"),
2402 eq(pullRequests.isDraft, true)
2403 )
2404 : eq(pullRequests.state, state);
2405
0074234Claude2406 const prList = await db
2407 .select({
2408 pr: pullRequests,
2409 author: { username: users.username },
2410 })
2411 .from(pullRequests)
2412 .innerJoin(users, eq(pullRequests.authorId, users.id))
2413 .where(
d790b49Claude2414 and(
2415 eq(pullRequests.repositoryId, resolved.repo.id),
2416 stateFilter,
2417 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
80bd7c8Claude2418 authorFilter ? eq(users.username, authorFilter) : undefined,
d790b49Claude2419 )
0074234Claude2420 )
f5b9ef5Claude2421 .orderBy(
2422 sortPr === "oldest" ? asc(pullRequests.createdAt)
2423 : sortPr === "updated" ? desc(pullRequests.updatedAt)
2424 : desc(pullRequests.createdAt) // newest (default)
2425 );
0074234Claude2426
0369e77Claude2427 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude2428 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude2429 const commentCountMap = new Map<string, number>();
1aef949Claude2430 if (prList.length > 0) {
2431 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude2432 const [reviewRows, commentRows] = await Promise.all([
2433 db
2434 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
2435 .from(prReviews)
2436 .where(inArray(prReviews.pullRequestId, prIds)),
2437 db
2438 .select({
2439 prId: prComments.pullRequestId,
2440 cnt: sql<number>`count(*)::int`,
2441 })
2442 .from(prComments)
2443 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
2444 .groupBy(prComments.pullRequestId),
2445 ]);
1aef949Claude2446 for (const r of reviewRows) {
2447 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
2448 if (r.state === "approved") entry.approved = true;
2449 if (r.state === "changes_requested") entry.changesRequested = true;
2450 reviewMap.set(r.prId, entry);
2451 }
0369e77Claude2452 for (const r of commentRows) {
2453 commentCountMap.set(r.prId, Number(r.cnt));
2454 }
1aef949Claude2455 }
2456
0074234Claude2457 const [counts] = await db
2458 .select({
2459 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude2460 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude2461 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
2462 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
2463 })
2464 .from(pullRequests)
2465 .where(eq(pullRequests.repositoryId, resolved.repo.id));
2466
b078860Claude2467 const openCount = counts?.open ?? 0;
2468 const mergedCount = counts?.merged ?? 0;
2469 const closedCount = counts?.closed ?? 0;
2470 const draftCount = counts?.draft ?? 0;
2471 const allCount = openCount + mergedCount + closedCount;
2472
2473 // "All" is presentational only — the DB query for state='all' matches
2474 // nothing, so we render a friendlier empty state when picked. We do NOT
2475 // change the query logic to keep this commit purely visual.
80bd7c8Claude2476 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude2477 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude2478 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
2479 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
2480 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
2481 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
2482 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude2483 ];
2484 const isAllState = state === "all";
cb5a796Claude2485 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
2486 const prListPendingCount = viewerIsOwnerOnPrList
2487 ? await countPendingForRepo(resolved.repo.id)
2488 : 0;
b078860Claude2489
0074234Claude2490 return c.html(
2491 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2492 <RepoHeader owner={ownerName} repo={repoName} />
2493 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2494 <PendingCommentsBanner
2495 owner={ownerName}
2496 repo={repoName}
2497 count={prListPendingCount}
2498 />
b078860Claude2499 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2500
2501 <div class="prs-hero">
2502 <div class="prs-hero-inner">
2503 <div class="prs-hero-text">
2504 <div class="prs-hero-eyebrow">Pull requests</div>
2505 <h1 class="prs-hero-title">
2506 Review, <span class="gradient-text">merge with AI</span>.
2507 </h1>
2508 <p class="prs-hero-sub">
2509 {openCount === 0 && allCount === 0
2510 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2511 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
2512 </p>
2513 </div>
7a28902Claude2514 <div class="prs-hero-actions">
2515 <a
2516 href={`/${ownerName}/${repoName}/pulls/insights`}
2517 class="prs-cta"
2518 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
2519 >
2520 Insights
2521 </a>
2522 {user && (
b078860Claude2523 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
2524 + New pull request
2525 </a>
7a28902Claude2526 )}
2527 </div>
b078860Claude2528 </div>
2529 </div>
2530
2531 <nav class="prs-tabs" aria-label="Pull request filters">
2532 {tabPills.map((t) => {
2533 const isActive =
2534 state === t.key ||
2535 (t.key === "open" &&
2536 state !== "merged" &&
2537 state !== "closed" &&
2538 state !== "all" &&
2539 state !== "draft");
2540 return (
2541 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2542 <span>{t.label}</span>
2543 <span class="prs-tab-count">{t.count}</span>
2544 </a>
2545 );
2546 })}
2547 </nav>
2548
d790b49Claude2549 <form
2550 method="get"
2551 action={`/${ownerName}/${repoName}/pulls`}
2552 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2553 >
2554 <input type="hidden" name="state" value={state} />
2555 <input
2556 type="search"
2557 name="q"
2558 value={searchQ}
2559 placeholder="Search pull requests…"
2560 class="issues-search-input"
2561 style="flex:1;max-width:380px"
2562 />
80bd7c8Claude2563 <input
2564 type="text"
2565 name="author"
2566 value={authorFilter}
2567 placeholder="Filter by author…"
2568 class="issues-search-input"
2569 style="max-width:200px"
2570 />
d790b49Claude2571 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2572 {(searchQ || authorFilter) && (
d790b49Claude2573 <a
2574 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2575 class="issues-filter-clear"
2576 >
2577 Clear
2578 </a>
2579 )}
2580 </form>
f5b9ef5Claude2581
2582 <div class="prs-sort-row">
2583 <span class="prs-sort-label">Sort:</span>
2584 {(["newest", "oldest", "updated"] as const).map((s) => (
2585 <a
2586 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2587 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2588 >
2589 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2590 </a>
2591 ))}
2592 </div>
2593
0074234Claude2594 {prList.length === 0 ? (
b078860Claude2595 <div class="prs-empty">
ea9ed4cClaude2596 <div class="prs-empty-inner">
2597 <strong>
80bd7c8Claude2598 {searchQ || authorFilter
2599 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2600 : isAllState
2601 ? "Pick a filter above to browse PRs."
2602 : `No ${state} pull requests.`}
ea9ed4cClaude2603 </strong>
2604 <p class="prs-empty-sub">
80bd7c8Claude2605 {searchQ || authorFilter
2606 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2607 : state === "open"
2608 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2609 : isAllState
2610 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2611 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2612 </p>
2613 <div class="prs-empty-cta">
80bd7c8Claude2614 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2615 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2616 + New pull request
2617 </a>
2618 )}
80bd7c8Claude2619 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2620 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2621 View open PRs
2622 </a>
2623 )}
2624 <a href={`/${ownerName}/${repoName}`} class="btn">
2625 Back to code
2626 </a>
2627 </div>
2628 </div>
b078860Claude2629 </div>
0074234Claude2630 ) : (
b078860Claude2631 <div class="prs-list">
2632 {prList.map(({ pr, author }) => {
2633 const stateClass =
2634 pr.state === "open"
2635 ? pr.isDraft
2636 ? "state-draft"
2637 : "state-open"
2638 : pr.state === "merged"
2639 ? "state-merged"
2640 : "state-closed";
2641 const icon =
2642 pr.state === "open"
2643 ? pr.isDraft
2644 ? "◌"
2645 : "○"
2646 : pr.state === "merged"
2647 ? "⮌"
2648 : "✓";
1aef949Claude2649 const rv = reviewMap.get(pr.id);
b078860Claude2650 return (
2651 <a
2652 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2653 class="prs-row"
2654 style="text-decoration:none;color:inherit"
0074234Claude2655 >
b078860Claude2656 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2657 {icon}
0074234Claude2658 </div>
b078860Claude2659 <div class="prs-row-body">
2660 <h3 class="prs-row-title">
2661 <span>{pr.title}</span>
2662 <span class="prs-row-number">#{pr.number}</span>
2663 </h3>
2664 <div class="prs-row-meta">
2665 <span
2666 class="prs-branch-chips"
2667 title={`${pr.headBranch} into ${pr.baseBranch}`}
2668 >
2669 <span class="prs-branch-chip">{pr.headBranch}</span>
2670 <span class="prs-branch-arrow">{"→"}</span>
2671 <span class="prs-branch-chip">{pr.baseBranch}</span>
2672 </span>
2673 <span>
2674 by{" "}
2675 <strong style="color:var(--text)">
2676 {author.username}
2677 </strong>{" "}
2678 {formatRelative(pr.createdAt)}
2679 </span>
2680 <span class="prs-row-tags">
2681 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2682 {pr.state === "merged" && (
2683 <span class="prs-tag is-merged">Merged</span>
2684 )}
1aef949Claude2685 {rv?.approved && !rv.changesRequested && (
2686 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
2687 )}
2688 {rv?.changesRequested && (
2689 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
2690 )}
0369e77Claude2691 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
2692 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
2693 💬 {commentCountMap.get(pr.id)}
2694 </span>
2695 )}
b078860Claude2696 </span>
2697 </div>
0074234Claude2698 </div>
b078860Claude2699 </a>
2700 );
2701 })}
2702 </div>
0074234Claude2703 )}
2704 </Layout>
2705 );
2706});
2707
7a28902Claude2708/* ─────────────────────────────────────────────────────────────────────────
2709 * PR Insights — 90-day analytics for the pull request activity of a repo.
2710 * Route: GET /:owner/:repo/pulls/insights
2711 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
2712 * "insights" is not swallowed by the :number param.
2713 * ───────────────────────────────────────────────────────────────────────── */
2714
2715/** Format a millisecond duration as human-readable string. */
2716function formatMsDuration(ms: number): string {
2717 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
2718 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
2719 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
2720 return `${Math.round(ms / 86_400_000)}d`;
2721}
2722
2723/** Format an ISO week string as "Jan 15". */
2724function formatWeekLabel(isoWeek: string): string {
2725 try {
2726 const d = new Date(isoWeek);
2727 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2728 } catch {
2729 return isoWeek.slice(5, 10);
2730 }
2731}
2732
2733const PR_INSIGHTS_STYLES = `
2734 .pri-page { padding-bottom: 48px; }
2735 .pri-hero {
2736 position: relative;
2737 margin: 0 0 var(--space-5);
2738 padding: 22px 26px 24px;
2739 background: var(--bg-elevated);
2740 border: 1px solid var(--border);
2741 border-radius: 16px;
2742 overflow: hidden;
2743 }
2744 .pri-hero::before {
2745 content: '';
2746 position: absolute; top: 0; left: 0; right: 0;
2747 height: 2px;
2748 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2749 opacity: 0.7;
2750 pointer-events: none;
2751 }
2752 .pri-hero-eyebrow {
2753 font-size: 12px;
2754 color: var(--text-muted);
2755 text-transform: uppercase;
2756 letter-spacing: 0.08em;
2757 font-weight: 600;
2758 margin-bottom: 8px;
2759 }
2760 .pri-hero-title {
2761 font-family: var(--font-display);
2762 font-size: clamp(26px, 3.4vw, 34px);
2763 font-weight: 800;
2764 letter-spacing: -0.025em;
2765 line-height: 1.06;
2766 margin: 0 0 8px;
2767 color: var(--text-strong);
2768 }
2769 .pri-hero-title .gradient-text {
2770 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2771 -webkit-background-clip: text;
2772 background-clip: text;
2773 -webkit-text-fill-color: transparent;
2774 color: transparent;
2775 }
2776 .pri-hero-sub {
2777 font-size: 14.5px;
2778 color: var(--text-muted);
2779 margin: 0;
2780 line-height: 1.5;
2781 }
2782 .pri-section { margin-bottom: 32px; }
2783 .pri-section-title {
2784 font-size: 13px;
2785 font-weight: 700;
2786 text-transform: uppercase;
2787 letter-spacing: 0.06em;
2788 color: var(--text-muted);
2789 margin: 0 0 14px;
2790 }
2791 .pri-cards {
2792 display: grid;
2793 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
2794 gap: 12px;
2795 }
2796 .pri-card {
2797 padding: 16px 18px;
2798 background: var(--bg-elevated);
2799 border: 1px solid var(--border);
2800 border-radius: 12px;
2801 }
2802 .pri-card-label {
2803 font-size: 12px;
2804 font-weight: 600;
2805 color: var(--text-muted);
2806 text-transform: uppercase;
2807 letter-spacing: 0.05em;
2808 margin-bottom: 6px;
2809 }
2810 .pri-card-value {
2811 font-size: 28px;
2812 font-weight: 800;
2813 letter-spacing: -0.04em;
2814 color: var(--text-strong);
2815 line-height: 1;
2816 }
2817 .pri-card-sub {
2818 font-size: 12px;
2819 color: var(--text-muted);
2820 margin-top: 4px;
2821 }
2822 .pri-chart {
2823 display: flex;
2824 align-items: flex-end;
2825 gap: 6px;
2826 height: 120px;
2827 background: var(--bg-elevated);
2828 border: 1px solid var(--border);
2829 border-radius: 12px;
2830 padding: 16px 16px 0;
2831 }
2832 .pri-bar-col {
2833 flex: 1;
2834 display: flex;
2835 flex-direction: column;
2836 align-items: center;
2837 justify-content: flex-end;
2838 height: 100%;
2839 gap: 4px;
2840 }
2841 .pri-bar {
2842 width: 100%;
2843 min-height: 4px;
2844 border-radius: 4px 4px 0 0;
2845 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 100%);
2846 transition: opacity 140ms;
2847 }
2848 .pri-bar:hover { opacity: 0.8; }
2849 .pri-bar-label {
2850 font-size: 10px;
2851 color: var(--text-muted);
2852 text-align: center;
2853 padding-bottom: 8px;
2854 white-space: nowrap;
2855 overflow: hidden;
2856 text-overflow: ellipsis;
2857 max-width: 100%;
2858 }
2859 .pri-table {
2860 width: 100%;
2861 border-collapse: collapse;
2862 font-size: 13.5px;
2863 }
2864 .pri-table th {
2865 text-align: left;
2866 font-size: 12px;
2867 font-weight: 600;
2868 text-transform: uppercase;
2869 letter-spacing: 0.05em;
2870 color: var(--text-muted);
2871 padding: 8px 12px;
2872 border-bottom: 1px solid var(--border);
2873 }
2874 .pri-table td {
2875 padding: 10px 12px;
2876 border-bottom: 1px solid var(--border);
2877 color: var(--text);
2878 }
2879 .pri-table tr:last-child td { border-bottom: none; }
2880 .pri-table-wrap {
2881 background: var(--bg-elevated);
2882 border: 1px solid var(--border);
2883 border-radius: 12px;
2884 overflow: hidden;
2885 }
2886 .pri-age-row {
2887 display: flex;
2888 align-items: center;
2889 gap: 12px;
2890 padding: 10px 0;
2891 border-bottom: 1px solid var(--border);
2892 font-size: 13.5px;
2893 }
2894 .pri-age-row:last-child { border-bottom: none; }
2895 .pri-age-label {
2896 flex: 0 0 80px;
2897 color: var(--text-muted);
2898 font-size: 12.5px;
2899 font-weight: 600;
2900 }
2901 .pri-age-bar-wrap {
2902 flex: 1;
2903 height: 8px;
2904 background: var(--bg-secondary);
2905 border-radius: 9999px;
2906 overflow: hidden;
2907 }
2908 .pri-age-bar {
2909 height: 100%;
2910 border-radius: 9999px;
2911 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
2912 min-width: 4px;
2913 }
2914 .pri-age-count {
2915 flex: 0 0 32px;
2916 text-align: right;
2917 font-weight: 600;
2918 color: var(--text-strong);
2919 font-size: 13px;
2920 }
2921 .pri-sparkline {
2922 display: flex;
2923 align-items: flex-end;
2924 gap: 3px;
2925 height: 40px;
2926 }
2927 .pri-spark-bar {
2928 flex: 1;
2929 min-height: 2px;
2930 border-radius: 2px 2px 0 0;
2931 background: var(--accent, #8c6dff);
2932 opacity: 0.7;
2933 }
2934 .pri-empty {
2935 color: var(--text-muted);
2936 font-size: 14px;
2937 padding: 24px 0;
2938 text-align: center;
2939 }
2940 @media (max-width: 600px) {
2941 .pri-cards { grid-template-columns: repeat(2, 1fr); }
2942 .pri-hero { padding: 18px 18px 20px; }
2943 }
2944`;
2945
2946pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
2947 const { owner: ownerName, repo: repoName } = c.req.param();
2948 const user = c.get("user");
2949
2950 const resolved = await resolveRepo(ownerName, repoName);
2951 if (!resolved) return c.notFound();
2952
2953 const repoId = resolved.repo.id;
2954 const now = Date.now();
2955
2956 // 1. Merged PRs in last 90 days (avg merge time)
2957 const mergedPRs = await db
2958 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
2959 .from(pullRequests)
2960 .where(and(
2961 eq(pullRequests.repositoryId, repoId),
2962 eq(pullRequests.state, "merged"),
2963 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2964 ));
2965
2966 const avgMergeMs = mergedPRs.length > 0
2967 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
2968 : null;
2969
2970 // 2. PR throughput (last 8 weeks)
2971 const weeklyPRs = await db
2972 .select({
2973 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
2974 count: sql<number>`count(*)::int`,
2975 })
2976 .from(pullRequests)
2977 .where(and(
2978 eq(pullRequests.repositoryId, repoId),
2979 sql`${pullRequests.createdAt} > now() - interval '56 days'`
2980 ))
2981 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
2982 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
2983
2984 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
2985
2986 // 3. PR merge rate (last 90 days)
2987 const [rateCounts] = await db
2988 .select({
2989 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
2990 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
2991 })
2992 .from(pullRequests)
2993 .where(and(
2994 eq(pullRequests.repositoryId, repoId),
2995 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2996 ));
2997
2998 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
2999 const mergeRate = totalResolved > 0
3000 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
3001 : null;
3002
3003 // 4. Top reviewers (last 90 days)
3004 const reviewerCounts = await db
3005 .select({
3006 userId: prReviews.reviewerId,
3007 username: users.username,
3008 count: sql<number>`count(*)::int`,
3009 })
3010 .from(prReviews)
3011 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3012 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
3013 .where(and(
3014 eq(pullRequests.repositoryId, repoId),
3015 sql`${prReviews.createdAt} > now() - interval '90 days'`
3016 ))
3017 .groupBy(prReviews.reviewerId, users.username)
3018 .orderBy(desc(sql`count(*)`))
3019 .limit(5);
3020
3021 // 5. Average reviews per merged PR
3022 const [avgReviewRow] = await db
3023 .select({
3024 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
3025 })
3026 .from(pullRequests)
3027 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3028 .where(and(
3029 eq(pullRequests.repositoryId, repoId),
3030 eq(pullRequests.state, "merged"),
3031 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
3032 ));
3033
3034 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
3035 ? Math.round(avgReviewRow.avgReviews * 10) / 10
3036 : null;
3037
3038 // 6. Review turnaround — avg time from PR open to first review
3039 const prsWithReviews = await db
3040 .select({
3041 createdAt: pullRequests.createdAt,
3042 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
3043 })
3044 .from(pullRequests)
3045 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
3046 .where(and(
3047 eq(pullRequests.repositoryId, repoId),
3048 sql`${pullRequests.createdAt} > now() - interval '90 days'`
3049 ))
3050 .groupBy(pullRequests.id, pullRequests.createdAt);
3051
3052 const avgReviewTurnaroundMs = prsWithReviews.length > 0
3053 ? prsWithReviews.reduce((s, row) => {
3054 const firstMs = new Date(row.firstReview).getTime();
3055 return s + Math.max(0, firstMs - row.createdAt.getTime());
3056 }, 0) / prsWithReviews.length
3057 : null;
3058
3059 // 7. Open PRs by age bucket
3060 const openPRs = await db
3061 .select({ createdAt: pullRequests.createdAt })
3062 .from(pullRequests)
3063 .where(and(
3064 eq(pullRequests.repositoryId, repoId),
3065 eq(pullRequests.state, "open")
3066 ));
3067
3068 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
3069 for (const { createdAt } of openPRs) {
3070 const ageDays = (now - createdAt.getTime()) / 86_400_000;
3071 if (ageDays < 1) ageBuckets.lt1d++;
3072 else if (ageDays < 3) ageBuckets.d1to3++;
3073 else if (ageDays < 7) ageBuckets.d3to7++;
3074 else if (ageDays < 30) ageBuckets.d7to30++;
3075 else ageBuckets.gt30d++;
3076 }
3077 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
3078
3079 // 8. 7-day merge sparkline
3080 const sparklineRows = await db
3081 .select({
3082 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
3083 count: sql<number>`count(*)::int`,
3084 })
3085 .from(pullRequests)
3086 .where(and(
3087 eq(pullRequests.repositoryId, repoId),
3088 eq(pullRequests.state, "merged"),
3089 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
3090 ))
3091 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
3092 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
3093
3094 const sparkMap = new Map<string, number>();
3095 for (const row of sparklineRows) {
3096 sparkMap.set(row.day.slice(0, 10), row.count);
3097 }
3098 const sparkline: number[] = [];
3099 for (let i = 6; i >= 0; i--) {
3100 const d = new Date(now - i * 86_400_000);
3101 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
3102 }
3103 const maxSpark = Math.max(1, ...sparkline);
3104
3105 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
3106 { label: "< 1 day", key: "lt1d" },
3107 { label: "1–3 days", key: "d1to3" },
3108 { label: "3–7 days", key: "d3to7" },
3109 { label: "7–30 days", key: "d7to30" },
3110 { label: "> 30 days", key: "gt30d" },
3111 ];
3112
3113 return c.html(
3114 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
3115 <RepoHeader owner={ownerName} repo={repoName} />
3116 <PrNav owner={ownerName} repo={repoName} active="pulls" />
3117 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
3118
3119 <div class="pri-page">
3120 {/* Hero */}
3121 <div class="pri-hero">
3122 <div class="pri-hero-eyebrow">Pull requests</div>
3123 <h1 class="pri-hero-title">
3124 PR <span class="gradient-text">Insights</span>
3125 </h1>
3126 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
3127 </div>
3128
3129 {/* Stat cards */}
3130 <div class="pri-section">
3131 <div class="pri-section-title">At a glance</div>
3132 <div class="pri-cards">
3133 <div class="pri-card">
3134 <div class="pri-card-label">Avg merge time</div>
3135 <div class="pri-card-value">
3136 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
3137 </div>
3138 <div class="pri-card-sub">last 90 days</div>
3139 </div>
3140 <div class="pri-card">
3141 <div class="pri-card-label">Total merged</div>
3142 <div class="pri-card-value">{mergedPRs.length}</div>
3143 <div class="pri-card-sub">last 90 days</div>
3144 </div>
3145 <div class="pri-card">
3146 <div class="pri-card-label">Open PRs</div>
3147 <div class="pri-card-value">{openPRs.length}</div>
3148 <div class="pri-card-sub">right now</div>
3149 </div>
3150 <div class="pri-card">
3151 <div class="pri-card-label">Merge rate</div>
3152 <div class="pri-card-value">
3153 {mergeRate != null ? `${mergeRate}%` : "—"}
3154 </div>
3155 <div class="pri-card-sub">merged vs closed</div>
3156 </div>
3157 <div class="pri-card">
3158 <div class="pri-card-label">Avg reviews / PR</div>
3159 <div class="pri-card-value">
3160 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
3161 </div>
3162 <div class="pri-card-sub">merged PRs, 90d</div>
3163 </div>
3164 <div class="pri-card">
3165 <div class="pri-card-label">Top reviewer</div>
3166 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
3167 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
3168 </div>
3169 <div class="pri-card-sub">
3170 {reviewerCounts.length > 0
3171 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
3172 : "no reviews yet"}
3173 </div>
3174 </div>
3175 </div>
3176 </div>
3177
3178 {/* Review turnaround */}
3179 <div class="pri-section">
3180 <div class="pri-section-title">Review turnaround</div>
3181 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
3182 <div class="pri-card">
3183 <div class="pri-card-label">Avg time to first review</div>
3184 <div class="pri-card-value">
3185 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
3186 </div>
3187 <div class="pri-card-sub">
3188 {prsWithReviews.length > 0
3189 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
3190 : "no reviewed PRs in 90d"}
3191 </div>
3192 </div>
3193 </div>
3194 </div>
3195
3196 {/* Weekly throughput bar chart */}
3197 <div class="pri-section">
3198 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
3199 {weeklyPRs.length === 0 ? (
3200 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
3201 ) : (
3202 <div class="pri-chart">
3203 {weeklyPRs.map((w) => (
3204 <div class="pri-bar-col">
3205 <div
3206 class="pri-bar"
3207 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
3208 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
3209 />
3210 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
3211 </div>
3212 ))}
3213 </div>
3214 )}
3215 </div>
3216
3217 {/* 7-day merge sparkline */}
3218 <div class="pri-section">
3219 <div class="pri-section-title">Merges this week (daily)</div>
3220 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
3221 <div class="pri-sparkline">
3222 {sparkline.map((v) => (
3223 <div
3224 class="pri-spark-bar"
3225 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
3226 title={`${v} merge${v === 1 ? "" : "s"}`}
3227 />
3228 ))}
3229 </div>
3230 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
3231 <span>7 days ago</span>
3232 <span>Today</span>
3233 </div>
3234 </div>
3235 </div>
3236
3237 {/* Top reviewers table */}
3238 <div class="pri-section">
3239 <div class="pri-section-title">Top reviewers (last 90 days)</div>
3240 {reviewerCounts.length === 0 ? (
3241 <div class="pri-empty">No reviews posted in the last 90 days.</div>
3242 ) : (
3243 <div class="pri-table-wrap">
3244 <table class="pri-table">
3245 <thead>
3246 <tr>
3247 <th>#</th>
3248 <th>Reviewer</th>
3249 <th>Reviews</th>
3250 </tr>
3251 </thead>
3252 <tbody>
3253 {reviewerCounts.map((r, i) => (
3254 <tr>
3255 <td style="color:var(--text-muted)">{i + 1}</td>
3256 <td>
3257 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
3258 {r.username}
3259 </a>
3260 </td>
3261 <td style="font-weight:600">{r.count}</td>
3262 </tr>
3263 ))}
3264 </tbody>
3265 </table>
3266 </div>
3267 )}
3268 </div>
3269
3270 {/* Open PRs by age */}
3271 <div class="pri-section">
3272 <div class="pri-section-title">Open PRs by age</div>
3273 {openPRs.length === 0 ? (
3274 <div class="pri-empty">No open pull requests.</div>
3275 ) : (
3276 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
3277 {ageBucketDefs.map(({ label, key }) => (
3278 <div class="pri-age-row">
3279 <span class="pri-age-label">{label}</span>
3280 <div class="pri-age-bar-wrap">
3281 <div
3282 class="pri-age-bar"
3283 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
3284 />
3285 </div>
3286 <span class="pri-age-count">{ageBuckets[key]}</span>
3287 </div>
3288 ))}
3289 </div>
3290 )}
3291 </div>
3292
3293 {/* Back link */}
3294 <div>
3295 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
3296 {"←"} Back to pull requests
3297 </a>
3298 </div>
3299 </div>
3300 </Layout>
3301 );
3302});
3303
0074234Claude3304// New PR form
3305pulls.get(
3306 "/:owner/:repo/pulls/new",
3307 softAuth,
3308 requireAuth,
04f6b7fClaude3309 requireRepoAccess("write"),
0074234Claude3310 async (c) => {
3311 const { owner: ownerName, repo: repoName } = c.req.param();
3312 const user = c.get("user")!;
3313 const branches = await listBranches(ownerName, repoName);
3314 const error = c.req.query("error");
3315 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude3316 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude3317
3318 return c.html(
3319 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
3320 <RepoHeader owner={ownerName} repo={repoName} />
3321 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude3322 <Container maxWidth={800}>
3323 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude3324 {error && (
bb0f894Claude3325 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude3326 )}
0316dbbClaude3327 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
3328 <Flex gap={12} align="center" style="margin-bottom: 16px">
3329 <Select name="base">
0074234Claude3330 {branches.map((b) => (
3331 <option value={b} selected={b === defaultBase}>
3332 {b}
3333 </option>
3334 ))}
bb0f894Claude3335 </Select>
3336 <Text muted>&larr;</Text>
3337 <Select name="head">
0074234Claude3338 {branches
3339 .filter((b) => b !== defaultBase)
3340 .concat(defaultBase === branches[0] ? [] : [branches[0]])
3341 .map((b) => (
3342 <option value={b}>{b}</option>
3343 ))}
bb0f894Claude3344 </Select>
3345 </Flex>
3346 <FormGroup>
3347 <Input
0074234Claude3348 name="title"
3349 required
3350 placeholder="Title"
bb0f894Claude3351 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]3352 aria-label="Pull request title"
0074234Claude3353 />
bb0f894Claude3354 </FormGroup>
3355 <FormGroup>
3356 <TextArea
0074234Claude3357 name="body"
81c73c1Claude3358 id="pr-body"
0074234Claude3359 rows={8}
3360 placeholder="Description (Markdown supported)"
bb0f894Claude3361 mono
0074234Claude3362 />
bb0f894Claude3363 </FormGroup>
81c73c1Claude3364 <Flex gap={8} align="center">
3365 <Button type="submit" variant="primary">
3366 Create pull request
3367 </Button>
3368 <button
3369 type="button"
3370 id="ai-suggest-desc"
3371 class="btn"
3372 style="font-weight:500"
3373 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
3374 >
3375 Suggest description with AI
3376 </button>
3377 <span
3378 id="ai-suggest-status"
3379 style="color:var(--text-muted);font-size:13px"
3380 />
3381 </Flex>
bb0f894Claude3382 </Form>
81c73c1Claude3383 <script
3384 dangerouslySetInnerHTML={{
3385 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
3386 }}
3387 />
bb0f894Claude3388 </Container>
0074234Claude3389 </Layout>
3390 );
3391 }
3392);
3393
81c73c1Claude3394// AI-suggested PR description — JSON endpoint driven by the form button.
3395// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
3396// 200; the inline script reads `ok` to decide what to do.
3397pulls.post(
3398 "/:owner/:repo/ai/pr-description",
3399 softAuth,
3400 requireAuth,
3401 requireRepoAccess("write"),
3402 async (c) => {
3403 const { owner: ownerName, repo: repoName } = c.req.param();
3404 if (!isAiAvailable()) {
3405 return c.json({
3406 ok: false,
3407 error: "AI is not available — set ANTHROPIC_API_KEY.",
3408 });
3409 }
3410 const body = await c.req.parseBody();
3411 const title = String(body.title || "").trim();
3412 const baseBranch = String(body.base || "").trim();
3413 const headBranch = String(body.head || "").trim();
3414 if (!baseBranch || !headBranch) {
3415 return c.json({ ok: false, error: "Pick base + head branches first." });
3416 }
3417 if (baseBranch === headBranch) {
3418 return c.json({ ok: false, error: "Base and head must differ." });
3419 }
3420
3421 let diff = "";
3422 try {
3423 const cwd = getRepoPath(ownerName, repoName);
3424 const proc = Bun.spawn(
3425 [
3426 "git",
3427 "diff",
3428 `${baseBranch}...${headBranch}`,
3429 "--",
3430 ],
3431 { cwd, stdout: "pipe", stderr: "pipe" }
3432 );
6ea2109Claude3433 // 30s ceiling — without this a pathological diff (huge binary or
3434 // a corrupt ref) hangs the request indefinitely.
3435 const killer = setTimeout(() => proc.kill(), 30_000);
3436 try {
3437 diff = await new Response(proc.stdout).text();
3438 await proc.exited;
3439 } finally {
3440 clearTimeout(killer);
3441 }
81c73c1Claude3442 } catch {
3443 diff = "";
3444 }
3445 if (!diff.trim()) {
3446 return c.json({
3447 ok: false,
3448 error: "No diff between branches — nothing to summarise.",
3449 });
3450 }
3451
3452 let summary = "";
3453 try {
3454 summary = await generatePrSummary(title || "(untitled)", diff);
3455 } catch (err) {
3456 const msg = err instanceof Error ? err.message : "AI request failed.";
3457 return c.json({ ok: false, error: msg });
3458 }
3459 if (!summary.trim()) {
3460 return c.json({ ok: false, error: "AI returned an empty draft." });
3461 }
3462 return c.json({ ok: true, body: summary });
3463 }
3464);
3465
0074234Claude3466// Create PR
3467pulls.post(
3468 "/:owner/:repo/pulls/new",
3469 softAuth,
3470 requireAuth,
04f6b7fClaude3471 requireRepoAccess("write"),
0074234Claude3472 async (c) => {
3473 const { owner: ownerName, repo: repoName } = c.req.param();
3474 const user = c.get("user")!;
3475 const body = await c.req.parseBody();
3476 const title = String(body.title || "").trim();
3477 const prBody = String(body.body || "").trim();
3478 const baseBranch = String(body.base || "main");
3479 const headBranch = String(body.head || "");
3480
3481 if (!title || !headBranch) {
3482 return c.redirect(
3483 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
3484 );
3485 }
3486
3487 if (baseBranch === headBranch) {
3488 return c.redirect(
3489 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
3490 );
3491 }
3492
3493 const resolved = await resolveRepo(ownerName, repoName);
3494 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3495
6fc53bdClaude3496 const isDraft = String(body.draft || "") === "1";
3497
0074234Claude3498 const [pr] = await db
3499 .insert(pullRequests)
3500 .values({
3501 repositoryId: resolved.repo.id,
3502 authorId: user.id,
3503 title,
3504 body: prBody || null,
3505 baseBranch,
3506 headBranch,
6fc53bdClaude3507 isDraft,
0074234Claude3508 })
3509 .returning();
3510
ec9e3e3Claude3511 // CODEOWNERS — auto-request reviewers based on changed files.
3512 // Fire-and-forget; errors never block PR creation.
3513 (async () => {
3514 try {
3515 const repoDir = getRepoPath(ownerName, repoName);
3516 // Get list of changed files between base and head
3517 const diffProc = Bun.spawn(
3518 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
3519 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3520 );
3521 const rawDiff = await new Response(diffProc.stdout).text();
3522 await diffProc.exited;
3523 const changedFiles = rawDiff.trim().split("\n").filter(Boolean);
3524
3525 if (changedFiles.length > 0) {
3526 // Get CODEOWNERS from the default branch of the repo
3527 const rules = await getCodeownersForRepo(
3528 ownerName,
3529 repoName,
3530 resolved.repo.defaultBranch
3531 );
3532 if (rules.length > 0) {
3533 const ownerUsernames = await reviewersForChangedFiles(
3534 resolved.repo.id,
3535 changedFiles
3536 );
3537 // Filter out the PR author
3538 const filteredOwners = ownerUsernames.filter(
3539 (u) => u !== resolved.owner.username
3540 );
3541
3542 if (filteredOwners.length > 0) {
3543 // Look up user IDs for the owner usernames
3544 const reviewerUsers = await db
3545 .select({ id: users.id, username: users.username })
3546 .from(users)
3547 .where(
3548 inArray(
3549 users.username,
3550 filteredOwners
3551 )
3552 );
3553
3554 // Create review request rows (UNIQUE constraint prevents dupes)
3555 if (reviewerUsers.length > 0) {
3556 await db
3557 .insert(prReviewRequests)
3558 .values(
3559 reviewerUsers.map((u) => ({
3560 prId: pr.id,
3561 reviewerId: u.id,
3562 requestedBy: null as string | null,
3563 }))
3564 )
3565 .onConflictDoNothing();
3566
3567 // Add a PR comment announcing the auto-assigned reviewers
3568 const mentionList = reviewerUsers
3569 .map((u) => `@${u.username}`)
3570 .join(", ");
3571 await db.insert(prComments).values({
3572 pullRequestId: pr.id,
3573 authorId: user.id,
3574 body: `AI: Requested review from ${mentionList} based on CODEOWNERS`,
3575 isAiReview: true,
3576 });
3577 }
3578 }
3579 }
3580 }
3581 } catch (err) {
3582 console.warn("[codeowners] auto-assign failed:", err instanceof Error ? err.message : err);
3583 }
3584 })();
3585
6fc53bdClaude3586 // Skip AI review on drafts — it runs again when the PR is marked ready.
3587 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3588 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3589 (err) => console.error("[ai-review] Failed:", err)
3590 );
3591 }
3592
3cbe3d6Claude3593 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3594 triggerPrTriage({
3595 ownerName,
3596 repoName,
3597 repositoryId: resolved.repo.id,
3598 prId: pr.id,
3599 prAuthorId: user.id,
3600 title,
3601 body: prBody,
3602 baseBranch,
3603 headBranch,
3604 }).catch((err) => console.error("[pr-triage] Failed:", err));
3605
1d4ff60Claude3606 // Chat notifier — fan out to Slack/Discord/Teams.
3607 import("../lib/chat-notifier")
3608 .then((m) =>
3609 m.notifyChatChannels({
3610 ownerUserId: resolved.repo.ownerId,
3611 repositoryId: resolved.repo.id,
3612 event: {
3613 event: "pr.opened",
3614 repo: `${ownerName}/${repoName}`,
3615 title: `#${pr.number} ${title}`,
3616 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3617 body: prBody || undefined,
3618 actor: user.username,
3619 },
3620 })
3621 )
3622 .catch((err) =>
3623 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3624 );
3625
9dd96b9Test User3626 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude3627 import("../lib/auto-merge")
3628 .then((m) => m.tryAutoMergeNow(pr.id))
3629 .catch((err) => {
3630 console.warn(
3631 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
3632 err instanceof Error ? err.message : err
3633 );
3634 });
9dd96b9Test User3635
1df50d5Claude3636 // Migration 0077 — PR preview build. Fire-and-forget; skips when
3637 // PREVIEW_DOMAIN is unset or the repo has no preview_build_command.
3638 // Resolve head SHA asynchronously so we don't block the redirect.
3639 resolveRef(ownerName, repoName, headBranch)
3640 .then((headSha) => {
3641 if (!headSha) return;
3642 return import("../lib/preview-builder").then((m) =>
3643 m.buildPreview(pr.id, resolved.repo.id, headSha)
3644 );
3645 })
3646 .catch(() => {});
3647
0074234Claude3648 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
3649 }
3650);
3651
3652// View single PR
04f6b7fClaude3653pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude3654 const { owner: ownerName, repo: repoName } = c.req.param();
3655 const prNum = parseInt(c.req.param("number"), 10);
3656 const user = c.get("user");
3657 const tab = c.req.query("tab") || "conversation";
3658
3659 const resolved = await resolveRepo(ownerName, repoName);
3660 if (!resolved) return c.notFound();
3661
3662 const [pr] = await db
3663 .select()
3664 .from(pullRequests)
3665 .where(
3666 and(
3667 eq(pullRequests.repositoryId, resolved.repo.id),
3668 eq(pullRequests.number, prNum)
3669 )
3670 )
3671 .limit(1);
3672
3673 if (!pr) return c.notFound();
3674
3675 const [author] = await db
3676 .select()
3677 .from(users)
3678 .where(eq(users.id, pr.authorId))
3679 .limit(1);
3680
cb5a796Claude3681 const allCommentsRaw = await db
0074234Claude3682 .select({
3683 comment: prComments,
cb5a796Claude3684 author: { id: users.id, username: users.username },
0074234Claude3685 })
3686 .from(prComments)
3687 .innerJoin(users, eq(prComments.authorId, users.id))
3688 .where(eq(prComments.pullRequestId, pr.id))
3689 .orderBy(asc(prComments.createdAt));
3690
cb5a796Claude3691 // Filter pending/rejected/spam for non-owner, non-author viewers.
3692 // Owner always sees everything; comment author sees their own pending
3693 // with an "Awaiting approval" badge in the render below.
3694 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
3695 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
3696 if (viewerIsRepoOwner) return true;
3697 if (comment.moderationStatus === "approved") return true;
3698 if (
3699 user &&
3700 cAuthor.id === user.id &&
3701 comment.moderationStatus === "pending"
3702 ) {
3703 return true;
3704 }
3705 return false;
3706 });
3707 const prPendingCount = viewerIsRepoOwner
3708 ? await countPendingForRepo(resolved.repo.id)
3709 : 0;
3710
6fc53bdClaude3711 // Reactions for the PR body + each comment, in parallel.
3712 const [prReactions, ...prCommentReactions] = await Promise.all([
3713 summariseReactions("pr", pr.id, user?.id),
3714 ...comments.map((row) =>
3715 summariseReactions("pr_comment", row.comment.id, user?.id)
3716 ),
3717 ]);
3718
0a67773Claude3719 // Formal reviews (Approve / Request Changes)
3720 const reviewRows = await db
3721 .select({
3722 id: prReviews.id,
3723 state: prReviews.state,
3724 body: prReviews.body,
3725 isAi: prReviews.isAi,
3726 createdAt: prReviews.createdAt,
3727 reviewerUsername: users.username,
3728 reviewerId: prReviews.reviewerId,
3729 })
3730 .from(prReviews)
3731 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3732 .where(eq(prReviews.pullRequestId, pr.id))
3733 .orderBy(asc(prReviews.createdAt));
3734 // Most recent review per reviewer determines the current state
3735 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
3736 for (const r of reviewRows) {
3737 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
3738 }
3739 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
3740 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
3741 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
3742
ec9e3e3Claude3743 // Requested reviewers from CODEOWNERS auto-assign (migration 0077).
3744 const requestedReviewerRows = await db
3745 .select({
3746 reviewerUsername: users.username,
3747 reviewerId: prReviewRequests.reviewerId,
3748 createdAt: prReviewRequests.createdAt,
3749 })
3750 .from(prReviewRequests)
3751 .innerJoin(users, eq(prReviewRequests.reviewerId, users.id))
3752 .where(eq(prReviewRequests.prId, pr.id))
3753 .orderBy(asc(prReviewRequests.createdAt))
3754 .catch(() => [] as { reviewerUsername: string; reviewerId: string; createdAt: Date }[]);
3755
ace34efClaude3756 // Suggested reviewers — best-effort, never throws
3757 let reviewerSuggestions: ReviewerCandidate[] = [];
3758 try {
3759 if (user) {
3760 reviewerSuggestions = await suggestReviewers(
3761 ownerName, repoName, pr.headBranch, pr.baseBranch,
3762 pr.authorId, resolved.repo.id
3763 );
3764 }
3765 } catch {
3766 // silent degradation
3767 }
3768
0074234Claude3769 const canManage =
3770 user &&
3771 (user.id === resolved.owner.id || user.id === pr.authorId);
3772
1d4ff60Claude3773 // Has any previous AI-test-generator run already tagged this PR? Used
3774 // both to hide the "Generate tests with AI" button and to short-circuit
3775 // the explicit POST handler.
3776 const hasAiTestsMarker = comments.some(({ comment }) =>
3777 (comment.body || "").includes(AI_TESTS_MARKER)
3778 );
3779
e883329Claude3780 const error = c.req.query("error");
c3e0c07Claude3781 const info = c.req.query("info");
e883329Claude3782
3783 // Get gate check status for open PRs
3784 let gateChecks: GateCheckResult[] = [];
240c477Claude3785 let ciStatuses: CommitStatus[] = [];
e883329Claude3786 if (pr.state === "open") {
3787 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3788 if (headSha) {
3789 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
3790 const aiApproved = aiComments.length === 0 || aiComments.some(
3791 ({ comment }) => comment.body.includes("**Approved**")
3792 );
240c477Claude3793 const [gateResult, fetchedCiStatuses] = await Promise.all([
3794 runAllGateChecks(
3795 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
3796 ),
3797 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
3798 ]);
e883329Claude3799 gateChecks = gateResult.checks;
240c477Claude3800 ciStatuses = fetchedCiStatuses;
e883329Claude3801 }
3802 }
3803
534f04aClaude3804 // Block M3 — pre-merge risk score. Cache-only on the request path so
3805 // the page never waits on Haiku. On a cache miss for an open PR we
3806 // kick off the computation fire-and-forget; the next refresh shows it.
3807 let prRisk: PrRiskScore | null = null;
3808 let prRiskCalculating = false;
3809 if (pr.state === "open") {
3810 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
3811 if (!prRisk) {
3812 prRiskCalculating = true;
a28cedeClaude3813 void computePrRiskForPullRequest(pr.id).catch((err) => {
3814 console.warn(
3815 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
3816 err instanceof Error ? err.message : err
3817 );
3818 });
534f04aClaude3819 }
3820 }
3821
4bbacbeClaude3822 // Migration 0062 — per-branch preview URL. The head branch always
3823 // has a preview row (unless it's the default branch, which never
3824 // happens for an open PR) once it has been pushed at least once.
3825 const preview = await getPreviewForBranch(
3826 (resolved.repo as { id: string }).id,
3827 pr.headBranch
3828 );
3829
0369e77Claude3830 // Branch ahead/behind counts — how many commits head is ahead of base and
3831 // how many commits base has advanced since head branched off.
3832 let branchAhead = 0;
3833 let branchBehind = 0;
3834 if (pr.state === "open") {
3835 try {
3836 const repoDir = getRepoPath(ownerName, repoName);
3837 const [aheadProc, behindProc] = [
3838 Bun.spawn(
3839 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
3840 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3841 ),
3842 Bun.spawn(
3843 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
3844 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3845 ),
3846 ];
3847 const [aheadTxt, behindTxt] = await Promise.all([
3848 new Response(aheadProc.stdout).text(),
3849 new Response(behindProc.stdout).text(),
3850 ]);
3851 await Promise.all([aheadProc.exited, behindProc.exited]);
3852 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
3853 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
3854 } catch { /* non-blocking */ }
3855 }
3856
6d1bbc2Claude3857 // Linked issues — parse closing keywords from PR title+body, look up issues
3858 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
3859 try {
3860 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3861 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3862 if (refs.length > 0) {
3863 linkedIssues = await db
3864 .select({ number: issues.number, title: issues.title, state: issues.state })
3865 .from(issues)
3866 .where(and(
3867 eq(issues.repositoryId, resolved.repo.id),
3868 inArray(issues.number, refs),
3869 ));
3870 }
3871 } catch { /* non-blocking */ }
3872
3873 // Task list progress — count markdown checkboxes in PR body
3874 let taskTotal = 0;
3875 let taskChecked = 0;
3876 if (pr.body) {
3877 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
3878 taskTotal++;
3879 if (m[1].trim() !== "") taskChecked++;
3880 }
3881 }
3882
74d8c4dClaude3883 // M15 — PR size badge (best-effort, non-blocking)
3884 let prSizeInfo: PrSizeInfo | null = null;
3885 try {
3886 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
3887 } catch { /* swallow — purely cosmetic */ }
3888
47a7a0aClaude3889 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude3890 let diffRaw = "";
3891 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude3892 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude3893 if (tab === "files") {
3894 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude3895 // Run the two git diffs in parallel — they're independent reads of
3896 // the same range. Previously sequential, doubling the wall time on
3897 // big PRs (100+ files = 10-30s for no reason).
0074234Claude3898 const proc = Bun.spawn(
3899 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
3900 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3901 );
3902 const statProc = Bun.spawn(
3903 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
3904 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3905 );
6ea2109Claude3906 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
3907 // would otherwise hang the whole request.
3908 const killer = setTimeout(() => {
3909 proc.kill();
3910 statProc.kill();
3911 }, 30_000);
3912 let stat = "";
3913 try {
3914 [diffRaw, stat] = await Promise.all([
3915 new Response(proc.stdout).text(),
3916 new Response(statProc.stdout).text(),
3917 ]);
3918 await Promise.all([proc.exited, statProc.exited]);
3919 } finally {
3920 clearTimeout(killer);
3921 }
0074234Claude3922
3923 diffFiles = stat
3924 .trim()
3925 .split("\n")
3926 .filter(Boolean)
3927 .map((line) => {
3928 const [add, del, filePath] = line.split("\t");
3929 return {
3930 path: filePath,
3931 status: "modified",
3932 additions: add === "-" ? 0 : parseInt(add, 10),
3933 deletions: del === "-" ? 0 : parseInt(del, 10),
3934 patch: "",
3935 };
3936 });
47a7a0aClaude3937
3938 // Fetch inline comments (file+line anchored) for the files tab
3939 const inlineRows = await db
3940 .select({
3941 id: prComments.id,
3942 filePath: prComments.filePath,
3943 lineNumber: prComments.lineNumber,
3944 body: prComments.body,
3945 isAiReview: prComments.isAiReview,
3946 createdAt: prComments.createdAt,
3947 authorUsername: users.username,
3948 })
3949 .from(prComments)
3950 .innerJoin(users, eq(prComments.authorId, users.id))
3951 .where(
3952 and(
3953 eq(prComments.pullRequestId, pr.id),
3954 eq(prComments.moderationStatus, "approved"),
3955 )
3956 )
3957 .orderBy(asc(prComments.createdAt));
3958
3959 diffInlineComments = inlineRows
3960 .filter(r => r.filePath != null && r.lineNumber != null)
3961 .map(r => ({
3962 id: r.id,
3963 filePath: r.filePath!,
3964 lineNumber: r.lineNumber!,
3965 authorUsername: r.authorUsername,
3966 body: renderMarkdown(r.body),
3967 isAiReview: r.isAiReview,
3968 createdAt: r.createdAt.toISOString(),
3969 }));
0074234Claude3970 }
3971
b078860Claude3972 // ─── Derived visual state ───
3973 const stateKey =
3974 pr.state === "open"
3975 ? pr.isDraft
3976 ? "draft"
3977 : "open"
3978 : pr.state;
3979 const stateLabel =
3980 stateKey === "open"
3981 ? "Open"
3982 : stateKey === "draft"
3983 ? "Draft"
3984 : stateKey === "merged"
3985 ? "Merged"
3986 : "Closed";
3987 const stateIcon =
3988 stateKey === "open"
3989 ? "○"
3990 : stateKey === "draft"
3991 ? "◌"
3992 : stateKey === "merged"
3993 ? "⮌"
3994 : "✓";
3995 const commentCount = comments.length;
3996 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
3997 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
3998 const mergeBlocked =
3999 gateChecks.length > 0 &&
4000 gateChecks.some(
4001 (c) => !c.passed && c.name !== "Merge check"
4002 );
4003
b558f23Claude4004 // Commits tab — list commits included in this PR (base..head range)
4005 let prCommits: GitCommit[] = [];
4006 if (tab === "commits") {
4007 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
4008 }
4009
0074234Claude4010 return c.html(
4011 <Layout
4012 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
4013 user={user}
4014 >
4015 <RepoHeader owner={ownerName} repo={repoName} />
4016 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude4017 <PendingCommentsBanner
4018 owner={ownerName}
4019 repo={repoName}
4020 count={prPendingCount}
4021 />
b078860Claude4022 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude4023 <div
4024 id="live-comment-banner"
4025 class="alert"
4026 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
4027 >
4028 <strong class="js-live-count">0</strong> new comment(s) —{" "}
4029 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
4030 reload to view
4031 </a>
4032 </div>
4033 <script
4034 dangerouslySetInnerHTML={{
4035 __html: liveCommentBannerScript({
4036 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
4037 bannerElementId: "live-comment-banner",
4038 }),
4039 }}
4040 />
b078860Claude4041
4042 <div class="prs-detail-hero">
b558f23Claude4043 <div class="prs-edit-title-wrap">
4044 <h1 class="prs-detail-title" id="pr-title-display">
4045 {pr.title}{" "}
4046 <span class="prs-detail-num">#{pr.number}</span>
4047 </h1>
4048 {canManage && pr.state === "open" && (
4049 <button
4050 type="button"
4051 class="prs-edit-btn"
4052 id="pr-edit-toggle"
4053 onclick={`
4054 document.getElementById('pr-title-display').style.display='none';
4055 document.getElementById('pr-edit-toggle').style.display='none';
4056 document.getElementById('pr-edit-form').style.display='flex';
4057 document.getElementById('pr-title-input').focus();
4058 `}
4059 >
4060 Edit
4061 </button>
4062 )}
4063 </div>
4064 {canManage && pr.state === "open" && (
4065 <form
4066 id="pr-edit-form"
4067 method="post"
4068 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
4069 class="prs-edit-form"
4070 style="display:none"
4071 >
4072 <input
4073 id="pr-title-input"
4074 type="text"
4075 name="title"
4076 value={pr.title}
4077 required
4078 maxlength={256}
4079 placeholder="Pull request title"
4080 />
4081 <div class="prs-edit-actions">
4082 <button type="submit" class="prs-edit-save-btn">Save</button>
4083 <button
4084 type="button"
4085 class="prs-edit-cancel-btn"
4086 onclick={`
4087 document.getElementById('pr-edit-form').style.display='none';
4088 document.getElementById('pr-title-display').style.display='';
4089 document.getElementById('pr-edit-toggle').style.display='';
4090 `}
4091 >
4092 Cancel
4093 </button>
4094 </div>
4095 </form>
4096 )}
b078860Claude4097 <div class="prs-detail-meta">
4098 <span class={`prs-state-pill state-${stateKey}`}>
4099 <span aria-hidden="true">{stateIcon}</span>
4100 <span>{stateLabel}</span>
4101 </span>
74d8c4dClaude4102 {prSizeInfo && (
4103 <span
4104 class="prs-size-badge"
4105 style={`color:${prSizeInfo.color};background:${prSizeInfo.bgColor}`}
4106 title={`${prSizeInfo.linesChanged} lines changed (+${prSizeInfo.added} −${prSizeInfo.deleted})`}
4107 >
4108 {prSizeInfo.label}
4109 </span>
4110 )}
67dc4e1Claude4111 <TrioVerdictPills
4112 comments={comments.map(({ comment }) => comment)}
4113 />
b078860Claude4114 <span>
4115 <strong>{author?.username}</strong> wants to merge
4116 </span>
4117 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
4118 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
4119 <span class="prs-branch-arrow-lg">{"→"}</span>
4120 <span class="prs-branch-pill">{pr.baseBranch}</span>
4121 </span>
0369e77Claude4122 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
4123 <span
4124 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
4125 title={branchBehind > 0
4126 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
4127 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
4128 >
4129 {branchAhead > 0 ? `↑${branchAhead}` : ""}
4130 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
4131 {branchBehind > 0 ? `↓${branchBehind}` : ""}
4132 </span>
4133 )}
b078860Claude4134 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude4135 {taskTotal > 0 && (
4136 <span
4137 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
4138 title={`${taskChecked} of ${taskTotal} tasks completed`}
4139 >
4140 <span class="prs-tasks-progress" aria-hidden="true">
4141 <span
4142 class="prs-tasks-progress-bar"
4143 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
4144 ></span>
4145 </span>
4146 {taskChecked}/{taskTotal} tasks
4147 </span>
4148 )}
4149 {canManage && pr.state === "open" && branchBehind > 0 && (
4150 <form
4151 method="post"
4152 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
4153 class="prs-inline-form"
4154 >
4155 <button
4156 type="submit"
4157 class="prs-update-branch-btn"
4158 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
4159 >
4160 ↑ Update branch
4161 </button>
4162 </form>
4163 )}
3c03977Claude4164 <span
4165 id="live-pill"
4166 class="live-pill"
4167 title="People editing this PR right now"
4168 >
4169 <span class="live-pill-dot" aria-hidden="true"></span>
4170 <span>
4171 Live: <strong id="live-count">0</strong> editing
4172 </span>
4173 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
4174 </span>
4bbacbeClaude4175 {preview && (
4176 <a
4177 class={`preview-prpill is-${preview.status}`}
4178 href={
4179 preview.status === "ready"
4180 ? preview.previewUrl
4181 : `/${ownerName}/${repoName}/previews`
4182 }
4183 target={preview.status === "ready" ? "_blank" : undefined}
4184 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
4185 title={`Preview · ${previewStatusLabel(preview.status)}`}
4186 >
4187 <span class="preview-prpill-dot" aria-hidden="true"></span>
4188 <span>Preview: </span>
4189 <span>{previewStatusLabel(preview.status)}</span>
4190 </a>
4191 )}
b078860Claude4192 {canManage && pr.state === "open" && pr.isDraft && (
4193 <form
4194 method="post"
4195 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4196 class="prs-inline-form prs-detail-actions"
4197 >
4198 <button type="submit" class="prs-merge-ready-btn">
4199 Ready for review
4200 </button>
4201 </form>
4202 )}
4203 </div>
4204 </div>
3c03977Claude4205 <script
4206 dangerouslySetInnerHTML={{
4207 __html: LIVE_COEDIT_SCRIPT(pr.id),
4208 }}
4209 />
829a046Claude4210 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude4211 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude4212 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude4213
25b1ff7Claude4214 {/* Presence styles + bar (shown only on the files tab so cursor pills work) */}
4215 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES }} />
4216 {/* Toast container — always present for join/leave toasts */}
4217 <div id="presence-toasts" class="presence-toast-wrap" aria-live="polite" />
4218 {user && (
4219 <>
4220 <div class="presence-bar" id="presence-bar">
4221 <span class="presence-bar-label">Live reviewers</span>
4222 <div class="presence-avatars" id="presence-avatars" />
4223 <span class="presence-count" id="presence-count">Loading…</span>
4224 </div>
4225 <script
4226 dangerouslySetInnerHTML={{
4227 __html: PR_PRESENCE_SCRIPT(ownerName, repoName, pr.number),
4228 }}
4229 />
4230 </>
4231 )}
4232
b078860Claude4233 <nav class="prs-detail-tabs" aria-label="Pull request sections">
4234 <a
4235 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
4236 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
4237 >
4238 Conversation
4239 <span class="prs-detail-tab-count">{commentCount}</span>
4240 </a>
b558f23Claude4241 <a
4242 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
4243 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
4244 >
4245 Commits
4246 {branchAhead > 0 && (
4247 <span class="prs-detail-tab-count">{branchAhead}</span>
4248 )}
4249 </a>
b078860Claude4250 <a
4251 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
4252 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4253 >
4254 Files changed
4255 {diffFiles.length > 0 && (
4256 <span class="prs-detail-tab-count">{diffFiles.length}</span>
4257 )}
4258 </a>
4259 </nav>
4260
b558f23Claude4261 {tab === "commits" ? (
4262 <div class="prs-commits-list">
4263 {prCommits.length === 0 ? (
4264 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
4265 ) : (
4266 prCommits.map((commit) => (
4267 <div class="prs-commit-row">
4268 <span class="prs-commit-dot" aria-hidden="true"></span>
4269 <div class="prs-commit-body">
4270 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
4271 <div class="prs-commit-meta">
4272 <strong>{commit.author}</strong> committed{" "}
4273 {formatRelative(new Date(commit.date))}
4274 </div>
4275 </div>
4276 <a
4277 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
4278 class="prs-commit-sha"
4279 title="View commit"
4280 >
4281 {commit.sha.slice(0, 7)}
4282 </a>
4283 </div>
4284 ))
4285 )}
4286 </div>
4287 ) : tab === "files" ? (
ea9ed4cClaude4288 <DiffView
4289 raw={diffRaw}
4290 files={diffFiles}
4291 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
47a7a0aClaude4292 inlineComments={diffInlineComments}
4293 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
b5dd694Claude4294 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
ea9ed4cClaude4295 />
b078860Claude4296 ) : (
4297 <>
4298 {pr.body && (
4299 <CommentBox
4300 author={author?.username ?? "unknown"}
4301 date={pr.createdAt}
4302 body={renderMarkdown(pr.body)}
4303 />
4304 )}
4305
422a2d4Claude4306 {/* Block H — AI trio review (security/correctness/style). When
4307 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
4308 hoisted into a 3-column card grid above the normal comment
4309 stream so reviewers see verdicts at a glance. Disagreements
4310 are surfaced as a yellow callout. */}
4311 <TrioReviewGrid
4312 comments={comments.map(({ comment }) => comment)}
4313 />
4314
15db0e0Claude4315 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude4316 // Skip trio comments — already rendered in TrioReviewGrid above.
4317 if (isTrioComment(comment.body)) return null;
15db0e0Claude4318 const slashCmd = detectSlashCmdComment(comment.body);
4319 if (slashCmd) {
4320 const visible = stripSlashCmdMarker(comment.body);
4321 return (
4322 <div class={`slash-pill slash-cmd-${slashCmd}`}>
4323 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
4324 <span class="slash-pill-actor">
4325 <strong>{commentAuthor.username}</strong>
4326 {" ran "}
4327 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude4328 </span>
15db0e0Claude4329 <span class="slash-pill-time">
4330 {formatRelative(comment.createdAt)}
4331 </span>
4332 <div class="slash-pill-body">
4333 <MarkdownContent html={renderMarkdown(visible)} />
4334 </div>
4335 </div>
4336 );
4337 }
cb5a796Claude4338 const isPending = comment.moderationStatus === "pending";
15db0e0Claude4339 return (
cb5a796Claude4340 <div
4341 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
4342 >
15db0e0Claude4343 <div class="prs-comment-head">
4344 <strong>{commentAuthor.username}</strong>
a7460bfClaude4345 {commentAuthor.username === BOT_USERNAME && (
4346 <span class="prs-bot-badge">&#x1F916; bot</span>
4347 )}
15db0e0Claude4348 {comment.isAiReview && (
4349 <span class="prs-ai-badge">AI Review</span>
4350 )}
cb5a796Claude4351 {isPending && (
4352 <span
4353 class="modq-pending-badge"
4354 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
4355 >
4356 Awaiting approval
4357 </span>
4358 )}
15db0e0Claude4359 <span class="prs-comment-time">
4360 commented {formatRelative(comment.createdAt)}
4361 </span>
4362 {comment.filePath && (
4363 <span class="prs-comment-loc">
4364 {comment.filePath}
4365 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
4366 </span>
4367 )}
4368 </div>
4369 <div class="prs-comment-body">
4370 <MarkdownContent html={renderMarkdown(comment.body)} />
4371 </div>
0074234Claude4372 </div>
15db0e0Claude4373 );
4374 })}
0074234Claude4375
b078860Claude4376 {/* Quick link to the Files changed tab when there's a diff to look at. */}
4377 {pr.state !== "merged" && (
4378 <a
4379 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
4380 class="prs-files-card"
4381 >
4382 <span class="prs-files-card-icon" aria-hidden="true">
4383 {"▤"}
4384 </span>
4385 <div class="prs-files-card-text">
4386 <p class="prs-files-card-title">Files changed</p>
4387 <p class="prs-files-card-sub">
4388 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
4389 </p>
e883329Claude4390 </div>
b078860Claude4391 <span class="prs-files-card-cta">View diff {"→"}</span>
4392 </a>
4393 )}
4394
6d1bbc2Claude4395 {linkedIssues.length > 0 && (
4396 <div class="prs-linked-issues">
4397 <div class="prs-linked-issues-head">
4398 <span>Closing issues</span>
4399 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
4400 </div>
4401 {linkedIssues.map((issue) => (
4402 <a
4403 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
4404 class="prs-linked-issue-row"
4405 >
4406 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
4407 {issue.state === "open" ? "○" : "✓"}
4408 </span>
4409 <span class="prs-linked-issue-title">{issue.title}</span>
4410 <span class="prs-linked-issue-num">#{issue.number}</span>
4411 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
4412 {issue.state}
4413 </span>
4414 </a>
4415 ))}
4416 </div>
4417 )}
4418
b078860Claude4419 {error && (
4420 <div
4421 class="auth-error"
4422 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)"
4423 >
4424 {decodeURIComponent(error)}
4425 </div>
4426 )}
4427
4428 {info && (
4429 <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)">
4430 {decodeURIComponent(info)}
4431 </div>
4432 )}
e883329Claude4433
b078860Claude4434 {pr.state === "open" && (prRisk || prRiskCalculating) && (
4435 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
4436 )}
4437
ec9e3e3Claude4438 {/* ─── Requested reviewers (CODEOWNERS auto-assign, migration 0077) ─── */}
4439 {requestedReviewerRows.length > 0 && (
4440 <div class="prs-review-summary" style="margin-top:14px">
4441 <div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-weight:700;margin-bottom:4px">
4442 Review requested
4443 </div>
4444 {requestedReviewerRows.map((rr) => {
4445 const hasReviewed = latestReviewByReviewer.has(rr.reviewerId);
4446 const review = latestReviewByReviewer.get(rr.reviewerId);
4447 const statusIcon = !hasReviewed ? "⏳" : review?.state === "approved" ? "✓" : "✗";
4448 const statusColor = !hasReviewed
4449 ? "var(--text-muted)"
4450 : review?.state === "approved"
4451 ? "#34d399"
4452 : "#f87171";
4453 return (
4454 <div class="prs-review-row" style={`gap:8px`}>
4455 <span class="prs-reviewer-avatar">
4456 {rr.reviewerUsername.slice(0, 1).toUpperCase()}
4457 </span>
4458 <a href={`/${rr.reviewerUsername}`}
4459 style="flex:1;font-size:13px;color:var(--text);font-weight:600;text-decoration:none">
4460 {rr.reviewerUsername}
4461 </a>
4462 <span style={`font-size:12px;font-weight:600;color:${statusColor}`}>
4463 {statusIcon} {!hasReviewed ? "Pending" : review?.state === "approved" ? "Approved" : "Changes requested"}
4464 </span>
4465 </div>
4466 );
4467 })}
4468 </div>
4469 )}
4470
0a67773Claude4471 {/* ─── Review summary ─────────────────────────────────── */}
4472 {(approvals.length > 0 || changesRequested.length > 0) && (
4473 <div class="prs-review-summary">
4474 {approvals.length > 0 && (
4475 <div class="prs-review-row prs-review-approved">
4476 <span class="prs-review-icon">✓</span>
4477 <span>
4478 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4479 approved this pull request
4480 </span>
4481 </div>
4482 )}
4483 {changesRequested.length > 0 && (
4484 <div class="prs-review-row prs-review-changes">
4485 <span class="prs-review-icon">✗</span>
4486 <span>
4487 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
4488 requested changes
4489 </span>
4490 </div>
4491 )}
4492 </div>
4493 )}
4494
ace34efClaude4495 {/* Suggested reviewers */}
4496 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
4497 <div class="prs-review-summary" style="margin-top:12px">
4498 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
4499 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
4500 Suggested reviewers
4501 </span>
4502 {reviewerSuggestions.map((r) => (
4503 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
4504 style="display:flex;align-items:center;gap:8px;width:100%">
4505 <input type="hidden" name="reviewerId" value={r.userId} />
4506 <span class="prs-reviewer-avatar">
4507 {r.username.slice(0, 1).toUpperCase()}
4508 </span>
4509 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
4510 {r.username}
4511 </a>
4512 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
4513 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
4514 Request
4515 </button>
4516 </form>
4517 ))}
4518 </div>
4519 </div>
4520 )}
4521
b078860Claude4522 {pr.state === "open" && gateChecks.length > 0 && (
4523 <div class="prs-gate-card">
4524 <div class="prs-gate-head">
4525 <h3>Gate checks</h3>
4526 <span class="prs-gate-summary">
4527 {gatesAllPassed
4528 ? `All ${gateChecks.length} checks passed`
4529 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
4530 </span>
c3e0c07Claude4531 </div>
b078860Claude4532 {gateChecks.map((check) => {
4533 const isAi = /ai.*review/i.test(check.name);
4534 const isSkip = check.skipped === true;
4535 const statusClass = isSkip
4536 ? "is-skip"
4537 : check.passed
4538 ? "is-pass"
4539 : "is-fail";
4540 const statusGlyph = isSkip
4541 ? "—"
4542 : check.passed
4543 ? "✓"
4544 : "✗";
4545 const statusLabel = isSkip
4546 ? "Skipped"
4547 : check.passed
4548 ? "Passed"
4549 : "Failing";
4550 return (
4551 <div
4552 class="prs-gate-row"
4553 style={
4554 isAi
4555 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
4556 : ""
4557 }
4558 >
4559 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
4560 {statusGlyph}
4561 </span>
4562 <span class="prs-gate-name">
4563 {check.name}
4564 {isAi && (
4565 <span
4566 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"
4567 >
4568 AI
4569 </span>
4570 )}
4571 </span>
4572 <span class="prs-gate-details">{check.details}</span>
4573 <span class={`prs-gate-pill ${statusClass}`}>
4574 {statusLabel}
e883329Claude4575 </span>
4576 </div>
b078860Claude4577 );
4578 })}
4579 <div class="prs-gate-footer">
4580 {gatesAllPassed
4581 ? "All checks passed — ready to merge."
4582 : gateChecks.some(
4583 (c) => !c.passed && c.name === "Merge check"
4584 )
4585 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
4586 : "Some checks failed — resolve issues before merging."}
4587 {aiReviewCount > 0 && (
4588 <>
4589 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
4590 </>
4591 )}
4592 </div>
4593 </div>
4594 )}
4595
240c477Claude4596 {pr.state === "open" && ciStatuses.length > 0 && (
4597 <div class="prs-ci-card">
4598 <div class="prs-ci-head">
4599 <h3>CI checks</h3>
4600 <span class="prs-ci-summary">
4601 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
4602 </span>
4603 </div>
4604 {ciStatuses.map((status) => {
4605 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
4606 return (
4607 <div class="prs-ci-row">
4608 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
4609 <span class="prs-ci-context">{status.context}</span>
4610 {status.description && (
4611 <span class="prs-ci-desc">{status.description}</span>
4612 )}
4613 <span class={`prs-ci-pill is-${status.state}`}>
4614 {status.state}
4615 </span>
4616 {status.targetUrl && (
4617 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
4618 )}
4619 </div>
4620 );
4621 })}
4622 </div>
4623 )}
4624
b078860Claude4625 {/* ─── Merge area / state-aware action card ─────────────── */}
4626 {user && pr.state === "open" && (
4627 <div
4628 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
4629 >
4630 <div class="prs-merge-head">
4631 <strong>
4632 {pr.isDraft
4633 ? "Draft — ready for review?"
4634 : mergeBlocked
4635 ? "Merge blocked"
4636 : "Ready to merge"}
4637 </strong>
e883329Claude4638 </div>
b078860Claude4639 <p class="prs-merge-sub">
4640 {pr.isDraft
4641 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
4642 : mergeBlocked
4643 ? "Resolve the failing gate checks above before this PR can land."
4644 : gateChecks.length > 0
4645 ? gatesAllPassed
4646 ? "All gates green. Merge will fast-forward into the base branch."
4647 : "Conflicts will be auto-resolved by GlueCron AI on merge."
4648 : "Run gate checks by refreshing once your branch has a recent commit."}
4649 </p>
4650 <Form
4651 method="post"
4652 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
4653 >
4654 <FormGroup>
3c03977Claude4655 <div class="live-cursor-host" style="position:relative">
4656 <textarea
4657 name="body"
4658 id="pr-comment-body"
4659 data-live-field="comment_new"
6cd2f0eClaude4660 data-md-preview=""
3c03977Claude4661 rows={5}
4662 required
4663 placeholder="Leave a comment... (Markdown supported)"
4664 style="font-family:var(--font-mono);font-size:13px;width:100%"
4665 ></textarea>
4666 </div>
15db0e0Claude4667 <span class="slash-hint" title="Type a slash-command as the first line">
4668 Type <code>/</code> for commands —{" "}
4669 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
4670 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
4671 </span>
b078860Claude4672 </FormGroup>
4673 <div class="prs-merge-actions">
4674 <Button type="submit" variant="primary">
4675 Comment
4676 </Button>
0a67773Claude4677 {user && user.id !== pr.authorId && pr.state === "open" && (
4678 <>
4679 <button
4680 type="submit"
4681 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4682 name="review_state"
4683 value="approved"
4684 class="prs-review-approve-btn"
4685 title="Approve this pull request"
4686 >
4687 ✓ Approve
4688 </button>
4689 <button
4690 type="submit"
4691 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4692 name="review_state"
4693 value="changes_requested"
4694 class="prs-review-changes-btn"
4695 title="Request changes before merging"
4696 >
4697 ✗ Request changes
4698 </button>
4699 </>
4700 )}
b078860Claude4701 {canManage && (
4702 <>
4703 {pr.isDraft ? (
4704 <button
4705 type="submit"
4706 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4707 formnovalidate
4708 class="prs-merge-ready-btn"
4709 >
4710 Ready for review
4711 </button>
4712 ) : (
a164a6dClaude4713 <>
4714 <div class="prs-merge-strategy-wrap">
4715 <span class="prs-merge-strategy-label">Strategy</span>
4716 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
4717 <option value="merge">Merge commit</option>
4718 <option value="squash">Squash and merge</option>
4719 <option value="ff">Fast-forward</option>
4720 </select>
4721 </div>
4722 <button
4723 type="submit"
4724 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
4725 formnovalidate
4726 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
4727 title={
4728 mergeBlocked
4729 ? "Failing gate checks must be resolved before this PR can merge."
4730 : "Merge pull request"
4731 }
4732 >
4733 {"✔"} Merge pull request
4734 </button>
4735 </>
b078860Claude4736 )}
4737 {!pr.isDraft && (
4738 <button
0074234Claude4739 type="submit"
b078860Claude4740 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
4741 formnovalidate
4742 class="prs-merge-back-draft"
4743 title="Convert back to draft"
0074234Claude4744 >
b078860Claude4745 Convert to draft
4746 </button>
4747 )}
4748 {isAiReviewEnabled() && (
4749 <button
4750 type="submit"
4751 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
4752 formnovalidate
4753 class="btn"
4754 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
4755 >
4756 Re-run AI review
4757 </button>
4758 )}
1d4ff60Claude4759 {isAiReviewEnabled() && !hasAiTestsMarker && (
4760 <button
4761 type="submit"
4762 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
4763 formnovalidate
4764 class="btn"
4765 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."
4766 >
4767 Generate tests with AI
4768 </button>
4769 )}
b078860Claude4770 <Button
4771 type="submit"
4772 variant="danger"
4773 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
4774 >
4775 Close
4776 </Button>
4777 </>
4778 )}
4779 </div>
4780 </Form>
4781 </div>
4782 )}
4783
4784 {/* Read-only footers for non-open states. */}
4785 {pr.state === "merged" && (
4786 <div class="prs-merge-card is-merged">
4787 <div class="prs-merge-head">
4788 <strong>{"⮌"} Merged</strong>
0074234Claude4789 </div>
b078860Claude4790 <p class="prs-merge-sub">
4791 This pull request was merged into{" "}
4792 <code>{pr.baseBranch}</code>.
4793 </p>
4794 </div>
4795 )}
4796 {pr.state === "closed" && (
4797 <div class="prs-merge-card is-closed">
4798 <div class="prs-merge-head">
4799 <strong>{"✕"} Closed without merging</strong>
4800 </div>
4801 <p class="prs-merge-sub">
4802 This pull request was closed and not merged.
4803 </p>
4804 </div>
4805 )}
4806 </>
4807 )}
0074234Claude4808 </Layout>
4809 );
4810});
4811
6d1bbc2Claude4812// Update branch — merge base into head so the PR branch is up to date.
4813// Uses a git worktree so the bare repo stays clean. Write access required.
4814pulls.post(
4815 "/:owner/:repo/pulls/:number/update-branch",
4816 softAuth,
4817 requireAuth,
4818 requireRepoAccess("write"),
4819 async (c) => {
4820 const { owner: ownerName, repo: repoName } = c.req.param();
4821 const prNum = parseInt(c.req.param("number"), 10);
4822 const user = c.get("user")!;
4823 const resolved = await resolveRepo(ownerName, repoName);
4824 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4825
4826 const [pr] = await db
4827 .select()
4828 .from(pullRequests)
4829 .where(and(
4830 eq(pullRequests.repositoryId, resolved.repo.id),
4831 eq(pullRequests.number, prNum),
4832 ))
4833 .limit(1);
4834 if (!pr || pr.state !== "open") {
4835 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4836 }
4837
4838 const repoDir = getRepoPath(ownerName, repoName);
4839 const wt = `${repoDir}/_update_wt_${Date.now()}`;
4840 const gitEnv = {
4841 ...process.env,
4842 GIT_AUTHOR_NAME: user.displayName || user.username,
4843 GIT_AUTHOR_EMAIL: user.email,
4844 GIT_COMMITTER_NAME: user.displayName || user.username,
4845 GIT_COMMITTER_EMAIL: user.email,
4846 };
4847
4848 const addWt = Bun.spawn(
4849 ["git", "worktree", "add", wt, pr.headBranch],
4850 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4851 );
4852 if (await addWt.exited !== 0) {
4853 return c.redirect(
4854 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
4855 );
4856 }
4857
4858 let ok = false;
4859 try {
4860 const mergeProc = Bun.spawn(
4861 ["git", "merge", "--no-edit", pr.baseBranch],
4862 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
4863 );
4864 if (await mergeProc.exited === 0) {
4865 ok = true;
4866 } else {
4867 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4868 }
4869 } catch {
4870 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4871 }
4872
4873 await Bun.spawn(
4874 ["git", "worktree", "remove", "--force", wt],
4875 { cwd: repoDir }
4876 ).exited.catch(() => {});
4877
4878 if (ok) {
4879 return c.redirect(
4880 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
4881 );
4882 }
4883 return c.redirect(
4884 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
4885 );
4886 }
4887);
4888
b558f23Claude4889// Edit PR title (and optionally body). Owner or author only.
4890pulls.post(
4891 "/:owner/:repo/pulls/:number/edit",
4892 softAuth,
4893 requireAuth,
4894 requireRepoAccess("write"),
4895 async (c) => {
4896 const { owner: ownerName, repo: repoName } = c.req.param();
4897 const prNum = parseInt(c.req.param("number"), 10);
4898 const user = c.get("user")!;
4899 const resolved = await resolveRepo(ownerName, repoName);
4900 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4901
4902 const [pr] = await db
4903 .select()
4904 .from(pullRequests)
4905 .where(and(
4906 eq(pullRequests.repositoryId, resolved.repo.id),
4907 eq(pullRequests.number, prNum),
4908 ))
4909 .limit(1);
4910 if (!pr || pr.state !== "open") {
4911 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4912 }
4913 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
4914 if (!canEdit) {
4915 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4916 }
4917
4918 const body = await c.req.parseBody();
4919 const newTitle = String(body.title || "").trim().slice(0, 256);
4920 if (!newTitle) {
4921 return c.redirect(
4922 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
4923 );
4924 }
4925
4926 await db
4927 .update(pullRequests)
4928 .set({ title: newTitle, updatedAt: new Date() })
4929 .where(eq(pullRequests.id, pr.id));
4930
4931 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
4932 }
4933);
4934
cb5a796Claude4935// Add comment to PR.
4936//
4937// Permission model mirrors `issues.tsx`: any logged-in user with read
4938// access can submit; `decideInitialStatus` routes non-collaborators
4939// through the moderation queue. Slash commands only fire when the
4940// comment is auto-approved — we don't want a banned/pending comment to
4941// silently trigger AI work on the PR.
0074234Claude4942pulls.post(
4943 "/:owner/:repo/pulls/:number/comment",
4944 softAuth,
4945 requireAuth,
cb5a796Claude4946 requireRepoAccess("read"),
0074234Claude4947 async (c) => {
4948 const { owner: ownerName, repo: repoName } = c.req.param();
4949 const prNum = parseInt(c.req.param("number"), 10);
4950 const user = c.get("user")!;
4951 const body = await c.req.parseBody();
4952 const commentBody = String(body.body || "").trim();
47a7a0aClaude4953 const filePathRaw = String(body.file_path || "").trim();
4954 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
4955 const inlineFilePath = filePathRaw || undefined;
4956 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude4957
4958 if (!commentBody) {
4959 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4960 }
4961
4962 const resolved = await resolveRepo(ownerName, repoName);
4963 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4964
4965 const [pr] = await db
4966 .select()
4967 .from(pullRequests)
4968 .where(
4969 and(
4970 eq(pullRequests.repositoryId, resolved.repo.id),
4971 eq(pullRequests.number, prNum)
4972 )
4973 )
4974 .limit(1);
4975
4976 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4977
cb5a796Claude4978 const decision = await decideInitialStatus({
4979 commenterUserId: user.id,
4980 repositoryId: resolved.repo.id,
4981 kind: "pr",
4982 threadId: pr.id,
4983 });
4984
d4ac5c3Claude4985 const [inserted] = await db
4986 .insert(prComments)
4987 .values({
4988 pullRequestId: pr.id,
4989 authorId: user.id,
4990 body: commentBody,
cb5a796Claude4991 moderationStatus: decision.status,
47a7a0aClaude4992 filePath: inlineFilePath,
4993 lineNumber: inlineLineNumber,
d4ac5c3Claude4994 })
4995 .returning();
4996
cb5a796Claude4997 // Live update: only when the comment is actually visible.
4998 if (inserted && decision.status === "approved") {
d4ac5c3Claude4999 try {
5000 const { publish } = await import("../lib/sse");
5001 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
5002 event: "pr-comment",
5003 data: {
5004 pullRequestId: pr.id,
5005 commentId: inserted.id,
5006 authorId: user.id,
5007 authorUsername: user.username,
5008 },
5009 });
5010 } catch {
5011 /* SSE is best-effort */
5012 }
b7ecb14Claude5013 // Notify the PR author — fire-and-forget, never blocks the response.
5014 if (pr.authorId && pr.authorId !== user.id) {
5015 void import("../lib/notify").then(({ createNotification }) =>
5016 createNotification({
5017 userId: pr.authorId,
5018 type: "pr_comment",
5019 title: `New comment on "${pr.title}"`,
5020 body: commentBody.length > 200 ? commentBody.slice(0, 200) + "…" : commentBody,
5021 url: `/${ownerName}/${repoName}/pulls/${prNum}`,
5022 repoId: resolved.repo.id,
5023 })
5024 ).catch(() => { /* never block the response */ });
5025 }
d4ac5c3Claude5026 }
0074234Claude5027
cb5a796Claude5028 if (decision.status === "pending") {
5029 void notifyOwnerOfPendingComment({
5030 repositoryId: resolved.repo.id,
5031 commenterUsername: user.username,
5032 kind: "pr",
5033 threadNumber: prNum,
5034 ownerUsername: ownerName,
5035 repoName,
5036 });
5037 return c.redirect(
5038 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5039 );
5040 }
5041 if (decision.status === "rejected") {
5042 // Silent ban path — same UX as 'pending' so we don't leak the gate.
5043 return c.redirect(
5044 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
5045 );
5046 }
5047
15db0e0Claude5048 // Slash-command handoff. We always store the original comment above
5049 // first so free-form text that happens to start with `/` is preserved
5050 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude5051 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude5052 const parsed = parseSlashCommand(commentBody);
5053 if (parsed) {
5054 try {
5055 const result = await executeSlashCommand({
5056 command: parsed.command,
5057 args: parsed.args,
5058 prId: pr.id,
5059 userId: user.id,
5060 repositoryId: resolved.repo.id,
5061 });
5062 await db.insert(prComments).values({
5063 pullRequestId: pr.id,
5064 authorId: user.id,
5065 body: result.body,
5066 });
5067 } catch (err) {
5068 // Defence-in-depth — executeSlashCommand promises not to throw,
5069 // but if it ever does we want the PR thread to know.
5070 await db
5071 .insert(prComments)
5072 .values({
5073 pullRequestId: pr.id,
5074 authorId: user.id,
5075 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
5076 })
5077 .catch(() => {});
5078 }
5079 }
5080
47a7a0aClaude5081 // Inline comments go back to the files tab; conversation comments to the conversation tab
5082 const redirectTab = inlineFilePath ? "?tab=files" : "";
5083 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude5084 }
5085);
5086
b5dd694Claude5087// Apply a suggestion from a PR comment — commits the suggested code to the
5088// head branch on behalf of the logged-in user.
5089pulls.post(
5090 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
5091 softAuth,
5092 requireAuth,
5093 requireRepoAccess("read"),
5094 async (c) => {
5095 const { owner: ownerName, repo: repoName } = c.req.param();
5096 const prNum = parseInt(c.req.param("number"), 10);
5097 const commentId = c.req.param("commentId"); // UUID
5098 const user = c.get("user")!;
5099
5100 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
5101
5102 const resolved = await resolveRepo(ownerName, repoName);
5103 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5104
5105 const [pr] = await db
5106 .select()
5107 .from(pullRequests)
5108 .where(
5109 and(
5110 eq(pullRequests.repositoryId, resolved.repo.id),
5111 eq(pullRequests.number, prNum)
5112 )
5113 )
5114 .limit(1);
5115
5116 if (!pr || pr.state !== "open") {
5117 return c.redirect(`${backUrl}&error=pr_not_open`);
5118 }
5119
5120 // Only PR author or repo owner may apply suggestions.
5121 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
5122 return c.redirect(`${backUrl}&error=forbidden`);
5123 }
5124
5125 // Load the comment.
5126 const [comment] = await db
5127 .select()
5128 .from(prComments)
5129 .where(
5130 and(
5131 eq(prComments.id, commentId),
5132 eq(prComments.pullRequestId, pr.id)
5133 )
5134 )
5135 .limit(1);
5136
5137 if (!comment) {
5138 return c.redirect(`${backUrl}&error=comment_not_found`);
5139 }
5140
5141 // Parse suggestion block from comment body.
5142 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
5143 if (!m) {
5144 return c.redirect(`${backUrl}&error=no_suggestion`);
5145 }
5146 const suggestionCode = m[1];
5147
5148 // Get the commenter's details for the commit message co-author line.
5149 const [commenter] = await db
5150 .select()
5151 .from(users)
5152 .where(eq(users.id, comment.authorId))
5153 .limit(1);
5154
5155 // Fetch current file content from head branch.
5156 if (!comment.filePath) {
5157 return c.redirect(`${backUrl}&error=file_not_found`);
5158 }
5159 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
5160 if (!blob) {
5161 return c.redirect(`${backUrl}&error=file_not_found`);
5162 }
5163
5164 // Apply the patch — replace the target line(s) with suggestion lines.
5165 const lines = blob.content.split('\n');
5166 const lineIdx = (comment.lineNumber ?? 1) - 1;
5167 if (lineIdx < 0 || lineIdx >= lines.length) {
5168 return c.redirect(`${backUrl}&error=line_out_of_range`);
5169 }
5170 const suggestionLines = suggestionCode.split('\n');
5171 lines.splice(lineIdx, 1, ...suggestionLines);
5172 const newContent = lines.join('\n');
5173
5174 // Commit the change.
5175 const coAuthorLine = commenter
5176 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
5177 : "";
5178 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
5179
5180 const result = await createOrUpdateFileOnBranch({
5181 owner: ownerName,
5182 name: repoName,
5183 branch: pr.headBranch,
5184 filePath: comment.filePath,
5185 bytes: new TextEncoder().encode(newContent),
5186 message: commitMessage,
5187 authorName: user.username,
5188 authorEmail: `${user.username}@users.noreply.gluecron.com`,
5189 });
5190
5191 if ("error" in result) {
5192 return c.redirect(`${backUrl}&error=apply_failed`);
5193 }
5194
5195 // Post a follow-up comment noting the suggestion was applied.
5196 await db.insert(prComments).values({
5197 pullRequestId: pr.id,
5198 authorId: user.id,
5199 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
5200 });
5201
5202 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
5203 }
5204);
5205
0a67773Claude5206// Formal review — Approve / Request Changes / Comment
5207pulls.post(
5208 "/:owner/:repo/pulls/:number/review",
5209 softAuth,
5210 requireAuth,
5211 requireRepoAccess("read"),
5212 async (c) => {
5213 const { owner: ownerName, repo: repoName } = c.req.param();
5214 const prNum = parseInt(c.req.param("number"), 10);
5215 const user = c.get("user")!;
5216 const body = await c.req.parseBody();
5217 const reviewBody = String(body.body || "").trim();
5218 const reviewState = String(body.review_state || "commented");
5219
5220 const validStates = ["approved", "changes_requested", "commented"];
5221 if (!validStates.includes(reviewState)) {
5222 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5223 }
5224
5225 const resolved = await resolveRepo(ownerName, repoName);
5226 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5227
5228 const [pr] = await db
5229 .select()
5230 .from(pullRequests)
5231 .where(
5232 and(
5233 eq(pullRequests.repositoryId, resolved.repo.id),
5234 eq(pullRequests.number, prNum)
5235 )
5236 )
5237 .limit(1);
5238 if (!pr || pr.state !== "open") {
5239 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5240 }
5241 // Authors can't review their own PR
5242 if (pr.authorId === user.id) {
5243 return c.redirect(
5244 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
5245 );
5246 }
5247
5248 await db.insert(prReviews).values({
5249 pullRequestId: pr.id,
5250 reviewerId: user.id,
5251 state: reviewState,
5252 body: reviewBody || null,
5253 });
5254
5255 const stateLabel =
5256 reviewState === "approved" ? "Approved"
5257 : reviewState === "changes_requested" ? "Changes requested"
5258 : "Commented";
5259 return c.redirect(
5260 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
5261 );
5262 }
5263);
5264
e883329Claude5265// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude5266// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
5267// but we keep it at "write" for v1 so trusted collaborators can ship.
5268// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
5269// surface. Branch-protection rules (evaluated below) are the current mechanism
5270// for locking down merges further on specific branches.
0074234Claude5271pulls.post(
5272 "/:owner/:repo/pulls/:number/merge",
5273 softAuth,
5274 requireAuth,
04f6b7fClaude5275 requireRepoAccess("write"),
0074234Claude5276 async (c) => {
5277 const { owner: ownerName, repo: repoName } = c.req.param();
5278 const prNum = parseInt(c.req.param("number"), 10);
5279 const user = c.get("user")!;
5280
a164a6dClaude5281 // Read merge strategy from form (default: merge commit)
5282 let mergeStrategy = "merge";
5283 try {
5284 const body = await c.req.parseBody();
5285 const s = body.merge_strategy;
5286 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
5287 } catch { /* ignore parse errors — default to merge commit */ }
5288
0074234Claude5289 const resolved = await resolveRepo(ownerName, repoName);
5290 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5291
5292 const [pr] = await db
5293 .select()
5294 .from(pullRequests)
5295 .where(
5296 and(
5297 eq(pullRequests.repositoryId, resolved.repo.id),
5298 eq(pullRequests.number, prNum)
5299 )
5300 )
5301 .limit(1);
5302
5303 if (!pr || pr.state !== "open") {
5304 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5305 }
5306
6fc53bdClaude5307 // Draft PRs cannot be merged — must be marked ready first.
5308 if (pr.isDraft) {
5309 return c.redirect(
5310 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5311 "This PR is a draft. Mark it as ready for review before merging."
5312 )}`
5313 );
5314 }
5315
ec9e3e3Claude5316 // Required reviews check — branch-protection `required_approvals` gate.
5317 // Evaluated before running expensive gate checks so the feedback is fast.
5318 {
5319 const eligibility = await checkMergeEligible(pr.id, resolved.repo.id, pr.baseBranch);
5320 if (!eligibility.eligible && eligibility.reason) {
5321 return c.redirect(
5322 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(eligibility.reason)}`
5323 );
5324 }
5325 }
5326
e883329Claude5327 // Resolve head SHA
5328 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
5329 if (!headSha) {
5330 return c.redirect(
5331 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
5332 );
5333 }
5334
5335 // Check if AI review approved this PR
5336 const aiComments = await db
5337 .select()
5338 .from(prComments)
5339 .where(
5340 and(
5341 eq(prComments.pullRequestId, pr.id),
5342 eq(prComments.isAiReview, true)
5343 )
5344 );
5345 const aiApproved = aiComments.length === 0 || aiComments.some(
5346 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude5347 );
e883329Claude5348
5349 // Run all green gate checks (GateTest + mergeability + AI review)
5350 const gateResult = await runAllGateChecks(
5351 ownerName,
5352 repoName,
5353 pr.baseBranch,
5354 pr.headBranch,
5355 headSha,
5356 aiApproved
0074234Claude5357 );
5358
e883329Claude5359 // If GateTest or AI review failed (hard blocks), reject the merge
5360 const hardFailures = gateResult.checks.filter(
5361 (check) => !check.passed && check.name !== "Merge check"
5362 );
5363 if (hardFailures.length > 0) {
5364 const errorMsg = hardFailures
5365 .map((f) => `${f.name}: ${f.details}`)
5366 .join("; ");
0074234Claude5367 return c.redirect(
e883329Claude5368 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude5369 );
5370 }
5371
1e162a8Claude5372 // D5 — Branch-protection enforcement. Looks up the matching rule for the
5373 // base branch and blocks the merge if requireAiApproval / requireGreenGates
5374 // / requireHumanReview / requiredApprovals are not satisfied. Independent
5375 // of repo-global settings, so owners can lock specific branches down
5376 // further than the repo default.
5377 const protectionRule = await matchProtection(
5378 resolved.repo.id,
5379 pr.baseBranch
5380 );
5381 if (protectionRule) {
5382 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude5383 const required = await listRequiredChecks(protectionRule.id);
5384 const passingNames = required.length > 0
5385 ? await passingCheckNames(resolved.repo.id, headSha)
5386 : [];
5387 const decision = evaluateProtection(
5388 protectionRule,
5389 {
5390 aiApproved,
5391 humanApprovalCount: humanApprovals,
5392 gateResultGreen: hardFailures.length === 0,
5393 hasFailedGates: hardFailures.length > 0,
5394 passingCheckNames: passingNames,
5395 },
5396 required.map((r) => r.checkName)
5397 );
1e162a8Claude5398 if (!decision.allowed) {
5399 return c.redirect(
5400 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5401 decision.reasons.join(" ")
5402 )}`
5403 );
5404 }
5405 }
5406
e883329Claude5407 // Attempt the merge — with auto conflict resolution if needed
5408 const repoDir = getRepoPath(ownerName, repoName);
5409 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
5410 const hasConflicts = mergeCheck && !mergeCheck.passed;
5411
5412 if (hasConflicts && isAiReviewEnabled()) {
5413 // Use Claude to auto-resolve conflicts
5414 const mergeResult = await mergeWithAutoResolve(
5415 ownerName,
5416 repoName,
5417 pr.baseBranch,
5418 pr.headBranch,
5419 `Merge pull request #${pr.number}: ${pr.title}`
5420 );
5421
5422 if (!mergeResult.success) {
5423 return c.redirect(
5424 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
5425 );
5426 }
5427
5428 // Post a comment about the auto-resolution
5429 if (mergeResult.resolvedFiles.length > 0) {
5430 await db.insert(prComments).values({
5431 pullRequestId: pr.id,
5432 authorId: user.id,
5433 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
5434 isAiReview: true,
5435 });
5436 }
5437 } else {
a164a6dClaude5438 // Worktree-based merge: supports merge-commit, squash, and fast-forward
5439 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
5440 const gitEnv = {
5441 ...process.env,
5442 GIT_AUTHOR_NAME: user.displayName || user.username,
5443 GIT_AUTHOR_EMAIL: user.email,
5444 GIT_COMMITTER_NAME: user.displayName || user.username,
5445 GIT_COMMITTER_EMAIL: user.email,
5446 };
5447
5448 // Create linked worktree on the base branch
5449 const addWt = Bun.spawn(
5450 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude5451 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5452 );
a164a6dClaude5453 if (await addWt.exited !== 0) {
5454 return c.redirect(
5455 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
5456 );
5457 }
5458
5459 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
5460 let mergeOk = false;
5461
5462 try {
5463 if (mergeStrategy === "squash") {
5464 // Squash: stage all changes without committing
5465 const squashProc = Bun.spawn(
5466 ["git", "merge", "--squash", headSha],
5467 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5468 );
5469 if (await squashProc.exited !== 0) {
5470 const errTxt = await new Response(squashProc.stderr).text();
5471 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
5472 }
5473 // Commit the squashed changes
5474 const commitProc = Bun.spawn(
5475 ["git", "commit", "-m", commitMsg],
5476 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5477 );
5478 if (await commitProc.exited !== 0) {
5479 const errTxt = await new Response(commitProc.stderr).text();
5480 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
5481 }
5482 mergeOk = true;
5483 } else if (mergeStrategy === "ff") {
5484 // Fast-forward only — fail if FF is not possible
5485 const ffProc = Bun.spawn(
5486 ["git", "merge", "--ff-only", headSha],
5487 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5488 );
5489 if (await ffProc.exited !== 0) {
5490 const errTxt = await new Response(ffProc.stderr).text();
5491 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
5492 }
5493 mergeOk = true;
5494 } else {
5495 // Default: merge commit (--no-ff always creates a merge commit)
5496 const mergeProc = Bun.spawn(
5497 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
5498 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
5499 );
5500 if (await mergeProc.exited !== 0) {
5501 const errTxt = await new Response(mergeProc.stderr).text();
5502 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
5503 }
5504 mergeOk = true;
5505 }
5506 } catch (err) {
5507 // Always clean up the worktree before redirecting
5508 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
5509 const msg = err instanceof Error ? err.message : "Merge failed";
5510 return c.redirect(
5511 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
5512 );
5513 }
5514
5515 // Clean up worktree (changes are now in the bare repo via linked worktree)
5516 await Bun.spawn(
5517 ["git", "worktree", "remove", "--force", wt],
5518 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
5519 ).exited.catch(() => {});
e883329Claude5520
a164a6dClaude5521 if (!mergeOk) {
e883329Claude5522 return c.redirect(
a164a6dClaude5523 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude5524 );
5525 }
5526 }
5527
0074234Claude5528 await db
5529 .update(pullRequests)
5530 .set({
5531 state: "merged",
5532 mergedAt: new Date(),
5533 mergedBy: user.id,
5534 updatedAt: new Date(),
5535 })
5536 .where(eq(pullRequests.id, pr.id));
5537
8809b87Claude5538 // Chat notifier — fan out merge event to Slack/Discord/Teams.
5539 import("../lib/chat-notifier")
5540 .then((m) =>
5541 m.notifyChatChannels({
5542 ownerUserId: resolved.repo.ownerId,
5543 repositoryId: resolved.repo.id,
5544 event: {
5545 event: "pr.merged",
5546 repo: `${ownerName}/${repoName}`,
5547 title: `#${pr.number} ${pr.title}`,
5548 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
5549 actor: user.username,
5550 },
5551 })
5552 )
5553 .catch((err) =>
5554 console.warn(`[chat-notifier] PR merge notify failed:`, err)
5555 );
5556
d62fb36Claude5557 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
5558 // and auto-close each matching open issue with a back-link comment. Bounded
5559 // to the same repo for v1 (cross-repo refs ignored). Failures never block
5560 // the merge redirect.
5561 try {
5562 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
5563 const refs = extractClosingRefsMulti([pr.title, pr.body]);
5564 for (const n of refs) {
5565 const [issue] = await db
5566 .select()
5567 .from(issues)
5568 .where(
5569 and(
5570 eq(issues.repositoryId, resolved.repo.id),
5571 eq(issues.number, n)
5572 )
5573 )
5574 .limit(1);
5575 if (!issue || issue.state !== "open") continue;
5576 await db
5577 .update(issues)
5578 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
5579 .where(eq(issues.id, issue.id));
5580 await db.insert(issueComments).values({
5581 issueId: issue.id,
5582 authorId: user.id,
5583 body: `Closed by pull request #${pr.number}.`,
5584 });
5585 }
5586 } catch {
5587 // Never block the merge on close-keyword failures.
5588 }
5589
0074234Claude5590 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5591 }
5592);
5593
6fc53bdClaude5594// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
5595// hasn't run yet on this PR.
5596pulls.post(
5597 "/:owner/:repo/pulls/:number/ready",
5598 softAuth,
5599 requireAuth,
04f6b7fClaude5600 requireRepoAccess("write"),
6fc53bdClaude5601 async (c) => {
5602 const { owner: ownerName, repo: repoName } = c.req.param();
5603 const prNum = parseInt(c.req.param("number"), 10);
5604 const user = c.get("user")!;
5605
5606 const resolved = await resolveRepo(ownerName, repoName);
5607 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5608
5609 const [pr] = await db
5610 .select()
5611 .from(pullRequests)
5612 .where(
5613 and(
5614 eq(pullRequests.repositoryId, resolved.repo.id),
5615 eq(pullRequests.number, prNum)
5616 )
5617 )
5618 .limit(1);
5619 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5620
5621 // Only the author or repo owner can toggle draft state.
5622 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5623 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5624 }
5625
5626 if (pr.state === "open" && pr.isDraft) {
5627 await db
5628 .update(pullRequests)
5629 .set({ isDraft: false, updatedAt: new Date() })
5630 .where(eq(pullRequests.id, pr.id));
5631
5632 if (isAiReviewEnabled()) {
5633 triggerAiReview(
5634 ownerName,
5635 repoName,
5636 pr.id,
5637 pr.title,
0316dbbClaude5638 pr.body || "",
6fc53bdClaude5639 pr.baseBranch,
5640 pr.headBranch
5641 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
5642 }
5643 }
5644
5645 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5646 }
5647);
5648
5649// Convert a PR back to draft.
5650pulls.post(
5651 "/:owner/:repo/pulls/:number/draft",
5652 softAuth,
5653 requireAuth,
04f6b7fClaude5654 requireRepoAccess("write"),
6fc53bdClaude5655 async (c) => {
5656 const { owner: ownerName, repo: repoName } = c.req.param();
5657 const prNum = parseInt(c.req.param("number"), 10);
5658 const user = c.get("user")!;
5659
5660 const resolved = await resolveRepo(ownerName, repoName);
5661 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5662
5663 const [pr] = await db
5664 .select()
5665 .from(pullRequests)
5666 .where(
5667 and(
5668 eq(pullRequests.repositoryId, resolved.repo.id),
5669 eq(pullRequests.number, prNum)
5670 )
5671 )
5672 .limit(1);
5673 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5674
5675 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5676 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5677 }
5678
5679 if (pr.state === "open" && !pr.isDraft) {
5680 await db
5681 .update(pullRequests)
5682 .set({ isDraft: true, updatedAt: new Date() })
5683 .where(eq(pullRequests.id, pr.id));
5684 }
5685
5686 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5687 }
5688);
5689
0074234Claude5690// Close PR
5691pulls.post(
5692 "/:owner/:repo/pulls/:number/close",
5693 softAuth,
5694 requireAuth,
04f6b7fClaude5695 requireRepoAccess("write"),
0074234Claude5696 async (c) => {
5697 const { owner: ownerName, repo: repoName } = c.req.param();
5698 const prNum = parseInt(c.req.param("number"), 10);
5699
5700 const resolved = await resolveRepo(ownerName, repoName);
5701 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5702
5703 await db
5704 .update(pullRequests)
5705 .set({
5706 state: "closed",
5707 closedAt: new Date(),
5708 updatedAt: new Date(),
5709 })
5710 .where(
5711 and(
5712 eq(pullRequests.repositoryId, resolved.repo.id),
5713 eq(pullRequests.number, prNum)
5714 )
5715 );
5716
5717 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5718 }
5719);
5720
c3e0c07Claude5721// Re-run AI review on demand (e.g. after a force-push). Bypasses the
5722// idempotency marker via { force: true }. Write-access only.
5723pulls.post(
5724 "/:owner/:repo/pulls/:number/ai-rereview",
5725 softAuth,
5726 requireAuth,
5727 requireRepoAccess("write"),
5728 async (c) => {
5729 const { owner: ownerName, repo: repoName } = c.req.param();
5730 const prNum = parseInt(c.req.param("number"), 10);
5731 const resolved = await resolveRepo(ownerName, repoName);
5732 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5733
5734 const [pr] = await db
5735 .select()
5736 .from(pullRequests)
5737 .where(
5738 and(
5739 eq(pullRequests.repositoryId, resolved.repo.id),
5740 eq(pullRequests.number, prNum)
5741 )
5742 )
5743 .limit(1);
5744 if (!pr) {
5745 return c.redirect(`/${ownerName}/${repoName}/pulls`);
5746 }
5747
5748 if (!isAiReviewEnabled()) {
5749 return c.redirect(
5750 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5751 "AI review is not configured (ANTHROPIC_API_KEY)."
5752 )}`
5753 );
5754 }
5755
5756 // Fire-and-forget but with { force: true } to bypass the
5757 // already-reviewed marker. The function still never throws.
5758 triggerAiReview(
5759 ownerName,
5760 repoName,
5761 pr.id,
5762 pr.title || "",
5763 pr.body || "",
5764 pr.baseBranch,
5765 pr.headBranch,
5766 { force: true }
a28cedeClaude5767 ).catch((err) => {
5768 console.warn(
5769 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
5770 err instanceof Error ? err.message : err
5771 );
5772 });
c3e0c07Claude5773
5774 return c.redirect(
5775 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5776 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
5777 )}`
5778 );
5779 }
5780);
5781
1d4ff60Claude5782// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
5783// the PR's head branch carrying just the new test files. Write-access only.
5784// Idempotent — if `ai:added-tests` was previously applied we redirect with
5785// an `info` banner instead of re-firing.
5786pulls.post(
5787 "/:owner/:repo/pulls/:number/generate-tests",
5788 softAuth,
5789 requireAuth,
5790 requireRepoAccess("write"),
5791 async (c) => {
5792 const { owner: ownerName, repo: repoName } = c.req.param();
5793 const prNum = parseInt(c.req.param("number"), 10);
5794 const resolved = await resolveRepo(ownerName, repoName);
5795 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5796
5797 const [pr] = await db
5798 .select()
5799 .from(pullRequests)
5800 .where(
5801 and(
5802 eq(pullRequests.repositoryId, resolved.repo.id),
5803 eq(pullRequests.number, prNum)
5804 )
5805 )
5806 .limit(1);
5807 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5808
5809 if (!isAiReviewEnabled()) {
5810 return c.redirect(
5811 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5812 "AI test generation is not configured (ANTHROPIC_API_KEY)."
5813 )}`
5814 );
5815 }
5816
5817 // Fire-and-forget. The lib never throws.
5818 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
5819 .then((res) => {
5820 if (!res.ok) {
5821 console.warn(
5822 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
5823 );
5824 }
5825 })
5826 .catch((err) => {
5827 console.warn(
5828 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
5829 err instanceof Error ? err.message : err
5830 );
5831 });
5832
5833 return c.redirect(
5834 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5835 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
5836 )}`
5837 );
5838 }
5839);
5840
ace34efClaude5841// ─── Request review ───────────────────────────────────────────────────────────
5842pulls.post(
5843 "/:owner/:repo/pulls/:number/request-review",
5844 softAuth,
5845 requireAuth,
5846 requireRepoAccess("write"),
5847 async (c) => {
5848 const { owner: ownerName, repo: repoName } = c.req.param();
5849 const prNum = parseInt(c.req.param("number"), 10);
5850 const user = c.get("user")!;
5851
5852 const resolved = await resolveRepo(ownerName, repoName);
5853 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5854
5855 const [pr] = await db
5856 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
5857 .from(pullRequests)
5858 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5859 .limit(1);
5860
5861 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5862
5863 const body = await c.req.formData().catch(() => null);
5864 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
5865
5866 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
5867 return c.redirect(
5868 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
5869 );
5870 }
5871
f4abb8eClaude5872 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
5873 // requesting reviews from arbitrary user IDs outside this repository.
5874 const isOwner = reviewerId === resolved.owner.id;
5875 if (!isOwner) {
5876 const [collab] = await db
5877 .select({ id: repoCollaborators.id })
5878 .from(repoCollaborators)
5879 .where(
5880 and(
5881 eq(repoCollaborators.repositoryId, resolved.repo.id),
5882 eq(repoCollaborators.userId, reviewerId),
5883 isNotNull(repoCollaborators.acceptedAt)
5884 )
5885 )
5886 .limit(1);
5887 if (!collab) {
5888 return c.redirect(
5889 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
5890 );
5891 }
5892 }
5893
ace34efClaude5894 const { requestReview } = await import("../lib/reviewer-suggest");
5895 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
5896
5897 const msg = result.ok
5898 ? "Review requested successfully."
5899 : `Failed to request review: ${result.error ?? "unknown error"}`;
5900
5901 return c.redirect(
5902 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
5903 );
5904 }
5905);
5906
25b1ff7Claude5907// ─── WebSocket presence endpoint ─────────────────────────────────────────────
5908//
5909// GET /:owner/:repo/pulls/:number/presence (WebSocket upgrade)
5910//
5911// Unauthenticated connections are rejected with 401. On connect:
5912// → server sends {type:"init", sessionId, users:[...]}
5913// → server broadcasts {type:"join", user} to all other sessions in the room
5914//
5915// Accepted client messages:
5916// {type:"cursor", line: number} — user hovering a diff line
5917// {type:"typing", line: number, typing: bool} — textarea focus/blur
5918// {type:"ping"} — keep-alive (updates lastSeen)
5919//
5920// The WS `data` payload we store on each socket carries everything needed in
5921// the event handlers so no closure tricks are required.
5922
5923pulls.get(
5924 "/:owner/:repo/pulls/:number/presence",
5925 softAuth,
5926 upgradeWebSocket(async (c) => {
5927 const { owner: ownerName, repo: repoName, number: prNumStr } = c.req.param();
5928 const prNum = parseInt(prNumStr ?? "0", 10);
5929 const user = c.get("user");
5930
5931 // Auth check — no anonymous presence
5932 if (!user) {
5933 // upgradeWebSocket doesn't support returning a non-101 directly;
5934 // we return a dummy handler that immediately closes with 4001.
5935 return {
5936 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
5937 ws.close(4001, "Unauthorized");
5938 },
5939 onMessage() {},
5940 onClose() {},
5941 };
5942 }
5943
5944 // Resolve repo to get its numeric id for the room key
5945 const resolved = await resolveRepo(ownerName, repoName);
5946 if (!resolved || isNaN(prNum)) {
5947 return {
5948 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
5949 ws.close(4004, "Not found");
5950 },
5951 onMessage() {},
5952 onClose() {},
5953 };
5954 }
5955
5956 const prId = `${resolved.repo.id}:${prNum}`;
5957 const sessionId = `${user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
5958
5959 return {
5960 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
5961 // Register and join room
5962 registerSocket(prId, sessionId, {
5963 send: (data: string) => ws.send(data),
5964 readyState: ws.readyState,
5965 });
5966 const presenceUser = joinRoom(prId, sessionId, {
5967 userId: user.id,
5968 username: user.username,
5969 });
5970
5971 // Send init snapshot to the new joiner
5972 const currentUsers = getRoomUsers(prId);
5973 ws.send(
5974 JSON.stringify({
5975 type: "init",
5976 sessionId,
5977 users: currentUsers,
5978 })
5979 );
5980
5981 // Broadcast join to all OTHER sessions
5982 broadcastToRoom(
5983 prId,
5984 {
5985 type: "join",
5986 user: { ...presenceUser, sessionId },
5987 },
5988 sessionId
5989 );
5990 },
5991
5992 onMessage(evt: MessageEvent, _ws: import("hono/ws").WSContext) {
5993 let msg: { type: string; line?: number; typing?: boolean };
5994 try {
5995 msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
5996 } catch {
5997 return;
5998 }
5999
6000 if (msg.type === "ping") {
6001 pingSession(prId, sessionId);
6002 return;
6003 }
6004
6005 if (msg.type === "cursor") {
6006 const line = typeof msg.line === "number" ? msg.line : null;
6007 const updated = updatePresence(prId, sessionId, line, false);
6008 if (updated) {
6009 broadcastToRoom(
6010 prId,
6011 {
6012 type: "cursor",
6013 sessionId,
6014 username: updated.username,
6015 colour: updated.colour,
6016 line,
6017 },
6018 sessionId
6019 );
6020 }
6021 return;
6022 }
6023
6024 if (msg.type === "typing") {
6025 const line = typeof msg.line === "number" ? msg.line : null;
6026 const typing = !!msg.typing;
6027 const updated = updatePresence(prId, sessionId, line, typing);
6028 if (updated) {
6029 broadcastToRoom(
6030 prId,
6031 {
6032 type: "typing",
6033 sessionId,
6034 username: updated.username,
6035 colour: updated.colour,
6036 line,
6037 typing,
6038 },
6039 sessionId
6040 );
6041 }
6042 return;
6043 }
6044 },
6045
6046 onClose() {
6047 leaveRoom(prId, sessionId);
6048 unregisterSocket(prId, sessionId);
6049 broadcastToRoom(prId, { type: "leave", sessionId });
6050 },
6051 };
6052 })
6053);
6054
0074234Claude6055export default pulls;