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