Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame5261 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,
0074234Claude23 repositories,
24 users,
d62fb36Claude25 issues,
26 issueComments,
f4abb8eClaude27 repoCollaborators,
0074234Claude28} from "../db/schema";
29import { Layout } from "../views/layout";
ea9ed4cClaude30import { RepoHeader } from "../views/components";
cb5a796Claude31import { PendingCommentsBanner } from "../views/pending-comments-banner";
47a7a0aClaude32import { DiffView, type InlineDiffComment } from "../views/diff-view";
6fc53bdClaude33import { ReactionsBar } from "../views/reactions";
34import { summariseReactions } from "../lib/reactions";
24cf2caClaude35import { loadPrTemplate } from "../lib/templates";
0074234Claude36import { renderMarkdown } from "../lib/markdown";
15db0e0Claude37import {
38 parseSlashCommand,
39 executeSlashCommand,
40 detectSlashCmdComment,
41 stripSlashCmdMarker,
42} from "../lib/pr-slash-commands";
b584e52Claude43import { liveCommentBannerScript } from "../lib/sse-client";
829a046Claude44import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
6cd2f0eClaude45import { markdownPreviewScript } from "../lib/markdown-preview";
80bd7c8Claude46import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
0074234Claude47import { softAuth, requireAuth } from "../middleware/auth";
48import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude49import { requireRepoAccess } from "../middleware/repo-access";
cb5a796Claude50import {
51 decideInitialStatus,
52 notifyOwnerOfPendingComment,
53 countPendingForRepo,
54} from "../lib/comment-moderation";
0316dbbClaude55import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
79ed944Claude56import {
57 TRIO_COMMENT_MARKER,
58 TRIO_SUMMARY_MARKER,
59 type TrioPersona,
60} from "../lib/ai-review-trio";
1d4ff60Claude61import {
62 generateTestsForPr,
63 AI_TESTS_MARKER,
64} from "../lib/ai-test-generator";
0316dbbClaude65import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude66import { generatePrSummary } from "../lib/ai-generators";
67import { isAiAvailable } from "../lib/ai-client";
534f04aClaude68import {
69 computePrRiskForPullRequest,
70 getCachedPrRisk,
71 type PrRiskScore,
72} from "../lib/pr-risk";
0316dbbClaude73import { runAllGateChecks } from "../lib/gate";
74import type { GateCheckResult } from "../lib/gate";
75import {
76 matchProtection,
77 countHumanApprovals,
78 listRequiredChecks,
79 passingCheckNames,
80 evaluateProtection,
81} from "../lib/branch-protection";
82import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude83import {
84 listBranches,
85 getRepoPath,
e883329Claude86 resolveRef,
b5dd694Claude87 getBlob,
88 createOrUpdateFileOnBranch,
b558f23Claude89 commitsBetween,
0074234Claude90} from "../git/repository";
b558f23Claude91import type { GitDiffFile, GitCommit } from "../git/repository";
240c477Claude92import { listStatuses } from "../lib/commit-statuses";
93import type { CommitStatus } from "../db/schema";
0074234Claude94import { html } from "hono/html";
4bbacbeClaude95import {
96 getPreviewForBranch,
97 previewStatusLabel,
98} from "../lib/branch-previews";
1e162a8Claude99import {
bb0f894Claude100 Flex,
101 Container,
102 Badge,
103 Button,
104 LinkButton,
105 Form,
106 FormGroup,
107 Input,
108 TextArea,
109 Select,
110 EmptyState,
111 FilterTabs,
112 TabNav,
113 List,
114 ListItem,
115 Text,
116 Alert,
117 MarkdownContent,
118 CommentBox,
119 formatRelative,
120} from "../views/ui";
0074234Claude121
ace34efClaude122import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
123
0074234Claude124const pulls = new Hono<AuthEnv>();
125
b078860Claude126/* ──────────────────────────────────────────────────────────────────────
127 * Inline CSS for the list page. Scoped with `.prs-*` so we do not bleed
128 * into the issue tracker or any other route. Tokens come from layout.tsx
129 * `:root` so light/dark stays consistent if/when light mode lands.
130 * ──────────────────────────────────────────────────────────────────── */
131const PRS_LIST_STYLES = `
132 .prs-hero {
133 position: relative;
134 margin: 0 0 var(--space-5);
135 padding: 22px 26px 24px;
136 background: var(--bg-elevated);
137 border: 1px solid var(--border);
138 border-radius: 16px;
139 overflow: hidden;
140 }
141 .prs-hero::before {
142 content: '';
143 position: absolute; top: 0; left: 0; right: 0;
144 height: 2px;
145 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
146 opacity: 0.7;
147 pointer-events: none;
148 }
149 .prs-hero-inner {
150 position: relative;
151 display: flex;
152 justify-content: space-between;
153 align-items: flex-end;
154 gap: 20px;
155 flex-wrap: wrap;
156 }
157 .prs-hero-text { flex: 1; min-width: 280px; }
158 .prs-hero-eyebrow {
159 font-size: 12px;
160 color: var(--text-muted);
161 text-transform: uppercase;
162 letter-spacing: 0.08em;
163 font-weight: 600;
164 margin-bottom: 8px;
165 }
166 .prs-hero-title {
167 font-family: var(--font-display);
168 font-size: clamp(26px, 3.4vw, 34px);
169 font-weight: 800;
170 letter-spacing: -0.025em;
171 line-height: 1.06;
172 margin: 0 0 8px;
173 color: var(--text-strong);
174 }
175 .prs-hero-title .gradient-text {
176 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
177 -webkit-background-clip: text;
178 background-clip: text;
179 -webkit-text-fill-color: transparent;
180 color: transparent;
181 }
182 .prs-hero-sub {
183 font-size: 14.5px;
184 color: var(--text-muted);
185 margin: 0;
186 line-height: 1.5;
187 max-width: 620px;
188 }
189 .prs-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
190 .prs-cta {
191 display: inline-flex; align-items: center; gap: 6px;
192 padding: 10px 16px;
193 border-radius: 10px;
194 font-size: 13.5px;
195 font-weight: 600;
196 color: #fff;
197 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
198 border: 1px solid rgba(140,109,255,0.55);
199 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
200 text-decoration: none;
201 transition: transform 120ms ease, box-shadow 160ms ease;
202 }
203 .prs-cta:hover {
204 transform: translateY(-1px);
205 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.6);
206 color: #fff;
207 }
208
209 .prs-tabs {
210 display: flex; flex-wrap: wrap; gap: 6px;
211 margin: 0 0 18px;
212 padding: 6px;
213 background: var(--bg-secondary);
214 border: 1px solid var(--border);
215 border-radius: 12px;
216 }
217 .prs-tab {
218 display: inline-flex; align-items: center; gap: 8px;
219 padding: 7px 13px;
220 font-size: 13px;
221 font-weight: 500;
222 color: var(--text-muted);
223 border-radius: 8px;
224 text-decoration: none;
225 transition: background 120ms ease, color 120ms ease;
226 }
227 .prs-tab:hover { background: var(--bg-hover); color: var(--text); }
228 .prs-tab.is-active {
229 background: var(--bg-elevated);
230 color: var(--text-strong);
231 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 4px 14px -8px rgba(0,0,0,0.4);
232 }
233 .prs-tab-count {
234 display: inline-flex; align-items: center; justify-content: center;
235 min-width: 22px; padding: 2px 7px;
236 font-size: 11.5px;
237 font-weight: 600;
238 border-radius: 9999px;
239 background: var(--bg-tertiary);
240 color: var(--text-muted);
241 }
242 .prs-tab.is-active .prs-tab-count {
243 background: rgba(140,109,255,0.18);
244 color: var(--text-link);
245 }
246
247 .prs-list { display: flex; flex-direction: column; gap: 10px; }
248 .prs-row {
249 position: relative;
250 display: flex; align-items: flex-start; gap: 14px;
251 padding: 14px 16px;
252 background: var(--bg-elevated);
253 border: 1px solid var(--border);
254 border-radius: 12px;
255 transition: transform 140ms ease, border-color 140ms ease, box-shadow 160ms ease;
256 }
257 .prs-row:hover {
258 transform: translateY(-1px);
259 border-color: var(--border-strong);
260 box-shadow: 0 10px 22px -14px rgba(0,0,0,0.5);
261 }
262 .prs-row-icon {
263 flex: 0 0 auto;
264 width: 26px; height: 26px;
265 display: inline-flex; align-items: center; justify-content: center;
266 border-radius: 9999px;
267 font-size: 13px;
268 margin-top: 2px;
269 }
270 .prs-row-icon.state-open { color: var(--green); background: rgba(52,211,153,0.12); }
271 .prs-row-icon.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); }
272 .prs-row-icon.state-closed { color: var(--red); background: rgba(248,113,113,0.12); }
273 .prs-row-icon.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); }
274 .prs-row-body { flex: 1; min-width: 0; }
275 .prs-row-title {
276 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
277 font-size: 15px; font-weight: 600;
278 color: var(--text-strong);
279 line-height: 1.35;
280 margin: 0 0 6px;
281 }
282 .prs-row-number {
283 color: var(--text-muted);
284 font-weight: 400;
285 font-size: 14px;
286 }
287 .prs-row-meta {
288 display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
289 font-size: 12.5px;
290 color: var(--text-muted);
291 }
292 .prs-branch-chips {
293 display: inline-flex; align-items: center; gap: 6px;
294 font-family: var(--font-mono);
295 font-size: 11.5px;
296 }
297 .prs-branch-chip {
298 padding: 2px 8px;
299 border-radius: 9999px;
300 background: var(--bg-tertiary);
301 border: 1px solid var(--border);
302 color: var(--text);
303 }
304 .prs-branch-arrow {
305 color: var(--text-faint);
306 font-size: 11px;
307 }
308 .prs-row-tags {
309 display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px;
310 margin-left: auto;
311 }
312 .prs-tag {
313 display: inline-flex; align-items: center; gap: 4px;
314 padding: 2px 8px;
315 font-size: 11px;
316 font-weight: 600;
317 border-radius: 9999px;
318 border: 1px solid var(--border);
319 background: var(--bg-secondary);
320 color: var(--text-muted);
321 line-height: 1.6;
322 }
323 .prs-tag.is-draft {
324 color: var(--text-muted);
325 border-color: var(--border-strong);
326 }
327 .prs-tag.is-merged {
328 color: var(--text-link);
329 border-color: rgba(140,109,255,0.45);
330 background: rgba(140,109,255,0.10);
331 }
1aef949Claude332 .prs-tag.is-approved {
333 color: #34d399;
334 border-color: rgba(52,211,153,0.40);
335 background: rgba(52,211,153,0.08);
336 }
337 .prs-tag.is-changes {
338 color: #f87171;
339 border-color: rgba(248,113,113,0.40);
340 background: rgba(248,113,113,0.08);
341 }
b078860Claude342
343 .prs-empty {
ea9ed4cClaude344 position: relative;
345 padding: 56px 32px;
b078860Claude346 text-align: center;
347 border: 1px dashed var(--border);
ea9ed4cClaude348 border-radius: 16px;
349 background: var(--bg-elevated);
b078860Claude350 color: var(--text-muted);
ea9ed4cClaude351 overflow: hidden;
b078860Claude352 }
ea9ed4cClaude353 .prs-empty::before {
354 content: '';
355 position: absolute;
356 inset: -40% -20% auto auto;
357 width: 320px; height: 320px;
358 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 50%, transparent 75%);
359 filter: blur(70px);
360 opacity: 0.55;
361 pointer-events: none;
362 animation: prsEmptyOrb 16s ease-in-out infinite;
363 }
364 @keyframes prsEmptyOrb {
365 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
366 50% { transform: scale(1.12) translate(-12px, 10px); opacity: 0.8; }
367 }
368 @media (prefers-reduced-motion: reduce) {
369 .prs-empty::before { animation: none; }
370 }
371 .prs-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
b078860Claude372 .prs-empty strong {
373 display: block;
374 color: var(--text-strong);
ea9ed4cClaude375 font-family: var(--font-display);
376 font-size: 22px;
377 font-weight: 700;
378 letter-spacing: -0.018em;
379 margin-bottom: 2px;
380 }
381 .prs-empty-sub {
382 font-size: 14.5px;
383 color: var(--text-muted);
384 line-height: 1.55;
385 max-width: 460px;
386 margin: 0 0 18px;
b078860Claude387 }
ea9ed4cClaude388 .prs-empty-cta { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
b078860Claude389
390 @media (max-width: 720px) {
391 .prs-hero-inner { flex-direction: column; align-items: flex-start; }
392 .prs-hero-actions { width: 100%; }
393 .prs-row-tags { margin-left: 0; }
394 }
f1dc7c7Claude395
396 /* Additional mobile rules. Additive only. */
397 @media (max-width: 720px) {
398 .prs-hero { padding: 18px 18px 20px; }
399 .prs-hero-actions .prs-cta { flex: 1; min-width: 0; justify-content: center; min-height: 44px; }
400 .prs-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
401 .prs-tab { min-height: 40px; padding: 9px 14px; white-space: nowrap; }
402 .prs-row { padding: 12px 14px; gap: 10px; }
403 .prs-row-icon { width: 24px; height: 24px; }
404 }
f5b9ef5Claude405
406 /* ─── Sort controls (PR list) ─── */
407 .prs-sort-row {
408 display: flex;
409 align-items: center;
410 gap: 6px;
411 margin: 0 0 12px;
412 flex-wrap: wrap;
413 }
414 .prs-sort-label {
415 font-size: 12.5px;
416 color: var(--text-muted);
417 font-weight: 600;
418 margin-right: 2px;
419 }
420 .prs-sort-opt {
421 font-size: 12.5px;
422 color: var(--text-muted);
423 text-decoration: none;
424 padding: 3px 10px;
425 border-radius: 9999px;
426 border: 1px solid transparent;
427 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
428 }
429 .prs-sort-opt:hover {
430 background: var(--bg-hover);
431 color: var(--text);
432 }
433 .prs-sort-opt.is-active {
434 background: rgba(140,109,255,0.12);
435 color: var(--text-link);
436 border-color: rgba(140,109,255,0.35);
437 font-weight: 600;
438 }
b078860Claude439`;
440
441/* ──────────────────────────────────────────────────────────────────────
442 * Inline CSS for the detail page. Same `.prs-*` namespace.
443 * ──────────────────────────────────────────────────────────────────── */
444const PRS_DETAIL_STYLES = `
445 .prs-detail-hero {
446 position: relative;
447 margin: 0 0 var(--space-4);
448 padding: 24px 26px;
449 background: var(--bg-elevated);
450 border: 1px solid var(--border);
451 border-radius: 16px;
452 overflow: hidden;
453 }
454 .prs-detail-hero::before {
455 content: '';
456 position: absolute; top: 0; left: 0; right: 0;
457 height: 2px;
458 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
459 opacity: 0.7;
460 pointer-events: none;
461 }
462 .prs-detail-title {
463 font-family: var(--font-display);
464 font-size: clamp(22px, 2.6vw, 28px);
465 font-weight: 700;
466 letter-spacing: -0.022em;
467 line-height: 1.2;
468 color: var(--text-strong);
469 margin: 0 0 12px;
470 }
471 .prs-detail-num {
472 color: var(--text-muted);
473 font-weight: 400;
474 }
475 .prs-state-pill {
476 display: inline-flex; align-items: center; gap: 6px;
477 padding: 6px 12px;
478 border-radius: 9999px;
479 font-size: 12.5px;
480 font-weight: 600;
481 line-height: 1;
482 border: 1px solid transparent;
483 }
484 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
485 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
486 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
487 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
488
489 .prs-detail-meta {
490 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
491 font-size: 13px;
492 color: var(--text-muted);
493 }
494 .prs-detail-meta strong { color: var(--text); }
495 .prs-detail-branches {
496 display: inline-flex; align-items: center; gap: 6px;
497 font-family: var(--font-mono);
498 font-size: 12px;
499 }
500 .prs-branch-pill {
501 padding: 3px 9px;
502 border-radius: 9999px;
503 background: var(--bg-tertiary);
504 border: 1px solid var(--border);
505 color: var(--text);
506 }
507 .prs-branch-pill.is-head { color: var(--text-strong); }
508 .prs-branch-arrow-lg {
509 color: var(--accent);
510 font-size: 14px;
511 font-weight: 700;
512 }
0369e77Claude513 .prs-branch-sync {
514 display: inline-flex; align-items: center; gap: 4px;
515 font-size: 11.5px; font-weight: 600;
516 padding: 2px 8px;
517 border-radius: 9999px;
518 border: 1px solid var(--border);
519 background: var(--bg-secondary);
520 color: var(--text-muted);
521 cursor: default;
522 }
523 .prs-branch-sync.is-behind {
524 color: #f87171;
525 border-color: rgba(248,113,113,0.35);
526 background: rgba(248,113,113,0.07);
527 }
528 .prs-branch-sync.is-synced {
529 color: #34d399;
530 border-color: rgba(52,211,153,0.35);
531 background: rgba(52,211,153,0.07);
532 }
b078860Claude533
534 .prs-detail-actions {
535 display: inline-flex; gap: 8px; margin-left: auto;
536 }
537
538 .prs-detail-tabs {
539 display: flex; gap: 4px;
540 margin: 0 0 16px;
541 border-bottom: 1px solid var(--border);
542 }
543 .prs-detail-tab {
544 padding: 10px 14px;
545 font-size: 13.5px;
546 font-weight: 500;
547 color: var(--text-muted);
548 text-decoration: none;
549 border-bottom: 2px solid transparent;
550 transition: color 120ms ease, border-color 120ms ease;
551 margin-bottom: -1px;
552 }
553 .prs-detail-tab:hover { color: var(--text); }
554 .prs-detail-tab.is-active {
555 color: var(--text-strong);
556 border-bottom-color: var(--accent);
557 }
558 .prs-detail-tab-count {
559 display: inline-flex; align-items: center; justify-content: center;
560 min-width: 20px; padding: 0 6px; margin-left: 6px;
561 height: 18px;
562 font-size: 11px;
563 font-weight: 600;
564 border-radius: 9999px;
565 background: var(--bg-tertiary);
566 color: var(--text-muted);
567 }
568
569 /* Gate / check status section */
570 .prs-gate-card {
571 margin-top: 20px;
572 background: var(--bg-elevated);
573 border: 1px solid var(--border);
574 border-radius: 14px;
575 overflow: hidden;
576 }
577 .prs-gate-head {
578 display: flex; align-items: center; gap: 10px;
579 padding: 14px 18px;
580 border-bottom: 1px solid var(--border);
581 }
582 .prs-gate-head h3 {
583 margin: 0;
584 font-size: 14px;
585 font-weight: 600;
586 color: var(--text-strong);
587 }
588 .prs-gate-summary {
589 margin-left: auto;
590 font-size: 12px;
591 color: var(--text-muted);
592 }
593 .prs-gate-row {
594 display: flex; align-items: center; gap: 12px;
595 padding: 12px 18px;
596 border-bottom: 1px solid var(--border-subtle);
597 }
598 .prs-gate-row:last-child { border-bottom: 0; }
599 .prs-gate-icon {
600 flex: 0 0 auto;
601 width: 22px; height: 22px;
602 display: inline-flex; align-items: center; justify-content: center;
603 border-radius: 9999px;
604 font-size: 12px;
605 font-weight: 700;
606 }
607 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
608 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
609 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
610 .prs-gate-name {
611 font-size: 13px;
612 font-weight: 600;
613 color: var(--text);
614 min-width: 140px;
615 }
616 .prs-gate-details {
617 flex: 1; min-width: 0;
618 font-size: 12.5px;
619 color: var(--text-muted);
620 }
621 .prs-gate-pill {
622 flex: 0 0 auto;
623 padding: 3px 10px;
624 border-radius: 9999px;
625 font-size: 11px;
626 font-weight: 600;
627 line-height: 1.5;
628 border: 1px solid transparent;
629 }
630 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
631 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
632 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
633 .prs-gate-footer {
634 padding: 12px 18px;
635 background: var(--bg-secondary);
636 font-size: 12px;
637 color: var(--text-muted);
638 }
639
640 /* Comment cards */
641 .prs-comment {
642 margin-top: 14px;
643 background: var(--bg-elevated);
644 border: 1px solid var(--border);
645 border-radius: 12px;
646 overflow: hidden;
647 }
648 .prs-comment-head {
649 display: flex; align-items: center; gap: 10px;
650 padding: 10px 14px;
651 background: var(--bg-secondary);
652 border-bottom: 1px solid var(--border);
653 font-size: 13px;
654 flex-wrap: wrap;
655 }
656 .prs-comment-head strong { color: var(--text-strong); }
657 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
658 .prs-comment-loc {
659 font-family: var(--font-mono);
660 font-size: 11.5px;
661 color: var(--text-muted);
662 background: var(--bg-tertiary);
663 padding: 2px 8px;
664 border-radius: 6px;
665 }
666 .prs-comment-body { padding: 14px 18px; }
667 .prs-comment.is-ai {
668 border-color: rgba(140,109,255,0.45);
669 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
670 }
671 .prs-comment.is-ai .prs-comment-head {
672 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
673 border-bottom-color: rgba(140,109,255,0.30);
674 }
675 .prs-ai-badge {
676 display: inline-flex; align-items: center; gap: 4px;
677 padding: 2px 9px;
678 font-size: 10.5px;
679 font-weight: 700;
680 letter-spacing: 0.04em;
681 text-transform: uppercase;
682 color: #fff;
683 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
684 border-radius: 9999px;
685 }
686
687 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
688 .prs-files-card {
689 margin-top: 18px;
690 padding: 14px 18px;
691 display: flex; align-items: center; gap: 14px;
692 background: var(--bg-elevated);
693 border: 1px solid var(--border);
694 border-radius: 12px;
695 text-decoration: none;
696 color: inherit;
697 transition: border-color 120ms ease, transform 140ms ease;
698 }
699 .prs-files-card:hover {
700 border-color: rgba(140,109,255,0.45);
701 transform: translateY(-1px);
702 }
703 .prs-files-card-icon {
704 width: 36px; height: 36px;
705 display: inline-flex; align-items: center; justify-content: center;
706 border-radius: 10px;
707 background: rgba(140,109,255,0.12);
708 color: var(--text-link);
709 font-size: 18px;
710 }
711 .prs-files-card-text { flex: 1; min-width: 0; }
712 .prs-files-card-title {
713 font-size: 14px;
714 font-weight: 600;
715 color: var(--text-strong);
716 margin: 0 0 2px;
717 }
718 .prs-files-card-sub {
719 font-size: 12.5px;
720 color: var(--text-muted);
721 margin: 0;
722 }
723 .prs-files-card-cta {
724 font-size: 12.5px;
725 color: var(--text-link);
726 font-weight: 600;
727 }
728
729 /* Merge area */
730 .prs-merge-card {
731 position: relative;
732 margin-top: 22px;
733 padding: 18px;
734 background: var(--bg-elevated);
735 border-radius: 14px;
736 overflow: hidden;
737 }
738 .prs-merge-card::before {
739 content: '';
740 position: absolute; inset: 0;
741 padding: 1px;
742 border-radius: 14px;
743 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
744 -webkit-mask:
745 linear-gradient(#000 0 0) content-box,
746 linear-gradient(#000 0 0);
747 -webkit-mask-composite: xor;
748 mask-composite: exclude;
749 pointer-events: none;
750 }
751 .prs-merge-card.is-closed::before { background: var(--border-strong); }
752 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
753 .prs-merge-head {
754 display: flex; align-items: center; gap: 12px;
755 margin-bottom: 12px;
756 }
757 .prs-merge-head strong {
758 font-family: var(--font-display);
759 font-size: 15px;
760 color: var(--text-strong);
761 font-weight: 700;
762 }
763 .prs-merge-sub {
764 font-size: 13px;
765 color: var(--text-muted);
766 margin: 0 0 12px;
767 }
768 .prs-merge-actions {
769 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
770 }
771 .prs-merge-btn {
772 display: inline-flex; align-items: center; gap: 6px;
773 padding: 9px 16px;
774 border-radius: 10px;
775 font-size: 13.5px;
776 font-weight: 600;
777 color: #fff;
778 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
779 border: 1px solid rgba(52,211,153,0.55);
780 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
781 cursor: pointer;
782 transition: transform 120ms ease, box-shadow 160ms ease;
783 }
784 .prs-merge-btn:hover {
785 transform: translateY(-1px);
786 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
787 }
788 .prs-merge-btn[disabled],
789 .prs-merge-btn.is-disabled {
790 opacity: 0.55;
791 cursor: not-allowed;
792 transform: none;
793 box-shadow: none;
794 }
795 .prs-merge-ready-btn {
796 display: inline-flex; align-items: center; gap: 6px;
797 padding: 9px 16px;
798 border-radius: 10px;
799 font-size: 13.5px;
800 font-weight: 600;
801 color: #fff;
802 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
803 border: 1px solid rgba(140,109,255,0.55);
804 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
805 cursor: pointer;
806 transition: transform 120ms ease, box-shadow 160ms ease;
807 }
808 .prs-merge-ready-btn:hover {
809 transform: translateY(-1px);
810 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
811 }
812 .prs-merge-back-draft {
813 background: none; border: 1px solid var(--border-strong);
814 color: var(--text-muted);
815 padding: 9px 14px; border-radius: 10px;
816 font-size: 13px; cursor: pointer;
817 }
818 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
819
a164a6dClaude820 /* Merge strategy selector */
821 .prs-merge-strategy-wrap {
822 display: inline-flex; align-items: center;
823 background: var(--bg-elevated);
824 border: 1px solid var(--border);
825 border-radius: 10px;
826 overflow: hidden;
827 }
828 .prs-merge-strategy-label {
829 font-size: 11.5px; font-weight: 600;
830 color: var(--text-muted);
831 padding: 0 10px 0 12px;
832 white-space: nowrap;
833 }
834 .prs-merge-strategy-select {
835 background: transparent;
836 border: none;
837 color: var(--text);
838 font-size: 13px;
839 padding: 7px 10px 7px 4px;
840 cursor: pointer;
841 outline: none;
842 appearance: auto;
843 }
844 .prs-merge-strategy-select:focus { outline: 2px solid rgba(140,109,255,0.45); }
845
0a67773Claude846 /* Review summary banner */
847 .prs-review-summary {
848 display: flex; flex-direction: column; gap: 6px;
849 padding: 12px 16px;
850 background: var(--bg-elevated);
851 border: 1px solid var(--border);
852 border-radius: var(--r-md, 8px);
853 margin-bottom: 12px;
854 }
855 .prs-review-row {
856 display: flex; align-items: center; gap: 10px;
857 font-size: 13px;
858 }
859 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
860 .prs-review-approved .prs-review-icon { color: #34d399; }
861 .prs-review-changes .prs-review-icon { color: #f87171; }
ace34efClaude862 .prs-reviewer-avatar {
863 width: 24px; height: 24px; border-radius: 50%;
864 background: var(--accent); color: #fff;
865 display: flex; align-items: center; justify-content: center;
866 font-size: 11px; font-weight: 700; flex-shrink: 0;
867 }
0a67773Claude868
869 /* Review action buttons */
870 .prs-review-approve-btn {
871 display: inline-flex; align-items: center; gap: 5px;
872 padding: 8px 14px; border-radius: 8px; font-size: 13px;
873 font-weight: 600; cursor: pointer;
874 background: rgba(52,211,153,0.12);
875 color: #34d399;
876 border: 1px solid rgba(52,211,153,0.35);
877 transition: background 120ms;
878 }
879 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
880 .prs-review-changes-btn {
881 display: inline-flex; align-items: center; gap: 5px;
882 padding: 8px 14px; border-radius: 8px; font-size: 13px;
883 font-weight: 600; cursor: pointer;
884 background: rgba(248,113,113,0.10);
885 color: #f87171;
886 border: 1px solid rgba(248,113,113,0.30);
887 transition: background 120ms;
888 }
889 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
890
b078860Claude891 /* Inline form helpers */
892 .prs-inline-form { display: inline-flex; }
893
894 /* Comment composer */
895 .prs-composer { margin-top: 22px; }
896 .prs-composer textarea {
897 border-radius: 12px;
898 }
899
900 @media (max-width: 720px) {
901 .prs-detail-actions { margin-left: 0; }
902 .prs-merge-actions { width: 100%; }
903 .prs-merge-actions > * { flex: 1; min-width: 0; }
904 }
f1dc7c7Claude905
906 /* Additional mobile rules. Additive only. */
907 @media (max-width: 720px) {
908 .prs-detail-hero { padding: 18px; }
909 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
910 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
911 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
912 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
913 .prs-gate-name { min-width: 0; }
914 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
915 .prs-gate-summary { margin-left: 0; }
916 .prs-merge-btn,
917 .prs-merge-ready-btn,
918 .prs-merge-back-draft { min-height: 44px; }
919 .prs-comment-body { padding: 12px 14px; }
920 .prs-comment-head { padding: 10px 12px; }
921 .prs-files-card { padding: 12px 14px; }
922 }
3c03977Claude923
924 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
925 .live-pill {
926 display: inline-flex;
927 align-items: center;
928 gap: 8px;
929 padding: 4px 10px 4px 8px;
930 margin-left: 6px;
931 background: var(--bg-elevated);
932 border: 1px solid var(--border);
933 border-radius: 9999px;
934 font-size: 12px;
935 color: var(--text-muted);
936 line-height: 1;
937 vertical-align: middle;
938 }
939 .live-pill.is-busy { color: var(--text); }
940 .live-pill-dot {
941 width: 8px; height: 8px;
942 border-radius: 9999px;
943 background: #34d399;
944 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
945 animation: live-pulse 1.6s ease-in-out infinite;
946 }
947 @keyframes live-pulse {
948 0%, 100% { opacity: 1; }
949 50% { opacity: 0.55; }
950 }
951 .live-avatars {
952 display: inline-flex;
953 margin-left: 2px;
954 }
955 .live-avatar {
956 display: inline-flex;
957 align-items: center;
958 justify-content: center;
959 width: 22px; height: 22px;
960 border-radius: 9999px;
961 font-size: 10px;
962 font-weight: 700;
963 color: #0b1020;
964 margin-left: -6px;
965 border: 2px solid var(--bg-elevated);
966 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
967 }
968 .live-avatar:first-child { margin-left: 0; }
969 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
970 .live-cursor-host {
971 position: relative;
972 }
973 .live-cursor-overlay {
974 position: absolute;
975 inset: 0;
976 pointer-events: none;
977 overflow: hidden;
978 border-radius: inherit;
979 }
980 .live-cursor {
981 position: absolute;
982 width: 2px;
983 height: 18px;
984 border-radius: 2px;
985 transform: translate(-1px, 0);
986 transition: transform 80ms linear, opacity 200ms ease;
987 }
988 .live-cursor::after {
989 content: attr(data-label);
990 position: absolute;
991 top: -16px;
992 left: -2px;
993 font-size: 10px;
994 line-height: 1;
995 color: #0b1020;
996 background: inherit;
997 padding: 2px 5px;
998 border-radius: 4px 4px 4px 0;
999 white-space: nowrap;
1000 font-weight: 600;
1001 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
1002 }
1003 .live-cursor.is-idle { opacity: 0.4; }
1004 .live-edit-tag {
1005 display: inline-block;
1006 margin-left: 6px;
1007 padding: 1px 6px;
1008 font-size: 10px;
1009 font-weight: 600;
1010 letter-spacing: 0.02em;
1011 color: #0b1020;
1012 border-radius: 9999px;
1013 }
15db0e0Claude1014
1015 /* ─── Slash-command pill + composer hint ─── */
1016 .slash-hint {
1017 display: inline-flex;
1018 align-items: center;
1019 gap: 6px;
1020 margin-top: 6px;
1021 padding: 3px 9px;
1022 font-size: 11.5px;
1023 color: var(--text-muted);
1024 background: var(--bg-elevated);
1025 border: 1px dashed var(--border);
1026 border-radius: 9999px;
1027 width: fit-content;
1028 }
1029 .slash-hint code {
1030 background: rgba(110, 168, 255, 0.12);
1031 color: var(--text-strong);
1032 padding: 0 5px;
1033 border-radius: 4px;
1034 font-size: 11px;
1035 }
1036 .slash-pill {
1037 display: grid;
1038 grid-template-columns: auto 1fr auto;
1039 align-items: center;
1040 column-gap: 10px;
1041 row-gap: 6px;
1042 margin: 10px 0;
1043 padding: 10px 14px;
1044 background: linear-gradient(
1045 135deg,
1046 rgba(110, 168, 255, 0.08),
1047 rgba(163, 113, 247, 0.06)
1048 );
1049 border: 1px solid rgba(110, 168, 255, 0.32);
1050 border-left: 3px solid var(--accent, #6ea8ff);
1051 border-radius: var(--radius);
1052 font-size: 13px;
1053 color: var(--text);
1054 }
1055 .slash-pill-icon {
1056 font-size: 14px;
1057 line-height: 1;
1058 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
1059 }
1060 .slash-pill-actor { color: var(--text-muted); }
1061 .slash-pill-actor strong { color: var(--text-strong); }
1062 .slash-pill-cmd {
1063 background: rgba(110, 168, 255, 0.16);
1064 color: var(--text-strong);
1065 padding: 1px 6px;
1066 border-radius: 4px;
1067 font-size: 12.5px;
1068 }
1069 .slash-pill-time {
1070 color: var(--text-muted);
1071 font-size: 12px;
1072 justify-self: end;
1073 }
1074 .slash-pill-body {
1075 grid-column: 1 / -1;
1076 color: var(--text);
1077 font-size: 13px;
1078 line-height: 1.55;
1079 }
1080 .slash-pill-body p:first-child { margin-top: 0; }
1081 .slash-pill-body p:last-child { margin-bottom: 0; }
1082 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
1083 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1084 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
1085 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude1086
1087 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
1088 .preview-prpill {
1089 display: inline-flex; align-items: center; gap: 6px;
1090 padding: 3px 10px;
1091 border-radius: 9999px;
1092 font-family: var(--font-mono);
1093 font-size: 11.5px;
1094 font-weight: 600;
1095 background: rgba(255,255,255,0.04);
1096 color: var(--text-muted);
1097 text-decoration: none;
1098 border: 1px solid var(--border);
1099 }
1100 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
1101 .preview-prpill .preview-prpill-dot {
1102 width: 7px; height: 7px;
1103 border-radius: 9999px;
1104 background: currentColor;
1105 }
1106 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1107 .preview-prpill.is-building .preview-prpill-dot {
1108 animation: previewPrPulse 1.4s ease-in-out infinite;
1109 }
1110 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1111 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1112 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1113 @keyframes previewPrPulse {
1114 0%, 100% { opacity: 1; }
1115 50% { opacity: 0.4; }
1116 }
79ed944Claude1117
1118 /* ─── AI Trio Review — 3-column verdict cards ─── */
1119 .trio-wrap {
1120 margin-top: 18px;
1121 padding: 16px;
1122 background: var(--bg-elevated);
1123 border: 1px solid var(--border);
1124 border-radius: 14px;
1125 }
1126 .trio-header {
1127 display: flex; align-items: center; gap: 10px;
1128 margin: 0 0 12px;
1129 font-size: 13.5px;
1130 color: var(--text);
1131 }
1132 .trio-header strong { color: var(--text-strong); }
1133 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1134 .trio-header-dot {
1135 width: 8px; height: 8px; border-radius: 9999px;
1136 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1137 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1138 }
1139 .trio-grid {
1140 display: grid;
1141 grid-template-columns: repeat(3, minmax(0, 1fr));
1142 gap: 12px;
1143 }
1144 .trio-card {
1145 background: var(--bg-secondary);
1146 border: 1px solid var(--border);
1147 border-radius: 12px;
1148 overflow: hidden;
1149 display: flex; flex-direction: column;
1150 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1151 }
1152 .trio-card-head {
1153 display: flex; align-items: center; gap: 8px;
1154 padding: 10px 12px;
1155 border-bottom: 1px solid var(--border);
1156 background: rgba(255,255,255,0.02);
1157 font-size: 13px;
1158 }
1159 .trio-card-icon {
1160 display: inline-flex; align-items: center; justify-content: center;
1161 width: 22px; height: 22px;
1162 border-radius: 9999px;
1163 font-size: 12px;
1164 background: rgba(255,255,255,0.05);
1165 }
1166 .trio-card-title {
1167 color: var(--text-strong);
1168 font-weight: 600;
1169 letter-spacing: 0.01em;
1170 }
1171 .trio-card-verdict {
1172 margin-left: auto;
1173 font-size: 11px;
1174 font-weight: 700;
1175 letter-spacing: 0.06em;
1176 text-transform: uppercase;
1177 padding: 3px 9px;
1178 border-radius: 9999px;
1179 background: var(--bg-tertiary);
1180 color: var(--text-muted);
1181 border: 1px solid var(--border-strong);
1182 }
1183 .trio-card-body {
1184 padding: 12px 14px;
1185 font-size: 13px;
1186 color: var(--text);
1187 flex: 1;
1188 min-height: 64px;
1189 line-height: 1.55;
1190 }
1191 .trio-card-body p { margin: 0 0 8px; }
1192 .trio-card-body p:last-child { margin-bottom: 0; }
1193 .trio-card-body ul { margin: 0; padding-left: 18px; }
1194 .trio-card-body code {
1195 font-family: var(--font-mono);
1196 font-size: 12px;
1197 background: var(--bg-tertiary);
1198 padding: 1px 6px;
1199 border-radius: 5px;
1200 }
1201 .trio-card-empty {
1202 color: var(--text-muted);
1203 font-style: italic;
1204 font-size: 12.5px;
1205 }
1206
1207 /* Pass state — neutral, no accent. */
1208 .trio-card.is-pass .trio-card-verdict {
1209 color: var(--green);
1210 border-color: rgba(52,211,153,0.35);
1211 background: rgba(52,211,153,0.12);
1212 }
1213
1214 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1215 .trio-card.trio-security.is-fail {
1216 border-color: rgba(248,113,113,0.55);
1217 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1218 }
1219 .trio-card.trio-security.is-fail .trio-card-head {
1220 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1221 border-bottom-color: rgba(248,113,113,0.30);
1222 }
1223 .trio-card.trio-security.is-fail .trio-card-verdict {
1224 color: #fecaca;
1225 border-color: rgba(248,113,113,0.55);
1226 background: rgba(248,113,113,0.20);
1227 }
1228
1229 .trio-card.trio-correctness.is-fail {
1230 border-color: rgba(251,191,36,0.55);
1231 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1232 }
1233 .trio-card.trio-correctness.is-fail .trio-card-head {
1234 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1235 border-bottom-color: rgba(251,191,36,0.30);
1236 }
1237 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1238 color: #fde68a;
1239 border-color: rgba(251,191,36,0.55);
1240 background: rgba(251,191,36,0.20);
1241 }
1242
1243 .trio-card.trio-style.is-fail {
1244 border-color: rgba(96,165,250,0.55);
1245 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1246 }
1247 .trio-card.trio-style.is-fail .trio-card-head {
1248 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1249 border-bottom-color: rgba(96,165,250,0.30);
1250 }
1251 .trio-card.trio-style.is-fail .trio-card-verdict {
1252 color: #bfdbfe;
1253 border-color: rgba(96,165,250,0.55);
1254 background: rgba(96,165,250,0.20);
1255 }
1256
1257 /* Disagreement callout strip — yellow, prominent. */
1258 .trio-disagreement-strip {
1259 display: flex;
1260 gap: 12px;
1261 margin-top: 14px;
1262 padding: 12px 14px;
1263 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1264 border: 1px solid rgba(251,191,36,0.45);
1265 border-radius: 10px;
1266 color: var(--text);
1267 font-size: 13px;
1268 }
1269 .trio-disagreement-icon {
1270 flex: 0 0 auto;
1271 width: 26px; height: 26px;
1272 display: inline-flex; align-items: center; justify-content: center;
1273 border-radius: 9999px;
1274 background: rgba(251,191,36,0.25);
1275 color: #fde68a;
1276 font-size: 14px;
1277 }
1278 .trio-disagreement-body strong {
1279 display: block;
1280 color: #fde68a;
1281 margin: 0 0 4px;
1282 font-weight: 700;
1283 }
1284 .trio-disagreement-list {
1285 margin: 0;
1286 padding-left: 18px;
1287 color: var(--text);
1288 font-size: 12.5px;
1289 line-height: 1.55;
1290 }
1291 .trio-disagreement-list code {
1292 font-family: var(--font-mono);
1293 font-size: 11.5px;
1294 background: var(--bg-tertiary);
1295 padding: 1px 5px;
1296 border-radius: 4px;
1297 }
1298
1299 @media (max-width: 720px) {
1300 .trio-grid { grid-template-columns: 1fr; }
1301 .trio-wrap { padding: 12px; }
1302 }
6d1bbc2Claude1303
1304 /* ─── Task list progress pill ─── */
1305 .prs-tasks-pill {
1306 display: inline-flex; align-items: center; gap: 5px;
1307 font-size: 11.5px; font-weight: 600;
1308 padding: 2px 9px; border-radius: 9999px;
1309 border: 1px solid var(--border);
1310 background: var(--bg-elevated);
1311 color: var(--text-muted);
1312 }
1313 .prs-tasks-pill.is-complete {
1314 color: #34d399;
1315 border-color: rgba(52,211,153,0.40);
1316 background: rgba(52,211,153,0.08);
1317 }
1318 .prs-tasks-progress { display: inline-block; width: 36px; height: 4px; border-radius: 9999px; background: var(--border); overflow: hidden; vertical-align: middle; }
1319 .prs-tasks-progress-bar { height: 100%; border-radius: 9999px; background: #34d399; }
1320
1321 /* ─── Update branch button ─── */
1322 .prs-update-branch-btn {
1323 display: inline-flex; align-items: center; gap: 5px;
1324 padding: 4px 12px; border-radius: 8px; font-size: 12.5px;
1325 font-weight: 600; cursor: pointer;
1326 background: rgba(96,165,250,0.10);
1327 color: #60a5fa;
1328 border: 1px solid rgba(96,165,250,0.30);
1329 transition: background 120ms;
1330 }
1331 .prs-update-branch-btn:hover { background: rgba(96,165,250,0.20); }
1332
1333 /* ─── Linked issues panel ─── */
1334 .prs-linked-issues {
1335 margin-top: 16px;
1336 border: 1px solid var(--border);
1337 border-radius: 12px;
1338 overflow: hidden;
1339 }
1340 .prs-linked-issues-head {
1341 display: flex; align-items: center; justify-content: space-between;
1342 padding: 10px 16px;
1343 background: var(--bg-elevated);
1344 border-bottom: 1px solid var(--border);
1345 font-size: 13px; font-weight: 600; color: var(--text);
1346 }
1347 .prs-linked-issues-count {
1348 font-size: 11px; font-weight: 700;
1349 padding: 1px 7px; border-radius: 9999px;
1350 background: var(--bg-tertiary);
1351 color: var(--text-muted);
1352 }
1353 .prs-linked-issue-row {
1354 display: flex; align-items: center; gap: 10px;
1355 padding: 9px 16px;
1356 border-bottom: 1px solid var(--border);
1357 font-size: 13px;
1358 text-decoration: none; color: inherit;
1359 }
1360 .prs-linked-issue-row:last-child { border-bottom: none; }
1361 .prs-linked-issue-row:hover { background: var(--bg-hover); }
1362 .prs-linked-issue-icon { flex: 0 0 auto; font-size: 14px; }
1363 .prs-linked-issue-icon.is-open { color: #34d399; }
1364 .prs-linked-issue-icon.is-closed { color: #8b949e; }
1365 .prs-linked-issue-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1366 .prs-linked-issue-num { color: var(--text-muted); font-size: 12px; }
1367 .prs-linked-issue-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
1368 .prs-linked-issue-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
1369 .prs-linked-issue-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
b558f23Claude1370
1371 /* ─── Commits tab ─── */
1372 .prs-commits-list { display: flex; flex-direction: column; gap: 0; margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1373 .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; }
1374 .prs-commit-row:last-child { border-bottom: none; }
1375 .prs-commit-row:hover { background: var(--bg-hover); }
1376 .prs-commit-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-top: 6px; }
1377 .prs-commit-body { flex: 1 1 auto; min-width: 0; }
1378 .prs-commit-msg { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
1379 .prs-commit-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
1380 .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; }
1381 .prs-commit-sha:hover { color: var(--accent); }
1382 .prs-commits-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13.5px; }
1383
1384 /* ─── Edit PR title/body ─── */
1385 .prs-edit-title-wrap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1386 .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; }
1387 .prs-edit-btn:hover { color: var(--text); border-color: var(--text-muted); }
1388 .prs-edit-form { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
1389 .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; }
1390 .prs-edit-actions { display: flex; gap: 8px; }
1391 .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; }
1392 .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; }
240c477Claude1393
1394 /* ─── CI status checks ─── */
1395 .prs-ci-card { margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1396 .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); }
1397 .prs-ci-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text); }
1398 .prs-ci-summary { font-size: 12px; color: var(--text-muted); }
1399 .prs-ci-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
1400 .prs-ci-row:last-child { border-bottom: none; }
1401 .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; }
1402 .prs-ci-icon.is-success { background: rgba(52,211,153,0.20); color: #34d399; }
1403 .prs-ci-icon.is-pending { background: rgba(251,191,36,0.20); color: #fbbf24; }
1404 .prs-ci-icon.is-failure, .prs-ci-icon.is-error { background: rgba(248,113,113,0.20); color: #f87171; }
1405 .prs-ci-context { flex: 1 1 auto; font-size: 13px; font-weight: 500; color: var(--text); }
1406 .prs-ci-desc { font-size: 12px; color: var(--text-muted); }
1407 .prs-ci-pill { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 9999px; }
1408 .prs-ci-pill.is-success { color: #34d399; background: rgba(52,211,153,0.10); }
1409 .prs-ci-pill.is-pending { color: #fbbf24; background: rgba(251,191,36,0.10); }
1410 .prs-ci-pill.is-failure, .prs-ci-pill.is-error { color: #f87171; background: rgba(248,113,113,0.10); }
1411 .prs-ci-link { font-size: 12px; color: var(--accent); text-decoration: none; }
1412 .prs-ci-link:hover { text-decoration: underline; }
b078860Claude1413`;
1414
81c73c1Claude1415/**
1416 * Tiny inline JS that drives the "Suggest description with AI" button.
1417 * On click, gathers form values, POSTs JSON to the given endpoint, and
1418 * pipes the response into the #pr-body textarea. All DOM lookups are
1419 * defensive — element absence is a silent no-op.
1420 *
1421 * Built as a string template so it lives next to its server-side caller
1422 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1423 * to avoid </script> breakouts.
1424 */
1425function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1426 const url = JSON.stringify(endpointUrl)
1427 .split("<").join("\\u003C")
1428 .split(">").join("\\u003E")
1429 .split("&").join("\\u0026");
1430 return (
1431 "(function(){try{" +
1432 "var btn=document.getElementById('ai-suggest-desc');" +
1433 "var status=document.getElementById('ai-suggest-status');" +
1434 "var body=document.getElementById('pr-body');" +
1435 "var form=btn&&btn.closest&&btn.closest('form');" +
1436 "if(!btn||!body||!form)return;" +
1437 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1438 "var fd=new FormData(form);" +
1439 "var title=String(fd.get('title')||'').trim();" +
1440 "var base=String(fd.get('base')||'').trim();" +
1441 "var head=String(fd.get('head')||'').trim();" +
1442 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1443 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1444 "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'})" +
1445 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1446 ".then(function(j){btn.disabled=false;" +
1447 "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;}}" +
1448 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1449 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1450 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1451 "});" +
1452 "}catch(e){}})();"
1453 );
1454}
1455
3c03977Claude1456/**
1457 * Live co-editing client. Connects to the per-PR SSE feed and:
1458 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1459 * status colour per user).
1460 * - Renders tinted cursor caret overlays inside #pr-body and every
1461 * `[data-live-field]` element.
1462 * - Broadcasts the local user's cursor position (selectionStart /
1463 * selectionEnd) debounced at 100ms.
1464 * - Broadcasts content patches (`replace` of the whole textarea —
1465 * last-write-wins v1) debounced at 250ms.
1466 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1467 * to the matching local field if untouched.
1468 *
1469 * All endpoint URLs are JSON-escaped via safe replacements so they
1470 * can't break out of the <script> tag.
1471 */
1472function LIVE_COEDIT_SCRIPT(prId: string): string {
1473 const idJson = JSON.stringify(prId)
1474 .split("<").join("\\u003C")
1475 .split(">").join("\\u003E")
1476 .split("&").join("\\u0026");
1477 return (
1478 "(function(){try{" +
1479 "if(typeof EventSource==='undefined')return;" +
1480 "var prId=" + idJson + ";" +
1481 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1482 "var pill=document.getElementById('live-pill');" +
1483 "var avEl=document.getElementById('live-avatars');" +
1484 "var countEl=document.getElementById('live-count');" +
1485 "var sessionId=null;var myColor=null;" +
1486 "var presence={};" + // sessionId -> {color,status,userId,initials}
1487 "var lastApplied={};" + // field -> last server value (for echo suppression)
1488 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1489 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1490 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1491 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1492 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1493 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1494 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1495 "avEl.innerHTML=html;}}" +
1496 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1497 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1498 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1499 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1500 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1501 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1502 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1503 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1504 "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);}" +
1505 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1506 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1507 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1508 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1509 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1510 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1511 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1512 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1513 "}catch(e){}" +
1514 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1515 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1516 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1517 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1518 "var es;var delay=1000;" +
1519 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1520 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1521 "(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){}});" +
1522 "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){}});" +
1523 "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){}});" +
1524 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1525 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1526 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1527 "var patch=d.patch;if(!patch||!patch.field)return;" +
1528 "var ta=fieldEl(patch.field);if(!ta)return;" +
1529 "if(document.activeElement===ta)return;" + // don't trample local typing
1530 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1531 "}catch(e){}});" +
1532 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1533 "}connect();" +
1534 "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){}}" +
1535 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1536 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1537 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1538 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1539 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1540 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1541 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1542 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1543 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1544 "}" +
1545 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1546 "var live=document.querySelectorAll('[data-live-field]');" +
1547 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1548 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1549 "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){}});" +
1550 "}catch(e){}})();"
1551 );
1552}
1553
0074234Claude1554async function resolveRepo(ownerName: string, repoName: string) {
1555 const [owner] = await db
1556 .select()
1557 .from(users)
1558 .where(eq(users.username, ownerName))
1559 .limit(1);
1560 if (!owner) return null;
1561 const [repo] = await db
1562 .select()
1563 .from(repositories)
1564 .where(
1565 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1566 )
1567 .limit(1);
1568 if (!repo) return null;
1569 return { owner, repo };
1570}
1571
1572// PR Nav helper
1573const PrNav = ({
1574 owner,
1575 repo,
1576 active,
1577}: {
1578 owner: string;
1579 repo: string;
1580 active: "code" | "issues" | "pulls" | "commits";
1581}) => (
bb0f894Claude1582 <TabNav
1583 tabs={[
1584 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1585 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1586 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1587 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1588 ]}
1589 />
0074234Claude1590);
1591
534f04aClaude1592/**
1593 * Block M3 — pre-merge risk score card. Pure presentational helper.
1594 * Rendered in the conversation tab above the gate checks block. Hidden
1595 * entirely when the PR is closed/merged or there is nothing cached and
1596 * nothing in-flight.
1597 */
1598function PrRiskCard({
1599 risk,
1600 calculating,
1601}: {
1602 risk: PrRiskScore | null;
1603 calculating: boolean;
1604}) {
1605 if (!risk) {
1606 return (
1607 <div
1608 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1609 >
1610 <strong style="font-size: 13px; color: var(--text)">
1611 Risk score: calculating…
1612 </strong>
1613 <div style="font-size: 12px; margin-top: 4px">
1614 Refresh in a moment to see the pre-merge risk score for this PR.
1615 </div>
1616 </div>
1617 );
1618 }
1619
1620 const palette = riskBandPalette(risk.band);
1621 const label = riskBandLabel(risk.band);
1622
1623 return (
1624 <div
1625 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1626 >
1627 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1628 <strong>Risk score:</strong>
1629 <span style={`color:${palette.border};font-weight:600`}>
1630 {palette.icon} {label} ({risk.score}/10)
1631 </span>
1632 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1633 {risk.commitSha.slice(0, 7)}
1634 </span>
1635 </div>
1636 {risk.aiSummary && (
1637 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1638 {risk.aiSummary}
1639 </div>
1640 )}
1641 <details style="margin-top:10px">
1642 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1643 See full signal breakdown
1644 </summary>
1645 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1646 <li>files changed: {risk.signals.filesChanged}</li>
1647 <li>
1648 lines added/removed: {risk.signals.linesAdded} /{" "}
1649 {risk.signals.linesRemoved}
1650 </li>
1651 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1652 <li>
1653 schema migration touched:{" "}
1654 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1655 </li>
1656 <li>
1657 locked / sensitive path touched:{" "}
1658 {risk.signals.lockedPathTouched ? "yes" : "no"}
1659 </li>
1660 <li>
1661 adds new dependency:{" "}
1662 {risk.signals.addsNewDependency ? "yes" : "no"}
1663 </li>
1664 <li>
1665 bumps major dependency:{" "}
1666 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1667 </li>
1668 <li>
1669 tests added for new code:{" "}
1670 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1671 </li>
1672 <li>
1673 diff-minus-test ratio:{" "}
1674 {risk.signals.diffMinusTestRatio.toFixed(2)}
1675 </li>
1676 </ul>
1677 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1678 How is this calculated? The score is a transparent sum of
1679 weighted signals — see <code>src/lib/pr-risk.ts</code>
1680 {" "}<code>computePrRiskScore</code>.
1681 </div>
1682 </details>
1683 {calculating && (
1684 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1685 (recomputing for the latest commit — refresh to update)
1686 </div>
1687 )}
1688 </div>
1689 );
1690}
1691
1692function riskBandPalette(band: PrRiskScore["band"]): {
1693 border: string;
1694 icon: string;
1695} {
1696 switch (band) {
1697 case "low":
1698 return { border: "var(--green)", icon: "" };
1699 case "medium":
1700 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1701 case "high":
1702 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1703 case "critical":
1704 return { border: "var(--red)", icon: "\u{1F6D1}" };
1705 }
1706}
1707
1708function riskBandLabel(band: PrRiskScore["band"]): string {
1709 switch (band) {
1710 case "low":
1711 return "LOW";
1712 case "medium":
1713 return "MEDIUM";
1714 case "high":
1715 return "HIGH";
1716 case "critical":
1717 return "CRITICAL";
1718 }
1719}
1720
422a2d4Claude1721// ---------------------------------------------------------------------------
1722// AI Trio Review — 3-column card grid + disagreement callout.
1723//
1724// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
1725// per run: one per persona (security/correctness/style) plus a top-level
1726// summary. We surface them here as a single grid above the normal
1727// comment stream so reviewers see the verdicts at a glance.
1728// ---------------------------------------------------------------------------
1729
1730const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
1731
1732interface TrioCommentLike {
1733 body: string;
1734}
1735
1736function isTrioComment(body: string | null | undefined): boolean {
1737 if (!body) return false;
1738 return (
1739 body.includes(TRIO_SUMMARY_MARKER) ||
1740 body.includes(TRIO_COMMENT_MARKER.security) ||
1741 body.includes(TRIO_COMMENT_MARKER.correctness) ||
1742 body.includes(TRIO_COMMENT_MARKER.style)
1743 );
1744}
1745
1746function trioPersonaOfComment(body: string): TrioPersona | null {
1747 for (const p of TRIO_PERSONAS) {
1748 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
1749 }
1750 return null;
1751}
1752
1753/**
1754 * Best-effort verdict parse from a persona comment body. The body shape
1755 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
1756 * we only need the "Pass" / "Fail" word from the H2 heading.
1757 */
1758function trioVerdictOfBody(body: string): "pass" | "fail" | null {
1759 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
1760 if (!m) return null;
1761 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
1762}
1763
1764/**
1765 * Parse the disagreement bullet list out of the summary comment so we
1766 * can render it as a polished callout strip. Returns [] when nothing
1767 * matches — the comment author may have edited the marker out.
1768 */
1769function parseDisagreements(summaryBody: string): Array<{
1770 file: string;
1771 failing: string;
1772 passing: string;
1773}> {
1774 const out: Array<{ file: string; failing: string; passing: string }> = [];
1775 // Each disagreement line looks like:
1776 // - `path:42` — security, style say ✗, correctness say ✓
1777 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
1778 let m: RegExpExecArray | null;
1779 while ((m = re.exec(summaryBody)) !== null) {
1780 out.push({
1781 file: m[1].trim(),
1782 failing: m[2].trim().replace(/[,\s]+$/g, ""),
1783 passing: m[3].trim().replace(/[,\s]+$/g, ""),
1784 });
1785 }
1786 return out;
1787}
1788
1789function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
1790 // Find the most recent persona comments + summary. We iterate from
1791 // the end so re-reviews (multiple runs on the same PR) display the
1792 // freshest verdict.
1793 const latest: Partial<Record<TrioPersona, string>> = {};
1794 let summaryBody: string | null = null;
1795 for (let i = comments.length - 1; i >= 0; i--) {
1796 const body = comments[i].body || "";
1797 if (!isTrioComment(body)) continue;
1798 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
1799 summaryBody = body;
1800 continue;
1801 }
1802 const persona = trioPersonaOfComment(body);
1803 if (persona && !latest[persona]) latest[persona] = body;
1804 }
1805 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1806 if (!anyPersona && !summaryBody) return null;
1807
1808 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
1809
1810 return (
1811 <div class="trio-wrap">
1812 <div class="trio-header">
1813 <span class="trio-header-dot" aria-hidden="true"></span>
1814 <strong>AI Trio Review</strong>
1815 <span class="trio-header-sub">
1816 Three independent reviewers ran in parallel.
1817 </span>
1818 </div>
1819 <div class="trio-grid">
1820 {TRIO_PERSONAS.map((persona) => {
1821 const body = latest[persona];
1822 const verdict = body ? trioVerdictOfBody(body) : null;
1823 const stateClass =
1824 verdict === "fail"
1825 ? "is-fail"
1826 : verdict === "pass"
1827 ? "is-pass"
1828 : "is-pending";
1829 return (
1830 <div class={`trio-card trio-${persona} ${stateClass}`}>
1831 <div class="trio-card-head">
1832 <span class="trio-card-icon" aria-hidden="true">
1833 {persona === "security"
1834 ? "🛡"
1835 : persona === "correctness"
1836 ? "✓"
1837 : "✎"}
1838 </span>
1839 <strong class="trio-card-title">
1840 {persona[0].toUpperCase() + persona.slice(1)}
1841 </strong>
1842 <span class="trio-card-verdict">
1843 {verdict === "pass"
1844 ? "Pass"
1845 : verdict === "fail"
1846 ? "Fail"
1847 : "Pending"}
1848 </span>
1849 </div>
1850 <div class="trio-card-body">
1851 {body ? (
1852 <MarkdownContent
1853 html={renderMarkdown(stripTrioHeading(body))}
1854 />
1855 ) : (
1856 <span class="trio-card-empty">
1857 Awaiting reviewer output.
1858 </span>
1859 )}
1860 </div>
1861 </div>
1862 );
1863 })}
1864 </div>
1865 {disagreements.length > 0 && (
1866 <div class="trio-disagreement-strip" role="note">
1867 <span class="trio-disagreement-icon" aria-hidden="true">
1868
1869 </span>
1870 <div class="trio-disagreement-body">
1871 <strong>Reviewers disagree — review carefully.</strong>
1872 <ul class="trio-disagreement-list">
1873 {disagreements.map((d) => (
1874 <li>
1875 <code>{d.file}</code> — {d.failing} says ✗,{" "}
1876 {d.passing} says ✓
1877 </li>
1878 ))}
1879 </ul>
1880 </div>
1881 </div>
1882 )}
1883 </div>
1884 );
1885}
1886
1887/**
1888 * Strip the marker comment + first H2 heading from a persona body so
1889 * the card body shows just the findings list (verdict is already in
1890 * the card head). Best-effort — malformed bodies render whole.
1891 */
1892function stripTrioHeading(body: string): string {
1893 return body
1894 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
1895 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
1896 .trim();
1897}
1898
0074234Claude1899// List PRs
04f6b7fClaude1900pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1901 const { owner: ownerName, repo: repoName } = c.req.param();
1902 const user = c.get("user");
1903 const state = c.req.query("state") || "open";
d790b49Claude1904 const searchQ = c.req.query("q")?.trim() || "";
80bd7c8Claude1905 const authorFilter = c.req.query("author")?.trim() || "";
f5b9ef5Claude1906 const sortPr = (c.req.query("sort") || "newest").trim();
0074234Claude1907
ea9ed4cClaude1908 // ── Loading skeleton (flag-gated) ──
1909 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
1910 // the user see the page structure before counts + select resolve.
1911 // Behind a flag for now — we don't ship flashes.
1912 if (c.req.query("skeleton") === "1") {
1913 return c.html(
1914 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1915 <RepoHeader owner={ownerName} repo={repoName} />
1916 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1917 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1918 <style
1919 dangerouslySetInnerHTML={{
1920 __html: `
404b398Claude1921 .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; }
1922 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude1923 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
1924 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
1925 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
1926 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
1927 .prs-skel-row { height: 76px; border-radius: 12px; }
1928 `,
1929 }}
1930 />
1931 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
1932 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
1933 <div class="prs-skel-list" aria-hidden="true">
1934 {Array.from({ length: 6 }).map(() => (
1935 <div class="prs-skel prs-skel-row" />
1936 ))}
1937 </div>
1938 <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">
1939 Loading pull requests for {ownerName}/{repoName}…
1940 </span>
1941 </Layout>
1942 );
1943 }
1944
0074234Claude1945 const resolved = await resolveRepo(ownerName, repoName);
1946 if (!resolved) return c.notFound();
1947
6fc53bdClaude1948 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
1949 const stateFilter =
1950 state === "draft"
1951 ? and(
1952 eq(pullRequests.state, "open"),
1953 eq(pullRequests.isDraft, true)
1954 )
1955 : eq(pullRequests.state, state);
1956
0074234Claude1957 const prList = await db
1958 .select({
1959 pr: pullRequests,
1960 author: { username: users.username },
1961 })
1962 .from(pullRequests)
1963 .innerJoin(users, eq(pullRequests.authorId, users.id))
1964 .where(
d790b49Claude1965 and(
1966 eq(pullRequests.repositoryId, resolved.repo.id),
1967 stateFilter,
1968 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
80bd7c8Claude1969 authorFilter ? eq(users.username, authorFilter) : undefined,
d790b49Claude1970 )
0074234Claude1971 )
f5b9ef5Claude1972 .orderBy(
1973 sortPr === "oldest" ? asc(pullRequests.createdAt)
1974 : sortPr === "updated" ? desc(pullRequests.updatedAt)
1975 : desc(pullRequests.createdAt) // newest (default)
1976 );
0074234Claude1977
0369e77Claude1978 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude1979 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude1980 const commentCountMap = new Map<string, number>();
1aef949Claude1981 if (prList.length > 0) {
1982 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude1983 const [reviewRows, commentRows] = await Promise.all([
1984 db
1985 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
1986 .from(prReviews)
1987 .where(inArray(prReviews.pullRequestId, prIds)),
1988 db
1989 .select({
1990 prId: prComments.pullRequestId,
1991 cnt: sql<number>`count(*)::int`,
1992 })
1993 .from(prComments)
1994 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
1995 .groupBy(prComments.pullRequestId),
1996 ]);
1aef949Claude1997 for (const r of reviewRows) {
1998 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
1999 if (r.state === "approved") entry.approved = true;
2000 if (r.state === "changes_requested") entry.changesRequested = true;
2001 reviewMap.set(r.prId, entry);
2002 }
0369e77Claude2003 for (const r of commentRows) {
2004 commentCountMap.set(r.prId, Number(r.cnt));
2005 }
1aef949Claude2006 }
2007
0074234Claude2008 const [counts] = await db
2009 .select({
2010 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude2011 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude2012 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
2013 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
2014 })
2015 .from(pullRequests)
2016 .where(eq(pullRequests.repositoryId, resolved.repo.id));
2017
b078860Claude2018 const openCount = counts?.open ?? 0;
2019 const mergedCount = counts?.merged ?? 0;
2020 const closedCount = counts?.closed ?? 0;
2021 const draftCount = counts?.draft ?? 0;
2022 const allCount = openCount + mergedCount + closedCount;
2023
2024 // "All" is presentational only — the DB query for state='all' matches
2025 // nothing, so we render a friendlier empty state when picked. We do NOT
2026 // change the query logic to keep this commit purely visual.
80bd7c8Claude2027 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude2028 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude2029 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
2030 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
2031 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
2032 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
2033 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude2034 ];
2035 const isAllState = state === "all";
cb5a796Claude2036 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
2037 const prListPendingCount = viewerIsOwnerOnPrList
2038 ? await countPendingForRepo(resolved.repo.id)
2039 : 0;
b078860Claude2040
0074234Claude2041 return c.html(
2042 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
2043 <RepoHeader owner={ownerName} repo={repoName} />
2044 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude2045 <PendingCommentsBanner
2046 owner={ownerName}
2047 repo={repoName}
2048 count={prListPendingCount}
2049 />
b078860Claude2050 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2051
2052 <div class="prs-hero">
2053 <div class="prs-hero-inner">
2054 <div class="prs-hero-text">
2055 <div class="prs-hero-eyebrow">Pull requests</div>
2056 <h1 class="prs-hero-title">
2057 Review, <span class="gradient-text">merge with AI</span>.
2058 </h1>
2059 <p class="prs-hero-sub">
2060 {openCount === 0 && allCount === 0
2061 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2062 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
2063 </p>
2064 </div>
7a28902Claude2065 <div class="prs-hero-actions">
2066 <a
2067 href={`/${ownerName}/${repoName}/pulls/insights`}
2068 class="prs-cta"
2069 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
2070 >
2071 Insights
2072 </a>
2073 {user && (
b078860Claude2074 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
2075 + New pull request
2076 </a>
7a28902Claude2077 )}
2078 </div>
b078860Claude2079 </div>
2080 </div>
2081
2082 <nav class="prs-tabs" aria-label="Pull request filters">
2083 {tabPills.map((t) => {
2084 const isActive =
2085 state === t.key ||
2086 (t.key === "open" &&
2087 state !== "merged" &&
2088 state !== "closed" &&
2089 state !== "all" &&
2090 state !== "draft");
2091 return (
2092 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2093 <span>{t.label}</span>
2094 <span class="prs-tab-count">{t.count}</span>
2095 </a>
2096 );
2097 })}
2098 </nav>
2099
d790b49Claude2100 <form
2101 method="get"
2102 action={`/${ownerName}/${repoName}/pulls`}
2103 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2104 >
2105 <input type="hidden" name="state" value={state} />
2106 <input
2107 type="search"
2108 name="q"
2109 value={searchQ}
2110 placeholder="Search pull requests…"
2111 class="issues-search-input"
2112 style="flex:1;max-width:380px"
2113 />
80bd7c8Claude2114 <input
2115 type="text"
2116 name="author"
2117 value={authorFilter}
2118 placeholder="Filter by author…"
2119 class="issues-search-input"
2120 style="max-width:200px"
2121 />
d790b49Claude2122 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2123 {(searchQ || authorFilter) && (
d790b49Claude2124 <a
2125 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2126 class="issues-filter-clear"
2127 >
2128 Clear
2129 </a>
2130 )}
2131 </form>
f5b9ef5Claude2132
2133 <div class="prs-sort-row">
2134 <span class="prs-sort-label">Sort:</span>
2135 {(["newest", "oldest", "updated"] as const).map((s) => (
2136 <a
2137 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2138 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2139 >
2140 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2141 </a>
2142 ))}
2143 </div>
2144
0074234Claude2145 {prList.length === 0 ? (
b078860Claude2146 <div class="prs-empty">
ea9ed4cClaude2147 <div class="prs-empty-inner">
2148 <strong>
80bd7c8Claude2149 {searchQ || authorFilter
2150 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2151 : isAllState
2152 ? "Pick a filter above to browse PRs."
2153 : `No ${state} pull requests.`}
ea9ed4cClaude2154 </strong>
2155 <p class="prs-empty-sub">
80bd7c8Claude2156 {searchQ || authorFilter
2157 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2158 : state === "open"
2159 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2160 : isAllState
2161 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2162 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2163 </p>
2164 <div class="prs-empty-cta">
80bd7c8Claude2165 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2166 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2167 + New pull request
2168 </a>
2169 )}
80bd7c8Claude2170 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2171 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2172 View open PRs
2173 </a>
2174 )}
2175 <a href={`/${ownerName}/${repoName}`} class="btn">
2176 Back to code
2177 </a>
2178 </div>
2179 </div>
b078860Claude2180 </div>
0074234Claude2181 ) : (
b078860Claude2182 <div class="prs-list">
2183 {prList.map(({ pr, author }) => {
2184 const stateClass =
2185 pr.state === "open"
2186 ? pr.isDraft
2187 ? "state-draft"
2188 : "state-open"
2189 : pr.state === "merged"
2190 ? "state-merged"
2191 : "state-closed";
2192 const icon =
2193 pr.state === "open"
2194 ? pr.isDraft
2195 ? "◌"
2196 : "○"
2197 : pr.state === "merged"
2198 ? "⮌"
2199 : "✓";
1aef949Claude2200 const rv = reviewMap.get(pr.id);
b078860Claude2201 return (
2202 <a
2203 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2204 class="prs-row"
2205 style="text-decoration:none;color:inherit"
0074234Claude2206 >
b078860Claude2207 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2208 {icon}
0074234Claude2209 </div>
b078860Claude2210 <div class="prs-row-body">
2211 <h3 class="prs-row-title">
2212 <span>{pr.title}</span>
2213 <span class="prs-row-number">#{pr.number}</span>
2214 </h3>
2215 <div class="prs-row-meta">
2216 <span
2217 class="prs-branch-chips"
2218 title={`${pr.headBranch} into ${pr.baseBranch}`}
2219 >
2220 <span class="prs-branch-chip">{pr.headBranch}</span>
2221 <span class="prs-branch-arrow">{"→"}</span>
2222 <span class="prs-branch-chip">{pr.baseBranch}</span>
2223 </span>
2224 <span>
2225 by{" "}
2226 <strong style="color:var(--text)">
2227 {author.username}
2228 </strong>{" "}
2229 {formatRelative(pr.createdAt)}
2230 </span>
2231 <span class="prs-row-tags">
2232 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2233 {pr.state === "merged" && (
2234 <span class="prs-tag is-merged">Merged</span>
2235 )}
1aef949Claude2236 {rv?.approved && !rv.changesRequested && (
2237 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
2238 )}
2239 {rv?.changesRequested && (
2240 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
2241 )}
0369e77Claude2242 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
2243 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
2244 💬 {commentCountMap.get(pr.id)}
2245 </span>
2246 )}
b078860Claude2247 </span>
2248 </div>
0074234Claude2249 </div>
b078860Claude2250 </a>
2251 );
2252 })}
2253 </div>
0074234Claude2254 )}
2255 </Layout>
2256 );
2257});
2258
7a28902Claude2259/* ─────────────────────────────────────────────────────────────────────────
2260 * PR Insights — 90-day analytics for the pull request activity of a repo.
2261 * Route: GET /:owner/:repo/pulls/insights
2262 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
2263 * "insights" is not swallowed by the :number param.
2264 * ───────────────────────────────────────────────────────────────────────── */
2265
2266/** Format a millisecond duration as human-readable string. */
2267function formatMsDuration(ms: number): string {
2268 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
2269 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
2270 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
2271 return `${Math.round(ms / 86_400_000)}d`;
2272}
2273
2274/** Format an ISO week string as "Jan 15". */
2275function formatWeekLabel(isoWeek: string): string {
2276 try {
2277 const d = new Date(isoWeek);
2278 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2279 } catch {
2280 return isoWeek.slice(5, 10);
2281 }
2282}
2283
2284const PR_INSIGHTS_STYLES = `
2285 .pri-page { padding-bottom: 48px; }
2286 .pri-hero {
2287 position: relative;
2288 margin: 0 0 var(--space-5);
2289 padding: 22px 26px 24px;
2290 background: var(--bg-elevated);
2291 border: 1px solid var(--border);
2292 border-radius: 16px;
2293 overflow: hidden;
2294 }
2295 .pri-hero::before {
2296 content: '';
2297 position: absolute; top: 0; left: 0; right: 0;
2298 height: 2px;
2299 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2300 opacity: 0.7;
2301 pointer-events: none;
2302 }
2303 .pri-hero-eyebrow {
2304 font-size: 12px;
2305 color: var(--text-muted);
2306 text-transform: uppercase;
2307 letter-spacing: 0.08em;
2308 font-weight: 600;
2309 margin-bottom: 8px;
2310 }
2311 .pri-hero-title {
2312 font-family: var(--font-display);
2313 font-size: clamp(26px, 3.4vw, 34px);
2314 font-weight: 800;
2315 letter-spacing: -0.025em;
2316 line-height: 1.06;
2317 margin: 0 0 8px;
2318 color: var(--text-strong);
2319 }
2320 .pri-hero-title .gradient-text {
2321 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2322 -webkit-background-clip: text;
2323 background-clip: text;
2324 -webkit-text-fill-color: transparent;
2325 color: transparent;
2326 }
2327 .pri-hero-sub {
2328 font-size: 14.5px;
2329 color: var(--text-muted);
2330 margin: 0;
2331 line-height: 1.5;
2332 }
2333 .pri-section { margin-bottom: 32px; }
2334 .pri-section-title {
2335 font-size: 13px;
2336 font-weight: 700;
2337 text-transform: uppercase;
2338 letter-spacing: 0.06em;
2339 color: var(--text-muted);
2340 margin: 0 0 14px;
2341 }
2342 .pri-cards {
2343 display: grid;
2344 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
2345 gap: 12px;
2346 }
2347 .pri-card {
2348 padding: 16px 18px;
2349 background: var(--bg-elevated);
2350 border: 1px solid var(--border);
2351 border-radius: 12px;
2352 }
2353 .pri-card-label {
2354 font-size: 12px;
2355 font-weight: 600;
2356 color: var(--text-muted);
2357 text-transform: uppercase;
2358 letter-spacing: 0.05em;
2359 margin-bottom: 6px;
2360 }
2361 .pri-card-value {
2362 font-size: 28px;
2363 font-weight: 800;
2364 letter-spacing: -0.04em;
2365 color: var(--text-strong);
2366 line-height: 1;
2367 }
2368 .pri-card-sub {
2369 font-size: 12px;
2370 color: var(--text-muted);
2371 margin-top: 4px;
2372 }
2373 .pri-chart {
2374 display: flex;
2375 align-items: flex-end;
2376 gap: 6px;
2377 height: 120px;
2378 background: var(--bg-elevated);
2379 border: 1px solid var(--border);
2380 border-radius: 12px;
2381 padding: 16px 16px 0;
2382 }
2383 .pri-bar-col {
2384 flex: 1;
2385 display: flex;
2386 flex-direction: column;
2387 align-items: center;
2388 justify-content: flex-end;
2389 height: 100%;
2390 gap: 4px;
2391 }
2392 .pri-bar {
2393 width: 100%;
2394 min-height: 4px;
2395 border-radius: 4px 4px 0 0;
2396 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 100%);
2397 transition: opacity 140ms;
2398 }
2399 .pri-bar:hover { opacity: 0.8; }
2400 .pri-bar-label {
2401 font-size: 10px;
2402 color: var(--text-muted);
2403 text-align: center;
2404 padding-bottom: 8px;
2405 white-space: nowrap;
2406 overflow: hidden;
2407 text-overflow: ellipsis;
2408 max-width: 100%;
2409 }
2410 .pri-table {
2411 width: 100%;
2412 border-collapse: collapse;
2413 font-size: 13.5px;
2414 }
2415 .pri-table th {
2416 text-align: left;
2417 font-size: 12px;
2418 font-weight: 600;
2419 text-transform: uppercase;
2420 letter-spacing: 0.05em;
2421 color: var(--text-muted);
2422 padding: 8px 12px;
2423 border-bottom: 1px solid var(--border);
2424 }
2425 .pri-table td {
2426 padding: 10px 12px;
2427 border-bottom: 1px solid var(--border);
2428 color: var(--text);
2429 }
2430 .pri-table tr:last-child td { border-bottom: none; }
2431 .pri-table-wrap {
2432 background: var(--bg-elevated);
2433 border: 1px solid var(--border);
2434 border-radius: 12px;
2435 overflow: hidden;
2436 }
2437 .pri-age-row {
2438 display: flex;
2439 align-items: center;
2440 gap: 12px;
2441 padding: 10px 0;
2442 border-bottom: 1px solid var(--border);
2443 font-size: 13.5px;
2444 }
2445 .pri-age-row:last-child { border-bottom: none; }
2446 .pri-age-label {
2447 flex: 0 0 80px;
2448 color: var(--text-muted);
2449 font-size: 12.5px;
2450 font-weight: 600;
2451 }
2452 .pri-age-bar-wrap {
2453 flex: 1;
2454 height: 8px;
2455 background: var(--bg-secondary);
2456 border-radius: 9999px;
2457 overflow: hidden;
2458 }
2459 .pri-age-bar {
2460 height: 100%;
2461 border-radius: 9999px;
2462 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
2463 min-width: 4px;
2464 }
2465 .pri-age-count {
2466 flex: 0 0 32px;
2467 text-align: right;
2468 font-weight: 600;
2469 color: var(--text-strong);
2470 font-size: 13px;
2471 }
2472 .pri-sparkline {
2473 display: flex;
2474 align-items: flex-end;
2475 gap: 3px;
2476 height: 40px;
2477 }
2478 .pri-spark-bar {
2479 flex: 1;
2480 min-height: 2px;
2481 border-radius: 2px 2px 0 0;
2482 background: var(--accent, #8c6dff);
2483 opacity: 0.7;
2484 }
2485 .pri-empty {
2486 color: var(--text-muted);
2487 font-size: 14px;
2488 padding: 24px 0;
2489 text-align: center;
2490 }
2491 @media (max-width: 600px) {
2492 .pri-cards { grid-template-columns: repeat(2, 1fr); }
2493 .pri-hero { padding: 18px 18px 20px; }
2494 }
2495`;
2496
2497pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
2498 const { owner: ownerName, repo: repoName } = c.req.param();
2499 const user = c.get("user");
2500
2501 const resolved = await resolveRepo(ownerName, repoName);
2502 if (!resolved) return c.notFound();
2503
2504 const repoId = resolved.repo.id;
2505 const now = Date.now();
2506
2507 // 1. Merged PRs in last 90 days (avg merge time)
2508 const mergedPRs = await db
2509 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
2510 .from(pullRequests)
2511 .where(and(
2512 eq(pullRequests.repositoryId, repoId),
2513 eq(pullRequests.state, "merged"),
2514 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2515 ));
2516
2517 const avgMergeMs = mergedPRs.length > 0
2518 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
2519 : null;
2520
2521 // 2. PR throughput (last 8 weeks)
2522 const weeklyPRs = await db
2523 .select({
2524 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
2525 count: sql<number>`count(*)::int`,
2526 })
2527 .from(pullRequests)
2528 .where(and(
2529 eq(pullRequests.repositoryId, repoId),
2530 sql`${pullRequests.createdAt} > now() - interval '56 days'`
2531 ))
2532 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
2533 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
2534
2535 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
2536
2537 // 3. PR merge rate (last 90 days)
2538 const [rateCounts] = await db
2539 .select({
2540 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
2541 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
2542 })
2543 .from(pullRequests)
2544 .where(and(
2545 eq(pullRequests.repositoryId, repoId),
2546 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2547 ));
2548
2549 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
2550 const mergeRate = totalResolved > 0
2551 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
2552 : null;
2553
2554 // 4. Top reviewers (last 90 days)
2555 const reviewerCounts = await db
2556 .select({
2557 userId: prReviews.reviewerId,
2558 username: users.username,
2559 count: sql<number>`count(*)::int`,
2560 })
2561 .from(prReviews)
2562 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2563 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
2564 .where(and(
2565 eq(pullRequests.repositoryId, repoId),
2566 sql`${prReviews.createdAt} > now() - interval '90 days'`
2567 ))
2568 .groupBy(prReviews.reviewerId, users.username)
2569 .orderBy(desc(sql`count(*)`))
2570 .limit(5);
2571
2572 // 5. Average reviews per merged PR
2573 const [avgReviewRow] = await db
2574 .select({
2575 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
2576 })
2577 .from(pullRequests)
2578 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2579 .where(and(
2580 eq(pullRequests.repositoryId, repoId),
2581 eq(pullRequests.state, "merged"),
2582 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2583 ));
2584
2585 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
2586 ? Math.round(avgReviewRow.avgReviews * 10) / 10
2587 : null;
2588
2589 // 6. Review turnaround — avg time from PR open to first review
2590 const prsWithReviews = await db
2591 .select({
2592 createdAt: pullRequests.createdAt,
2593 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
2594 })
2595 .from(pullRequests)
2596 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2597 .where(and(
2598 eq(pullRequests.repositoryId, repoId),
2599 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2600 ))
2601 .groupBy(pullRequests.id, pullRequests.createdAt);
2602
2603 const avgReviewTurnaroundMs = prsWithReviews.length > 0
2604 ? prsWithReviews.reduce((s, row) => {
2605 const firstMs = new Date(row.firstReview).getTime();
2606 return s + Math.max(0, firstMs - row.createdAt.getTime());
2607 }, 0) / prsWithReviews.length
2608 : null;
2609
2610 // 7. Open PRs by age bucket
2611 const openPRs = await db
2612 .select({ createdAt: pullRequests.createdAt })
2613 .from(pullRequests)
2614 .where(and(
2615 eq(pullRequests.repositoryId, repoId),
2616 eq(pullRequests.state, "open")
2617 ));
2618
2619 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
2620 for (const { createdAt } of openPRs) {
2621 const ageDays = (now - createdAt.getTime()) / 86_400_000;
2622 if (ageDays < 1) ageBuckets.lt1d++;
2623 else if (ageDays < 3) ageBuckets.d1to3++;
2624 else if (ageDays < 7) ageBuckets.d3to7++;
2625 else if (ageDays < 30) ageBuckets.d7to30++;
2626 else ageBuckets.gt30d++;
2627 }
2628 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
2629
2630 // 8. 7-day merge sparkline
2631 const sparklineRows = await db
2632 .select({
2633 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
2634 count: sql<number>`count(*)::int`,
2635 })
2636 .from(pullRequests)
2637 .where(and(
2638 eq(pullRequests.repositoryId, repoId),
2639 eq(pullRequests.state, "merged"),
2640 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
2641 ))
2642 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
2643 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
2644
2645 const sparkMap = new Map<string, number>();
2646 for (const row of sparklineRows) {
2647 sparkMap.set(row.day.slice(0, 10), row.count);
2648 }
2649 const sparkline: number[] = [];
2650 for (let i = 6; i >= 0; i--) {
2651 const d = new Date(now - i * 86_400_000);
2652 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
2653 }
2654 const maxSpark = Math.max(1, ...sparkline);
2655
2656 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
2657 { label: "< 1 day", key: "lt1d" },
2658 { label: "1–3 days", key: "d1to3" },
2659 { label: "3–7 days", key: "d3to7" },
2660 { label: "7–30 days", key: "d7to30" },
2661 { label: "> 30 days", key: "gt30d" },
2662 ];
2663
2664 return c.html(
2665 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
2666 <RepoHeader owner={ownerName} repo={repoName} />
2667 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2668 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
2669
2670 <div class="pri-page">
2671 {/* Hero */}
2672 <div class="pri-hero">
2673 <div class="pri-hero-eyebrow">Pull requests</div>
2674 <h1 class="pri-hero-title">
2675 PR <span class="gradient-text">Insights</span>
2676 </h1>
2677 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
2678 </div>
2679
2680 {/* Stat cards */}
2681 <div class="pri-section">
2682 <div class="pri-section-title">At a glance</div>
2683 <div class="pri-cards">
2684 <div class="pri-card">
2685 <div class="pri-card-label">Avg merge time</div>
2686 <div class="pri-card-value">
2687 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
2688 </div>
2689 <div class="pri-card-sub">last 90 days</div>
2690 </div>
2691 <div class="pri-card">
2692 <div class="pri-card-label">Total merged</div>
2693 <div class="pri-card-value">{mergedPRs.length}</div>
2694 <div class="pri-card-sub">last 90 days</div>
2695 </div>
2696 <div class="pri-card">
2697 <div class="pri-card-label">Open PRs</div>
2698 <div class="pri-card-value">{openPRs.length}</div>
2699 <div class="pri-card-sub">right now</div>
2700 </div>
2701 <div class="pri-card">
2702 <div class="pri-card-label">Merge rate</div>
2703 <div class="pri-card-value">
2704 {mergeRate != null ? `${mergeRate}%` : "—"}
2705 </div>
2706 <div class="pri-card-sub">merged vs closed</div>
2707 </div>
2708 <div class="pri-card">
2709 <div class="pri-card-label">Avg reviews / PR</div>
2710 <div class="pri-card-value">
2711 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
2712 </div>
2713 <div class="pri-card-sub">merged PRs, 90d</div>
2714 </div>
2715 <div class="pri-card">
2716 <div class="pri-card-label">Top reviewer</div>
2717 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
2718 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
2719 </div>
2720 <div class="pri-card-sub">
2721 {reviewerCounts.length > 0
2722 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
2723 : "no reviews yet"}
2724 </div>
2725 </div>
2726 </div>
2727 </div>
2728
2729 {/* Review turnaround */}
2730 <div class="pri-section">
2731 <div class="pri-section-title">Review turnaround</div>
2732 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
2733 <div class="pri-card">
2734 <div class="pri-card-label">Avg time to first review</div>
2735 <div class="pri-card-value">
2736 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
2737 </div>
2738 <div class="pri-card-sub">
2739 {prsWithReviews.length > 0
2740 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
2741 : "no reviewed PRs in 90d"}
2742 </div>
2743 </div>
2744 </div>
2745 </div>
2746
2747 {/* Weekly throughput bar chart */}
2748 <div class="pri-section">
2749 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
2750 {weeklyPRs.length === 0 ? (
2751 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
2752 ) : (
2753 <div class="pri-chart">
2754 {weeklyPRs.map((w) => (
2755 <div class="pri-bar-col">
2756 <div
2757 class="pri-bar"
2758 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
2759 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
2760 />
2761 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
2762 </div>
2763 ))}
2764 </div>
2765 )}
2766 </div>
2767
2768 {/* 7-day merge sparkline */}
2769 <div class="pri-section">
2770 <div class="pri-section-title">Merges this week (daily)</div>
2771 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
2772 <div class="pri-sparkline">
2773 {sparkline.map((v) => (
2774 <div
2775 class="pri-spark-bar"
2776 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
2777 title={`${v} merge${v === 1 ? "" : "s"}`}
2778 />
2779 ))}
2780 </div>
2781 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
2782 <span>7 days ago</span>
2783 <span>Today</span>
2784 </div>
2785 </div>
2786 </div>
2787
2788 {/* Top reviewers table */}
2789 <div class="pri-section">
2790 <div class="pri-section-title">Top reviewers (last 90 days)</div>
2791 {reviewerCounts.length === 0 ? (
2792 <div class="pri-empty">No reviews posted in the last 90 days.</div>
2793 ) : (
2794 <div class="pri-table-wrap">
2795 <table class="pri-table">
2796 <thead>
2797 <tr>
2798 <th>#</th>
2799 <th>Reviewer</th>
2800 <th>Reviews</th>
2801 </tr>
2802 </thead>
2803 <tbody>
2804 {reviewerCounts.map((r, i) => (
2805 <tr>
2806 <td style="color:var(--text-muted)">{i + 1}</td>
2807 <td>
2808 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
2809 {r.username}
2810 </a>
2811 </td>
2812 <td style="font-weight:600">{r.count}</td>
2813 </tr>
2814 ))}
2815 </tbody>
2816 </table>
2817 </div>
2818 )}
2819 </div>
2820
2821 {/* Open PRs by age */}
2822 <div class="pri-section">
2823 <div class="pri-section-title">Open PRs by age</div>
2824 {openPRs.length === 0 ? (
2825 <div class="pri-empty">No open pull requests.</div>
2826 ) : (
2827 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
2828 {ageBucketDefs.map(({ label, key }) => (
2829 <div class="pri-age-row">
2830 <span class="pri-age-label">{label}</span>
2831 <div class="pri-age-bar-wrap">
2832 <div
2833 class="pri-age-bar"
2834 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
2835 />
2836 </div>
2837 <span class="pri-age-count">{ageBuckets[key]}</span>
2838 </div>
2839 ))}
2840 </div>
2841 )}
2842 </div>
2843
2844 {/* Back link */}
2845 <div>
2846 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
2847 {"←"} Back to pull requests
2848 </a>
2849 </div>
2850 </div>
2851 </Layout>
2852 );
2853});
2854
0074234Claude2855// New PR form
2856pulls.get(
2857 "/:owner/:repo/pulls/new",
2858 softAuth,
2859 requireAuth,
04f6b7fClaude2860 requireRepoAccess("write"),
0074234Claude2861 async (c) => {
2862 const { owner: ownerName, repo: repoName } = c.req.param();
2863 const user = c.get("user")!;
2864 const branches = await listBranches(ownerName, repoName);
2865 const error = c.req.query("error");
2866 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude2867 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude2868
2869 return c.html(
2870 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
2871 <RepoHeader owner={ownerName} repo={repoName} />
2872 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude2873 <Container maxWidth={800}>
2874 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude2875 {error && (
bb0f894Claude2876 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude2877 )}
0316dbbClaude2878 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
2879 <Flex gap={12} align="center" style="margin-bottom: 16px">
2880 <Select name="base">
0074234Claude2881 {branches.map((b) => (
2882 <option value={b} selected={b === defaultBase}>
2883 {b}
2884 </option>
2885 ))}
bb0f894Claude2886 </Select>
2887 <Text muted>&larr;</Text>
2888 <Select name="head">
0074234Claude2889 {branches
2890 .filter((b) => b !== defaultBase)
2891 .concat(defaultBase === branches[0] ? [] : [branches[0]])
2892 .map((b) => (
2893 <option value={b}>{b}</option>
2894 ))}
bb0f894Claude2895 </Select>
2896 </Flex>
2897 <FormGroup>
2898 <Input
0074234Claude2899 name="title"
2900 required
2901 placeholder="Title"
bb0f894Claude2902 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]2903 aria-label="Pull request title"
0074234Claude2904 />
bb0f894Claude2905 </FormGroup>
2906 <FormGroup>
2907 <TextArea
0074234Claude2908 name="body"
81c73c1Claude2909 id="pr-body"
0074234Claude2910 rows={8}
2911 placeholder="Description (Markdown supported)"
bb0f894Claude2912 mono
0074234Claude2913 />
bb0f894Claude2914 </FormGroup>
81c73c1Claude2915 <Flex gap={8} align="center">
2916 <Button type="submit" variant="primary">
2917 Create pull request
2918 </Button>
2919 <button
2920 type="button"
2921 id="ai-suggest-desc"
2922 class="btn"
2923 style="font-weight:500"
2924 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
2925 >
2926 Suggest description with AI
2927 </button>
2928 <span
2929 id="ai-suggest-status"
2930 style="color:var(--text-muted);font-size:13px"
2931 />
2932 </Flex>
bb0f894Claude2933 </Form>
81c73c1Claude2934 <script
2935 dangerouslySetInnerHTML={{
2936 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
2937 }}
2938 />
bb0f894Claude2939 </Container>
0074234Claude2940 </Layout>
2941 );
2942 }
2943);
2944
81c73c1Claude2945// AI-suggested PR description — JSON endpoint driven by the form button.
2946// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
2947// 200; the inline script reads `ok` to decide what to do.
2948pulls.post(
2949 "/:owner/:repo/ai/pr-description",
2950 softAuth,
2951 requireAuth,
2952 requireRepoAccess("write"),
2953 async (c) => {
2954 const { owner: ownerName, repo: repoName } = c.req.param();
2955 if (!isAiAvailable()) {
2956 return c.json({
2957 ok: false,
2958 error: "AI is not available — set ANTHROPIC_API_KEY.",
2959 });
2960 }
2961 const body = await c.req.parseBody();
2962 const title = String(body.title || "").trim();
2963 const baseBranch = String(body.base || "").trim();
2964 const headBranch = String(body.head || "").trim();
2965 if (!baseBranch || !headBranch) {
2966 return c.json({ ok: false, error: "Pick base + head branches first." });
2967 }
2968 if (baseBranch === headBranch) {
2969 return c.json({ ok: false, error: "Base and head must differ." });
2970 }
2971
2972 let diff = "";
2973 try {
2974 const cwd = getRepoPath(ownerName, repoName);
2975 const proc = Bun.spawn(
2976 [
2977 "git",
2978 "diff",
2979 `${baseBranch}...${headBranch}`,
2980 "--",
2981 ],
2982 { cwd, stdout: "pipe", stderr: "pipe" }
2983 );
6ea2109Claude2984 // 30s ceiling — without this a pathological diff (huge binary or
2985 // a corrupt ref) hangs the request indefinitely.
2986 const killer = setTimeout(() => proc.kill(), 30_000);
2987 try {
2988 diff = await new Response(proc.stdout).text();
2989 await proc.exited;
2990 } finally {
2991 clearTimeout(killer);
2992 }
81c73c1Claude2993 } catch {
2994 diff = "";
2995 }
2996 if (!diff.trim()) {
2997 return c.json({
2998 ok: false,
2999 error: "No diff between branches — nothing to summarise.",
3000 });
3001 }
3002
3003 let summary = "";
3004 try {
3005 summary = await generatePrSummary(title || "(untitled)", diff);
3006 } catch (err) {
3007 const msg = err instanceof Error ? err.message : "AI request failed.";
3008 return c.json({ ok: false, error: msg });
3009 }
3010 if (!summary.trim()) {
3011 return c.json({ ok: false, error: "AI returned an empty draft." });
3012 }
3013 return c.json({ ok: true, body: summary });
3014 }
3015);
3016
0074234Claude3017// Create PR
3018pulls.post(
3019 "/:owner/:repo/pulls/new",
3020 softAuth,
3021 requireAuth,
04f6b7fClaude3022 requireRepoAccess("write"),
0074234Claude3023 async (c) => {
3024 const { owner: ownerName, repo: repoName } = c.req.param();
3025 const user = c.get("user")!;
3026 const body = await c.req.parseBody();
3027 const title = String(body.title || "").trim();
3028 const prBody = String(body.body || "").trim();
3029 const baseBranch = String(body.base || "main");
3030 const headBranch = String(body.head || "");
3031
3032 if (!title || !headBranch) {
3033 return c.redirect(
3034 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
3035 );
3036 }
3037
3038 if (baseBranch === headBranch) {
3039 return c.redirect(
3040 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
3041 );
3042 }
3043
3044 const resolved = await resolveRepo(ownerName, repoName);
3045 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3046
6fc53bdClaude3047 const isDraft = String(body.draft || "") === "1";
3048
0074234Claude3049 const [pr] = await db
3050 .insert(pullRequests)
3051 .values({
3052 repositoryId: resolved.repo.id,
3053 authorId: user.id,
3054 title,
3055 body: prBody || null,
3056 baseBranch,
3057 headBranch,
6fc53bdClaude3058 isDraft,
0074234Claude3059 })
3060 .returning();
3061
6fc53bdClaude3062 // Skip AI review on drafts — it runs again when the PR is marked ready.
3063 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3064 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3065 (err) => console.error("[ai-review] Failed:", err)
3066 );
3067 }
3068
3cbe3d6Claude3069 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3070 triggerPrTriage({
3071 ownerName,
3072 repoName,
3073 repositoryId: resolved.repo.id,
3074 prId: pr.id,
3075 prAuthorId: user.id,
3076 title,
3077 body: prBody,
3078 baseBranch,
3079 headBranch,
3080 }).catch((err) => console.error("[pr-triage] Failed:", err));
3081
1d4ff60Claude3082 // Chat notifier — fan out to Slack/Discord/Teams.
3083 import("../lib/chat-notifier")
3084 .then((m) =>
3085 m.notifyChatChannels({
3086 ownerUserId: resolved.repo.ownerId,
3087 repositoryId: resolved.repo.id,
3088 event: {
3089 event: "pr.opened",
3090 repo: `${ownerName}/${repoName}`,
3091 title: `#${pr.number} ${title}`,
3092 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3093 body: prBody || undefined,
3094 actor: user.username,
3095 },
3096 })
3097 )
3098 .catch((err) =>
3099 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3100 );
3101
9dd96b9Test User3102 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude3103 import("../lib/auto-merge")
3104 .then((m) => m.tryAutoMergeNow(pr.id))
3105 .catch((err) => {
3106 console.warn(
3107 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
3108 err instanceof Error ? err.message : err
3109 );
3110 });
9dd96b9Test User3111
0074234Claude3112 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
3113 }
3114);
3115
3116// View single PR
04f6b7fClaude3117pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude3118 const { owner: ownerName, repo: repoName } = c.req.param();
3119 const prNum = parseInt(c.req.param("number"), 10);
3120 const user = c.get("user");
3121 const tab = c.req.query("tab") || "conversation";
3122
3123 const resolved = await resolveRepo(ownerName, repoName);
3124 if (!resolved) return c.notFound();
3125
3126 const [pr] = await db
3127 .select()
3128 .from(pullRequests)
3129 .where(
3130 and(
3131 eq(pullRequests.repositoryId, resolved.repo.id),
3132 eq(pullRequests.number, prNum)
3133 )
3134 )
3135 .limit(1);
3136
3137 if (!pr) return c.notFound();
3138
3139 const [author] = await db
3140 .select()
3141 .from(users)
3142 .where(eq(users.id, pr.authorId))
3143 .limit(1);
3144
cb5a796Claude3145 const allCommentsRaw = await db
0074234Claude3146 .select({
3147 comment: prComments,
cb5a796Claude3148 author: { id: users.id, username: users.username },
0074234Claude3149 })
3150 .from(prComments)
3151 .innerJoin(users, eq(prComments.authorId, users.id))
3152 .where(eq(prComments.pullRequestId, pr.id))
3153 .orderBy(asc(prComments.createdAt));
3154
cb5a796Claude3155 // Filter pending/rejected/spam for non-owner, non-author viewers.
3156 // Owner always sees everything; comment author sees their own pending
3157 // with an "Awaiting approval" badge in the render below.
3158 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
3159 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
3160 if (viewerIsRepoOwner) return true;
3161 if (comment.moderationStatus === "approved") return true;
3162 if (
3163 user &&
3164 cAuthor.id === user.id &&
3165 comment.moderationStatus === "pending"
3166 ) {
3167 return true;
3168 }
3169 return false;
3170 });
3171 const prPendingCount = viewerIsRepoOwner
3172 ? await countPendingForRepo(resolved.repo.id)
3173 : 0;
3174
6fc53bdClaude3175 // Reactions for the PR body + each comment, in parallel.
3176 const [prReactions, ...prCommentReactions] = await Promise.all([
3177 summariseReactions("pr", pr.id, user?.id),
3178 ...comments.map((row) =>
3179 summariseReactions("pr_comment", row.comment.id, user?.id)
3180 ),
3181 ]);
3182
0a67773Claude3183 // Formal reviews (Approve / Request Changes)
3184 const reviewRows = await db
3185 .select({
3186 id: prReviews.id,
3187 state: prReviews.state,
3188 body: prReviews.body,
3189 isAi: prReviews.isAi,
3190 createdAt: prReviews.createdAt,
3191 reviewerUsername: users.username,
3192 reviewerId: prReviews.reviewerId,
3193 })
3194 .from(prReviews)
3195 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3196 .where(eq(prReviews.pullRequestId, pr.id))
3197 .orderBy(asc(prReviews.createdAt));
3198 // Most recent review per reviewer determines the current state
3199 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
3200 for (const r of reviewRows) {
3201 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
3202 }
3203 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
3204 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
3205 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
3206
ace34efClaude3207 // Suggested reviewers — best-effort, never throws
3208 let reviewerSuggestions: ReviewerCandidate[] = [];
3209 try {
3210 if (user) {
3211 reviewerSuggestions = await suggestReviewers(
3212 ownerName, repoName, pr.headBranch, pr.baseBranch,
3213 pr.authorId, resolved.repo.id
3214 );
3215 }
3216 } catch {
3217 // silent degradation
3218 }
3219
0074234Claude3220 const canManage =
3221 user &&
3222 (user.id === resolved.owner.id || user.id === pr.authorId);
3223
1d4ff60Claude3224 // Has any previous AI-test-generator run already tagged this PR? Used
3225 // both to hide the "Generate tests with AI" button and to short-circuit
3226 // the explicit POST handler.
3227 const hasAiTestsMarker = comments.some(({ comment }) =>
3228 (comment.body || "").includes(AI_TESTS_MARKER)
3229 );
3230
e883329Claude3231 const error = c.req.query("error");
c3e0c07Claude3232 const info = c.req.query("info");
e883329Claude3233
3234 // Get gate check status for open PRs
3235 let gateChecks: GateCheckResult[] = [];
240c477Claude3236 let ciStatuses: CommitStatus[] = [];
e883329Claude3237 if (pr.state === "open") {
3238 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3239 if (headSha) {
3240 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
3241 const aiApproved = aiComments.length === 0 || aiComments.some(
3242 ({ comment }) => comment.body.includes("**Approved**")
3243 );
240c477Claude3244 const [gateResult, fetchedCiStatuses] = await Promise.all([
3245 runAllGateChecks(
3246 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
3247 ),
3248 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
3249 ]);
e883329Claude3250 gateChecks = gateResult.checks;
240c477Claude3251 ciStatuses = fetchedCiStatuses;
e883329Claude3252 }
3253 }
3254
534f04aClaude3255 // Block M3 — pre-merge risk score. Cache-only on the request path so
3256 // the page never waits on Haiku. On a cache miss for an open PR we
3257 // kick off the computation fire-and-forget; the next refresh shows it.
3258 let prRisk: PrRiskScore | null = null;
3259 let prRiskCalculating = false;
3260 if (pr.state === "open") {
3261 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
3262 if (!prRisk) {
3263 prRiskCalculating = true;
a28cedeClaude3264 void computePrRiskForPullRequest(pr.id).catch((err) => {
3265 console.warn(
3266 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
3267 err instanceof Error ? err.message : err
3268 );
3269 });
534f04aClaude3270 }
3271 }
3272
4bbacbeClaude3273 // Migration 0062 — per-branch preview URL. The head branch always
3274 // has a preview row (unless it's the default branch, which never
3275 // happens for an open PR) once it has been pushed at least once.
3276 const preview = await getPreviewForBranch(
3277 (resolved.repo as { id: string }).id,
3278 pr.headBranch
3279 );
3280
0369e77Claude3281 // Branch ahead/behind counts — how many commits head is ahead of base and
3282 // how many commits base has advanced since head branched off.
3283 let branchAhead = 0;
3284 let branchBehind = 0;
3285 if (pr.state === "open") {
3286 try {
3287 const repoDir = getRepoPath(ownerName, repoName);
3288 const [aheadProc, behindProc] = [
3289 Bun.spawn(
3290 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
3291 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3292 ),
3293 Bun.spawn(
3294 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
3295 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3296 ),
3297 ];
3298 const [aheadTxt, behindTxt] = await Promise.all([
3299 new Response(aheadProc.stdout).text(),
3300 new Response(behindProc.stdout).text(),
3301 ]);
3302 await Promise.all([aheadProc.exited, behindProc.exited]);
3303 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
3304 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
3305 } catch { /* non-blocking */ }
3306 }
3307
6d1bbc2Claude3308 // Linked issues — parse closing keywords from PR title+body, look up issues
3309 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
3310 try {
3311 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3312 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3313 if (refs.length > 0) {
3314 linkedIssues = await db
3315 .select({ number: issues.number, title: issues.title, state: issues.state })
3316 .from(issues)
3317 .where(and(
3318 eq(issues.repositoryId, resolved.repo.id),
3319 inArray(issues.number, refs),
3320 ));
3321 }
3322 } catch { /* non-blocking */ }
3323
3324 // Task list progress — count markdown checkboxes in PR body
3325 let taskTotal = 0;
3326 let taskChecked = 0;
3327 if (pr.body) {
3328 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
3329 taskTotal++;
3330 if (m[1].trim() !== "") taskChecked++;
3331 }
3332 }
3333
47a7a0aClaude3334 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude3335 let diffRaw = "";
3336 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude3337 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude3338 if (tab === "files") {
3339 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude3340 // Run the two git diffs in parallel — they're independent reads of
3341 // the same range. Previously sequential, doubling the wall time on
3342 // big PRs (100+ files = 10-30s for no reason).
0074234Claude3343 const proc = Bun.spawn(
3344 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
3345 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3346 );
3347 const statProc = Bun.spawn(
3348 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
3349 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3350 );
6ea2109Claude3351 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
3352 // would otherwise hang the whole request.
3353 const killer = setTimeout(() => {
3354 proc.kill();
3355 statProc.kill();
3356 }, 30_000);
3357 let stat = "";
3358 try {
3359 [diffRaw, stat] = await Promise.all([
3360 new Response(proc.stdout).text(),
3361 new Response(statProc.stdout).text(),
3362 ]);
3363 await Promise.all([proc.exited, statProc.exited]);
3364 } finally {
3365 clearTimeout(killer);
3366 }
0074234Claude3367
3368 diffFiles = stat
3369 .trim()
3370 .split("\n")
3371 .filter(Boolean)
3372 .map((line) => {
3373 const [add, del, filePath] = line.split("\t");
3374 return {
3375 path: filePath,
3376 status: "modified",
3377 additions: add === "-" ? 0 : parseInt(add, 10),
3378 deletions: del === "-" ? 0 : parseInt(del, 10),
3379 patch: "",
3380 };
3381 });
47a7a0aClaude3382
3383 // Fetch inline comments (file+line anchored) for the files tab
3384 const inlineRows = await db
3385 .select({
3386 id: prComments.id,
3387 filePath: prComments.filePath,
3388 lineNumber: prComments.lineNumber,
3389 body: prComments.body,
3390 isAiReview: prComments.isAiReview,
3391 createdAt: prComments.createdAt,
3392 authorUsername: users.username,
3393 })
3394 .from(prComments)
3395 .innerJoin(users, eq(prComments.authorId, users.id))
3396 .where(
3397 and(
3398 eq(prComments.pullRequestId, pr.id),
3399 eq(prComments.moderationStatus, "approved"),
3400 )
3401 )
3402 .orderBy(asc(prComments.createdAt));
3403
3404 diffInlineComments = inlineRows
3405 .filter(r => r.filePath != null && r.lineNumber != null)
3406 .map(r => ({
3407 id: r.id,
3408 filePath: r.filePath!,
3409 lineNumber: r.lineNumber!,
3410 authorUsername: r.authorUsername,
3411 body: renderMarkdown(r.body),
3412 isAiReview: r.isAiReview,
3413 createdAt: r.createdAt.toISOString(),
3414 }));
0074234Claude3415 }
3416
b078860Claude3417 // ─── Derived visual state ───
3418 const stateKey =
3419 pr.state === "open"
3420 ? pr.isDraft
3421 ? "draft"
3422 : "open"
3423 : pr.state;
3424 const stateLabel =
3425 stateKey === "open"
3426 ? "Open"
3427 : stateKey === "draft"
3428 ? "Draft"
3429 : stateKey === "merged"
3430 ? "Merged"
3431 : "Closed";
3432 const stateIcon =
3433 stateKey === "open"
3434 ? "○"
3435 : stateKey === "draft"
3436 ? "◌"
3437 : stateKey === "merged"
3438 ? "⮌"
3439 : "✓";
3440 const commentCount = comments.length;
3441 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
3442 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
3443 const mergeBlocked =
3444 gateChecks.length > 0 &&
3445 gateChecks.some(
3446 (c) => !c.passed && c.name !== "Merge check"
3447 );
3448
b558f23Claude3449 // Commits tab — list commits included in this PR (base..head range)
3450 let prCommits: GitCommit[] = [];
3451 if (tab === "commits") {
3452 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
3453 }
3454
0074234Claude3455 return c.html(
3456 <Layout
3457 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
3458 user={user}
3459 >
3460 <RepoHeader owner={ownerName} repo={repoName} />
3461 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude3462 <PendingCommentsBanner
3463 owner={ownerName}
3464 repo={repoName}
3465 count={prPendingCount}
3466 />
b078860Claude3467 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude3468 <div
3469 id="live-comment-banner"
3470 class="alert"
3471 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
3472 >
3473 <strong class="js-live-count">0</strong> new comment(s) —{" "}
3474 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
3475 reload to view
3476 </a>
3477 </div>
3478 <script
3479 dangerouslySetInnerHTML={{
3480 __html: liveCommentBannerScript({
3481 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
3482 bannerElementId: "live-comment-banner",
3483 }),
3484 }}
3485 />
b078860Claude3486
3487 <div class="prs-detail-hero">
b558f23Claude3488 <div class="prs-edit-title-wrap">
3489 <h1 class="prs-detail-title" id="pr-title-display">
3490 {pr.title}{" "}
3491 <span class="prs-detail-num">#{pr.number}</span>
3492 </h1>
3493 {canManage && pr.state === "open" && (
3494 <button
3495 type="button"
3496 class="prs-edit-btn"
3497 id="pr-edit-toggle"
3498 onclick={`
3499 document.getElementById('pr-title-display').style.display='none';
3500 document.getElementById('pr-edit-toggle').style.display='none';
3501 document.getElementById('pr-edit-form').style.display='flex';
3502 document.getElementById('pr-title-input').focus();
3503 `}
3504 >
3505 Edit
3506 </button>
3507 )}
3508 </div>
3509 {canManage && pr.state === "open" && (
3510 <form
3511 id="pr-edit-form"
3512 method="post"
3513 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
3514 class="prs-edit-form"
3515 style="display:none"
3516 >
3517 <input
3518 id="pr-title-input"
3519 type="text"
3520 name="title"
3521 value={pr.title}
3522 required
3523 maxlength={256}
3524 placeholder="Pull request title"
3525 />
3526 <div class="prs-edit-actions">
3527 <button type="submit" class="prs-edit-save-btn">Save</button>
3528 <button
3529 type="button"
3530 class="prs-edit-cancel-btn"
3531 onclick={`
3532 document.getElementById('pr-edit-form').style.display='none';
3533 document.getElementById('pr-title-display').style.display='';
3534 document.getElementById('pr-edit-toggle').style.display='';
3535 `}
3536 >
3537 Cancel
3538 </button>
3539 </div>
3540 </form>
3541 )}
b078860Claude3542 <div class="prs-detail-meta">
3543 <span class={`prs-state-pill state-${stateKey}`}>
3544 <span aria-hidden="true">{stateIcon}</span>
3545 <span>{stateLabel}</span>
3546 </span>
3547 <span>
3548 <strong>{author?.username}</strong> wants to merge
3549 </span>
3550 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
3551 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
3552 <span class="prs-branch-arrow-lg">{"→"}</span>
3553 <span class="prs-branch-pill">{pr.baseBranch}</span>
3554 </span>
0369e77Claude3555 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
3556 <span
3557 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
3558 title={branchBehind > 0
3559 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
3560 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
3561 >
3562 {branchAhead > 0 ? `↑${branchAhead}` : ""}
3563 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
3564 {branchBehind > 0 ? `↓${branchBehind}` : ""}
3565 </span>
3566 )}
b078860Claude3567 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude3568 {taskTotal > 0 && (
3569 <span
3570 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
3571 title={`${taskChecked} of ${taskTotal} tasks completed`}
3572 >
3573 <span class="prs-tasks-progress" aria-hidden="true">
3574 <span
3575 class="prs-tasks-progress-bar"
3576 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
3577 ></span>
3578 </span>
3579 {taskChecked}/{taskTotal} tasks
3580 </span>
3581 )}
3582 {canManage && pr.state === "open" && branchBehind > 0 && (
3583 <form
3584 method="post"
3585 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
3586 class="prs-inline-form"
3587 >
3588 <button
3589 type="submit"
3590 class="prs-update-branch-btn"
3591 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
3592 >
3593 ↑ Update branch
3594 </button>
3595 </form>
3596 )}
3c03977Claude3597 <span
3598 id="live-pill"
3599 class="live-pill"
3600 title="People editing this PR right now"
3601 >
3602 <span class="live-pill-dot" aria-hidden="true"></span>
3603 <span>
3604 Live: <strong id="live-count">0</strong> editing
3605 </span>
3606 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
3607 </span>
4bbacbeClaude3608 {preview && (
3609 <a
3610 class={`preview-prpill is-${preview.status}`}
3611 href={
3612 preview.status === "ready"
3613 ? preview.previewUrl
3614 : `/${ownerName}/${repoName}/previews`
3615 }
3616 target={preview.status === "ready" ? "_blank" : undefined}
3617 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
3618 title={`Preview · ${previewStatusLabel(preview.status)}`}
3619 >
3620 <span class="preview-prpill-dot" aria-hidden="true"></span>
3621 <span>Preview: </span>
3622 <span>{previewStatusLabel(preview.status)}</span>
3623 </a>
3624 )}
b078860Claude3625 {canManage && pr.state === "open" && pr.isDraft && (
3626 <form
3627 method="post"
3628 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3629 class="prs-inline-form prs-detail-actions"
3630 >
3631 <button type="submit" class="prs-merge-ready-btn">
3632 Ready for review
3633 </button>
3634 </form>
3635 )}
3636 </div>
3637 </div>
3c03977Claude3638 <script
3639 dangerouslySetInnerHTML={{
3640 __html: LIVE_COEDIT_SCRIPT(pr.id),
3641 }}
3642 />
829a046Claude3643 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude3644 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude3645 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude3646
b078860Claude3647 <nav class="prs-detail-tabs" aria-label="Pull request sections">
3648 <a
3649 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
3650 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
3651 >
3652 Conversation
3653 <span class="prs-detail-tab-count">{commentCount}</span>
3654 </a>
b558f23Claude3655 <a
3656 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
3657 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
3658 >
3659 Commits
3660 {branchAhead > 0 && (
3661 <span class="prs-detail-tab-count">{branchAhead}</span>
3662 )}
3663 </a>
b078860Claude3664 <a
3665 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
3666 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3667 >
3668 Files changed
3669 {diffFiles.length > 0 && (
3670 <span class="prs-detail-tab-count">{diffFiles.length}</span>
3671 )}
3672 </a>
3673 </nav>
3674
b558f23Claude3675 {tab === "commits" ? (
3676 <div class="prs-commits-list">
3677 {prCommits.length === 0 ? (
3678 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
3679 ) : (
3680 prCommits.map((commit) => (
3681 <div class="prs-commit-row">
3682 <span class="prs-commit-dot" aria-hidden="true"></span>
3683 <div class="prs-commit-body">
3684 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
3685 <div class="prs-commit-meta">
3686 <strong>{commit.author}</strong> committed{" "}
3687 {formatRelative(new Date(commit.date))}
3688 </div>
3689 </div>
3690 <a
3691 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
3692 class="prs-commit-sha"
3693 title="View commit"
3694 >
3695 {commit.sha.slice(0, 7)}
3696 </a>
3697 </div>
3698 ))
3699 )}
3700 </div>
3701 ) : tab === "files" ? (
ea9ed4cClaude3702 <DiffView
3703 raw={diffRaw}
3704 files={diffFiles}
3705 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
47a7a0aClaude3706 inlineComments={diffInlineComments}
3707 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
b5dd694Claude3708 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
ea9ed4cClaude3709 />
b078860Claude3710 ) : (
3711 <>
3712 {pr.body && (
3713 <CommentBox
3714 author={author?.username ?? "unknown"}
3715 date={pr.createdAt}
3716 body={renderMarkdown(pr.body)}
3717 />
3718 )}
3719
422a2d4Claude3720 {/* Block H — AI trio review (security/correctness/style). When
3721 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
3722 hoisted into a 3-column card grid above the normal comment
3723 stream so reviewers see verdicts at a glance. Disagreements
3724 are surfaced as a yellow callout. */}
3725 <TrioReviewGrid
3726 comments={comments.map(({ comment }) => comment)}
3727 />
3728
15db0e0Claude3729 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude3730 // Skip trio comments — already rendered in TrioReviewGrid above.
3731 if (isTrioComment(comment.body)) return null;
15db0e0Claude3732 const slashCmd = detectSlashCmdComment(comment.body);
3733 if (slashCmd) {
3734 const visible = stripSlashCmdMarker(comment.body);
3735 return (
3736 <div class={`slash-pill slash-cmd-${slashCmd}`}>
3737 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
3738 <span class="slash-pill-actor">
3739 <strong>{commentAuthor.username}</strong>
3740 {" ran "}
3741 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude3742 </span>
15db0e0Claude3743 <span class="slash-pill-time">
3744 {formatRelative(comment.createdAt)}
3745 </span>
3746 <div class="slash-pill-body">
3747 <MarkdownContent html={renderMarkdown(visible)} />
3748 </div>
3749 </div>
3750 );
3751 }
cb5a796Claude3752 const isPending = comment.moderationStatus === "pending";
15db0e0Claude3753 return (
cb5a796Claude3754 <div
3755 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
3756 >
15db0e0Claude3757 <div class="prs-comment-head">
3758 <strong>{commentAuthor.username}</strong>
3759 {comment.isAiReview && (
3760 <span class="prs-ai-badge">AI Review</span>
3761 )}
cb5a796Claude3762 {isPending && (
3763 <span
3764 class="modq-pending-badge"
3765 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
3766 >
3767 Awaiting approval
3768 </span>
3769 )}
15db0e0Claude3770 <span class="prs-comment-time">
3771 commented {formatRelative(comment.createdAt)}
3772 </span>
3773 {comment.filePath && (
3774 <span class="prs-comment-loc">
3775 {comment.filePath}
3776 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
3777 </span>
3778 )}
3779 </div>
3780 <div class="prs-comment-body">
3781 <MarkdownContent html={renderMarkdown(comment.body)} />
3782 </div>
0074234Claude3783 </div>
15db0e0Claude3784 );
3785 })}
0074234Claude3786
b078860Claude3787 {/* Quick link to the Files changed tab when there's a diff to look at. */}
3788 {pr.state !== "merged" && (
3789 <a
3790 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3791 class="prs-files-card"
3792 >
3793 <span class="prs-files-card-icon" aria-hidden="true">
3794 {"▤"}
3795 </span>
3796 <div class="prs-files-card-text">
3797 <p class="prs-files-card-title">Files changed</p>
3798 <p class="prs-files-card-sub">
3799 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
3800 </p>
e883329Claude3801 </div>
b078860Claude3802 <span class="prs-files-card-cta">View diff {"→"}</span>
3803 </a>
3804 )}
3805
6d1bbc2Claude3806 {linkedIssues.length > 0 && (
3807 <div class="prs-linked-issues">
3808 <div class="prs-linked-issues-head">
3809 <span>Closing issues</span>
3810 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
3811 </div>
3812 {linkedIssues.map((issue) => (
3813 <a
3814 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
3815 class="prs-linked-issue-row"
3816 >
3817 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
3818 {issue.state === "open" ? "○" : "✓"}
3819 </span>
3820 <span class="prs-linked-issue-title">{issue.title}</span>
3821 <span class="prs-linked-issue-num">#{issue.number}</span>
3822 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
3823 {issue.state}
3824 </span>
3825 </a>
3826 ))}
3827 </div>
3828 )}
3829
b078860Claude3830 {error && (
3831 <div
3832 class="auth-error"
3833 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)"
3834 >
3835 {decodeURIComponent(error)}
3836 </div>
3837 )}
3838
3839 {info && (
3840 <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)">
3841 {decodeURIComponent(info)}
3842 </div>
3843 )}
e883329Claude3844
b078860Claude3845 {pr.state === "open" && (prRisk || prRiskCalculating) && (
3846 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
3847 )}
3848
0a67773Claude3849 {/* ─── Review summary ─────────────────────────────────── */}
3850 {(approvals.length > 0 || changesRequested.length > 0) && (
3851 <div class="prs-review-summary">
3852 {approvals.length > 0 && (
3853 <div class="prs-review-row prs-review-approved">
3854 <span class="prs-review-icon">✓</span>
3855 <span>
3856 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
3857 approved this pull request
3858 </span>
3859 </div>
3860 )}
3861 {changesRequested.length > 0 && (
3862 <div class="prs-review-row prs-review-changes">
3863 <span class="prs-review-icon">✗</span>
3864 <span>
3865 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
3866 requested changes
3867 </span>
3868 </div>
3869 )}
3870 </div>
3871 )}
3872
ace34efClaude3873 {/* Suggested reviewers */}
3874 {reviewerSuggestions.length > 0 && user && user.id !== pr.authorId && (
3875 <div class="prs-review-summary" style="margin-top:12px">
3876 <div class="prs-review-row" style="flex-direction:column;align-items:flex-start;gap:8px">
3877 <span style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--fg-muted);font-weight:700">
3878 Suggested reviewers
3879 </span>
3880 {reviewerSuggestions.map((r) => (
3881 <form method="post" action={`/${ownerName}/${repoName}/pulls/${pr.number}/request-review`}
3882 style="display:flex;align-items:center;gap:8px;width:100%">
3883 <input type="hidden" name="reviewerId" value={r.userId} />
3884 <span class="prs-reviewer-avatar">
3885 {r.username.slice(0, 1).toUpperCase()}
3886 </span>
3887 <a href={`/${r.username}`} style="flex:1;font-size:13px;color:var(--fg);font-weight:600;text-decoration:none">
3888 {r.username}
3889 </a>
3890 <span style="font-size:11px;color:var(--fg-muted)">{r.commitCount}c</span>
3891 <button type="submit" class="btn" style="font-size:12px;padding:3px 9px">
3892 Request
3893 </button>
3894 </form>
3895 ))}
3896 </div>
3897 </div>
3898 )}
3899
b078860Claude3900 {pr.state === "open" && gateChecks.length > 0 && (
3901 <div class="prs-gate-card">
3902 <div class="prs-gate-head">
3903 <h3>Gate checks</h3>
3904 <span class="prs-gate-summary">
3905 {gatesAllPassed
3906 ? `All ${gateChecks.length} checks passed`
3907 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
3908 </span>
c3e0c07Claude3909 </div>
b078860Claude3910 {gateChecks.map((check) => {
3911 const isAi = /ai.*review/i.test(check.name);
3912 const isSkip = check.skipped === true;
3913 const statusClass = isSkip
3914 ? "is-skip"
3915 : check.passed
3916 ? "is-pass"
3917 : "is-fail";
3918 const statusGlyph = isSkip
3919 ? "—"
3920 : check.passed
3921 ? "✓"
3922 : "✗";
3923 const statusLabel = isSkip
3924 ? "Skipped"
3925 : check.passed
3926 ? "Passed"
3927 : "Failing";
3928 return (
3929 <div
3930 class="prs-gate-row"
3931 style={
3932 isAi
3933 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
3934 : ""
3935 }
3936 >
3937 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
3938 {statusGlyph}
3939 </span>
3940 <span class="prs-gate-name">
3941 {check.name}
3942 {isAi && (
3943 <span
3944 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"
3945 >
3946 AI
3947 </span>
3948 )}
3949 </span>
3950 <span class="prs-gate-details">{check.details}</span>
3951 <span class={`prs-gate-pill ${statusClass}`}>
3952 {statusLabel}
e883329Claude3953 </span>
3954 </div>
b078860Claude3955 );
3956 })}
3957 <div class="prs-gate-footer">
3958 {gatesAllPassed
3959 ? "All checks passed — ready to merge."
3960 : gateChecks.some(
3961 (c) => !c.passed && c.name === "Merge check"
3962 )
3963 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
3964 : "Some checks failed — resolve issues before merging."}
3965 {aiReviewCount > 0 && (
3966 <>
3967 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
3968 </>
3969 )}
3970 </div>
3971 </div>
3972 )}
3973
240c477Claude3974 {pr.state === "open" && ciStatuses.length > 0 && (
3975 <div class="prs-ci-card">
3976 <div class="prs-ci-head">
3977 <h3>CI checks</h3>
3978 <span class="prs-ci-summary">
3979 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
3980 </span>
3981 </div>
3982 {ciStatuses.map((status) => {
3983 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
3984 return (
3985 <div class="prs-ci-row">
3986 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
3987 <span class="prs-ci-context">{status.context}</span>
3988 {status.description && (
3989 <span class="prs-ci-desc">{status.description}</span>
3990 )}
3991 <span class={`prs-ci-pill is-${status.state}`}>
3992 {status.state}
3993 </span>
3994 {status.targetUrl && (
3995 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
3996 )}
3997 </div>
3998 );
3999 })}
4000 </div>
4001 )}
4002
b078860Claude4003 {/* ─── Merge area / state-aware action card ─────────────── */}
4004 {user && pr.state === "open" && (
4005 <div
4006 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
4007 >
4008 <div class="prs-merge-head">
4009 <strong>
4010 {pr.isDraft
4011 ? "Draft — ready for review?"
4012 : mergeBlocked
4013 ? "Merge blocked"
4014 : "Ready to merge"}
4015 </strong>
e883329Claude4016 </div>
b078860Claude4017 <p class="prs-merge-sub">
4018 {pr.isDraft
4019 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
4020 : mergeBlocked
4021 ? "Resolve the failing gate checks above before this PR can land."
4022 : gateChecks.length > 0
4023 ? gatesAllPassed
4024 ? "All gates green. Merge will fast-forward into the base branch."
4025 : "Conflicts will be auto-resolved by GlueCron AI on merge."
4026 : "Run gate checks by refreshing once your branch has a recent commit."}
4027 </p>
4028 <Form
4029 method="post"
4030 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
4031 >
4032 <FormGroup>
3c03977Claude4033 <div class="live-cursor-host" style="position:relative">
4034 <textarea
4035 name="body"
4036 id="pr-comment-body"
4037 data-live-field="comment_new"
6cd2f0eClaude4038 data-md-preview=""
3c03977Claude4039 rows={5}
4040 required
4041 placeholder="Leave a comment... (Markdown supported)"
4042 style="font-family:var(--font-mono);font-size:13px;width:100%"
4043 ></textarea>
4044 </div>
15db0e0Claude4045 <span class="slash-hint" title="Type a slash-command as the first line">
4046 Type <code>/</code> for commands —{" "}
4047 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
4048 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
4049 </span>
b078860Claude4050 </FormGroup>
4051 <div class="prs-merge-actions">
4052 <Button type="submit" variant="primary">
4053 Comment
4054 </Button>
0a67773Claude4055 {user && user.id !== pr.authorId && pr.state === "open" && (
4056 <>
4057 <button
4058 type="submit"
4059 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4060 name="review_state"
4061 value="approved"
4062 class="prs-review-approve-btn"
4063 title="Approve this pull request"
4064 >
4065 ✓ Approve
4066 </button>
4067 <button
4068 type="submit"
4069 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
4070 name="review_state"
4071 value="changes_requested"
4072 class="prs-review-changes-btn"
4073 title="Request changes before merging"
4074 >
4075 ✗ Request changes
4076 </button>
4077 </>
4078 )}
b078860Claude4079 {canManage && (
4080 <>
4081 {pr.isDraft ? (
4082 <button
4083 type="submit"
4084 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
4085 formnovalidate
4086 class="prs-merge-ready-btn"
4087 >
4088 Ready for review
4089 </button>
4090 ) : (
a164a6dClaude4091 <>
4092 <div class="prs-merge-strategy-wrap">
4093 <span class="prs-merge-strategy-label">Strategy</span>
4094 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
4095 <option value="merge">Merge commit</option>
4096 <option value="squash">Squash and merge</option>
4097 <option value="ff">Fast-forward</option>
4098 </select>
4099 </div>
4100 <button
4101 type="submit"
4102 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
4103 formnovalidate
4104 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
4105 title={
4106 mergeBlocked
4107 ? "Failing gate checks must be resolved before this PR can merge."
4108 : "Merge pull request"
4109 }
4110 >
4111 {"✔"} Merge pull request
4112 </button>
4113 </>
b078860Claude4114 )}
4115 {!pr.isDraft && (
4116 <button
0074234Claude4117 type="submit"
b078860Claude4118 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
4119 formnovalidate
4120 class="prs-merge-back-draft"
4121 title="Convert back to draft"
0074234Claude4122 >
b078860Claude4123 Convert to draft
4124 </button>
4125 )}
4126 {isAiReviewEnabled() && (
4127 <button
4128 type="submit"
4129 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
4130 formnovalidate
4131 class="btn"
4132 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
4133 >
4134 Re-run AI review
4135 </button>
4136 )}
1d4ff60Claude4137 {isAiReviewEnabled() && !hasAiTestsMarker && (
4138 <button
4139 type="submit"
4140 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
4141 formnovalidate
4142 class="btn"
4143 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."
4144 >
4145 Generate tests with AI
4146 </button>
4147 )}
b078860Claude4148 <Button
4149 type="submit"
4150 variant="danger"
4151 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
4152 >
4153 Close
4154 </Button>
4155 </>
4156 )}
4157 </div>
4158 </Form>
4159 </div>
4160 )}
4161
4162 {/* Read-only footers for non-open states. */}
4163 {pr.state === "merged" && (
4164 <div class="prs-merge-card is-merged">
4165 <div class="prs-merge-head">
4166 <strong>{"⮌"} Merged</strong>
0074234Claude4167 </div>
b078860Claude4168 <p class="prs-merge-sub">
4169 This pull request was merged into{" "}
4170 <code>{pr.baseBranch}</code>.
4171 </p>
4172 </div>
4173 )}
4174 {pr.state === "closed" && (
4175 <div class="prs-merge-card is-closed">
4176 <div class="prs-merge-head">
4177 <strong>{"✕"} Closed without merging</strong>
4178 </div>
4179 <p class="prs-merge-sub">
4180 This pull request was closed and not merged.
4181 </p>
4182 </div>
4183 )}
4184 </>
4185 )}
0074234Claude4186 </Layout>
4187 );
4188});
4189
6d1bbc2Claude4190// Update branch — merge base into head so the PR branch is up to date.
4191// Uses a git worktree so the bare repo stays clean. Write access required.
4192pulls.post(
4193 "/:owner/:repo/pulls/:number/update-branch",
4194 softAuth,
4195 requireAuth,
4196 requireRepoAccess("write"),
4197 async (c) => {
4198 const { owner: ownerName, repo: repoName } = c.req.param();
4199 const prNum = parseInt(c.req.param("number"), 10);
4200 const user = c.get("user")!;
4201 const resolved = await resolveRepo(ownerName, repoName);
4202 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4203
4204 const [pr] = await db
4205 .select()
4206 .from(pullRequests)
4207 .where(and(
4208 eq(pullRequests.repositoryId, resolved.repo.id),
4209 eq(pullRequests.number, prNum),
4210 ))
4211 .limit(1);
4212 if (!pr || pr.state !== "open") {
4213 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4214 }
4215
4216 const repoDir = getRepoPath(ownerName, repoName);
4217 const wt = `${repoDir}/_update_wt_${Date.now()}`;
4218 const gitEnv = {
4219 ...process.env,
4220 GIT_AUTHOR_NAME: user.displayName || user.username,
4221 GIT_AUTHOR_EMAIL: user.email,
4222 GIT_COMMITTER_NAME: user.displayName || user.username,
4223 GIT_COMMITTER_EMAIL: user.email,
4224 };
4225
4226 const addWt = Bun.spawn(
4227 ["git", "worktree", "add", wt, pr.headBranch],
4228 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4229 );
4230 if (await addWt.exited !== 0) {
4231 return c.redirect(
4232 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
4233 );
4234 }
4235
4236 let ok = false;
4237 try {
4238 const mergeProc = Bun.spawn(
4239 ["git", "merge", "--no-edit", pr.baseBranch],
4240 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
4241 );
4242 if (await mergeProc.exited === 0) {
4243 ok = true;
4244 } else {
4245 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4246 }
4247 } catch {
4248 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4249 }
4250
4251 await Bun.spawn(
4252 ["git", "worktree", "remove", "--force", wt],
4253 { cwd: repoDir }
4254 ).exited.catch(() => {});
4255
4256 if (ok) {
4257 return c.redirect(
4258 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
4259 );
4260 }
4261 return c.redirect(
4262 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
4263 );
4264 }
4265);
4266
b558f23Claude4267// Edit PR title (and optionally body). Owner or author only.
4268pulls.post(
4269 "/:owner/:repo/pulls/:number/edit",
4270 softAuth,
4271 requireAuth,
4272 requireRepoAccess("write"),
4273 async (c) => {
4274 const { owner: ownerName, repo: repoName } = c.req.param();
4275 const prNum = parseInt(c.req.param("number"), 10);
4276 const user = c.get("user")!;
4277 const resolved = await resolveRepo(ownerName, repoName);
4278 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4279
4280 const [pr] = await db
4281 .select()
4282 .from(pullRequests)
4283 .where(and(
4284 eq(pullRequests.repositoryId, resolved.repo.id),
4285 eq(pullRequests.number, prNum),
4286 ))
4287 .limit(1);
4288 if (!pr || pr.state !== "open") {
4289 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4290 }
4291 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
4292 if (!canEdit) {
4293 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4294 }
4295
4296 const body = await c.req.parseBody();
4297 const newTitle = String(body.title || "").trim().slice(0, 256);
4298 if (!newTitle) {
4299 return c.redirect(
4300 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
4301 );
4302 }
4303
4304 await db
4305 .update(pullRequests)
4306 .set({ title: newTitle, updatedAt: new Date() })
4307 .where(eq(pullRequests.id, pr.id));
4308
4309 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
4310 }
4311);
4312
cb5a796Claude4313// Add comment to PR.
4314//
4315// Permission model mirrors `issues.tsx`: any logged-in user with read
4316// access can submit; `decideInitialStatus` routes non-collaborators
4317// through the moderation queue. Slash commands only fire when the
4318// comment is auto-approved — we don't want a banned/pending comment to
4319// silently trigger AI work on the PR.
0074234Claude4320pulls.post(
4321 "/:owner/:repo/pulls/:number/comment",
4322 softAuth,
4323 requireAuth,
cb5a796Claude4324 requireRepoAccess("read"),
0074234Claude4325 async (c) => {
4326 const { owner: ownerName, repo: repoName } = c.req.param();
4327 const prNum = parseInt(c.req.param("number"), 10);
4328 const user = c.get("user")!;
4329 const body = await c.req.parseBody();
4330 const commentBody = String(body.body || "").trim();
47a7a0aClaude4331 const filePathRaw = String(body.file_path || "").trim();
4332 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
4333 const inlineFilePath = filePathRaw || undefined;
4334 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude4335
4336 if (!commentBody) {
4337 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4338 }
4339
4340 const resolved = await resolveRepo(ownerName, repoName);
4341 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4342
4343 const [pr] = await db
4344 .select()
4345 .from(pullRequests)
4346 .where(
4347 and(
4348 eq(pullRequests.repositoryId, resolved.repo.id),
4349 eq(pullRequests.number, prNum)
4350 )
4351 )
4352 .limit(1);
4353
4354 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4355
cb5a796Claude4356 const decision = await decideInitialStatus({
4357 commenterUserId: user.id,
4358 repositoryId: resolved.repo.id,
4359 kind: "pr",
4360 threadId: pr.id,
4361 });
4362
d4ac5c3Claude4363 const [inserted] = await db
4364 .insert(prComments)
4365 .values({
4366 pullRequestId: pr.id,
4367 authorId: user.id,
4368 body: commentBody,
cb5a796Claude4369 moderationStatus: decision.status,
47a7a0aClaude4370 filePath: inlineFilePath,
4371 lineNumber: inlineLineNumber,
d4ac5c3Claude4372 })
4373 .returning();
4374
cb5a796Claude4375 // Live update: only when the comment is actually visible.
4376 if (inserted && decision.status === "approved") {
d4ac5c3Claude4377 try {
4378 const { publish } = await import("../lib/sse");
4379 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
4380 event: "pr-comment",
4381 data: {
4382 pullRequestId: pr.id,
4383 commentId: inserted.id,
4384 authorId: user.id,
4385 authorUsername: user.username,
4386 },
4387 });
4388 } catch {
4389 /* SSE is best-effort */
4390 }
4391 }
0074234Claude4392
cb5a796Claude4393 if (decision.status === "pending") {
4394 void notifyOwnerOfPendingComment({
4395 repositoryId: resolved.repo.id,
4396 commenterUsername: user.username,
4397 kind: "pr",
4398 threadNumber: prNum,
4399 ownerUsername: ownerName,
4400 repoName,
4401 });
4402 return c.redirect(
4403 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4404 );
4405 }
4406 if (decision.status === "rejected") {
4407 // Silent ban path — same UX as 'pending' so we don't leak the gate.
4408 return c.redirect(
4409 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4410 );
4411 }
4412
15db0e0Claude4413 // Slash-command handoff. We always store the original comment above
4414 // first so free-form text that happens to start with `/` is preserved
4415 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude4416 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude4417 const parsed = parseSlashCommand(commentBody);
4418 if (parsed) {
4419 try {
4420 const result = await executeSlashCommand({
4421 command: parsed.command,
4422 args: parsed.args,
4423 prId: pr.id,
4424 userId: user.id,
4425 repositoryId: resolved.repo.id,
4426 });
4427 await db.insert(prComments).values({
4428 pullRequestId: pr.id,
4429 authorId: user.id,
4430 body: result.body,
4431 });
4432 } catch (err) {
4433 // Defence-in-depth — executeSlashCommand promises not to throw,
4434 // but if it ever does we want the PR thread to know.
4435 await db
4436 .insert(prComments)
4437 .values({
4438 pullRequestId: pr.id,
4439 authorId: user.id,
4440 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
4441 })
4442 .catch(() => {});
4443 }
4444 }
4445
47a7a0aClaude4446 // Inline comments go back to the files tab; conversation comments to the conversation tab
4447 const redirectTab = inlineFilePath ? "?tab=files" : "";
4448 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude4449 }
4450);
4451
b5dd694Claude4452// Apply a suggestion from a PR comment — commits the suggested code to the
4453// head branch on behalf of the logged-in user.
4454pulls.post(
4455 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
4456 softAuth,
4457 requireAuth,
4458 requireRepoAccess("read"),
4459 async (c) => {
4460 const { owner: ownerName, repo: repoName } = c.req.param();
4461 const prNum = parseInt(c.req.param("number"), 10);
4462 const commentId = c.req.param("commentId"); // UUID
4463 const user = c.get("user")!;
4464
4465 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
4466
4467 const resolved = await resolveRepo(ownerName, repoName);
4468 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4469
4470 const [pr] = await db
4471 .select()
4472 .from(pullRequests)
4473 .where(
4474 and(
4475 eq(pullRequests.repositoryId, resolved.repo.id),
4476 eq(pullRequests.number, prNum)
4477 )
4478 )
4479 .limit(1);
4480
4481 if (!pr || pr.state !== "open") {
4482 return c.redirect(`${backUrl}&error=pr_not_open`);
4483 }
4484
4485 // Only PR author or repo owner may apply suggestions.
4486 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
4487 return c.redirect(`${backUrl}&error=forbidden`);
4488 }
4489
4490 // Load the comment.
4491 const [comment] = await db
4492 .select()
4493 .from(prComments)
4494 .where(
4495 and(
4496 eq(prComments.id, commentId),
4497 eq(prComments.pullRequestId, pr.id)
4498 )
4499 )
4500 .limit(1);
4501
4502 if (!comment) {
4503 return c.redirect(`${backUrl}&error=comment_not_found`);
4504 }
4505
4506 // Parse suggestion block from comment body.
4507 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
4508 if (!m) {
4509 return c.redirect(`${backUrl}&error=no_suggestion`);
4510 }
4511 const suggestionCode = m[1];
4512
4513 // Get the commenter's details for the commit message co-author line.
4514 const [commenter] = await db
4515 .select()
4516 .from(users)
4517 .where(eq(users.id, comment.authorId))
4518 .limit(1);
4519
4520 // Fetch current file content from head branch.
4521 if (!comment.filePath) {
4522 return c.redirect(`${backUrl}&error=file_not_found`);
4523 }
4524 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
4525 if (!blob) {
4526 return c.redirect(`${backUrl}&error=file_not_found`);
4527 }
4528
4529 // Apply the patch — replace the target line(s) with suggestion lines.
4530 const lines = blob.content.split('\n');
4531 const lineIdx = (comment.lineNumber ?? 1) - 1;
4532 if (lineIdx < 0 || lineIdx >= lines.length) {
4533 return c.redirect(`${backUrl}&error=line_out_of_range`);
4534 }
4535 const suggestionLines = suggestionCode.split('\n');
4536 lines.splice(lineIdx, 1, ...suggestionLines);
4537 const newContent = lines.join('\n');
4538
4539 // Commit the change.
4540 const coAuthorLine = commenter
4541 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
4542 : "";
4543 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
4544
4545 const result = await createOrUpdateFileOnBranch({
4546 owner: ownerName,
4547 name: repoName,
4548 branch: pr.headBranch,
4549 filePath: comment.filePath,
4550 bytes: new TextEncoder().encode(newContent),
4551 message: commitMessage,
4552 authorName: user.username,
4553 authorEmail: `${user.username}@users.noreply.gluecron.com`,
4554 });
4555
4556 if ("error" in result) {
4557 return c.redirect(`${backUrl}&error=apply_failed`);
4558 }
4559
4560 // Post a follow-up comment noting the suggestion was applied.
4561 await db.insert(prComments).values({
4562 pullRequestId: pr.id,
4563 authorId: user.id,
4564 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
4565 });
4566
4567 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
4568 }
4569);
4570
0a67773Claude4571// Formal review — Approve / Request Changes / Comment
4572pulls.post(
4573 "/:owner/:repo/pulls/:number/review",
4574 softAuth,
4575 requireAuth,
4576 requireRepoAccess("read"),
4577 async (c) => {
4578 const { owner: ownerName, repo: repoName } = c.req.param();
4579 const prNum = parseInt(c.req.param("number"), 10);
4580 const user = c.get("user")!;
4581 const body = await c.req.parseBody();
4582 const reviewBody = String(body.body || "").trim();
4583 const reviewState = String(body.review_state || "commented");
4584
4585 const validStates = ["approved", "changes_requested", "commented"];
4586 if (!validStates.includes(reviewState)) {
4587 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4588 }
4589
4590 const resolved = await resolveRepo(ownerName, repoName);
4591 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4592
4593 const [pr] = await db
4594 .select()
4595 .from(pullRequests)
4596 .where(
4597 and(
4598 eq(pullRequests.repositoryId, resolved.repo.id),
4599 eq(pullRequests.number, prNum)
4600 )
4601 )
4602 .limit(1);
4603 if (!pr || pr.state !== "open") {
4604 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4605 }
4606 // Authors can't review their own PR
4607 if (pr.authorId === user.id) {
4608 return c.redirect(
4609 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
4610 );
4611 }
4612
4613 await db.insert(prReviews).values({
4614 pullRequestId: pr.id,
4615 reviewerId: user.id,
4616 state: reviewState,
4617 body: reviewBody || null,
4618 });
4619
4620 const stateLabel =
4621 reviewState === "approved" ? "Approved"
4622 : reviewState === "changes_requested" ? "Changes requested"
4623 : "Commented";
4624 return c.redirect(
4625 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
4626 );
4627 }
4628);
4629
e883329Claude4630// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude4631// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
4632// but we keep it at "write" for v1 so trusted collaborators can ship.
4633// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
4634// surface. Branch-protection rules (evaluated below) are the current mechanism
4635// for locking down merges further on specific branches.
0074234Claude4636pulls.post(
4637 "/:owner/:repo/pulls/:number/merge",
4638 softAuth,
4639 requireAuth,
04f6b7fClaude4640 requireRepoAccess("write"),
0074234Claude4641 async (c) => {
4642 const { owner: ownerName, repo: repoName } = c.req.param();
4643 const prNum = parseInt(c.req.param("number"), 10);
4644 const user = c.get("user")!;
4645
a164a6dClaude4646 // Read merge strategy from form (default: merge commit)
4647 let mergeStrategy = "merge";
4648 try {
4649 const body = await c.req.parseBody();
4650 const s = body.merge_strategy;
4651 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
4652 } catch { /* ignore parse errors — default to merge commit */ }
4653
0074234Claude4654 const resolved = await resolveRepo(ownerName, repoName);
4655 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4656
4657 const [pr] = await db
4658 .select()
4659 .from(pullRequests)
4660 .where(
4661 and(
4662 eq(pullRequests.repositoryId, resolved.repo.id),
4663 eq(pullRequests.number, prNum)
4664 )
4665 )
4666 .limit(1);
4667
4668 if (!pr || pr.state !== "open") {
4669 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4670 }
4671
6fc53bdClaude4672 // Draft PRs cannot be merged — must be marked ready first.
4673 if (pr.isDraft) {
4674 return c.redirect(
4675 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4676 "This PR is a draft. Mark it as ready for review before merging."
4677 )}`
4678 );
4679 }
4680
e883329Claude4681 // Resolve head SHA
4682 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4683 if (!headSha) {
4684 return c.redirect(
4685 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
4686 );
4687 }
4688
4689 // Check if AI review approved this PR
4690 const aiComments = await db
4691 .select()
4692 .from(prComments)
4693 .where(
4694 and(
4695 eq(prComments.pullRequestId, pr.id),
4696 eq(prComments.isAiReview, true)
4697 )
4698 );
4699 const aiApproved = aiComments.length === 0 || aiComments.some(
4700 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude4701 );
e883329Claude4702
4703 // Run all green gate checks (GateTest + mergeability + AI review)
4704 const gateResult = await runAllGateChecks(
4705 ownerName,
4706 repoName,
4707 pr.baseBranch,
4708 pr.headBranch,
4709 headSha,
4710 aiApproved
0074234Claude4711 );
4712
e883329Claude4713 // If GateTest or AI review failed (hard blocks), reject the merge
4714 const hardFailures = gateResult.checks.filter(
4715 (check) => !check.passed && check.name !== "Merge check"
4716 );
4717 if (hardFailures.length > 0) {
4718 const errorMsg = hardFailures
4719 .map((f) => `${f.name}: ${f.details}`)
4720 .join("; ");
0074234Claude4721 return c.redirect(
e883329Claude4722 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude4723 );
4724 }
4725
1e162a8Claude4726 // D5 — Branch-protection enforcement. Looks up the matching rule for the
4727 // base branch and blocks the merge if requireAiApproval / requireGreenGates
4728 // / requireHumanReview / requiredApprovals are not satisfied. Independent
4729 // of repo-global settings, so owners can lock specific branches down
4730 // further than the repo default.
4731 const protectionRule = await matchProtection(
4732 resolved.repo.id,
4733 pr.baseBranch
4734 );
4735 if (protectionRule) {
4736 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude4737 const required = await listRequiredChecks(protectionRule.id);
4738 const passingNames = required.length > 0
4739 ? await passingCheckNames(resolved.repo.id, headSha)
4740 : [];
4741 const decision = evaluateProtection(
4742 protectionRule,
4743 {
4744 aiApproved,
4745 humanApprovalCount: humanApprovals,
4746 gateResultGreen: hardFailures.length === 0,
4747 hasFailedGates: hardFailures.length > 0,
4748 passingCheckNames: passingNames,
4749 },
4750 required.map((r) => r.checkName)
4751 );
1e162a8Claude4752 if (!decision.allowed) {
4753 return c.redirect(
4754 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4755 decision.reasons.join(" ")
4756 )}`
4757 );
4758 }
4759 }
4760
e883329Claude4761 // Attempt the merge — with auto conflict resolution if needed
4762 const repoDir = getRepoPath(ownerName, repoName);
4763 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
4764 const hasConflicts = mergeCheck && !mergeCheck.passed;
4765
4766 if (hasConflicts && isAiReviewEnabled()) {
4767 // Use Claude to auto-resolve conflicts
4768 const mergeResult = await mergeWithAutoResolve(
4769 ownerName,
4770 repoName,
4771 pr.baseBranch,
4772 pr.headBranch,
4773 `Merge pull request #${pr.number}: ${pr.title}`
4774 );
4775
4776 if (!mergeResult.success) {
4777 return c.redirect(
4778 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
4779 );
4780 }
4781
4782 // Post a comment about the auto-resolution
4783 if (mergeResult.resolvedFiles.length > 0) {
4784 await db.insert(prComments).values({
4785 pullRequestId: pr.id,
4786 authorId: user.id,
4787 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
4788 isAiReview: true,
4789 });
4790 }
4791 } else {
a164a6dClaude4792 // Worktree-based merge: supports merge-commit, squash, and fast-forward
4793 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
4794 const gitEnv = {
4795 ...process.env,
4796 GIT_AUTHOR_NAME: user.displayName || user.username,
4797 GIT_AUTHOR_EMAIL: user.email,
4798 GIT_COMMITTER_NAME: user.displayName || user.username,
4799 GIT_COMMITTER_EMAIL: user.email,
4800 };
4801
4802 // Create linked worktree on the base branch
4803 const addWt = Bun.spawn(
4804 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude4805 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4806 );
a164a6dClaude4807 if (await addWt.exited !== 0) {
4808 return c.redirect(
4809 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
4810 );
4811 }
4812
4813 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
4814 let mergeOk = false;
4815
4816 try {
4817 if (mergeStrategy === "squash") {
4818 // Squash: stage all changes without committing
4819 const squashProc = Bun.spawn(
4820 ["git", "merge", "--squash", headSha],
4821 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4822 );
4823 if (await squashProc.exited !== 0) {
4824 const errTxt = await new Response(squashProc.stderr).text();
4825 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
4826 }
4827 // Commit the squashed changes
4828 const commitProc = Bun.spawn(
4829 ["git", "commit", "-m", commitMsg],
4830 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4831 );
4832 if (await commitProc.exited !== 0) {
4833 const errTxt = await new Response(commitProc.stderr).text();
4834 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
4835 }
4836 mergeOk = true;
4837 } else if (mergeStrategy === "ff") {
4838 // Fast-forward only — fail if FF is not possible
4839 const ffProc = Bun.spawn(
4840 ["git", "merge", "--ff-only", headSha],
4841 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4842 );
4843 if (await ffProc.exited !== 0) {
4844 const errTxt = await new Response(ffProc.stderr).text();
4845 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
4846 }
4847 mergeOk = true;
4848 } else {
4849 // Default: merge commit (--no-ff always creates a merge commit)
4850 const mergeProc = Bun.spawn(
4851 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
4852 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4853 );
4854 if (await mergeProc.exited !== 0) {
4855 const errTxt = await new Response(mergeProc.stderr).text();
4856 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
4857 }
4858 mergeOk = true;
4859 }
4860 } catch (err) {
4861 // Always clean up the worktree before redirecting
4862 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
4863 const msg = err instanceof Error ? err.message : "Merge failed";
4864 return c.redirect(
4865 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
4866 );
4867 }
4868
4869 // Clean up worktree (changes are now in the bare repo via linked worktree)
4870 await Bun.spawn(
4871 ["git", "worktree", "remove", "--force", wt],
4872 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4873 ).exited.catch(() => {});
e883329Claude4874
a164a6dClaude4875 if (!mergeOk) {
e883329Claude4876 return c.redirect(
a164a6dClaude4877 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude4878 );
4879 }
4880 }
4881
0074234Claude4882 await db
4883 .update(pullRequests)
4884 .set({
4885 state: "merged",
4886 mergedAt: new Date(),
4887 mergedBy: user.id,
4888 updatedAt: new Date(),
4889 })
4890 .where(eq(pullRequests.id, pr.id));
4891
8809b87Claude4892 // Chat notifier — fan out merge event to Slack/Discord/Teams.
4893 import("../lib/chat-notifier")
4894 .then((m) =>
4895 m.notifyChatChannels({
4896 ownerUserId: resolved.repo.ownerId,
4897 repositoryId: resolved.repo.id,
4898 event: {
4899 event: "pr.merged",
4900 repo: `${ownerName}/${repoName}`,
4901 title: `#${pr.number} ${pr.title}`,
4902 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
4903 actor: user.username,
4904 },
4905 })
4906 )
4907 .catch((err) =>
4908 console.warn(`[chat-notifier] PR merge notify failed:`, err)
4909 );
4910
d62fb36Claude4911 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
4912 // and auto-close each matching open issue with a back-link comment. Bounded
4913 // to the same repo for v1 (cross-repo refs ignored). Failures never block
4914 // the merge redirect.
4915 try {
4916 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
4917 const refs = extractClosingRefsMulti([pr.title, pr.body]);
4918 for (const n of refs) {
4919 const [issue] = await db
4920 .select()
4921 .from(issues)
4922 .where(
4923 and(
4924 eq(issues.repositoryId, resolved.repo.id),
4925 eq(issues.number, n)
4926 )
4927 )
4928 .limit(1);
4929 if (!issue || issue.state !== "open") continue;
4930 await db
4931 .update(issues)
4932 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
4933 .where(eq(issues.id, issue.id));
4934 await db.insert(issueComments).values({
4935 issueId: issue.id,
4936 authorId: user.id,
4937 body: `Closed by pull request #${pr.number}.`,
4938 });
4939 }
4940 } catch {
4941 // Never block the merge on close-keyword failures.
4942 }
4943
0074234Claude4944 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4945 }
4946);
4947
6fc53bdClaude4948// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
4949// hasn't run yet on this PR.
4950pulls.post(
4951 "/:owner/:repo/pulls/:number/ready",
4952 softAuth,
4953 requireAuth,
04f6b7fClaude4954 requireRepoAccess("write"),
6fc53bdClaude4955 async (c) => {
4956 const { owner: ownerName, repo: repoName } = c.req.param();
4957 const prNum = parseInt(c.req.param("number"), 10);
4958 const user = c.get("user")!;
4959
4960 const resolved = await resolveRepo(ownerName, repoName);
4961 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4962
4963 const [pr] = await db
4964 .select()
4965 .from(pullRequests)
4966 .where(
4967 and(
4968 eq(pullRequests.repositoryId, resolved.repo.id),
4969 eq(pullRequests.number, prNum)
4970 )
4971 )
4972 .limit(1);
4973 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4974
4975 // Only the author or repo owner can toggle draft state.
4976 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
4977 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4978 }
4979
4980 if (pr.state === "open" && pr.isDraft) {
4981 await db
4982 .update(pullRequests)
4983 .set({ isDraft: false, updatedAt: new Date() })
4984 .where(eq(pullRequests.id, pr.id));
4985
4986 if (isAiReviewEnabled()) {
4987 triggerAiReview(
4988 ownerName,
4989 repoName,
4990 pr.id,
4991 pr.title,
0316dbbClaude4992 pr.body || "",
6fc53bdClaude4993 pr.baseBranch,
4994 pr.headBranch
4995 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
4996 }
4997 }
4998
4999 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5000 }
5001);
5002
5003// Convert a PR back to draft.
5004pulls.post(
5005 "/:owner/:repo/pulls/:number/draft",
5006 softAuth,
5007 requireAuth,
04f6b7fClaude5008 requireRepoAccess("write"),
6fc53bdClaude5009 async (c) => {
5010 const { owner: ownerName, repo: repoName } = c.req.param();
5011 const prNum = parseInt(c.req.param("number"), 10);
5012 const user = c.get("user")!;
5013
5014 const resolved = await resolveRepo(ownerName, repoName);
5015 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5016
5017 const [pr] = await db
5018 .select()
5019 .from(pullRequests)
5020 .where(
5021 and(
5022 eq(pullRequests.repositoryId, resolved.repo.id),
5023 eq(pullRequests.number, prNum)
5024 )
5025 )
5026 .limit(1);
5027 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5028
5029 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
5030 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5031 }
5032
5033 if (pr.state === "open" && !pr.isDraft) {
5034 await db
5035 .update(pullRequests)
5036 .set({ isDraft: true, updatedAt: new Date() })
5037 .where(eq(pullRequests.id, pr.id));
5038 }
5039
5040 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5041 }
5042);
5043
0074234Claude5044// Close PR
5045pulls.post(
5046 "/:owner/:repo/pulls/:number/close",
5047 softAuth,
5048 requireAuth,
04f6b7fClaude5049 requireRepoAccess("write"),
0074234Claude5050 async (c) => {
5051 const { owner: ownerName, repo: repoName } = c.req.param();
5052 const prNum = parseInt(c.req.param("number"), 10);
5053
5054 const resolved = await resolveRepo(ownerName, repoName);
5055 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5056
5057 await db
5058 .update(pullRequests)
5059 .set({
5060 state: "closed",
5061 closedAt: new Date(),
5062 updatedAt: new Date(),
5063 })
5064 .where(
5065 and(
5066 eq(pullRequests.repositoryId, resolved.repo.id),
5067 eq(pullRequests.number, prNum)
5068 )
5069 );
5070
5071 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
5072 }
5073);
5074
c3e0c07Claude5075// Re-run AI review on demand (e.g. after a force-push). Bypasses the
5076// idempotency marker via { force: true }. Write-access only.
5077pulls.post(
5078 "/:owner/:repo/pulls/:number/ai-rereview",
5079 softAuth,
5080 requireAuth,
5081 requireRepoAccess("write"),
5082 async (c) => {
5083 const { owner: ownerName, repo: repoName } = c.req.param();
5084 const prNum = parseInt(c.req.param("number"), 10);
5085 const resolved = await resolveRepo(ownerName, repoName);
5086 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5087
5088 const [pr] = await db
5089 .select()
5090 .from(pullRequests)
5091 .where(
5092 and(
5093 eq(pullRequests.repositoryId, resolved.repo.id),
5094 eq(pullRequests.number, prNum)
5095 )
5096 )
5097 .limit(1);
5098 if (!pr) {
5099 return c.redirect(`/${ownerName}/${repoName}/pulls`);
5100 }
5101
5102 if (!isAiReviewEnabled()) {
5103 return c.redirect(
5104 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5105 "AI review is not configured (ANTHROPIC_API_KEY)."
5106 )}`
5107 );
5108 }
5109
5110 // Fire-and-forget but with { force: true } to bypass the
5111 // already-reviewed marker. The function still never throws.
5112 triggerAiReview(
5113 ownerName,
5114 repoName,
5115 pr.id,
5116 pr.title || "",
5117 pr.body || "",
5118 pr.baseBranch,
5119 pr.headBranch,
5120 { force: true }
a28cedeClaude5121 ).catch((err) => {
5122 console.warn(
5123 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
5124 err instanceof Error ? err.message : err
5125 );
5126 });
c3e0c07Claude5127
5128 return c.redirect(
5129 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5130 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
5131 )}`
5132 );
5133 }
5134);
5135
1d4ff60Claude5136// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
5137// the PR's head branch carrying just the new test files. Write-access only.
5138// Idempotent — if `ai:added-tests` was previously applied we redirect with
5139// an `info` banner instead of re-firing.
5140pulls.post(
5141 "/:owner/:repo/pulls/:number/generate-tests",
5142 softAuth,
5143 requireAuth,
5144 requireRepoAccess("write"),
5145 async (c) => {
5146 const { owner: ownerName, repo: repoName } = c.req.param();
5147 const prNum = parseInt(c.req.param("number"), 10);
5148 const resolved = await resolveRepo(ownerName, repoName);
5149 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5150
5151 const [pr] = await db
5152 .select()
5153 .from(pullRequests)
5154 .where(
5155 and(
5156 eq(pullRequests.repositoryId, resolved.repo.id),
5157 eq(pullRequests.number, prNum)
5158 )
5159 )
5160 .limit(1);
5161 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5162
5163 if (!isAiReviewEnabled()) {
5164 return c.redirect(
5165 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5166 "AI test generation is not configured (ANTHROPIC_API_KEY)."
5167 )}`
5168 );
5169 }
5170
5171 // Fire-and-forget. The lib never throws.
5172 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
5173 .then((res) => {
5174 if (!res.ok) {
5175 console.warn(
5176 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
5177 );
5178 }
5179 })
5180 .catch((err) => {
5181 console.warn(
5182 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
5183 err instanceof Error ? err.message : err
5184 );
5185 });
5186
5187 return c.redirect(
5188 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5189 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
5190 )}`
5191 );
5192 }
5193);
5194
ace34efClaude5195// ─── Request review ───────────────────────────────────────────────────────────
5196pulls.post(
5197 "/:owner/:repo/pulls/:number/request-review",
5198 softAuth,
5199 requireAuth,
5200 requireRepoAccess("write"),
5201 async (c) => {
5202 const { owner: ownerName, repo: repoName } = c.req.param();
5203 const prNum = parseInt(c.req.param("number"), 10);
5204 const user = c.get("user")!;
5205
5206 const resolved = await resolveRepo(ownerName, repoName);
5207 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5208
5209 const [pr] = await db
5210 .select({ id: pullRequests.id, number: pullRequests.number, authorId: pullRequests.authorId })
5211 .from(pullRequests)
5212 .where(and(eq(pullRequests.repositoryId, resolved.repo.id), eq(pullRequests.number, prNum)))
5213 .limit(1);
5214
5215 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5216
5217 const body = await c.req.formData().catch(() => null);
5218 const reviewerId = (body?.get("reviewerId") as string | null)?.trim();
5219
5220 if (!reviewerId || reviewerId === pr.authorId || reviewerId === user.id) {
5221 return c.redirect(
5222 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Invalid reviewer selection.")}`
5223 );
5224 }
5225
f4abb8eClaude5226 // Verify the reviewer is the repo owner or an accepted collaborator — prevents
5227 // requesting reviews from arbitrary user IDs outside this repository.
5228 const isOwner = reviewerId === resolved.owner.id;
5229 if (!isOwner) {
5230 const [collab] = await db
5231 .select({ id: repoCollaborators.id })
5232 .from(repoCollaborators)
5233 .where(
5234 and(
5235 eq(repoCollaborators.repositoryId, resolved.repo.id),
5236 eq(repoCollaborators.userId, reviewerId),
5237 isNotNull(repoCollaborators.acceptedAt)
5238 )
5239 )
5240 .limit(1);
5241 if (!collab) {
5242 return c.redirect(
5243 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Reviewer must be a repository collaborator.")}`
5244 );
5245 }
5246 }
5247
ace34efClaude5248 const { requestReview } = await import("../lib/reviewer-suggest");
5249 const result = await requestReview(pr.id, resolved.repo.id, reviewerId, user.id);
5250
5251 const msg = result.ok
5252 ? "Review requested successfully."
5253 : `Failed to request review: ${result.error ?? "unknown error"}`;
5254
5255 return c.redirect(
5256 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(msg)}`
5257 );
5258 }
5259);
5260
0074234Claude5261export default pulls;