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