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.tsxBlame5094 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 }
b078860Claude402`;
403
404/* ──────────────────────────────────────────────────────────────────────
405 * Inline CSS for the detail page. Same `.prs-*` namespace.
406 * ──────────────────────────────────────────────────────────────────── */
407const PRS_DETAIL_STYLES = `
408 .prs-detail-hero {
409 position: relative;
410 margin: 0 0 var(--space-4);
411 padding: 24px 26px;
412 background: var(--bg-elevated);
413 border: 1px solid var(--border);
414 border-radius: 16px;
415 overflow: hidden;
416 }
417 .prs-detail-hero::before {
418 content: '';
419 position: absolute; top: 0; left: 0; right: 0;
420 height: 2px;
421 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
422 opacity: 0.7;
423 pointer-events: none;
424 }
425 .prs-detail-title {
426 font-family: var(--font-display);
427 font-size: clamp(22px, 2.6vw, 28px);
428 font-weight: 700;
429 letter-spacing: -0.022em;
430 line-height: 1.2;
431 color: var(--text-strong);
432 margin: 0 0 12px;
433 }
434 .prs-detail-num {
435 color: var(--text-muted);
436 font-weight: 400;
437 }
438 .prs-state-pill {
439 display: inline-flex; align-items: center; gap: 6px;
440 padding: 6px 12px;
441 border-radius: 9999px;
442 font-size: 12.5px;
443 font-weight: 600;
444 line-height: 1;
445 border: 1px solid transparent;
446 }
447 .prs-state-pill.state-open { color: var(--green); background: rgba(52,211,153,0.12); border-color: rgba(52,211,153,0.35); }
448 .prs-state-pill.state-merged { color: #b69dff; background: rgba(140,109,255,0.16); border-color: rgba(140,109,255,0.45); }
449 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
450 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
451
452 .prs-detail-meta {
453 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
454 font-size: 13px;
455 color: var(--text-muted);
456 }
457 .prs-detail-meta strong { color: var(--text); }
458 .prs-detail-branches {
459 display: inline-flex; align-items: center; gap: 6px;
460 font-family: var(--font-mono);
461 font-size: 12px;
462 }
463 .prs-branch-pill {
464 padding: 3px 9px;
465 border-radius: 9999px;
466 background: var(--bg-tertiary);
467 border: 1px solid var(--border);
468 color: var(--text);
469 }
470 .prs-branch-pill.is-head { color: var(--text-strong); }
471 .prs-branch-arrow-lg {
472 color: var(--accent);
473 font-size: 14px;
474 font-weight: 700;
475 }
0369e77Claude476 .prs-branch-sync {
477 display: inline-flex; align-items: center; gap: 4px;
478 font-size: 11.5px; font-weight: 600;
479 padding: 2px 8px;
480 border-radius: 9999px;
481 border: 1px solid var(--border);
482 background: var(--bg-secondary);
483 color: var(--text-muted);
484 cursor: default;
485 }
486 .prs-branch-sync.is-behind {
487 color: #f87171;
488 border-color: rgba(248,113,113,0.35);
489 background: rgba(248,113,113,0.07);
490 }
491 .prs-branch-sync.is-synced {
492 color: #34d399;
493 border-color: rgba(52,211,153,0.35);
494 background: rgba(52,211,153,0.07);
495 }
b078860Claude496
497 .prs-detail-actions {
498 display: inline-flex; gap: 8px; margin-left: auto;
499 }
500
501 .prs-detail-tabs {
502 display: flex; gap: 4px;
503 margin: 0 0 16px;
504 border-bottom: 1px solid var(--border);
505 }
506 .prs-detail-tab {
507 padding: 10px 14px;
508 font-size: 13.5px;
509 font-weight: 500;
510 color: var(--text-muted);
511 text-decoration: none;
512 border-bottom: 2px solid transparent;
513 transition: color 120ms ease, border-color 120ms ease;
514 margin-bottom: -1px;
515 }
516 .prs-detail-tab:hover { color: var(--text); }
517 .prs-detail-tab.is-active {
518 color: var(--text-strong);
519 border-bottom-color: var(--accent);
520 }
521 .prs-detail-tab-count {
522 display: inline-flex; align-items: center; justify-content: center;
523 min-width: 20px; padding: 0 6px; margin-left: 6px;
524 height: 18px;
525 font-size: 11px;
526 font-weight: 600;
527 border-radius: 9999px;
528 background: var(--bg-tertiary);
529 color: var(--text-muted);
530 }
531
532 /* Gate / check status section */
533 .prs-gate-card {
534 margin-top: 20px;
535 background: var(--bg-elevated);
536 border: 1px solid var(--border);
537 border-radius: 14px;
538 overflow: hidden;
539 }
540 .prs-gate-head {
541 display: flex; align-items: center; gap: 10px;
542 padding: 14px 18px;
543 border-bottom: 1px solid var(--border);
544 }
545 .prs-gate-head h3 {
546 margin: 0;
547 font-size: 14px;
548 font-weight: 600;
549 color: var(--text-strong);
550 }
551 .prs-gate-summary {
552 margin-left: auto;
553 font-size: 12px;
554 color: var(--text-muted);
555 }
556 .prs-gate-row {
557 display: flex; align-items: center; gap: 12px;
558 padding: 12px 18px;
559 border-bottom: 1px solid var(--border-subtle);
560 }
561 .prs-gate-row:last-child { border-bottom: 0; }
562 .prs-gate-icon {
563 flex: 0 0 auto;
564 width: 22px; height: 22px;
565 display: inline-flex; align-items: center; justify-content: center;
566 border-radius: 9999px;
567 font-size: 12px;
568 font-weight: 700;
569 }
570 .prs-gate-icon.is-pass { color: var(--green); background: rgba(52,211,153,0.14); }
571 .prs-gate-icon.is-fail { color: var(--red); background: rgba(248,113,113,0.14); }
572 .prs-gate-icon.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.05); }
573 .prs-gate-name {
574 font-size: 13px;
575 font-weight: 600;
576 color: var(--text);
577 min-width: 140px;
578 }
579 .prs-gate-details {
580 flex: 1; min-width: 0;
581 font-size: 12.5px;
582 color: var(--text-muted);
583 }
584 .prs-gate-pill {
585 flex: 0 0 auto;
586 padding: 3px 10px;
587 border-radius: 9999px;
588 font-size: 11px;
589 font-weight: 600;
590 line-height: 1.5;
591 border: 1px solid transparent;
592 }
593 .prs-gate-pill.is-pass { color: var(--green); background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.30); }
594 .prs-gate-pill.is-fail { color: var(--red); background: rgba(248,113,113,0.10); border-color: rgba(248,113,113,0.30); }
595 .prs-gate-pill.is-skip { color: var(--text-muted); background: rgba(255,255,255,0.04); border-color: var(--border-strong); }
596 .prs-gate-footer {
597 padding: 12px 18px;
598 background: var(--bg-secondary);
599 font-size: 12px;
600 color: var(--text-muted);
601 }
602
603 /* Comment cards */
604 .prs-comment {
605 margin-top: 14px;
606 background: var(--bg-elevated);
607 border: 1px solid var(--border);
608 border-radius: 12px;
609 overflow: hidden;
610 }
611 .prs-comment-head {
612 display: flex; align-items: center; gap: 10px;
613 padding: 10px 14px;
614 background: var(--bg-secondary);
615 border-bottom: 1px solid var(--border);
616 font-size: 13px;
617 flex-wrap: wrap;
618 }
619 .prs-comment-head strong { color: var(--text-strong); }
620 .prs-comment-time { color: var(--text-muted); font-size: 12.5px; }
621 .prs-comment-loc {
622 font-family: var(--font-mono);
623 font-size: 11.5px;
624 color: var(--text-muted);
625 background: var(--bg-tertiary);
626 padding: 2px 8px;
627 border-radius: 6px;
628 }
629 .prs-comment-body { padding: 14px 18px; }
630 .prs-comment.is-ai {
631 border-color: rgba(140,109,255,0.45);
632 box-shadow: 0 0 0 1px rgba(140,109,255,0.10), 0 6px 24px -10px rgba(140,109,255,0.30);
633 }
634 .prs-comment.is-ai .prs-comment-head {
635 background: linear-gradient(90deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
636 border-bottom-color: rgba(140,109,255,0.30);
637 }
638 .prs-ai-badge {
639 display: inline-flex; align-items: center; gap: 4px;
640 padding: 2px 9px;
641 font-size: 10.5px;
642 font-weight: 700;
643 letter-spacing: 0.04em;
644 text-transform: uppercase;
645 color: #fff;
646 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
647 border-radius: 9999px;
648 }
649
650 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
651 .prs-files-card {
652 margin-top: 18px;
653 padding: 14px 18px;
654 display: flex; align-items: center; gap: 14px;
655 background: var(--bg-elevated);
656 border: 1px solid var(--border);
657 border-radius: 12px;
658 text-decoration: none;
659 color: inherit;
660 transition: border-color 120ms ease, transform 140ms ease;
661 }
662 .prs-files-card:hover {
663 border-color: rgba(140,109,255,0.45);
664 transform: translateY(-1px);
665 }
666 .prs-files-card-icon {
667 width: 36px; height: 36px;
668 display: inline-flex; align-items: center; justify-content: center;
669 border-radius: 10px;
670 background: rgba(140,109,255,0.12);
671 color: var(--text-link);
672 font-size: 18px;
673 }
674 .prs-files-card-text { flex: 1; min-width: 0; }
675 .prs-files-card-title {
676 font-size: 14px;
677 font-weight: 600;
678 color: var(--text-strong);
679 margin: 0 0 2px;
680 }
681 .prs-files-card-sub {
682 font-size: 12.5px;
683 color: var(--text-muted);
684 margin: 0;
685 }
686 .prs-files-card-cta {
687 font-size: 12.5px;
688 color: var(--text-link);
689 font-weight: 600;
690 }
691
692 /* Merge area */
693 .prs-merge-card {
694 position: relative;
695 margin-top: 22px;
696 padding: 18px;
697 background: var(--bg-elevated);
698 border-radius: 14px;
699 overflow: hidden;
700 }
701 .prs-merge-card::before {
702 content: '';
703 position: absolute; inset: 0;
704 padding: 1px;
705 border-radius: 14px;
706 background: linear-gradient(135deg, rgba(140,109,255,0.55) 0%, rgba(54,197,214,0.40) 100%);
707 -webkit-mask:
708 linear-gradient(#000 0 0) content-box,
709 linear-gradient(#000 0 0);
710 -webkit-mask-composite: xor;
711 mask-composite: exclude;
712 pointer-events: none;
713 }
714 .prs-merge-card.is-closed::before { background: var(--border-strong); }
715 .prs-merge-card.is-merged::before { background: linear-gradient(135deg, rgba(140,109,255,0.45), rgba(54,197,214,0.30)); }
716 .prs-merge-head {
717 display: flex; align-items: center; gap: 12px;
718 margin-bottom: 12px;
719 }
720 .prs-merge-head strong {
721 font-family: var(--font-display);
722 font-size: 15px;
723 color: var(--text-strong);
724 font-weight: 700;
725 }
726 .prs-merge-sub {
727 font-size: 13px;
728 color: var(--text-muted);
729 margin: 0 0 12px;
730 }
731 .prs-merge-actions {
732 display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
733 }
734 .prs-merge-btn {
735 display: inline-flex; align-items: center; gap: 6px;
736 padding: 9px 16px;
737 border-radius: 10px;
738 font-size: 13.5px;
739 font-weight: 600;
740 color: #fff;
741 background: linear-gradient(135deg, #34d399 0%, #2bb886 60%, #36c5d6 140%);
742 border: 1px solid rgba(52,211,153,0.55);
743 box-shadow: 0 6px 18px -8px rgba(52,211,153,0.55);
744 cursor: pointer;
745 transition: transform 120ms ease, box-shadow 160ms ease;
746 }
747 .prs-merge-btn:hover {
748 transform: translateY(-1px);
749 box-shadow: 0 10px 24px -8px rgba(52,211,153,0.55);
750 }
751 .prs-merge-btn[disabled],
752 .prs-merge-btn.is-disabled {
753 opacity: 0.55;
754 cursor: not-allowed;
755 transform: none;
756 box-shadow: none;
757 }
758 .prs-merge-ready-btn {
759 display: inline-flex; align-items: center; gap: 6px;
760 padding: 9px 16px;
761 border-radius: 10px;
762 font-size: 13.5px;
763 font-weight: 600;
764 color: #fff;
765 background: linear-gradient(135deg, #8c6dff 0%, #6f5be8 60%, #36c5d6 140%);
766 border: 1px solid rgba(140,109,255,0.55);
767 box-shadow: 0 6px 18px -8px rgba(140,109,255,0.55);
768 cursor: pointer;
769 transition: transform 120ms ease, box-shadow 160ms ease;
770 }
771 .prs-merge-ready-btn:hover {
772 transform: translateY(-1px);
773 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55);
774 }
775 .prs-merge-back-draft {
776 background: none; border: 1px solid var(--border-strong);
777 color: var(--text-muted);
778 padding: 9px 14px; border-radius: 10px;
779 font-size: 13px; cursor: pointer;
780 }
781 .prs-merge-back-draft:hover { color: var(--text); background: var(--bg-hover); }
782
a164a6dClaude783 /* Merge strategy selector */
784 .prs-merge-strategy-wrap {
785 display: inline-flex; align-items: center;
786 background: var(--bg-elevated);
787 border: 1px solid var(--border);
788 border-radius: 10px;
789 overflow: hidden;
790 }
791 .prs-merge-strategy-label {
792 font-size: 11.5px; font-weight: 600;
793 color: var(--text-muted);
794 padding: 0 10px 0 12px;
795 white-space: nowrap;
796 }
797 .prs-merge-strategy-select {
798 background: transparent;
799 border: none;
800 color: var(--text);
801 font-size: 13px;
802 padding: 7px 10px 7px 4px;
803 cursor: pointer;
804 outline: none;
805 appearance: auto;
806 }
807 .prs-merge-strategy-select:focus { outline: 2px solid rgba(140,109,255,0.45); }
808
0a67773Claude809 /* Review summary banner */
810 .prs-review-summary {
811 display: flex; flex-direction: column; gap: 6px;
812 padding: 12px 16px;
813 background: var(--bg-elevated);
814 border: 1px solid var(--border);
815 border-radius: var(--r-md, 8px);
816 margin-bottom: 12px;
817 }
818 .prs-review-row {
819 display: flex; align-items: center; gap: 10px;
820 font-size: 13px;
821 }
822 .prs-review-icon { font-size: 15px; font-weight: 700; flex-shrink: 0; }
823 .prs-review-approved .prs-review-icon { color: #34d399; }
824 .prs-review-changes .prs-review-icon { color: #f87171; }
825
826 /* Review action buttons */
827 .prs-review-approve-btn {
828 display: inline-flex; align-items: center; gap: 5px;
829 padding: 8px 14px; border-radius: 8px; font-size: 13px;
830 font-weight: 600; cursor: pointer;
831 background: rgba(52,211,153,0.12);
832 color: #34d399;
833 border: 1px solid rgba(52,211,153,0.35);
834 transition: background 120ms;
835 }
836 .prs-review-approve-btn:hover { background: rgba(52,211,153,0.22); }
837 .prs-review-changes-btn {
838 display: inline-flex; align-items: center; gap: 5px;
839 padding: 8px 14px; border-radius: 8px; font-size: 13px;
840 font-weight: 600; cursor: pointer;
841 background: rgba(248,113,113,0.10);
842 color: #f87171;
843 border: 1px solid rgba(248,113,113,0.30);
844 transition: background 120ms;
845 }
846 .prs-review-changes-btn:hover { background: rgba(248,113,113,0.20); }
847
b078860Claude848 /* Inline form helpers */
849 .prs-inline-form { display: inline-flex; }
850
851 /* Comment composer */
852 .prs-composer { margin-top: 22px; }
853 .prs-composer textarea {
854 border-radius: 12px;
855 }
856
857 @media (max-width: 720px) {
858 .prs-detail-actions { margin-left: 0; }
859 .prs-merge-actions { width: 100%; }
860 .prs-merge-actions > * { flex: 1; min-width: 0; }
861 }
f1dc7c7Claude862
863 /* Additional mobile rules. Additive only. */
864 @media (max-width: 720px) {
865 .prs-detail-hero { padding: 18px; }
866 .prs-detail-meta { gap: 8px 12px; font-size: 12.5px; }
867 .prs-detail-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
868 .prs-detail-tab { white-space: nowrap; min-height: 44px; padding: 12px 14px; }
869 .prs-gate-row { flex-wrap: wrap; padding: 12px 14px; }
870 .prs-gate-name { min-width: 0; }
871 .prs-gate-head { padding: 12px 14px; flex-wrap: wrap; }
872 .prs-gate-summary { margin-left: 0; }
873 .prs-merge-btn,
874 .prs-merge-ready-btn,
875 .prs-merge-back-draft { min-height: 44px; }
876 .prs-comment-body { padding: 12px 14px; }
877 .prs-comment-head { padding: 10px 12px; }
878 .prs-files-card { padding: 12px 14px; }
879 }
3c03977Claude880
881 /* ─── Live co-editing — presence pill + cursor ribbons ─── */
882 .live-pill {
883 display: inline-flex;
884 align-items: center;
885 gap: 8px;
886 padding: 4px 10px 4px 8px;
887 margin-left: 6px;
888 background: var(--bg-elevated);
889 border: 1px solid var(--border);
890 border-radius: 9999px;
891 font-size: 12px;
892 color: var(--text-muted);
893 line-height: 1;
894 vertical-align: middle;
895 }
896 .live-pill.is-busy { color: var(--text); }
897 .live-pill-dot {
898 width: 8px; height: 8px;
899 border-radius: 9999px;
900 background: #34d399;
901 box-shadow: 0 0 0 2px rgba(52,211,153,0.18);
902 animation: live-pulse 1.6s ease-in-out infinite;
903 }
904 @keyframes live-pulse {
905 0%, 100% { opacity: 1; }
906 50% { opacity: 0.55; }
907 }
908 .live-avatars {
909 display: inline-flex;
910 margin-left: 2px;
911 }
912 .live-avatar {
913 display: inline-flex;
914 align-items: center;
915 justify-content: center;
916 width: 22px; height: 22px;
917 border-radius: 9999px;
918 font-size: 10px;
919 font-weight: 700;
920 color: #0b1020;
921 margin-left: -6px;
922 border: 2px solid var(--bg-elevated);
923 box-shadow: 0 1px 2px rgba(0,0,0,0.25);
924 }
925 .live-avatar:first-child { margin-left: 0; }
926 .live-avatar.is-idle { opacity: 0.55; filter: grayscale(0.4); }
927 .live-cursor-host {
928 position: relative;
929 }
930 .live-cursor-overlay {
931 position: absolute;
932 inset: 0;
933 pointer-events: none;
934 overflow: hidden;
935 border-radius: inherit;
936 }
937 .live-cursor {
938 position: absolute;
939 width: 2px;
940 height: 18px;
941 border-radius: 2px;
942 transform: translate(-1px, 0);
943 transition: transform 80ms linear, opacity 200ms ease;
944 }
945 .live-cursor::after {
946 content: attr(data-label);
947 position: absolute;
948 top: -16px;
949 left: -2px;
950 font-size: 10px;
951 line-height: 1;
952 color: #0b1020;
953 background: inherit;
954 padding: 2px 5px;
955 border-radius: 4px 4px 4px 0;
956 white-space: nowrap;
957 font-weight: 600;
958 box-shadow: 0 1px 3px rgba(0,0,0,0.25);
959 }
960 .live-cursor.is-idle { opacity: 0.4; }
961 .live-edit-tag {
962 display: inline-block;
963 margin-left: 6px;
964 padding: 1px 6px;
965 font-size: 10px;
966 font-weight: 600;
967 letter-spacing: 0.02em;
968 color: #0b1020;
969 border-radius: 9999px;
970 }
15db0e0Claude971
972 /* ─── Slash-command pill + composer hint ─── */
973 .slash-hint {
974 display: inline-flex;
975 align-items: center;
976 gap: 6px;
977 margin-top: 6px;
978 padding: 3px 9px;
979 font-size: 11.5px;
980 color: var(--text-muted);
981 background: var(--bg-elevated);
982 border: 1px dashed var(--border);
983 border-radius: 9999px;
984 width: fit-content;
985 }
986 .slash-hint code {
987 background: rgba(110, 168, 255, 0.12);
988 color: var(--text-strong);
989 padding: 0 5px;
990 border-radius: 4px;
991 font-size: 11px;
992 }
993 .slash-pill {
994 display: grid;
995 grid-template-columns: auto 1fr auto;
996 align-items: center;
997 column-gap: 10px;
998 row-gap: 6px;
999 margin: 10px 0;
1000 padding: 10px 14px;
1001 background: linear-gradient(
1002 135deg,
1003 rgba(110, 168, 255, 0.08),
1004 rgba(163, 113, 247, 0.06)
1005 );
1006 border: 1px solid rgba(110, 168, 255, 0.32);
1007 border-left: 3px solid var(--accent, #6ea8ff);
1008 border-radius: var(--radius);
1009 font-size: 13px;
1010 color: var(--text);
1011 }
1012 .slash-pill-icon {
1013 font-size: 14px;
1014 line-height: 1;
1015 filter: drop-shadow(0 0 4px rgba(110, 168, 255, 0.45));
1016 }
1017 .slash-pill-actor { color: var(--text-muted); }
1018 .slash-pill-actor strong { color: var(--text-strong); }
1019 .slash-pill-cmd {
1020 background: rgba(110, 168, 255, 0.16);
1021 color: var(--text-strong);
1022 padding: 1px 6px;
1023 border-radius: 4px;
1024 font-size: 12.5px;
1025 }
1026 .slash-pill-time {
1027 color: var(--text-muted);
1028 font-size: 12px;
1029 justify-self: end;
1030 }
1031 .slash-pill-body {
1032 grid-column: 1 / -1;
1033 color: var(--text);
1034 font-size: 13px;
1035 line-height: 1.55;
1036 }
1037 .slash-pill-body p:first-child { margin-top: 0; }
1038 .slash-pill-body p:last-child { margin-bottom: 0; }
1039 .slash-pill.slash-cmd-merge { border-left-color: #56d364; }
1040 .slash-pill.slash-cmd-rebase { border-left-color: #f0883e; }
1041 .slash-pill.slash-cmd-needs-work { border-left-color: #f85149; }
1042 .slash-pill.slash-cmd-lgtm { border-left-color: #56d364; }
4bbacbeClaude1043
1044 /* ─── Branch-preview pill (migration 0062). Scoped .preview-*. */
1045 .preview-prpill {
1046 display: inline-flex; align-items: center; gap: 6px;
1047 padding: 3px 10px;
1048 border-radius: 9999px;
1049 font-family: var(--font-mono);
1050 font-size: 11.5px;
1051 font-weight: 600;
1052 background: rgba(255,255,255,0.04);
1053 color: var(--text-muted);
1054 text-decoration: none;
1055 border: 1px solid var(--border);
1056 }
1057 .preview-prpill:hover { color: var(--text-strong); border-color: rgba(140,109,255,0.45); }
1058 .preview-prpill .preview-prpill-dot {
1059 width: 7px; height: 7px;
1060 border-radius: 9999px;
1061 background: currentColor;
1062 }
1063 .preview-prpill.is-building { color: #fde68a; border-color: rgba(251,191,36,0.30); }
1064 .preview-prpill.is-building .preview-prpill-dot {
1065 animation: previewPrPulse 1.4s ease-in-out infinite;
1066 }
1067 .preview-prpill.is-ready { color: #6ee7b7; border-color: rgba(52,211,153,0.30); }
1068 .preview-prpill.is-failed { color: #fecaca; border-color: rgba(248,113,113,0.35); }
1069 .preview-prpill.is-expired { color: #cbd5e1; border-color: rgba(148,163,184,0.30); }
1070 @keyframes previewPrPulse {
1071 0%, 100% { opacity: 1; }
1072 50% { opacity: 0.4; }
1073 }
79ed944Claude1074
1075 /* ─── AI Trio Review — 3-column verdict cards ─── */
1076 .trio-wrap {
1077 margin-top: 18px;
1078 padding: 16px;
1079 background: var(--bg-elevated);
1080 border: 1px solid var(--border);
1081 border-radius: 14px;
1082 }
1083 .trio-header {
1084 display: flex; align-items: center; gap: 10px;
1085 margin: 0 0 12px;
1086 font-size: 13.5px;
1087 color: var(--text);
1088 }
1089 .trio-header strong { color: var(--text-strong); }
1090 .trio-header-sub { color: var(--text-muted); font-size: 12.5px; }
1091 .trio-header-dot {
1092 width: 8px; height: 8px; border-radius: 9999px;
1093 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1094 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1095 }
1096 .trio-grid {
1097 display: grid;
1098 grid-template-columns: repeat(3, minmax(0, 1fr));
1099 gap: 12px;
1100 }
1101 .trio-card {
1102 background: var(--bg-secondary);
1103 border: 1px solid var(--border);
1104 border-radius: 12px;
1105 overflow: hidden;
1106 display: flex; flex-direction: column;
1107 transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
1108 }
1109 .trio-card-head {
1110 display: flex; align-items: center; gap: 8px;
1111 padding: 10px 12px;
1112 border-bottom: 1px solid var(--border);
1113 background: rgba(255,255,255,0.02);
1114 font-size: 13px;
1115 }
1116 .trio-card-icon {
1117 display: inline-flex; align-items: center; justify-content: center;
1118 width: 22px; height: 22px;
1119 border-radius: 9999px;
1120 font-size: 12px;
1121 background: rgba(255,255,255,0.05);
1122 }
1123 .trio-card-title {
1124 color: var(--text-strong);
1125 font-weight: 600;
1126 letter-spacing: 0.01em;
1127 }
1128 .trio-card-verdict {
1129 margin-left: auto;
1130 font-size: 11px;
1131 font-weight: 700;
1132 letter-spacing: 0.06em;
1133 text-transform: uppercase;
1134 padding: 3px 9px;
1135 border-radius: 9999px;
1136 background: var(--bg-tertiary);
1137 color: var(--text-muted);
1138 border: 1px solid var(--border-strong);
1139 }
1140 .trio-card-body {
1141 padding: 12px 14px;
1142 font-size: 13px;
1143 color: var(--text);
1144 flex: 1;
1145 min-height: 64px;
1146 line-height: 1.55;
1147 }
1148 .trio-card-body p { margin: 0 0 8px; }
1149 .trio-card-body p:last-child { margin-bottom: 0; }
1150 .trio-card-body ul { margin: 0; padding-left: 18px; }
1151 .trio-card-body code {
1152 font-family: var(--font-mono);
1153 font-size: 12px;
1154 background: var(--bg-tertiary);
1155 padding: 1px 6px;
1156 border-radius: 5px;
1157 }
1158 .trio-card-empty {
1159 color: var(--text-muted);
1160 font-style: italic;
1161 font-size: 12.5px;
1162 }
1163
1164 /* Pass state — neutral, no accent. */
1165 .trio-card.is-pass .trio-card-verdict {
1166 color: var(--green);
1167 border-color: rgba(52,211,153,0.35);
1168 background: rgba(52,211,153,0.12);
1169 }
1170
1171 /* Per-persona fail accents: security=red, correctness=amber, style=blue. */
1172 .trio-card.trio-security.is-fail {
1173 border-color: rgba(248,113,113,0.55);
1174 box-shadow: 0 0 0 1px rgba(248,113,113,0.18), 0 8px 24px -12px rgba(248,113,113,0.45);
1175 }
1176 .trio-card.trio-security.is-fail .trio-card-head {
1177 background: linear-gradient(90deg, rgba(248,113,113,0.16), rgba(248,113,113,0.04));
1178 border-bottom-color: rgba(248,113,113,0.30);
1179 }
1180 .trio-card.trio-security.is-fail .trio-card-verdict {
1181 color: #fecaca;
1182 border-color: rgba(248,113,113,0.55);
1183 background: rgba(248,113,113,0.20);
1184 }
1185
1186 .trio-card.trio-correctness.is-fail {
1187 border-color: rgba(251,191,36,0.55);
1188 box-shadow: 0 0 0 1px rgba(251,191,36,0.18), 0 8px 24px -12px rgba(251,191,36,0.45);
1189 }
1190 .trio-card.trio-correctness.is-fail .trio-card-head {
1191 background: linear-gradient(90deg, rgba(251,191,36,0.16), rgba(251,191,36,0.04));
1192 border-bottom-color: rgba(251,191,36,0.30);
1193 }
1194 .trio-card.trio-correctness.is-fail .trio-card-verdict {
1195 color: #fde68a;
1196 border-color: rgba(251,191,36,0.55);
1197 background: rgba(251,191,36,0.20);
1198 }
1199
1200 .trio-card.trio-style.is-fail {
1201 border-color: rgba(96,165,250,0.55);
1202 box-shadow: 0 0 0 1px rgba(96,165,250,0.18), 0 8px 24px -12px rgba(96,165,250,0.45);
1203 }
1204 .trio-card.trio-style.is-fail .trio-card-head {
1205 background: linear-gradient(90deg, rgba(96,165,250,0.16), rgba(96,165,250,0.04));
1206 border-bottom-color: rgba(96,165,250,0.30);
1207 }
1208 .trio-card.trio-style.is-fail .trio-card-verdict {
1209 color: #bfdbfe;
1210 border-color: rgba(96,165,250,0.55);
1211 background: rgba(96,165,250,0.20);
1212 }
1213
1214 /* Disagreement callout strip — yellow, prominent. */
1215 .trio-disagreement-strip {
1216 display: flex;
1217 gap: 12px;
1218 margin-top: 14px;
1219 padding: 12px 14px;
1220 background: linear-gradient(90deg, rgba(251,191,36,0.14), rgba(251,191,36,0.04));
1221 border: 1px solid rgba(251,191,36,0.45);
1222 border-radius: 10px;
1223 color: var(--text);
1224 font-size: 13px;
1225 }
1226 .trio-disagreement-icon {
1227 flex: 0 0 auto;
1228 width: 26px; height: 26px;
1229 display: inline-flex; align-items: center; justify-content: center;
1230 border-radius: 9999px;
1231 background: rgba(251,191,36,0.25);
1232 color: #fde68a;
1233 font-size: 14px;
1234 }
1235 .trio-disagreement-body strong {
1236 display: block;
1237 color: #fde68a;
1238 margin: 0 0 4px;
1239 font-weight: 700;
1240 }
1241 .trio-disagreement-list {
1242 margin: 0;
1243 padding-left: 18px;
1244 color: var(--text);
1245 font-size: 12.5px;
1246 line-height: 1.55;
1247 }
1248 .trio-disagreement-list code {
1249 font-family: var(--font-mono);
1250 font-size: 11.5px;
1251 background: var(--bg-tertiary);
1252 padding: 1px 5px;
1253 border-radius: 4px;
1254 }
1255
1256 @media (max-width: 720px) {
1257 .trio-grid { grid-template-columns: 1fr; }
1258 .trio-wrap { padding: 12px; }
1259 }
6d1bbc2Claude1260
1261 /* ─── Task list progress pill ─── */
1262 .prs-tasks-pill {
1263 display: inline-flex; align-items: center; gap: 5px;
1264 font-size: 11.5px; font-weight: 600;
1265 padding: 2px 9px; border-radius: 9999px;
1266 border: 1px solid var(--border);
1267 background: var(--bg-elevated);
1268 color: var(--text-muted);
1269 }
1270 .prs-tasks-pill.is-complete {
1271 color: #34d399;
1272 border-color: rgba(52,211,153,0.40);
1273 background: rgba(52,211,153,0.08);
1274 }
1275 .prs-tasks-progress { display: inline-block; width: 36px; height: 4px; border-radius: 9999px; background: var(--border); overflow: hidden; vertical-align: middle; }
1276 .prs-tasks-progress-bar { height: 100%; border-radius: 9999px; background: #34d399; }
1277
1278 /* ─── Update branch button ─── */
1279 .prs-update-branch-btn {
1280 display: inline-flex; align-items: center; gap: 5px;
1281 padding: 4px 12px; border-radius: 8px; font-size: 12.5px;
1282 font-weight: 600; cursor: pointer;
1283 background: rgba(96,165,250,0.10);
1284 color: #60a5fa;
1285 border: 1px solid rgba(96,165,250,0.30);
1286 transition: background 120ms;
1287 }
1288 .prs-update-branch-btn:hover { background: rgba(96,165,250,0.20); }
1289
1290 /* ─── Linked issues panel ─── */
1291 .prs-linked-issues {
1292 margin-top: 16px;
1293 border: 1px solid var(--border);
1294 border-radius: 12px;
1295 overflow: hidden;
1296 }
1297 .prs-linked-issues-head {
1298 display: flex; align-items: center; justify-content: space-between;
1299 padding: 10px 16px;
1300 background: var(--bg-elevated);
1301 border-bottom: 1px solid var(--border);
1302 font-size: 13px; font-weight: 600; color: var(--text);
1303 }
1304 .prs-linked-issues-count {
1305 font-size: 11px; font-weight: 700;
1306 padding: 1px 7px; border-radius: 9999px;
1307 background: var(--bg-tertiary);
1308 color: var(--text-muted);
1309 }
1310 .prs-linked-issue-row {
1311 display: flex; align-items: center; gap: 10px;
1312 padding: 9px 16px;
1313 border-bottom: 1px solid var(--border);
1314 font-size: 13px;
1315 text-decoration: none; color: inherit;
1316 }
1317 .prs-linked-issue-row:last-child { border-bottom: none; }
1318 .prs-linked-issue-row:hover { background: var(--bg-hover); }
1319 .prs-linked-issue-icon { flex: 0 0 auto; font-size: 14px; }
1320 .prs-linked-issue-icon.is-open { color: #34d399; }
1321 .prs-linked-issue-icon.is-closed { color: #8b949e; }
1322 .prs-linked-issue-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1323 .prs-linked-issue-num { color: var(--text-muted); font-size: 12px; }
1324 .prs-linked-issue-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
1325 .prs-linked-issue-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
1326 .prs-linked-issue-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
b558f23Claude1327
1328 /* ─── Commits tab ─── */
1329 .prs-commits-list { display: flex; flex-direction: column; gap: 0; margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1330 .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; }
1331 .prs-commit-row:last-child { border-bottom: none; }
1332 .prs-commit-row:hover { background: var(--bg-hover); }
1333 .prs-commit-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-top: 6px; }
1334 .prs-commit-body { flex: 1 1 auto; min-width: 0; }
1335 .prs-commit-msg { font-size: 13.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
1336 .prs-commit-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
1337 .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; }
1338 .prs-commit-sha:hover { color: var(--accent); }
1339 .prs-commits-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13.5px; }
1340
1341 /* ─── Edit PR title/body ─── */
1342 .prs-edit-title-wrap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1343 .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; }
1344 .prs-edit-btn:hover { color: var(--text); border-color: var(--text-muted); }
1345 .prs-edit-form { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
1346 .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; }
1347 .prs-edit-actions { display: flex; gap: 8px; }
1348 .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; }
1349 .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; }
240c477Claude1350
1351 /* ─── CI status checks ─── */
1352 .prs-ci-card { margin-top: 14px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
1353 .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); }
1354 .prs-ci-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text); }
1355 .prs-ci-summary { font-size: 12px; color: var(--text-muted); }
1356 .prs-ci-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
1357 .prs-ci-row:last-child { border-bottom: none; }
1358 .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; }
1359 .prs-ci-icon.is-success { background: rgba(52,211,153,0.20); color: #34d399; }
1360 .prs-ci-icon.is-pending { background: rgba(251,191,36,0.20); color: #fbbf24; }
1361 .prs-ci-icon.is-failure, .prs-ci-icon.is-error { background: rgba(248,113,113,0.20); color: #f87171; }
1362 .prs-ci-context { flex: 1 1 auto; font-size: 13px; font-weight: 500; color: var(--text); }
1363 .prs-ci-desc { font-size: 12px; color: var(--text-muted); }
1364 .prs-ci-pill { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 9999px; }
1365 .prs-ci-pill.is-success { color: #34d399; background: rgba(52,211,153,0.10); }
1366 .prs-ci-pill.is-pending { color: #fbbf24; background: rgba(251,191,36,0.10); }
1367 .prs-ci-pill.is-failure, .prs-ci-pill.is-error { color: #f87171; background: rgba(248,113,113,0.10); }
1368 .prs-ci-link { font-size: 12px; color: var(--accent); text-decoration: none; }
1369 .prs-ci-link:hover { text-decoration: underline; }
b078860Claude1370`;
1371
81c73c1Claude1372/**
1373 * Tiny inline JS that drives the "Suggest description with AI" button.
1374 * On click, gathers form values, POSTs JSON to the given endpoint, and
1375 * pipes the response into the #pr-body textarea. All DOM lookups are
1376 * defensive — element absence is a silent no-op.
1377 *
1378 * Built as a string template so it lives next to its server-side caller
1379 * and there is no bundler dependency. The endpoint URL is JSON-escaped
1380 * to avoid </script> breakouts.
1381 */
1382function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
1383 const url = JSON.stringify(endpointUrl)
1384 .split("<").join("\\u003C")
1385 .split(">").join("\\u003E")
1386 .split("&").join("\\u0026");
1387 return (
1388 "(function(){try{" +
1389 "var btn=document.getElementById('ai-suggest-desc');" +
1390 "var status=document.getElementById('ai-suggest-status');" +
1391 "var body=document.getElementById('pr-body');" +
1392 "var form=btn&&btn.closest&&btn.closest('form');" +
1393 "if(!btn||!body||!form)return;" +
1394 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
1395 "var fd=new FormData(form);" +
1396 "var title=String(fd.get('title')||'').trim();" +
1397 "var base=String(fd.get('base')||'').trim();" +
1398 "var head=String(fd.get('head')||'').trim();" +
1399 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
1400 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
1401 "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'})" +
1402 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
1403 ".then(function(j){btn.disabled=false;" +
1404 "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;}}" +
1405 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
1406 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
1407 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
1408 "});" +
1409 "}catch(e){}})();"
1410 );
1411}
1412
3c03977Claude1413/**
1414 * Live co-editing client. Connects to the per-PR SSE feed and:
1415 * - Maintains a "Live: N editing" pill in the PR header (avatars +
1416 * status colour per user).
1417 * - Renders tinted cursor caret overlays inside #pr-body and every
1418 * `[data-live-field]` element.
1419 * - Broadcasts the local user's cursor position (selectionStart /
1420 * selectionEnd) debounced at 100ms.
1421 * - Broadcasts content patches (`replace` of the whole textarea —
1422 * last-write-wins v1) debounced at 250ms.
1423 * - Pings /heartbeat every 15s; on receiving a peer's edit applies it
1424 * to the matching local field if untouched.
1425 *
1426 * All endpoint URLs are JSON-escaped via safe replacements so they
1427 * can't break out of the <script> tag.
1428 */
1429function LIVE_COEDIT_SCRIPT(prId: string): string {
1430 const idJson = JSON.stringify(prId)
1431 .split("<").join("\\u003C")
1432 .split(">").join("\\u003E")
1433 .split("&").join("\\u0026");
1434 return (
1435 "(function(){try{" +
1436 "if(typeof EventSource==='undefined')return;" +
1437 "var prId=" + idJson + ";" +
1438 "var base='/api/v2/pulls/'+encodeURIComponent(prId)+'/live';" +
1439 "var pill=document.getElementById('live-pill');" +
1440 "var avEl=document.getElementById('live-avatars');" +
1441 "var countEl=document.getElementById('live-count');" +
1442 "var sessionId=null;var myColor=null;" +
1443 "var presence={};" + // sessionId -> {color,status,userId,initials}
1444 "var lastApplied={};" + // field -> last server value (for echo suppression)
1445 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
1446 "function initials(id){if(!id)return '?';var s=String(id);return s.slice(0,2).toUpperCase();}" +
1447 "function renderPresence(){if(!pill)return;var ids=Object.keys(presence).filter(function(k){return presence[k].status!=='left'&&k!==sessionId;});" +
1448 "var n=ids.length;if(countEl)countEl.textContent=String(n);" +
1449 "if(pill.classList){if(n>0)pill.classList.add('is-busy');else pill.classList.remove('is-busy');}" +
1450 "if(avEl){var html='';for(var i=0;i<ids.length&&i<5;i++){var p=presence[ids[i]];" +
1451 "html+='<span class=\"live-avatar'+(p.status==='idle'?' is-idle':'')+'\" style=\"background:'+esc(p.color)+'\" title=\"'+esc(p.label||'editor')+'\">'+esc(p.initials)+'</span>';}" +
1452 "avEl.innerHTML=html;}}" +
1453 "function ensureOverlay(host){if(!host)return null;var ov=host.querySelector(':scope > .live-cursor-overlay');" +
1454 "if(!ov){ov=document.createElement('div');ov.className='live-cursor-overlay';host.classList.add('live-cursor-host');host.appendChild(ov);}return ov;}" +
1455 "function fieldEl(field){if(field==='description')return document.getElementById('pr-body');" +
1456 "return document.querySelector('[data-live-field=\"'+(field.replace(/\"/g,'\\\\\"'))+'\"]');}" +
1457 "function placeCursor(sid,position){var p=presence[sid];if(!p||sid===sessionId)return;" +
1458 "var ta=fieldEl(position.field);if(!ta||!ta.parentElement)return;" +
1459 "var host=ta.parentElement;var ov=ensureOverlay(host);if(!ov)return;" +
1460 "var c=ov.querySelector('[data-sid=\"'+sid+'\"]');" +
1461 "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);}" +
1462 "var rect=ta.getBoundingClientRect();var hostRect=host.getBoundingClientRect();" +
1463 "var x=ta.offsetLeft+6;var y=ta.offsetTop+6;" +
1464 "try{var lineH=parseFloat(getComputedStyle(ta).lineHeight)||18;" +
1465 "var text=ta.value||'';var pos=Math.max(0,Math.min(text.length,position.range&&position.range.start||0));" +
1466 "var before=text.slice(0,pos);var nl=(before.match(/\\n/g)||[]).length;" +
1467 "var lastNl=before.lastIndexOf('\\n');var col=pos-lastNl-1;" +
1468 "x=ta.offsetLeft+6+Math.min(col*7,Math.max(0,rect.width-30));" +
1469 "y=ta.offsetTop+6+nl*lineH-ta.scrollTop;" +
1470 "}catch(e){}" +
1471 "c.style.transform='translate('+x+'px,'+y+'px)';" +
1472 "if(p.status==='idle')c.classList.add('is-idle');else c.classList.remove('is-idle');}" +
1473 "function removeCursor(sid){var nodes=document.querySelectorAll('[data-sid=\"'+sid+'\"]');" +
1474 "for(var i=0;i<nodes.length;i++){try{nodes[i].parentNode.removeChild(nodes[i]);}catch(e){}}}" +
1475 "var es;var delay=1000;" +
1476 "function connect(){try{es=new EventSource(base);}catch(e){setTimeout(connect,delay);return;}" +
1477 "es.addEventListener('hello',function(m){try{var d=JSON.parse(m.data);sessionId=d.sessionId||null;myColor=d.color||null;" +
1478 "(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){}});" +
1479 "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){}});" +
1480 "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){}});" +
1481 "es.addEventListener('presence-leave',function(m){try{var d=JSON.parse(m.data);delete presence[d.sessionId];removeCursor(d.sessionId);renderPresence();}catch(e){}});" +
1482 "es.addEventListener('cursor',function(m){try{var d=JSON.parse(m.data);placeCursor(d.sessionId,d.position);}catch(e){}});" +
1483 "es.addEventListener('edit',function(m){try{var d=JSON.parse(m.data);if(d.sessionId===sessionId)return;" +
1484 "var patch=d.patch;if(!patch||!patch.field)return;" +
1485 "var ta=fieldEl(patch.field);if(!ta)return;" +
1486 "if(document.activeElement===ta)return;" + // don't trample local typing
1487 "if(patch.op==='replace'&&typeof patch.value==='string'){ta.value=patch.value;lastApplied[patch.field]=patch.value;}" +
1488 "}catch(e){}});" +
1489 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
1490 "}connect();" +
1491 "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){}}" +
1492 "var cursorTimer=null;function sendCursor(field,start,end){if(!sessionId)return;if(cursorTimer)clearTimeout(cursorTimer);" +
1493 "cursorTimer=setTimeout(function(){post('/cursor',{sessionId:sessionId,position:{field:field,range:{start:start,end:end}}});},100);}" +
1494 "var editTimer=null;function sendEdit(field,value){if(!sessionId)return;if(editTimer)clearTimeout(editTimer);" +
1495 "editTimer=setTimeout(function(){post('/edit',{sessionId:sessionId,patch:{field:field,op:'replace',at:0,value:value}});lastApplied[field]=value;},250);}" +
1496 "function wire(el,field){if(!el||el.__liveWired)return;el.__liveWired=true;" +
1497 "el.addEventListener('input',function(){sendEdit(field,el.value);});" +
1498 "el.addEventListener('keyup',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1499 "el.addEventListener('click',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1500 "el.addEventListener('select',function(){sendCursor(field,el.selectionStart||0,el.selectionEnd||0);});" +
1501 "}" +
1502 "var body=document.getElementById('pr-body');if(body)wire(body,'description');" +
1503 "var live=document.querySelectorAll('[data-live-field]');" +
1504 "for(var i=0;i<live.length;i++){var f=live[i].getAttribute('data-live-field');if(f)wire(live[i],f);}" +
1505 "setInterval(function(){if(sessionId)post('/heartbeat',{sessionId:sessionId});},15000);" +
1506 "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){}});" +
1507 "}catch(e){}})();"
1508 );
1509}
1510
0074234Claude1511async function resolveRepo(ownerName: string, repoName: string) {
1512 const [owner] = await db
1513 .select()
1514 .from(users)
1515 .where(eq(users.username, ownerName))
1516 .limit(1);
1517 if (!owner) return null;
1518 const [repo] = await db
1519 .select()
1520 .from(repositories)
1521 .where(
1522 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1523 )
1524 .limit(1);
1525 if (!repo) return null;
1526 return { owner, repo };
1527}
1528
1529// PR Nav helper
1530const PrNav = ({
1531 owner,
1532 repo,
1533 active,
1534}: {
1535 owner: string;
1536 repo: string;
1537 active: "code" | "issues" | "pulls" | "commits";
1538}) => (
bb0f894Claude1539 <TabNav
1540 tabs={[
1541 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
1542 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
1543 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
1544 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
1545 ]}
1546 />
0074234Claude1547);
1548
534f04aClaude1549/**
1550 * Block M3 — pre-merge risk score card. Pure presentational helper.
1551 * Rendered in the conversation tab above the gate checks block. Hidden
1552 * entirely when the PR is closed/merged or there is nothing cached and
1553 * nothing in-flight.
1554 */
1555function PrRiskCard({
1556 risk,
1557 calculating,
1558}: {
1559 risk: PrRiskScore | null;
1560 calculating: boolean;
1561}) {
1562 if (!risk) {
1563 return (
1564 <div
1565 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
1566 >
1567 <strong style="font-size: 13px; color: var(--text)">
1568 Risk score: calculating…
1569 </strong>
1570 <div style="font-size: 12px; margin-top: 4px">
1571 Refresh in a moment to see the pre-merge risk score for this PR.
1572 </div>
1573 </div>
1574 );
1575 }
1576
1577 const palette = riskBandPalette(risk.band);
1578 const label = riskBandLabel(risk.band);
1579
1580 return (
1581 <div
1582 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
1583 >
1584 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
1585 <strong>Risk score:</strong>
1586 <span style={`color:${palette.border};font-weight:600`}>
1587 {palette.icon} {label} ({risk.score}/10)
1588 </span>
1589 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
1590 {risk.commitSha.slice(0, 7)}
1591 </span>
1592 </div>
1593 {risk.aiSummary && (
1594 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
1595 {risk.aiSummary}
1596 </div>
1597 )}
1598 <details style="margin-top:10px">
1599 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
1600 See full signal breakdown
1601 </summary>
1602 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
1603 <li>files changed: {risk.signals.filesChanged}</li>
1604 <li>
1605 lines added/removed: {risk.signals.linesAdded} /{" "}
1606 {risk.signals.linesRemoved}
1607 </li>
1608 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
1609 <li>
1610 schema migration touched:{" "}
1611 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
1612 </li>
1613 <li>
1614 locked / sensitive path touched:{" "}
1615 {risk.signals.lockedPathTouched ? "yes" : "no"}
1616 </li>
1617 <li>
1618 adds new dependency:{" "}
1619 {risk.signals.addsNewDependency ? "yes" : "no"}
1620 </li>
1621 <li>
1622 bumps major dependency:{" "}
1623 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
1624 </li>
1625 <li>
1626 tests added for new code:{" "}
1627 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
1628 </li>
1629 <li>
1630 diff-minus-test ratio:{" "}
1631 {risk.signals.diffMinusTestRatio.toFixed(2)}
1632 </li>
1633 </ul>
1634 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1635 How is this calculated? The score is a transparent sum of
1636 weighted signals — see <code>src/lib/pr-risk.ts</code>
1637 {" "}<code>computePrRiskScore</code>.
1638 </div>
1639 </details>
1640 {calculating && (
1641 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
1642 (recomputing for the latest commit — refresh to update)
1643 </div>
1644 )}
1645 </div>
1646 );
1647}
1648
1649function riskBandPalette(band: PrRiskScore["band"]): {
1650 border: string;
1651 icon: string;
1652} {
1653 switch (band) {
1654 case "low":
1655 return { border: "var(--green)", icon: "" };
1656 case "medium":
1657 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
1658 case "high":
1659 return { border: "var(--orange, #db6d28)", icon: "⚠" };
1660 case "critical":
1661 return { border: "var(--red)", icon: "\u{1F6D1}" };
1662 }
1663}
1664
1665function riskBandLabel(band: PrRiskScore["band"]): string {
1666 switch (band) {
1667 case "low":
1668 return "LOW";
1669 case "medium":
1670 return "MEDIUM";
1671 case "high":
1672 return "HIGH";
1673 case "critical":
1674 return "CRITICAL";
1675 }
1676}
1677
422a2d4Claude1678// ---------------------------------------------------------------------------
1679// AI Trio Review — 3-column card grid + disagreement callout.
1680//
1681// The trio reviewer (src/lib/ai-review-trio.ts) writes four prComments
1682// per run: one per persona (security/correctness/style) plus a top-level
1683// summary. We surface them here as a single grid above the normal
1684// comment stream so reviewers see the verdicts at a glance.
1685// ---------------------------------------------------------------------------
1686
1687const TRIO_PERSONAS: TrioPersona[] = ["security", "correctness", "style"];
1688
1689interface TrioCommentLike {
1690 body: string;
1691}
1692
1693function isTrioComment(body: string | null | undefined): boolean {
1694 if (!body) return false;
1695 return (
1696 body.includes(TRIO_SUMMARY_MARKER) ||
1697 body.includes(TRIO_COMMENT_MARKER.security) ||
1698 body.includes(TRIO_COMMENT_MARKER.correctness) ||
1699 body.includes(TRIO_COMMENT_MARKER.style)
1700 );
1701}
1702
1703function trioPersonaOfComment(body: string): TrioPersona | null {
1704 for (const p of TRIO_PERSONAS) {
1705 if (body.includes(TRIO_COMMENT_MARKER[p])) return p;
1706 }
1707 return null;
1708}
1709
1710/**
1711 * Best-effort verdict parse from a persona comment body. The body shape
1712 * is generated by `renderPersonaCommentBody` in `ai-review-trio.ts` —
1713 * we only need the "Pass" / "Fail" word from the H2 heading.
1714 */
1715function trioVerdictOfBody(body: string): "pass" | "fail" | null {
1716 const m = body.match(/##\s+AI\s+\w+\s+Review\s+—\s+(Pass|Fail)/i);
1717 if (!m) return null;
1718 return m[1].toLowerCase() === "pass" ? "pass" : "fail";
1719}
1720
1721/**
1722 * Parse the disagreement bullet list out of the summary comment so we
1723 * can render it as a polished callout strip. Returns [] when nothing
1724 * matches — the comment author may have edited the marker out.
1725 */
1726function parseDisagreements(summaryBody: string): Array<{
1727 file: string;
1728 failing: string;
1729 passing: string;
1730}> {
1731 const out: Array<{ file: string; failing: string; passing: string }> = [];
1732 // Each disagreement line looks like:
1733 // - `path:42` — security, style say ✗, correctness say ✓
1734 const re = /-\s+`([^`]+)`\s+—\s+([^✗]+)say\s+✗,\s+([^✓]+)say\s+✓/g;
1735 let m: RegExpExecArray | null;
1736 while ((m = re.exec(summaryBody)) !== null) {
1737 out.push({
1738 file: m[1].trim(),
1739 failing: m[2].trim().replace(/[,\s]+$/g, ""),
1740 passing: m[3].trim().replace(/[,\s]+$/g, ""),
1741 });
1742 }
1743 return out;
1744}
1745
1746function TrioReviewGrid({ comments }: { comments: TrioCommentLike[] }) {
1747 // Find the most recent persona comments + summary. We iterate from
1748 // the end so re-reviews (multiple runs on the same PR) display the
1749 // freshest verdict.
1750 const latest: Partial<Record<TrioPersona, string>> = {};
1751 let summaryBody: string | null = null;
1752 for (let i = comments.length - 1; i >= 0; i--) {
1753 const body = comments[i].body || "";
1754 if (!isTrioComment(body)) continue;
1755 if (body.includes(TRIO_SUMMARY_MARKER) && !summaryBody) {
1756 summaryBody = body;
1757 continue;
1758 }
1759 const persona = trioPersonaOfComment(body);
1760 if (persona && !latest[persona]) latest[persona] = body;
1761 }
1762 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1763 if (!anyPersona && !summaryBody) return null;
1764
1765 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
1766
1767 return (
1768 <div class="trio-wrap">
1769 <div class="trio-header">
1770 <span class="trio-header-dot" aria-hidden="true"></span>
1771 <strong>AI Trio Review</strong>
1772 <span class="trio-header-sub">
1773 Three independent reviewers ran in parallel.
1774 </span>
1775 </div>
1776 <div class="trio-grid">
1777 {TRIO_PERSONAS.map((persona) => {
1778 const body = latest[persona];
1779 const verdict = body ? trioVerdictOfBody(body) : null;
1780 const stateClass =
1781 verdict === "fail"
1782 ? "is-fail"
1783 : verdict === "pass"
1784 ? "is-pass"
1785 : "is-pending";
1786 return (
1787 <div class={`trio-card trio-${persona} ${stateClass}`}>
1788 <div class="trio-card-head">
1789 <span class="trio-card-icon" aria-hidden="true">
1790 {persona === "security"
1791 ? "🛡"
1792 : persona === "correctness"
1793 ? "✓"
1794 : "✎"}
1795 </span>
1796 <strong class="trio-card-title">
1797 {persona[0].toUpperCase() + persona.slice(1)}
1798 </strong>
1799 <span class="trio-card-verdict">
1800 {verdict === "pass"
1801 ? "Pass"
1802 : verdict === "fail"
1803 ? "Fail"
1804 : "Pending"}
1805 </span>
1806 </div>
1807 <div class="trio-card-body">
1808 {body ? (
1809 <MarkdownContent
1810 html={renderMarkdown(stripTrioHeading(body))}
1811 />
1812 ) : (
1813 <span class="trio-card-empty">
1814 Awaiting reviewer output.
1815 </span>
1816 )}
1817 </div>
1818 </div>
1819 );
1820 })}
1821 </div>
1822 {disagreements.length > 0 && (
1823 <div class="trio-disagreement-strip" role="note">
1824 <span class="trio-disagreement-icon" aria-hidden="true">
1825
1826 </span>
1827 <div class="trio-disagreement-body">
1828 <strong>Reviewers disagree — review carefully.</strong>
1829 <ul class="trio-disagreement-list">
1830 {disagreements.map((d) => (
1831 <li>
1832 <code>{d.file}</code> — {d.failing} says ✗,{" "}
1833 {d.passing} says ✓
1834 </li>
1835 ))}
1836 </ul>
1837 </div>
1838 </div>
1839 )}
1840 </div>
1841 );
1842}
1843
1844/**
1845 * Strip the marker comment + first H2 heading from a persona body so
1846 * the card body shows just the findings list (verdict is already in
1847 * the card head). Best-effort — malformed bodies render whole.
1848 */
1849function stripTrioHeading(body: string): string {
1850 return body
1851 .replace(/<!--\s*ai-trio:(?:security|correctness|style|summary)\s*-->\s*/g, "")
1852 .replace(/^##\s+AI\s+\w+\s+Review[^\n]*\n+/m, "")
1853 .trim();
1854}
1855
0074234Claude1856// List PRs
04f6b7fClaude1857pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude1858 const { owner: ownerName, repo: repoName } = c.req.param();
1859 const user = c.get("user");
1860 const state = c.req.query("state") || "open";
d790b49Claude1861 const searchQ = c.req.query("q")?.trim() || "";
80bd7c8Claude1862 const authorFilter = c.req.query("author")?.trim() || "";
0074234Claude1863
ea9ed4cClaude1864 // ── Loading skeleton (flag-gated) ──
1865 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
1866 // the user see the page structure before counts + select resolve.
1867 // Behind a flag for now — we don't ship flashes.
1868 if (c.req.query("skeleton") === "1") {
1869 return c.html(
1870 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1871 <RepoHeader owner={ownerName} repo={repoName} />
1872 <PrNav owner={ownerName} repo={repoName} active="pulls" />
1873 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
1874 <style
1875 dangerouslySetInnerHTML={{
1876 __html: `
404b398Claude1877 .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; }
1878 @keyframes prs-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
ea9ed4cClaude1879 @media (prefers-reduced-motion: reduce) { .prs-skel { animation: none; } }
1880 .prs-skel-hero { height: 152px; border-radius: 16px; margin: 0 0 var(--space-5); }
1881 .prs-skel-tabs { height: 40px; width: 360px; border-radius: 9999px; margin: 0 0 16px; }
1882 .prs-skel-list { display: flex; flex-direction: column; gap: 8px; }
1883 .prs-skel-row { height: 76px; border-radius: 12px; }
1884 `,
1885 }}
1886 />
1887 <div class="prs-skel prs-skel-hero" aria-hidden="true" />
1888 <div class="prs-skel prs-skel-tabs" aria-hidden="true" />
1889 <div class="prs-skel-list" aria-hidden="true">
1890 {Array.from({ length: 6 }).map(() => (
1891 <div class="prs-skel prs-skel-row" />
1892 ))}
1893 </div>
1894 <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">
1895 Loading pull requests for {ownerName}/{repoName}…
1896 </span>
1897 </Layout>
1898 );
1899 }
1900
0074234Claude1901 const resolved = await resolveRepo(ownerName, repoName);
1902 if (!resolved) return c.notFound();
1903
6fc53bdClaude1904 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
1905 const stateFilter =
1906 state === "draft"
1907 ? and(
1908 eq(pullRequests.state, "open"),
1909 eq(pullRequests.isDraft, true)
1910 )
1911 : eq(pullRequests.state, state);
1912
0074234Claude1913 const prList = await db
1914 .select({
1915 pr: pullRequests,
1916 author: { username: users.username },
1917 })
1918 .from(pullRequests)
1919 .innerJoin(users, eq(pullRequests.authorId, users.id))
1920 .where(
d790b49Claude1921 and(
1922 eq(pullRequests.repositoryId, resolved.repo.id),
1923 stateFilter,
1924 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
80bd7c8Claude1925 authorFilter ? eq(users.username, authorFilter) : undefined,
d790b49Claude1926 )
0074234Claude1927 )
1928 .orderBy(desc(pullRequests.createdAt));
1929
0369e77Claude1930 // Batch-load review states + comment counts for all PRs in the list
1aef949Claude1931 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
0369e77Claude1932 const commentCountMap = new Map<string, number>();
1aef949Claude1933 if (prList.length > 0) {
1934 const prIds = prList.map(({ pr }) => pr.id);
0369e77Claude1935 const [reviewRows, commentRows] = await Promise.all([
1936 db
1937 .select({ prId: prReviews.pullRequestId, state: prReviews.state })
1938 .from(prReviews)
1939 .where(inArray(prReviews.pullRequestId, prIds)),
1940 db
1941 .select({
1942 prId: prComments.pullRequestId,
1943 cnt: sql<number>`count(*)::int`,
1944 })
1945 .from(prComments)
1946 .where(and(inArray(prComments.pullRequestId, prIds), eq(prComments.isAiReview, false)))
1947 .groupBy(prComments.pullRequestId),
1948 ]);
1aef949Claude1949 for (const r of reviewRows) {
1950 const entry = reviewMap.get(r.prId) ?? { approved: false, changesRequested: false };
1951 if (r.state === "approved") entry.approved = true;
1952 if (r.state === "changes_requested") entry.changesRequested = true;
1953 reviewMap.set(r.prId, entry);
1954 }
0369e77Claude1955 for (const r of commentRows) {
1956 commentCountMap.set(r.prId, Number(r.cnt));
1957 }
1aef949Claude1958 }
1959
0074234Claude1960 const [counts] = await db
1961 .select({
1962 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude1963 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude1964 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
1965 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
1966 })
1967 .from(pullRequests)
1968 .where(eq(pullRequests.repositoryId, resolved.repo.id));
1969
b078860Claude1970 const openCount = counts?.open ?? 0;
1971 const mergedCount = counts?.merged ?? 0;
1972 const closedCount = counts?.closed ?? 0;
1973 const draftCount = counts?.draft ?? 0;
1974 const allCount = openCount + mergedCount + closedCount;
1975
1976 // "All" is presentational only — the DB query for state='all' matches
1977 // nothing, so we render a friendlier empty state when picked. We do NOT
1978 // change the query logic to keep this commit purely visual.
80bd7c8Claude1979 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
b078860Claude1980 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
80bd7c8Claude1981 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
1982 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
1983 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
1984 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
1985 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
b078860Claude1986 ];
1987 const isAllState = state === "all";
cb5a796Claude1988 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
1989 const prListPendingCount = viewerIsOwnerOnPrList
1990 ? await countPendingForRepo(resolved.repo.id)
1991 : 0;
b078860Claude1992
0074234Claude1993 return c.html(
1994 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
1995 <RepoHeader owner={ownerName} repo={repoName} />
1996 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude1997 <PendingCommentsBanner
1998 owner={ownerName}
1999 repo={repoName}
2000 count={prListPendingCount}
2001 />
b078860Claude2002 <style dangerouslySetInnerHTML={{ __html: PRS_LIST_STYLES }} />
2003
2004 <div class="prs-hero">
2005 <div class="prs-hero-inner">
2006 <div class="prs-hero-text">
2007 <div class="prs-hero-eyebrow">Pull requests</div>
2008 <h1 class="prs-hero-title">
2009 Review, <span class="gradient-text">merge with AI</span>.
2010 </h1>
2011 <p class="prs-hero-sub">
2012 {openCount === 0 && allCount === 0
2013 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
2014 : `${openCount} open, ${mergedCount} merged, ${closedCount} closed${draftCount > 0 ? ` · ${draftCount} draft${draftCount === 1 ? "" : "s"}` : ""}. AI review, gate checks, and auto-resolve included.`}
2015 </p>
2016 </div>
7a28902Claude2017 <div class="prs-hero-actions">
2018 <a
2019 href={`/${ownerName}/${repoName}/pulls/insights`}
2020 class="prs-cta"
2021 style="background:var(--bg-secondary);border-color:var(--border);color:var(--text);box-shadow:none"
2022 >
2023 Insights
2024 </a>
2025 {user && (
b078860Claude2026 <a href={`/${ownerName}/${repoName}/pulls/new`} class="prs-cta">
2027 + New pull request
2028 </a>
7a28902Claude2029 )}
2030 </div>
b078860Claude2031 </div>
2032 </div>
2033
2034 <nav class="prs-tabs" aria-label="Pull request filters">
2035 {tabPills.map((t) => {
2036 const isActive =
2037 state === t.key ||
2038 (t.key === "open" &&
2039 state !== "merged" &&
2040 state !== "closed" &&
2041 state !== "all" &&
2042 state !== "draft");
2043 return (
2044 <a class={`prs-tab${isActive ? " is-active" : ""}`} href={t.href}>
2045 <span>{t.label}</span>
2046 <span class="prs-tab-count">{t.count}</span>
2047 </a>
2048 );
2049 })}
2050 </nav>
2051
d790b49Claude2052 <form
2053 method="get"
2054 action={`/${ownerName}/${repoName}/pulls`}
2055 style="display:flex;gap:8px;align-items:center;margin-bottom:14px"
2056 >
2057 <input type="hidden" name="state" value={state} />
2058 <input
2059 type="search"
2060 name="q"
2061 value={searchQ}
2062 placeholder="Search pull requests…"
2063 class="issues-search-input"
2064 style="flex:1;max-width:380px"
2065 />
80bd7c8Claude2066 <input
2067 type="text"
2068 name="author"
2069 value={authorFilter}
2070 placeholder="Filter by author…"
2071 class="issues-search-input"
2072 style="max-width:200px"
2073 />
d790b49Claude2074 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
80bd7c8Claude2075 {(searchQ || authorFilter) && (
d790b49Claude2076 <a
2077 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
2078 class="issues-filter-clear"
2079 >
2080 Clear
2081 </a>
2082 )}
2083 </form>
0074234Claude2084 {prList.length === 0 ? (
b078860Claude2085 <div class="prs-empty">
ea9ed4cClaude2086 <div class="prs-empty-inner">
2087 <strong>
80bd7c8Claude2088 {searchQ || authorFilter
2089 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
d790b49Claude2090 : isAllState
2091 ? "Pick a filter above to browse PRs."
2092 : `No ${state} pull requests.`}
ea9ed4cClaude2093 </strong>
2094 <p class="prs-empty-sub">
80bd7c8Claude2095 {searchQ || authorFilter
2096 ? `Try a different search term or author, or clear the filter.`
d790b49Claude2097 : state === "open"
2098 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
2099 : isAllState
2100 ? "The combined view is coming soon — Open, Merged, Closed, and Draft are all live above."
2101 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
ea9ed4cClaude2102 </p>
2103 <div class="prs-empty-cta">
80bd7c8Claude2104 {user && state === "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2105 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
2106 + New pull request
2107 </a>
2108 )}
80bd7c8Claude2109 {state !== "open" && !searchQ && !authorFilter && (
ea9ed4cClaude2110 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
2111 View open PRs
2112 </a>
2113 )}
2114 <a href={`/${ownerName}/${repoName}`} class="btn">
2115 Back to code
2116 </a>
2117 </div>
2118 </div>
b078860Claude2119 </div>
0074234Claude2120 ) : (
b078860Claude2121 <div class="prs-list">
2122 {prList.map(({ pr, author }) => {
2123 const stateClass =
2124 pr.state === "open"
2125 ? pr.isDraft
2126 ? "state-draft"
2127 : "state-open"
2128 : pr.state === "merged"
2129 ? "state-merged"
2130 : "state-closed";
2131 const icon =
2132 pr.state === "open"
2133 ? pr.isDraft
2134 ? "◌"
2135 : "○"
2136 : pr.state === "merged"
2137 ? "⮌"
2138 : "✓";
1aef949Claude2139 const rv = reviewMap.get(pr.id);
b078860Claude2140 return (
2141 <a
2142 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
2143 class="prs-row"
2144 style="text-decoration:none;color:inherit"
0074234Claude2145 >
b078860Claude2146 <div class={`prs-row-icon ${stateClass}`} aria-hidden="true">
2147 {icon}
0074234Claude2148 </div>
b078860Claude2149 <div class="prs-row-body">
2150 <h3 class="prs-row-title">
2151 <span>{pr.title}</span>
2152 <span class="prs-row-number">#{pr.number}</span>
2153 </h3>
2154 <div class="prs-row-meta">
2155 <span
2156 class="prs-branch-chips"
2157 title={`${pr.headBranch} into ${pr.baseBranch}`}
2158 >
2159 <span class="prs-branch-chip">{pr.headBranch}</span>
2160 <span class="prs-branch-arrow">{"→"}</span>
2161 <span class="prs-branch-chip">{pr.baseBranch}</span>
2162 </span>
2163 <span>
2164 by{" "}
2165 <strong style="color:var(--text)">
2166 {author.username}
2167 </strong>{" "}
2168 {formatRelative(pr.createdAt)}
2169 </span>
2170 <span class="prs-row-tags">
2171 {pr.isDraft && <span class="prs-tag is-draft">Draft</span>}
2172 {pr.state === "merged" && (
2173 <span class="prs-tag is-merged">Merged</span>
2174 )}
1aef949Claude2175 {rv?.approved && !rv.changesRequested && (
2176 <span class="prs-tag is-approved" title="Approved by reviewer">✓ Approved</span>
2177 )}
2178 {rv?.changesRequested && (
2179 <span class="prs-tag is-changes" title="Changes requested">✗ Changes</span>
2180 )}
0369e77Claude2181 {(commentCountMap.get(pr.id) ?? 0) > 0 && (
2182 <span class="prs-tag" title={`${commentCountMap.get(pr.id)} comment${(commentCountMap.get(pr.id) ?? 0) === 1 ? "" : "s"}`}>
2183 💬 {commentCountMap.get(pr.id)}
2184 </span>
2185 )}
b078860Claude2186 </span>
2187 </div>
0074234Claude2188 </div>
b078860Claude2189 </a>
2190 );
2191 })}
2192 </div>
0074234Claude2193 )}
2194 </Layout>
2195 );
2196});
2197
7a28902Claude2198/* ─────────────────────────────────────────────────────────────────────────
2199 * PR Insights — 90-day analytics for the pull request activity of a repo.
2200 * Route: GET /:owner/:repo/pulls/insights
2201 * MUST be registered BEFORE the /:owner/:repo/pulls/:number detail route so
2202 * "insights" is not swallowed by the :number param.
2203 * ───────────────────────────────────────────────────────────────────────── */
2204
2205/** Format a millisecond duration as human-readable string. */
2206function formatMsDuration(ms: number): string {
2207 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
2208 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
2209 if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
2210 return `${Math.round(ms / 86_400_000)}d`;
2211}
2212
2213/** Format an ISO week string as "Jan 15". */
2214function formatWeekLabel(isoWeek: string): string {
2215 try {
2216 const d = new Date(isoWeek);
2217 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
2218 } catch {
2219 return isoWeek.slice(5, 10);
2220 }
2221}
2222
2223const PR_INSIGHTS_STYLES = `
2224 .pri-page { padding-bottom: 48px; }
2225 .pri-hero {
2226 position: relative;
2227 margin: 0 0 var(--space-5);
2228 padding: 22px 26px 24px;
2229 background: var(--bg-elevated);
2230 border: 1px solid var(--border);
2231 border-radius: 16px;
2232 overflow: hidden;
2233 }
2234 .pri-hero::before {
2235 content: '';
2236 position: absolute; top: 0; left: 0; right: 0;
2237 height: 2px;
2238 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2239 opacity: 0.7;
2240 pointer-events: none;
2241 }
2242 .pri-hero-eyebrow {
2243 font-size: 12px;
2244 color: var(--text-muted);
2245 text-transform: uppercase;
2246 letter-spacing: 0.08em;
2247 font-weight: 600;
2248 margin-bottom: 8px;
2249 }
2250 .pri-hero-title {
2251 font-family: var(--font-display);
2252 font-size: clamp(26px, 3.4vw, 34px);
2253 font-weight: 800;
2254 letter-spacing: -0.025em;
2255 line-height: 1.06;
2256 margin: 0 0 8px;
2257 color: var(--text-strong);
2258 }
2259 .pri-hero-title .gradient-text {
2260 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2261 -webkit-background-clip: text;
2262 background-clip: text;
2263 -webkit-text-fill-color: transparent;
2264 color: transparent;
2265 }
2266 .pri-hero-sub {
2267 font-size: 14.5px;
2268 color: var(--text-muted);
2269 margin: 0;
2270 line-height: 1.5;
2271 }
2272 .pri-section { margin-bottom: 32px; }
2273 .pri-section-title {
2274 font-size: 13px;
2275 font-weight: 700;
2276 text-transform: uppercase;
2277 letter-spacing: 0.06em;
2278 color: var(--text-muted);
2279 margin: 0 0 14px;
2280 }
2281 .pri-cards {
2282 display: grid;
2283 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
2284 gap: 12px;
2285 }
2286 .pri-card {
2287 padding: 16px 18px;
2288 background: var(--bg-elevated);
2289 border: 1px solid var(--border);
2290 border-radius: 12px;
2291 }
2292 .pri-card-label {
2293 font-size: 12px;
2294 font-weight: 600;
2295 color: var(--text-muted);
2296 text-transform: uppercase;
2297 letter-spacing: 0.05em;
2298 margin-bottom: 6px;
2299 }
2300 .pri-card-value {
2301 font-size: 28px;
2302 font-weight: 800;
2303 letter-spacing: -0.04em;
2304 color: var(--text-strong);
2305 line-height: 1;
2306 }
2307 .pri-card-sub {
2308 font-size: 12px;
2309 color: var(--text-muted);
2310 margin-top: 4px;
2311 }
2312 .pri-chart {
2313 display: flex;
2314 align-items: flex-end;
2315 gap: 6px;
2316 height: 120px;
2317 background: var(--bg-elevated);
2318 border: 1px solid var(--border);
2319 border-radius: 12px;
2320 padding: 16px 16px 0;
2321 }
2322 .pri-bar-col {
2323 flex: 1;
2324 display: flex;
2325 flex-direction: column;
2326 align-items: center;
2327 justify-content: flex-end;
2328 height: 100%;
2329 gap: 4px;
2330 }
2331 .pri-bar {
2332 width: 100%;
2333 min-height: 4px;
2334 border-radius: 4px 4px 0 0;
2335 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 100%);
2336 transition: opacity 140ms;
2337 }
2338 .pri-bar:hover { opacity: 0.8; }
2339 .pri-bar-label {
2340 font-size: 10px;
2341 color: var(--text-muted);
2342 text-align: center;
2343 padding-bottom: 8px;
2344 white-space: nowrap;
2345 overflow: hidden;
2346 text-overflow: ellipsis;
2347 max-width: 100%;
2348 }
2349 .pri-table {
2350 width: 100%;
2351 border-collapse: collapse;
2352 font-size: 13.5px;
2353 }
2354 .pri-table th {
2355 text-align: left;
2356 font-size: 12px;
2357 font-weight: 600;
2358 text-transform: uppercase;
2359 letter-spacing: 0.05em;
2360 color: var(--text-muted);
2361 padding: 8px 12px;
2362 border-bottom: 1px solid var(--border);
2363 }
2364 .pri-table td {
2365 padding: 10px 12px;
2366 border-bottom: 1px solid var(--border);
2367 color: var(--text);
2368 }
2369 .pri-table tr:last-child td { border-bottom: none; }
2370 .pri-table-wrap {
2371 background: var(--bg-elevated);
2372 border: 1px solid var(--border);
2373 border-radius: 12px;
2374 overflow: hidden;
2375 }
2376 .pri-age-row {
2377 display: flex;
2378 align-items: center;
2379 gap: 12px;
2380 padding: 10px 0;
2381 border-bottom: 1px solid var(--border);
2382 font-size: 13.5px;
2383 }
2384 .pri-age-row:last-child { border-bottom: none; }
2385 .pri-age-label {
2386 flex: 0 0 80px;
2387 color: var(--text-muted);
2388 font-size: 12.5px;
2389 font-weight: 600;
2390 }
2391 .pri-age-bar-wrap {
2392 flex: 1;
2393 height: 8px;
2394 background: var(--bg-secondary);
2395 border-radius: 9999px;
2396 overflow: hidden;
2397 }
2398 .pri-age-bar {
2399 height: 100%;
2400 border-radius: 9999px;
2401 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
2402 min-width: 4px;
2403 }
2404 .pri-age-count {
2405 flex: 0 0 32px;
2406 text-align: right;
2407 font-weight: 600;
2408 color: var(--text-strong);
2409 font-size: 13px;
2410 }
2411 .pri-sparkline {
2412 display: flex;
2413 align-items: flex-end;
2414 gap: 3px;
2415 height: 40px;
2416 }
2417 .pri-spark-bar {
2418 flex: 1;
2419 min-height: 2px;
2420 border-radius: 2px 2px 0 0;
2421 background: var(--accent, #8c6dff);
2422 opacity: 0.7;
2423 }
2424 .pri-empty {
2425 color: var(--text-muted);
2426 font-size: 14px;
2427 padding: 24px 0;
2428 text-align: center;
2429 }
2430 @media (max-width: 600px) {
2431 .pri-cards { grid-template-columns: repeat(2, 1fr); }
2432 .pri-hero { padding: 18px 18px 20px; }
2433 }
2434`;
2435
2436pulls.get("/:owner/:repo/pulls/insights", softAuth, requireRepoAccess("read"), async (c) => {
2437 const { owner: ownerName, repo: repoName } = c.req.param();
2438 const user = c.get("user");
2439
2440 const resolved = await resolveRepo(ownerName, repoName);
2441 if (!resolved) return c.notFound();
2442
2443 const repoId = resolved.repo.id;
2444 const now = Date.now();
2445
2446 // 1. Merged PRs in last 90 days (avg merge time)
2447 const mergedPRs = await db
2448 .select({ createdAt: pullRequests.createdAt, mergedAt: pullRequests.mergedAt })
2449 .from(pullRequests)
2450 .where(and(
2451 eq(pullRequests.repositoryId, repoId),
2452 eq(pullRequests.state, "merged"),
2453 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2454 ));
2455
2456 const avgMergeMs = mergedPRs.length > 0
2457 ? mergedPRs.reduce((s, p) => s + (p.mergedAt!.getTime() - p.createdAt.getTime()), 0) / mergedPRs.length
2458 : null;
2459
2460 // 2. PR throughput (last 8 weeks)
2461 const weeklyPRs = await db
2462 .select({
2463 week: sql<string>`date_trunc('week', ${pullRequests.createdAt})::text`,
2464 count: sql<number>`count(*)::int`,
2465 })
2466 .from(pullRequests)
2467 .where(and(
2468 eq(pullRequests.repositoryId, repoId),
2469 sql`${pullRequests.createdAt} > now() - interval '56 days'`
2470 ))
2471 .groupBy(sql`date_trunc('week', ${pullRequests.createdAt})`)
2472 .orderBy(sql`date_trunc('week', ${pullRequests.createdAt})`);
2473
2474 const maxWeekCount = weeklyPRs.length > 0 ? Math.max(...weeklyPRs.map((w) => w.count)) : 1;
2475
2476 // 3. PR merge rate (last 90 days)
2477 const [rateCounts] = await db
2478 .select({
2479 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
2480 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
2481 })
2482 .from(pullRequests)
2483 .where(and(
2484 eq(pullRequests.repositoryId, repoId),
2485 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2486 ));
2487
2488 const totalResolved = (rateCounts?.merged ?? 0) + (rateCounts?.closed ?? 0);
2489 const mergeRate = totalResolved > 0
2490 ? Math.round(((rateCounts?.merged ?? 0) / totalResolved) * 100)
2491 : null;
2492
2493 // 4. Top reviewers (last 90 days)
2494 const reviewerCounts = await db
2495 .select({
2496 userId: prReviews.reviewerId,
2497 username: users.username,
2498 count: sql<number>`count(*)::int`,
2499 })
2500 .from(prReviews)
2501 .innerJoin(users, eq(prReviews.reviewerId, users.id))
2502 .innerJoin(pullRequests, eq(prReviews.pullRequestId, pullRequests.id))
2503 .where(and(
2504 eq(pullRequests.repositoryId, repoId),
2505 sql`${prReviews.createdAt} > now() - interval '90 days'`
2506 ))
2507 .groupBy(prReviews.reviewerId, users.username)
2508 .orderBy(desc(sql`count(*)`))
2509 .limit(5);
2510
2511 // 5. Average reviews per merged PR
2512 const [avgReviewRow] = await db
2513 .select({
2514 avgReviews: sql<number>`(count(${prReviews.id})::float / nullif(count(distinct ${pullRequests.id}), 0))`,
2515 })
2516 .from(pullRequests)
2517 .leftJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2518 .where(and(
2519 eq(pullRequests.repositoryId, repoId),
2520 eq(pullRequests.state, "merged"),
2521 sql`${pullRequests.mergedAt} > now() - interval '90 days'`
2522 ));
2523
2524 const avgReviewsPerPr = avgReviewRow?.avgReviews != null
2525 ? Math.round(avgReviewRow.avgReviews * 10) / 10
2526 : null;
2527
2528 // 6. Review turnaround — avg time from PR open to first review
2529 const prsWithReviews = await db
2530 .select({
2531 createdAt: pullRequests.createdAt,
2532 firstReview: sql<string>`min(${prReviews.createdAt})::text`,
2533 })
2534 .from(pullRequests)
2535 .innerJoin(prReviews, eq(prReviews.pullRequestId, pullRequests.id))
2536 .where(and(
2537 eq(pullRequests.repositoryId, repoId),
2538 sql`${pullRequests.createdAt} > now() - interval '90 days'`
2539 ))
2540 .groupBy(pullRequests.id, pullRequests.createdAt);
2541
2542 const avgReviewTurnaroundMs = prsWithReviews.length > 0
2543 ? prsWithReviews.reduce((s, row) => {
2544 const firstMs = new Date(row.firstReview).getTime();
2545 return s + Math.max(0, firstMs - row.createdAt.getTime());
2546 }, 0) / prsWithReviews.length
2547 : null;
2548
2549 // 7. Open PRs by age bucket
2550 const openPRs = await db
2551 .select({ createdAt: pullRequests.createdAt })
2552 .from(pullRequests)
2553 .where(and(
2554 eq(pullRequests.repositoryId, repoId),
2555 eq(pullRequests.state, "open")
2556 ));
2557
2558 const ageBuckets = { lt1d: 0, d1to3: 0, d3to7: 0, d7to30: 0, gt30d: 0 };
2559 for (const { createdAt } of openPRs) {
2560 const ageDays = (now - createdAt.getTime()) / 86_400_000;
2561 if (ageDays < 1) ageBuckets.lt1d++;
2562 else if (ageDays < 3) ageBuckets.d1to3++;
2563 else if (ageDays < 7) ageBuckets.d3to7++;
2564 else if (ageDays < 30) ageBuckets.d7to30++;
2565 else ageBuckets.gt30d++;
2566 }
2567 const maxAgeBucket = Math.max(1, ...Object.values(ageBuckets));
2568
2569 // 8. 7-day merge sparkline
2570 const sparklineRows = await db
2571 .select({
2572 day: sql<string>`date_trunc('day', ${pullRequests.mergedAt})::text`,
2573 count: sql<number>`count(*)::int`,
2574 })
2575 .from(pullRequests)
2576 .where(and(
2577 eq(pullRequests.repositoryId, repoId),
2578 eq(pullRequests.state, "merged"),
2579 sql`${pullRequests.mergedAt} > now() - interval '7 days'`
2580 ))
2581 .groupBy(sql`date_trunc('day', ${pullRequests.mergedAt})`)
2582 .orderBy(sql`date_trunc('day', ${pullRequests.mergedAt})`);
2583
2584 const sparkMap = new Map<string, number>();
2585 for (const row of sparklineRows) {
2586 sparkMap.set(row.day.slice(0, 10), row.count);
2587 }
2588 const sparkline: number[] = [];
2589 for (let i = 6; i >= 0; i--) {
2590 const d = new Date(now - i * 86_400_000);
2591 sparkline.push(sparkMap.get(d.toISOString().slice(0, 10)) ?? 0);
2592 }
2593 const maxSpark = Math.max(1, ...sparkline);
2594
2595 const ageBucketDefs: Array<{ label: string; key: keyof typeof ageBuckets }> = [
2596 { label: "< 1 day", key: "lt1d" },
2597 { label: "1–3 days", key: "d1to3" },
2598 { label: "3–7 days", key: "d3to7" },
2599 { label: "7–30 days", key: "d7to30" },
2600 { label: "> 30 days", key: "gt30d" },
2601 ];
2602
2603 return c.html(
2604 <Layout title={`PR Insights — ${ownerName}/${repoName}`} user={user}>
2605 <RepoHeader owner={ownerName} repo={repoName} />
2606 <PrNav owner={ownerName} repo={repoName} active="pulls" />
2607 <style dangerouslySetInnerHTML={{ __html: PR_INSIGHTS_STYLES }} />
2608
2609 <div class="pri-page">
2610 {/* Hero */}
2611 <div class="pri-hero">
2612 <div class="pri-hero-eyebrow">Pull requests</div>
2613 <h1 class="pri-hero-title">
2614 PR <span class="gradient-text">Insights</span>
2615 </h1>
2616 <p class="pri-hero-sub">90-day analytics for {ownerName}/{repoName}</p>
2617 </div>
2618
2619 {/* Stat cards */}
2620 <div class="pri-section">
2621 <div class="pri-section-title">At a glance</div>
2622 <div class="pri-cards">
2623 <div class="pri-card">
2624 <div class="pri-card-label">Avg merge time</div>
2625 <div class="pri-card-value">
2626 {avgMergeMs != null ? formatMsDuration(avgMergeMs) : "—"}
2627 </div>
2628 <div class="pri-card-sub">last 90 days</div>
2629 </div>
2630 <div class="pri-card">
2631 <div class="pri-card-label">Total merged</div>
2632 <div class="pri-card-value">{mergedPRs.length}</div>
2633 <div class="pri-card-sub">last 90 days</div>
2634 </div>
2635 <div class="pri-card">
2636 <div class="pri-card-label">Open PRs</div>
2637 <div class="pri-card-value">{openPRs.length}</div>
2638 <div class="pri-card-sub">right now</div>
2639 </div>
2640 <div class="pri-card">
2641 <div class="pri-card-label">Merge rate</div>
2642 <div class="pri-card-value">
2643 {mergeRate != null ? `${mergeRate}%` : "—"}
2644 </div>
2645 <div class="pri-card-sub">merged vs closed</div>
2646 </div>
2647 <div class="pri-card">
2648 <div class="pri-card-label">Avg reviews / PR</div>
2649 <div class="pri-card-value">
2650 {avgReviewsPerPr != null ? String(avgReviewsPerPr) : "—"}
2651 </div>
2652 <div class="pri-card-sub">merged PRs, 90d</div>
2653 </div>
2654 <div class="pri-card">
2655 <div class="pri-card-label">Top reviewer</div>
2656 <div class="pri-card-value" style="font-size:18px;word-break:break-all">
2657 {reviewerCounts.length > 0 ? reviewerCounts[0].username : "—"}
2658 </div>
2659 <div class="pri-card-sub">
2660 {reviewerCounts.length > 0
2661 ? `${reviewerCounts[0].count} review${reviewerCounts[0].count === 1 ? "" : "s"}`
2662 : "no reviews yet"}
2663 </div>
2664 </div>
2665 </div>
2666 </div>
2667
2668 {/* Review turnaround */}
2669 <div class="pri-section">
2670 <div class="pri-section-title">Review turnaround</div>
2671 <div class="pri-cards" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
2672 <div class="pri-card">
2673 <div class="pri-card-label">Avg time to first review</div>
2674 <div class="pri-card-value">
2675 {avgReviewTurnaroundMs != null ? formatMsDuration(avgReviewTurnaroundMs) : "—"}
2676 </div>
2677 <div class="pri-card-sub">
2678 {prsWithReviews.length > 0
2679 ? `across ${prsWithReviews.length} PR${prsWithReviews.length === 1 ? "" : "s"} with reviews`
2680 : "no reviewed PRs in 90d"}
2681 </div>
2682 </div>
2683 </div>
2684 </div>
2685
2686 {/* Weekly throughput bar chart */}
2687 <div class="pri-section">
2688 <div class="pri-section-title">Weekly throughput (last 8 weeks)</div>
2689 {weeklyPRs.length === 0 ? (
2690 <div class="pri-empty">No PR activity in the last 8 weeks.</div>
2691 ) : (
2692 <div class="pri-chart">
2693 {weeklyPRs.map((w) => (
2694 <div class="pri-bar-col">
2695 <div
2696 class="pri-bar"
2697 style={`height: ${Math.max(4, Math.round((w.count / maxWeekCount) * 88))}px`}
2698 title={`${w.count} PR${w.count === 1 ? "" : "s"} week of ${formatWeekLabel(w.week)}`}
2699 />
2700 <span class="pri-bar-label">{formatWeekLabel(w.week)}</span>
2701 </div>
2702 ))}
2703 </div>
2704 )}
2705 </div>
2706
2707 {/* 7-day merge sparkline */}
2708 <div class="pri-section">
2709 <div class="pri-section-title">Merges this week (daily)</div>
2710 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px">
2711 <div class="pri-sparkline">
2712 {sparkline.map((v) => (
2713 <div
2714 class="pri-spark-bar"
2715 style={`height: ${Math.max(2, Math.round((v / maxSpark) * 36))}px`}
2716 title={`${v} merge${v === 1 ? "" : "s"}`}
2717 />
2718 ))}
2719 </div>
2720 <div style="font-size:11px;color:var(--text-muted);margin-top:6px;display:flex;justify-content:space-between">
2721 <span>7 days ago</span>
2722 <span>Today</span>
2723 </div>
2724 </div>
2725 </div>
2726
2727 {/* Top reviewers table */}
2728 <div class="pri-section">
2729 <div class="pri-section-title">Top reviewers (last 90 days)</div>
2730 {reviewerCounts.length === 0 ? (
2731 <div class="pri-empty">No reviews posted in the last 90 days.</div>
2732 ) : (
2733 <div class="pri-table-wrap">
2734 <table class="pri-table">
2735 <thead>
2736 <tr>
2737 <th>#</th>
2738 <th>Reviewer</th>
2739 <th>Reviews</th>
2740 </tr>
2741 </thead>
2742 <tbody>
2743 {reviewerCounts.map((r, i) => (
2744 <tr>
2745 <td style="color:var(--text-muted)">{i + 1}</td>
2746 <td>
2747 <a href={`/${r.username}`} style="color:var(--text-link);text-decoration:none">
2748 {r.username}
2749 </a>
2750 </td>
2751 <td style="font-weight:600">{r.count}</td>
2752 </tr>
2753 ))}
2754 </tbody>
2755 </table>
2756 </div>
2757 )}
2758 </div>
2759
2760 {/* Open PRs by age */}
2761 <div class="pri-section">
2762 <div class="pri-section-title">Open PRs by age</div>
2763 {openPRs.length === 0 ? (
2764 <div class="pri-empty">No open pull requests.</div>
2765 ) : (
2766 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:12px;padding:16px 20px">
2767 {ageBucketDefs.map(({ label, key }) => (
2768 <div class="pri-age-row">
2769 <span class="pri-age-label">{label}</span>
2770 <div class="pri-age-bar-wrap">
2771 <div
2772 class="pri-age-bar"
2773 style={`width: ${ageBuckets[key] > 0 ? Math.max(4, Math.round((ageBuckets[key] / maxAgeBucket) * 100)) : 0}%`}
2774 />
2775 </div>
2776 <span class="pri-age-count">{ageBuckets[key]}</span>
2777 </div>
2778 ))}
2779 </div>
2780 )}
2781 </div>
2782
2783 {/* Back link */}
2784 <div>
2785 <a href={`/${ownerName}/${repoName}/pulls`} style="color:var(--text-muted);font-size:13px;text-decoration:none">
2786 {"←"} Back to pull requests
2787 </a>
2788 </div>
2789 </div>
2790 </Layout>
2791 );
2792});
2793
0074234Claude2794// New PR form
2795pulls.get(
2796 "/:owner/:repo/pulls/new",
2797 softAuth,
2798 requireAuth,
04f6b7fClaude2799 requireRepoAccess("write"),
0074234Claude2800 async (c) => {
2801 const { owner: ownerName, repo: repoName } = c.req.param();
2802 const user = c.get("user")!;
2803 const branches = await listBranches(ownerName, repoName);
2804 const error = c.req.query("error");
2805 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude2806 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude2807
2808 return c.html(
2809 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
2810 <RepoHeader owner={ownerName} repo={repoName} />
2811 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude2812 <Container maxWidth={800}>
2813 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude2814 {error && (
bb0f894Claude2815 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude2816 )}
0316dbbClaude2817 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
2818 <Flex gap={12} align="center" style="margin-bottom: 16px">
2819 <Select name="base">
0074234Claude2820 {branches.map((b) => (
2821 <option value={b} selected={b === defaultBase}>
2822 {b}
2823 </option>
2824 ))}
bb0f894Claude2825 </Select>
2826 <Text muted>&larr;</Text>
2827 <Select name="head">
0074234Claude2828 {branches
2829 .filter((b) => b !== defaultBase)
2830 .concat(defaultBase === branches[0] ? [] : [branches[0]])
2831 .map((b) => (
2832 <option value={b}>{b}</option>
2833 ))}
bb0f894Claude2834 </Select>
2835 </Flex>
2836 <FormGroup>
2837 <Input
0074234Claude2838 name="title"
2839 required
2840 placeholder="Title"
bb0f894Claude2841 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]2842 aria-label="Pull request title"
0074234Claude2843 />
bb0f894Claude2844 </FormGroup>
2845 <FormGroup>
2846 <TextArea
0074234Claude2847 name="body"
81c73c1Claude2848 id="pr-body"
0074234Claude2849 rows={8}
2850 placeholder="Description (Markdown supported)"
bb0f894Claude2851 mono
0074234Claude2852 />
bb0f894Claude2853 </FormGroup>
81c73c1Claude2854 <Flex gap={8} align="center">
2855 <Button type="submit" variant="primary">
2856 Create pull request
2857 </Button>
2858 <button
2859 type="button"
2860 id="ai-suggest-desc"
2861 class="btn"
2862 style="font-weight:500"
2863 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
2864 >
2865 Suggest description with AI
2866 </button>
2867 <span
2868 id="ai-suggest-status"
2869 style="color:var(--text-muted);font-size:13px"
2870 />
2871 </Flex>
bb0f894Claude2872 </Form>
81c73c1Claude2873 <script
2874 dangerouslySetInnerHTML={{
2875 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
2876 }}
2877 />
bb0f894Claude2878 </Container>
0074234Claude2879 </Layout>
2880 );
2881 }
2882);
2883
81c73c1Claude2884// AI-suggested PR description — JSON endpoint driven by the form button.
2885// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
2886// 200; the inline script reads `ok` to decide what to do.
2887pulls.post(
2888 "/:owner/:repo/ai/pr-description",
2889 softAuth,
2890 requireAuth,
2891 requireRepoAccess("write"),
2892 async (c) => {
2893 const { owner: ownerName, repo: repoName } = c.req.param();
2894 if (!isAiAvailable()) {
2895 return c.json({
2896 ok: false,
2897 error: "AI is not available — set ANTHROPIC_API_KEY.",
2898 });
2899 }
2900 const body = await c.req.parseBody();
2901 const title = String(body.title || "").trim();
2902 const baseBranch = String(body.base || "").trim();
2903 const headBranch = String(body.head || "").trim();
2904 if (!baseBranch || !headBranch) {
2905 return c.json({ ok: false, error: "Pick base + head branches first." });
2906 }
2907 if (baseBranch === headBranch) {
2908 return c.json({ ok: false, error: "Base and head must differ." });
2909 }
2910
2911 let diff = "";
2912 try {
2913 const cwd = getRepoPath(ownerName, repoName);
2914 const proc = Bun.spawn(
2915 [
2916 "git",
2917 "diff",
2918 `${baseBranch}...${headBranch}`,
2919 "--",
2920 ],
2921 { cwd, stdout: "pipe", stderr: "pipe" }
2922 );
6ea2109Claude2923 // 30s ceiling — without this a pathological diff (huge binary or
2924 // a corrupt ref) hangs the request indefinitely.
2925 const killer = setTimeout(() => proc.kill(), 30_000);
2926 try {
2927 diff = await new Response(proc.stdout).text();
2928 await proc.exited;
2929 } finally {
2930 clearTimeout(killer);
2931 }
81c73c1Claude2932 } catch {
2933 diff = "";
2934 }
2935 if (!diff.trim()) {
2936 return c.json({
2937 ok: false,
2938 error: "No diff between branches — nothing to summarise.",
2939 });
2940 }
2941
2942 let summary = "";
2943 try {
2944 summary = await generatePrSummary(title || "(untitled)", diff);
2945 } catch (err) {
2946 const msg = err instanceof Error ? err.message : "AI request failed.";
2947 return c.json({ ok: false, error: msg });
2948 }
2949 if (!summary.trim()) {
2950 return c.json({ ok: false, error: "AI returned an empty draft." });
2951 }
2952 return c.json({ ok: true, body: summary });
2953 }
2954);
2955
0074234Claude2956// Create PR
2957pulls.post(
2958 "/:owner/:repo/pulls/new",
2959 softAuth,
2960 requireAuth,
04f6b7fClaude2961 requireRepoAccess("write"),
0074234Claude2962 async (c) => {
2963 const { owner: ownerName, repo: repoName } = c.req.param();
2964 const user = c.get("user")!;
2965 const body = await c.req.parseBody();
2966 const title = String(body.title || "").trim();
2967 const prBody = String(body.body || "").trim();
2968 const baseBranch = String(body.base || "main");
2969 const headBranch = String(body.head || "");
2970
2971 if (!title || !headBranch) {
2972 return c.redirect(
2973 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
2974 );
2975 }
2976
2977 if (baseBranch === headBranch) {
2978 return c.redirect(
2979 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
2980 );
2981 }
2982
2983 const resolved = await resolveRepo(ownerName, repoName);
2984 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2985
6fc53bdClaude2986 const isDraft = String(body.draft || "") === "1";
2987
0074234Claude2988 const [pr] = await db
2989 .insert(pullRequests)
2990 .values({
2991 repositoryId: resolved.repo.id,
2992 authorId: user.id,
2993 title,
2994 body: prBody || null,
2995 baseBranch,
2996 headBranch,
6fc53bdClaude2997 isDraft,
0074234Claude2998 })
2999 .returning();
3000
6fc53bdClaude3001 // Skip AI review on drafts — it runs again when the PR is marked ready.
3002 if (!isDraft && isAiReviewEnabled()) {
e883329Claude3003 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
3004 (err) => console.error("[ai-review] Failed:", err)
3005 );
3006 }
3007
3cbe3d6Claude3008 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
3009 triggerPrTriage({
3010 ownerName,
3011 repoName,
3012 repositoryId: resolved.repo.id,
3013 prId: pr.id,
3014 prAuthorId: user.id,
3015 title,
3016 body: prBody,
3017 baseBranch,
3018 headBranch,
3019 }).catch((err) => console.error("[pr-triage] Failed:", err));
3020
1d4ff60Claude3021 // Chat notifier — fan out to Slack/Discord/Teams.
3022 import("../lib/chat-notifier")
3023 .then((m) =>
3024 m.notifyChatChannels({
3025 ownerUserId: resolved.repo.ownerId,
3026 repositoryId: resolved.repo.id,
3027 event: {
3028 event: "pr.opened",
3029 repo: `${ownerName}/${repoName}`,
3030 title: `#${pr.number} ${title}`,
3031 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
3032 body: prBody || undefined,
3033 actor: user.username,
3034 },
3035 })
3036 )
3037 .catch((err) =>
3038 console.warn(`[chat-notifier] PR opened notify failed:`, err)
3039 );
3040
9dd96b9Test User3041 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude3042 import("../lib/auto-merge")
3043 .then((m) => m.tryAutoMergeNow(pr.id))
3044 .catch((err) => {
3045 console.warn(
3046 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
3047 err instanceof Error ? err.message : err
3048 );
3049 });
9dd96b9Test User3050
0074234Claude3051 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
3052 }
3053);
3054
3055// View single PR
04f6b7fClaude3056pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude3057 const { owner: ownerName, repo: repoName } = c.req.param();
3058 const prNum = parseInt(c.req.param("number"), 10);
3059 const user = c.get("user");
3060 const tab = c.req.query("tab") || "conversation";
3061
3062 const resolved = await resolveRepo(ownerName, repoName);
3063 if (!resolved) return c.notFound();
3064
3065 const [pr] = await db
3066 .select()
3067 .from(pullRequests)
3068 .where(
3069 and(
3070 eq(pullRequests.repositoryId, resolved.repo.id),
3071 eq(pullRequests.number, prNum)
3072 )
3073 )
3074 .limit(1);
3075
3076 if (!pr) return c.notFound();
3077
3078 const [author] = await db
3079 .select()
3080 .from(users)
3081 .where(eq(users.id, pr.authorId))
3082 .limit(1);
3083
cb5a796Claude3084 const allCommentsRaw = await db
0074234Claude3085 .select({
3086 comment: prComments,
cb5a796Claude3087 author: { id: users.id, username: users.username },
0074234Claude3088 })
3089 .from(prComments)
3090 .innerJoin(users, eq(prComments.authorId, users.id))
3091 .where(eq(prComments.pullRequestId, pr.id))
3092 .orderBy(asc(prComments.createdAt));
3093
cb5a796Claude3094 // Filter pending/rejected/spam for non-owner, non-author viewers.
3095 // Owner always sees everything; comment author sees their own pending
3096 // with an "Awaiting approval" badge in the render below.
3097 const viewerIsRepoOwner = !!(user && user.id === resolved.owner.id);
3098 const comments = allCommentsRaw.filter(({ comment, author: cAuthor }) => {
3099 if (viewerIsRepoOwner) return true;
3100 if (comment.moderationStatus === "approved") return true;
3101 if (
3102 user &&
3103 cAuthor.id === user.id &&
3104 comment.moderationStatus === "pending"
3105 ) {
3106 return true;
3107 }
3108 return false;
3109 });
3110 const prPendingCount = viewerIsRepoOwner
3111 ? await countPendingForRepo(resolved.repo.id)
3112 : 0;
3113
6fc53bdClaude3114 // Reactions for the PR body + each comment, in parallel.
3115 const [prReactions, ...prCommentReactions] = await Promise.all([
3116 summariseReactions("pr", pr.id, user?.id),
3117 ...comments.map((row) =>
3118 summariseReactions("pr_comment", row.comment.id, user?.id)
3119 ),
3120 ]);
3121
0a67773Claude3122 // Formal reviews (Approve / Request Changes)
3123 const reviewRows = await db
3124 .select({
3125 id: prReviews.id,
3126 state: prReviews.state,
3127 body: prReviews.body,
3128 isAi: prReviews.isAi,
3129 createdAt: prReviews.createdAt,
3130 reviewerUsername: users.username,
3131 reviewerId: prReviews.reviewerId,
3132 })
3133 .from(prReviews)
3134 .innerJoin(users, eq(prReviews.reviewerId, users.id))
3135 .where(eq(prReviews.pullRequestId, pr.id))
3136 .orderBy(asc(prReviews.createdAt));
3137 // Most recent review per reviewer determines the current state
3138 const latestReviewByReviewer = new Map<string, typeof reviewRows[0]>();
3139 for (const r of reviewRows) {
3140 if (r.state !== "commented") latestReviewByReviewer.set(r.reviewerId, r);
3141 }
3142 const approvals = [...latestReviewByReviewer.values()].filter(r => r.state === "approved");
3143 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
3144 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
3145
0074234Claude3146 const canManage =
3147 user &&
3148 (user.id === resolved.owner.id || user.id === pr.authorId);
3149
1d4ff60Claude3150 // Has any previous AI-test-generator run already tagged this PR? Used
3151 // both to hide the "Generate tests with AI" button and to short-circuit
3152 // the explicit POST handler.
3153 const hasAiTestsMarker = comments.some(({ comment }) =>
3154 (comment.body || "").includes(AI_TESTS_MARKER)
3155 );
3156
e883329Claude3157 const error = c.req.query("error");
c3e0c07Claude3158 const info = c.req.query("info");
e883329Claude3159
3160 // Get gate check status for open PRs
3161 let gateChecks: GateCheckResult[] = [];
240c477Claude3162 let ciStatuses: CommitStatus[] = [];
e883329Claude3163 if (pr.state === "open") {
3164 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
3165 if (headSha) {
3166 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
3167 const aiApproved = aiComments.length === 0 || aiComments.some(
3168 ({ comment }) => comment.body.includes("**Approved**")
3169 );
240c477Claude3170 const [gateResult, fetchedCiStatuses] = await Promise.all([
3171 runAllGateChecks(
3172 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
3173 ),
3174 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
3175 ]);
e883329Claude3176 gateChecks = gateResult.checks;
240c477Claude3177 ciStatuses = fetchedCiStatuses;
e883329Claude3178 }
3179 }
3180
534f04aClaude3181 // Block M3 — pre-merge risk score. Cache-only on the request path so
3182 // the page never waits on Haiku. On a cache miss for an open PR we
3183 // kick off the computation fire-and-forget; the next refresh shows it.
3184 let prRisk: PrRiskScore | null = null;
3185 let prRiskCalculating = false;
3186 if (pr.state === "open") {
3187 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
3188 if (!prRisk) {
3189 prRiskCalculating = true;
a28cedeClaude3190 void computePrRiskForPullRequest(pr.id).catch((err) => {
3191 console.warn(
3192 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
3193 err instanceof Error ? err.message : err
3194 );
3195 });
534f04aClaude3196 }
3197 }
3198
4bbacbeClaude3199 // Migration 0062 — per-branch preview URL. The head branch always
3200 // has a preview row (unless it's the default branch, which never
3201 // happens for an open PR) once it has been pushed at least once.
3202 const preview = await getPreviewForBranch(
3203 (resolved.repo as { id: string }).id,
3204 pr.headBranch
3205 );
3206
0369e77Claude3207 // Branch ahead/behind counts — how many commits head is ahead of base and
3208 // how many commits base has advanced since head branched off.
3209 let branchAhead = 0;
3210 let branchBehind = 0;
3211 if (pr.state === "open") {
3212 try {
3213 const repoDir = getRepoPath(ownerName, repoName);
3214 const [aheadProc, behindProc] = [
3215 Bun.spawn(
3216 ["git", "rev-list", "--count", `${pr.baseBranch}..${pr.headBranch}`],
3217 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3218 ),
3219 Bun.spawn(
3220 ["git", "rev-list", "--count", `${pr.headBranch}..${pr.baseBranch}`],
3221 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3222 ),
3223 ];
3224 const [aheadTxt, behindTxt] = await Promise.all([
3225 new Response(aheadProc.stdout).text(),
3226 new Response(behindProc.stdout).text(),
3227 ]);
3228 await Promise.all([aheadProc.exited, behindProc.exited]);
3229 branchAhead = parseInt(aheadTxt.trim(), 10) || 0;
3230 branchBehind = parseInt(behindTxt.trim(), 10) || 0;
3231 } catch { /* non-blocking */ }
3232 }
3233
6d1bbc2Claude3234 // Linked issues — parse closing keywords from PR title+body, look up issues
3235 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
3236 try {
3237 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3238 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3239 if (refs.length > 0) {
3240 linkedIssues = await db
3241 .select({ number: issues.number, title: issues.title, state: issues.state })
3242 .from(issues)
3243 .where(and(
3244 eq(issues.repositoryId, resolved.repo.id),
3245 inArray(issues.number, refs),
3246 ));
3247 }
3248 } catch { /* non-blocking */ }
3249
3250 // Task list progress — count markdown checkboxes in PR body
3251 let taskTotal = 0;
3252 let taskChecked = 0;
3253 if (pr.body) {
3254 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
3255 taskTotal++;
3256 if (m[1].trim() !== "") taskChecked++;
3257 }
3258 }
3259
47a7a0aClaude3260 // Get diff for "Files changed" tab + load inline comments for that tab
0074234Claude3261 let diffRaw = "";
3262 let diffFiles: GitDiffFile[] = [];
47a7a0aClaude3263 let diffInlineComments: InlineDiffComment[] = [];
0074234Claude3264 if (tab === "files") {
3265 const repoDir = getRepoPath(ownerName, repoName);
6ea2109Claude3266 // Run the two git diffs in parallel — they're independent reads of
3267 // the same range. Previously sequential, doubling the wall time on
3268 // big PRs (100+ files = 10-30s for no reason).
0074234Claude3269 const proc = Bun.spawn(
3270 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
3271 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3272 );
3273 const statProc = Bun.spawn(
3274 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
3275 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3276 );
6ea2109Claude3277 // 30s ceiling per spawn — a corrupt ref / pathological binary diff
3278 // would otherwise hang the whole request.
3279 const killer = setTimeout(() => {
3280 proc.kill();
3281 statProc.kill();
3282 }, 30_000);
3283 let stat = "";
3284 try {
3285 [diffRaw, stat] = await Promise.all([
3286 new Response(proc.stdout).text(),
3287 new Response(statProc.stdout).text(),
3288 ]);
3289 await Promise.all([proc.exited, statProc.exited]);
3290 } finally {
3291 clearTimeout(killer);
3292 }
0074234Claude3293
3294 diffFiles = stat
3295 .trim()
3296 .split("\n")
3297 .filter(Boolean)
3298 .map((line) => {
3299 const [add, del, filePath] = line.split("\t");
3300 return {
3301 path: filePath,
3302 status: "modified",
3303 additions: add === "-" ? 0 : parseInt(add, 10),
3304 deletions: del === "-" ? 0 : parseInt(del, 10),
3305 patch: "",
3306 };
3307 });
47a7a0aClaude3308
3309 // Fetch inline comments (file+line anchored) for the files tab
3310 const inlineRows = await db
3311 .select({
3312 id: prComments.id,
3313 filePath: prComments.filePath,
3314 lineNumber: prComments.lineNumber,
3315 body: prComments.body,
3316 isAiReview: prComments.isAiReview,
3317 createdAt: prComments.createdAt,
3318 authorUsername: users.username,
3319 })
3320 .from(prComments)
3321 .innerJoin(users, eq(prComments.authorId, users.id))
3322 .where(
3323 and(
3324 eq(prComments.pullRequestId, pr.id),
3325 eq(prComments.moderationStatus, "approved"),
3326 )
3327 )
3328 .orderBy(asc(prComments.createdAt));
3329
3330 diffInlineComments = inlineRows
3331 .filter(r => r.filePath != null && r.lineNumber != null)
3332 .map(r => ({
3333 id: r.id,
3334 filePath: r.filePath!,
3335 lineNumber: r.lineNumber!,
3336 authorUsername: r.authorUsername,
3337 body: renderMarkdown(r.body),
3338 isAiReview: r.isAiReview,
3339 createdAt: r.createdAt.toISOString(),
3340 }));
0074234Claude3341 }
3342
b078860Claude3343 // ─── Derived visual state ───
3344 const stateKey =
3345 pr.state === "open"
3346 ? pr.isDraft
3347 ? "draft"
3348 : "open"
3349 : pr.state;
3350 const stateLabel =
3351 stateKey === "open"
3352 ? "Open"
3353 : stateKey === "draft"
3354 ? "Draft"
3355 : stateKey === "merged"
3356 ? "Merged"
3357 : "Closed";
3358 const stateIcon =
3359 stateKey === "open"
3360 ? "○"
3361 : stateKey === "draft"
3362 ? "◌"
3363 : stateKey === "merged"
3364 ? "⮌"
3365 : "✓";
3366 const commentCount = comments.length;
3367 const aiReviewCount = comments.filter(({ comment }) => comment.isAiReview).length;
3368 const gatesAllPassed = gateChecks.length > 0 && gateChecks.every((c) => c.passed);
3369 const mergeBlocked =
3370 gateChecks.length > 0 &&
3371 gateChecks.some(
3372 (c) => !c.passed && c.name !== "Merge check"
3373 );
3374
b558f23Claude3375 // Commits tab — list commits included in this PR (base..head range)
3376 let prCommits: GitCommit[] = [];
3377 if (tab === "commits") {
3378 prCommits = await commitsBetween(ownerName, repoName, pr.baseBranch, pr.headBranch).catch(() => []);
3379 }
3380
0074234Claude3381 return c.html(
3382 <Layout
3383 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
3384 user={user}
3385 >
3386 <RepoHeader owner={ownerName} repo={repoName} />
3387 <PrNav owner={ownerName} repo={repoName} active="pulls" />
cb5a796Claude3388 <PendingCommentsBanner
3389 owner={ownerName}
3390 repo={repoName}
3391 count={prPendingCount}
3392 />
b078860Claude3393 <style dangerouslySetInnerHTML={{ __html: PRS_DETAIL_STYLES }} />
b584e52Claude3394 <div
3395 id="live-comment-banner"
3396 class="alert"
3397 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
3398 >
3399 <strong class="js-live-count">0</strong> new comment(s) —{" "}
3400 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
3401 reload to view
3402 </a>
3403 </div>
3404 <script
3405 dangerouslySetInnerHTML={{
3406 __html: liveCommentBannerScript({
3407 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
3408 bannerElementId: "live-comment-banner",
3409 }),
3410 }}
3411 />
b078860Claude3412
3413 <div class="prs-detail-hero">
b558f23Claude3414 <div class="prs-edit-title-wrap">
3415 <h1 class="prs-detail-title" id="pr-title-display">
3416 {pr.title}{" "}
3417 <span class="prs-detail-num">#{pr.number}</span>
3418 </h1>
3419 {canManage && pr.state === "open" && (
3420 <button
3421 type="button"
3422 class="prs-edit-btn"
3423 id="pr-edit-toggle"
3424 onclick={`
3425 document.getElementById('pr-title-display').style.display='none';
3426 document.getElementById('pr-edit-toggle').style.display='none';
3427 document.getElementById('pr-edit-form').style.display='flex';
3428 document.getElementById('pr-title-input').focus();
3429 `}
3430 >
3431 Edit
3432 </button>
3433 )}
3434 </div>
3435 {canManage && pr.state === "open" && (
3436 <form
3437 id="pr-edit-form"
3438 method="post"
3439 action={`/${ownerName}/${repoName}/pulls/${pr.number}/edit`}
3440 class="prs-edit-form"
3441 style="display:none"
3442 >
3443 <input
3444 id="pr-title-input"
3445 type="text"
3446 name="title"
3447 value={pr.title}
3448 required
3449 maxlength={256}
3450 placeholder="Pull request title"
3451 />
3452 <div class="prs-edit-actions">
3453 <button type="submit" class="prs-edit-save-btn">Save</button>
3454 <button
3455 type="button"
3456 class="prs-edit-cancel-btn"
3457 onclick={`
3458 document.getElementById('pr-edit-form').style.display='none';
3459 document.getElementById('pr-title-display').style.display='';
3460 document.getElementById('pr-edit-toggle').style.display='';
3461 `}
3462 >
3463 Cancel
3464 </button>
3465 </div>
3466 </form>
3467 )}
b078860Claude3468 <div class="prs-detail-meta">
3469 <span class={`prs-state-pill state-${stateKey}`}>
3470 <span aria-hidden="true">{stateIcon}</span>
3471 <span>{stateLabel}</span>
3472 </span>
3473 <span>
3474 <strong>{author?.username}</strong> wants to merge
3475 </span>
3476 <span class="prs-detail-branches" title={`${pr.headBranch} into ${pr.baseBranch}`}>
3477 <span class="prs-branch-pill is-head">{pr.headBranch}</span>
3478 <span class="prs-branch-arrow-lg">{"→"}</span>
3479 <span class="prs-branch-pill">{pr.baseBranch}</span>
3480 </span>
0369e77Claude3481 {pr.state === "open" && (branchAhead > 0 || branchBehind > 0) && (
3482 <span
3483 class={`prs-branch-sync${branchBehind > 0 ? " is-behind" : " is-synced"}`}
3484 title={branchBehind > 0
3485 ? `This branch is ${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind ${pr.baseBranch} — consider rebasing`
3486 : `This branch is ${branchAhead} commit${branchAhead === 1 ? "" : "s"} ahead of ${pr.baseBranch}`}
3487 >
3488 {branchAhead > 0 ? `↑${branchAhead}` : ""}
3489 {branchAhead > 0 && branchBehind > 0 ? " " : ""}
3490 {branchBehind > 0 ? `↓${branchBehind}` : ""}
3491 </span>
3492 )}
b078860Claude3493 <span>opened {formatRelative(pr.createdAt)}</span>
6d1bbc2Claude3494 {taskTotal > 0 && (
3495 <span
3496 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
3497 title={`${taskChecked} of ${taskTotal} tasks completed`}
3498 >
3499 <span class="prs-tasks-progress" aria-hidden="true">
3500 <span
3501 class="prs-tasks-progress-bar"
3502 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
3503 ></span>
3504 </span>
3505 {taskChecked}/{taskTotal} tasks
3506 </span>
3507 )}
3508 {canManage && pr.state === "open" && branchBehind > 0 && (
3509 <form
3510 method="post"
3511 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
3512 class="prs-inline-form"
3513 >
3514 <button
3515 type="submit"
3516 class="prs-update-branch-btn"
3517 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
3518 >
3519 ↑ Update branch
3520 </button>
3521 </form>
3522 )}
3c03977Claude3523 <span
3524 id="live-pill"
3525 class="live-pill"
3526 title="People editing this PR right now"
3527 >
3528 <span class="live-pill-dot" aria-hidden="true"></span>
3529 <span>
3530 Live: <strong id="live-count">0</strong> editing
3531 </span>
3532 <span id="live-avatars" class="live-avatars" aria-hidden="true"></span>
3533 </span>
4bbacbeClaude3534 {preview && (
3535 <a
3536 class={`preview-prpill is-${preview.status}`}
3537 href={
3538 preview.status === "ready"
3539 ? preview.previewUrl
3540 : `/${ownerName}/${repoName}/previews`
3541 }
3542 target={preview.status === "ready" ? "_blank" : undefined}
3543 rel={preview.status === "ready" ? "noopener noreferrer" : undefined}
3544 title={`Preview · ${previewStatusLabel(preview.status)}`}
3545 >
3546 <span class="preview-prpill-dot" aria-hidden="true"></span>
3547 <span>Preview: </span>
3548 <span>{previewStatusLabel(preview.status)}</span>
3549 </a>
3550 )}
b078860Claude3551 {canManage && pr.state === "open" && pr.isDraft && (
3552 <form
3553 method="post"
3554 action={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3555 class="prs-inline-form prs-detail-actions"
3556 >
3557 <button type="submit" class="prs-merge-ready-btn">
3558 Ready for review
3559 </button>
3560 </form>
3561 )}
3562 </div>
3563 </div>
3c03977Claude3564 <script
3565 dangerouslySetInnerHTML={{
3566 __html: LIVE_COEDIT_SCRIPT(pr.id),
3567 }}
3568 />
829a046Claude3569 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
6cd2f0eClaude3570 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
80bd7c8Claude3571 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
0074234Claude3572
b078860Claude3573 <nav class="prs-detail-tabs" aria-label="Pull request sections">
3574 <a
3575 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
3576 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
3577 >
3578 Conversation
3579 <span class="prs-detail-tab-count">{commentCount}</span>
3580 </a>
b558f23Claude3581 <a
3582 class={`prs-detail-tab${tab === "commits" ? " is-active" : ""}`}
3583 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=commits`}
3584 >
3585 Commits
3586 {branchAhead > 0 && (
3587 <span class="prs-detail-tab-count">{branchAhead}</span>
3588 )}
3589 </a>
b078860Claude3590 <a
3591 class={`prs-detail-tab${tab === "files" ? " is-active" : ""}`}
3592 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3593 >
3594 Files changed
3595 {diffFiles.length > 0 && (
3596 <span class="prs-detail-tab-count">{diffFiles.length}</span>
3597 )}
3598 </a>
3599 </nav>
3600
b558f23Claude3601 {tab === "commits" ? (
3602 <div class="prs-commits-list">
3603 {prCommits.length === 0 ? (
3604 <div class="prs-commits-empty">No commits between {pr.baseBranch} and {pr.headBranch}.</div>
3605 ) : (
3606 prCommits.map((commit) => (
3607 <div class="prs-commit-row">
3608 <span class="prs-commit-dot" aria-hidden="true"></span>
3609 <div class="prs-commit-body">
3610 <div class="prs-commit-msg" title={commit.message}>{commit.message}</div>
3611 <div class="prs-commit-meta">
3612 <strong>{commit.author}</strong> committed{" "}
3613 {formatRelative(new Date(commit.date))}
3614 </div>
3615 </div>
3616 <a
3617 href={`/${ownerName}/${repoName}/commit/${commit.sha}`}
3618 class="prs-commit-sha"
3619 title="View commit"
3620 >
3621 {commit.sha.slice(0, 7)}
3622 </a>
3623 </div>
3624 ))
3625 )}
3626 </div>
3627 ) : tab === "files" ? (
ea9ed4cClaude3628 <DiffView
3629 raw={diffRaw}
3630 files={diffFiles}
3631 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
47a7a0aClaude3632 inlineComments={diffInlineComments}
3633 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
b5dd694Claude3634 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
ea9ed4cClaude3635 />
b078860Claude3636 ) : (
3637 <>
3638 {pr.body && (
3639 <CommentBox
3640 author={author?.username ?? "unknown"}
3641 date={pr.createdAt}
3642 body={renderMarkdown(pr.body)}
3643 />
3644 )}
3645
422a2d4Claude3646 {/* Block H — AI trio review (security/correctness/style). When
3647 `AI_TRIO_REVIEW_ENABLED=1` the three persona comments are
3648 hoisted into a 3-column card grid above the normal comment
3649 stream so reviewers see verdicts at a glance. Disagreements
3650 are surfaced as a yellow callout. */}
3651 <TrioReviewGrid
3652 comments={comments.map(({ comment }) => comment)}
3653 />
3654
15db0e0Claude3655 {comments.map(({ comment, author: commentAuthor }) => {
422a2d4Claude3656 // Skip trio comments — already rendered in TrioReviewGrid above.
3657 if (isTrioComment(comment.body)) return null;
15db0e0Claude3658 const slashCmd = detectSlashCmdComment(comment.body);
3659 if (slashCmd) {
3660 const visible = stripSlashCmdMarker(comment.body);
3661 return (
3662 <div class={`slash-pill slash-cmd-${slashCmd}`}>
3663 <span class="slash-pill-icon" aria-hidden="true">{"⚡"}</span>
3664 <span class="slash-pill-actor">
3665 <strong>{commentAuthor.username}</strong>
3666 {" ran "}
3667 <code class="slash-pill-cmd">/{slashCmd}</code>
b078860Claude3668 </span>
15db0e0Claude3669 <span class="slash-pill-time">
3670 {formatRelative(comment.createdAt)}
3671 </span>
3672 <div class="slash-pill-body">
3673 <MarkdownContent html={renderMarkdown(visible)} />
3674 </div>
3675 </div>
3676 );
3677 }
cb5a796Claude3678 const isPending = comment.moderationStatus === "pending";
15db0e0Claude3679 return (
cb5a796Claude3680 <div
3681 class={`prs-comment${comment.isAiReview ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}
3682 >
15db0e0Claude3683 <div class="prs-comment-head">
3684 <strong>{commentAuthor.username}</strong>
3685 {comment.isAiReview && (
3686 <span class="prs-ai-badge">AI Review</span>
3687 )}
cb5a796Claude3688 {isPending && (
3689 <span
3690 class="modq-pending-badge"
3691 title="This comment is awaiting the repository owner's approval — only you and the owner can see it."
3692 >
3693 Awaiting approval
3694 </span>
3695 )}
15db0e0Claude3696 <span class="prs-comment-time">
3697 commented {formatRelative(comment.createdAt)}
3698 </span>
3699 {comment.filePath && (
3700 <span class="prs-comment-loc">
3701 {comment.filePath}
3702 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
3703 </span>
3704 )}
3705 </div>
3706 <div class="prs-comment-body">
3707 <MarkdownContent html={renderMarkdown(comment.body)} />
3708 </div>
0074234Claude3709 </div>
15db0e0Claude3710 );
3711 })}
0074234Claude3712
b078860Claude3713 {/* Quick link to the Files changed tab when there's a diff to look at. */}
3714 {pr.state !== "merged" && (
3715 <a
3716 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
3717 class="prs-files-card"
3718 >
3719 <span class="prs-files-card-icon" aria-hidden="true">
3720 {"▤"}
3721 </span>
3722 <div class="prs-files-card-text">
3723 <p class="prs-files-card-title">Files changed</p>
3724 <p class="prs-files-card-sub">
3725 Side-by-side diff for {pr.headBranch} {"→"} {pr.baseBranch}.
3726 </p>
e883329Claude3727 </div>
b078860Claude3728 <span class="prs-files-card-cta">View diff {"→"}</span>
3729 </a>
3730 )}
3731
6d1bbc2Claude3732 {linkedIssues.length > 0 && (
3733 <div class="prs-linked-issues">
3734 <div class="prs-linked-issues-head">
3735 <span>Closing issues</span>
3736 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
3737 </div>
3738 {linkedIssues.map((issue) => (
3739 <a
3740 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
3741 class="prs-linked-issue-row"
3742 >
3743 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
3744 {issue.state === "open" ? "○" : "✓"}
3745 </span>
3746 <span class="prs-linked-issue-title">{issue.title}</span>
3747 <span class="prs-linked-issue-num">#{issue.number}</span>
3748 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
3749 {issue.state}
3750 </span>
3751 </a>
3752 ))}
3753 </div>
3754 )}
3755
b078860Claude3756 {error && (
3757 <div
3758 class="auth-error"
3759 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)"
3760 >
3761 {decodeURIComponent(error)}
3762 </div>
3763 )}
3764
3765 {info && (
3766 <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)">
3767 {decodeURIComponent(info)}
3768 </div>
3769 )}
e883329Claude3770
b078860Claude3771 {pr.state === "open" && (prRisk || prRiskCalculating) && (
3772 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
3773 )}
3774
0a67773Claude3775 {/* ─── Review summary ─────────────────────────────────── */}
3776 {(approvals.length > 0 || changesRequested.length > 0) && (
3777 <div class="prs-review-summary">
3778 {approvals.length > 0 && (
3779 <div class="prs-review-row prs-review-approved">
3780 <span class="prs-review-icon">✓</span>
3781 <span>
3782 <strong>{approvals.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
3783 approved this pull request
3784 </span>
3785 </div>
3786 )}
3787 {changesRequested.length > 0 && (
3788 <div class="prs-review-row prs-review-changes">
3789 <span class="prs-review-icon">✗</span>
3790 <span>
3791 <strong>{changesRequested.map(r => r.reviewerUsername).join(", ")}</strong>{" "}
3792 requested changes
3793 </span>
3794 </div>
3795 )}
3796 </div>
3797 )}
3798
b078860Claude3799 {pr.state === "open" && gateChecks.length > 0 && (
3800 <div class="prs-gate-card">
3801 <div class="prs-gate-head">
3802 <h3>Gate checks</h3>
3803 <span class="prs-gate-summary">
3804 {gatesAllPassed
3805 ? `All ${gateChecks.length} checks passed`
3806 : `${gateChecks.filter((c) => !c.passed).length} of ${gateChecks.length} failing`}
3807 </span>
c3e0c07Claude3808 </div>
b078860Claude3809 {gateChecks.map((check) => {
3810 const isAi = /ai.*review/i.test(check.name);
3811 const isSkip = check.skipped === true;
3812 const statusClass = isSkip
3813 ? "is-skip"
3814 : check.passed
3815 ? "is-pass"
3816 : "is-fail";
3817 const statusGlyph = isSkip
3818 ? "—"
3819 : check.passed
3820 ? "✓"
3821 : "✗";
3822 const statusLabel = isSkip
3823 ? "Skipped"
3824 : check.passed
3825 ? "Passed"
3826 : "Failing";
3827 return (
3828 <div
3829 class="prs-gate-row"
3830 style={
3831 isAi
3832 ? "border-left: 3px solid rgba(140,109,255,0.55); padding-left: 15px"
3833 : ""
3834 }
3835 >
3836 <span class={`prs-gate-icon ${statusClass}`} aria-hidden="true">
3837 {statusGlyph}
3838 </span>
3839 <span class="prs-gate-name">
3840 {check.name}
3841 {isAi && (
3842 <span
3843 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"
3844 >
3845 AI
3846 </span>
3847 )}
3848 </span>
3849 <span class="prs-gate-details">{check.details}</span>
3850 <span class={`prs-gate-pill ${statusClass}`}>
3851 {statusLabel}
e883329Claude3852 </span>
3853 </div>
b078860Claude3854 );
3855 })}
3856 <div class="prs-gate-footer">
3857 {gatesAllPassed
3858 ? "All checks passed — ready to merge."
3859 : gateChecks.some(
3860 (c) => !c.passed && c.name === "Merge check"
3861 )
3862 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge."
3863 : "Some checks failed — resolve issues before merging."}
3864 {aiReviewCount > 0 && (
3865 <>
3866 {" "}· {aiReviewCount} AI review{aiReviewCount === 1 ? "" : "s"} on this PR.
3867 </>
3868 )}
3869 </div>
3870 </div>
3871 )}
3872
240c477Claude3873 {pr.state === "open" && ciStatuses.length > 0 && (
3874 <div class="prs-ci-card">
3875 <div class="prs-ci-head">
3876 <h3>CI checks</h3>
3877 <span class="prs-ci-summary">
3878 {ciStatuses.filter(s => s.state === "success").length}/{ciStatuses.length} passing
3879 </span>
3880 </div>
3881 {ciStatuses.map((status) => {
3882 const iconGlyph = status.state === "success" ? "✓" : status.state === "pending" ? "…" : "✗";
3883 return (
3884 <div class="prs-ci-row">
3885 <span class={`prs-ci-icon is-${status.state}`} aria-hidden="true">{iconGlyph}</span>
3886 <span class="prs-ci-context">{status.context}</span>
3887 {status.description && (
3888 <span class="prs-ci-desc">{status.description}</span>
3889 )}
3890 <span class={`prs-ci-pill is-${status.state}`}>
3891 {status.state}
3892 </span>
3893 {status.targetUrl && (
3894 <a href={status.targetUrl} class="prs-ci-link" target="_blank" rel="noopener noreferrer">Details</a>
3895 )}
3896 </div>
3897 );
3898 })}
3899 </div>
3900 )}
3901
b078860Claude3902 {/* ─── Merge area / state-aware action card ─────────────── */}
3903 {user && pr.state === "open" && (
3904 <div
3905 class={`prs-merge-card${pr.isDraft ? " is-draft" : ""}`}
3906 >
3907 <div class="prs-merge-head">
3908 <strong>
3909 {pr.isDraft
3910 ? "Draft — ready for review?"
3911 : mergeBlocked
3912 ? "Merge blocked"
3913 : "Ready to merge"}
3914 </strong>
e883329Claude3915 </div>
b078860Claude3916 <p class="prs-merge-sub">
3917 {pr.isDraft
3918 ? "This PR is in draft. Mark it ready to trigger AI review + gate checks."
3919 : mergeBlocked
3920 ? "Resolve the failing gate checks above before this PR can land."
3921 : gateChecks.length > 0
3922 ? gatesAllPassed
3923 ? "All gates green. Merge will fast-forward into the base branch."
3924 : "Conflicts will be auto-resolved by GlueCron AI on merge."
3925 : "Run gate checks by refreshing once your branch has a recent commit."}
3926 </p>
3927 <Form
3928 method="post"
3929 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
3930 >
3931 <FormGroup>
3c03977Claude3932 <div class="live-cursor-host" style="position:relative">
3933 <textarea
3934 name="body"
3935 id="pr-comment-body"
3936 data-live-field="comment_new"
6cd2f0eClaude3937 data-md-preview=""
3c03977Claude3938 rows={5}
3939 required
3940 placeholder="Leave a comment... (Markdown supported)"
3941 style="font-family:var(--font-mono);font-size:13px;width:100%"
3942 ></textarea>
3943 </div>
15db0e0Claude3944 <span class="slash-hint" title="Type a slash-command as the first line">
3945 Type <code>/</code> for commands —{" "}
3946 <code>/help</code>, <code>/merge</code>, <code>/rebase</code>,{" "}
3947 <code>/explain</code>, <code>/test</code>, <code>/lgtm</code>
3948 </span>
b078860Claude3949 </FormGroup>
3950 <div class="prs-merge-actions">
3951 <Button type="submit" variant="primary">
3952 Comment
3953 </Button>
0a67773Claude3954 {user && user.id !== pr.authorId && pr.state === "open" && (
3955 <>
3956 <button
3957 type="submit"
3958 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
3959 name="review_state"
3960 value="approved"
3961 class="prs-review-approve-btn"
3962 title="Approve this pull request"
3963 >
3964 ✓ Approve
3965 </button>
3966 <button
3967 type="submit"
3968 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/review`}
3969 name="review_state"
3970 value="changes_requested"
3971 class="prs-review-changes-btn"
3972 title="Request changes before merging"
3973 >
3974 ✗ Request changes
3975 </button>
3976 </>
3977 )}
b078860Claude3978 {canManage && (
3979 <>
3980 {pr.isDraft ? (
3981 <button
3982 type="submit"
3983 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
3984 formnovalidate
3985 class="prs-merge-ready-btn"
3986 >
3987 Ready for review
3988 </button>
3989 ) : (
a164a6dClaude3990 <>
3991 <div class="prs-merge-strategy-wrap">
3992 <span class="prs-merge-strategy-label">Strategy</span>
3993 <select name="merge_strategy" class="prs-merge-strategy-select" title="Choose how commits are combined into the base branch">
3994 <option value="merge">Merge commit</option>
3995 <option value="squash">Squash and merge</option>
3996 <option value="ff">Fast-forward</option>
3997 </select>
3998 </div>
3999 <button
4000 type="submit"
4001 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
4002 formnovalidate
4003 class={`prs-merge-btn${mergeBlocked ? " is-disabled" : ""}`}
4004 title={
4005 mergeBlocked
4006 ? "Failing gate checks must be resolved before this PR can merge."
4007 : "Merge pull request"
4008 }
4009 >
4010 {"✔"} Merge pull request
4011 </button>
4012 </>
b078860Claude4013 )}
4014 {!pr.isDraft && (
4015 <button
0074234Claude4016 type="submit"
b078860Claude4017 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
4018 formnovalidate
4019 class="prs-merge-back-draft"
4020 title="Convert back to draft"
0074234Claude4021 >
b078860Claude4022 Convert to draft
4023 </button>
4024 )}
4025 {isAiReviewEnabled() && (
4026 <button
4027 type="submit"
4028 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
4029 formnovalidate
4030 class="btn"
4031 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
4032 >
4033 Re-run AI review
4034 </button>
4035 )}
1d4ff60Claude4036 {isAiReviewEnabled() && !hasAiTestsMarker && (
4037 <button
4038 type="submit"
4039 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
4040 formnovalidate
4041 class="btn"
4042 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."
4043 >
4044 Generate tests with AI
4045 </button>
4046 )}
b078860Claude4047 <Button
4048 type="submit"
4049 variant="danger"
4050 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
4051 >
4052 Close
4053 </Button>
4054 </>
4055 )}
4056 </div>
4057 </Form>
4058 </div>
4059 )}
4060
4061 {/* Read-only footers for non-open states. */}
4062 {pr.state === "merged" && (
4063 <div class="prs-merge-card is-merged">
4064 <div class="prs-merge-head">
4065 <strong>{"⮌"} Merged</strong>
0074234Claude4066 </div>
b078860Claude4067 <p class="prs-merge-sub">
4068 This pull request was merged into{" "}
4069 <code>{pr.baseBranch}</code>.
4070 </p>
4071 </div>
4072 )}
4073 {pr.state === "closed" && (
4074 <div class="prs-merge-card is-closed">
4075 <div class="prs-merge-head">
4076 <strong>{"✕"} Closed without merging</strong>
4077 </div>
4078 <p class="prs-merge-sub">
4079 This pull request was closed and not merged.
4080 </p>
4081 </div>
4082 )}
4083 </>
4084 )}
0074234Claude4085 </Layout>
4086 );
4087});
4088
6d1bbc2Claude4089// Update branch — merge base into head so the PR branch is up to date.
4090// Uses a git worktree so the bare repo stays clean. Write access required.
4091pulls.post(
4092 "/:owner/:repo/pulls/:number/update-branch",
4093 softAuth,
4094 requireAuth,
4095 requireRepoAccess("write"),
4096 async (c) => {
4097 const { owner: ownerName, repo: repoName } = c.req.param();
4098 const prNum = parseInt(c.req.param("number"), 10);
4099 const user = c.get("user")!;
4100 const resolved = await resolveRepo(ownerName, repoName);
4101 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4102
4103 const [pr] = await db
4104 .select()
4105 .from(pullRequests)
4106 .where(and(
4107 eq(pullRequests.repositoryId, resolved.repo.id),
4108 eq(pullRequests.number, prNum),
4109 ))
4110 .limit(1);
4111 if (!pr || pr.state !== "open") {
4112 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4113 }
4114
4115 const repoDir = getRepoPath(ownerName, repoName);
4116 const wt = `${repoDir}/_update_wt_${Date.now()}`;
4117 const gitEnv = {
4118 ...process.env,
4119 GIT_AUTHOR_NAME: user.displayName || user.username,
4120 GIT_AUTHOR_EMAIL: user.email,
4121 GIT_COMMITTER_NAME: user.displayName || user.username,
4122 GIT_COMMITTER_EMAIL: user.email,
4123 };
4124
4125 const addWt = Bun.spawn(
4126 ["git", "worktree", "add", wt, pr.headBranch],
4127 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4128 );
4129 if (await addWt.exited !== 0) {
4130 return c.redirect(
4131 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
4132 );
4133 }
4134
4135 let ok = false;
4136 try {
4137 const mergeProc = Bun.spawn(
4138 ["git", "merge", "--no-edit", pr.baseBranch],
4139 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
4140 );
4141 if (await mergeProc.exited === 0) {
4142 ok = true;
4143 } else {
4144 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4145 }
4146 } catch {
4147 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
4148 }
4149
4150 await Bun.spawn(
4151 ["git", "worktree", "remove", "--force", wt],
4152 { cwd: repoDir }
4153 ).exited.catch(() => {});
4154
4155 if (ok) {
4156 return c.redirect(
4157 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
4158 );
4159 }
4160 return c.redirect(
4161 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
4162 );
4163 }
4164);
4165
b558f23Claude4166// Edit PR title (and optionally body). Owner or author only.
4167pulls.post(
4168 "/:owner/:repo/pulls/:number/edit",
4169 softAuth,
4170 requireAuth,
4171 requireRepoAccess("write"),
4172 async (c) => {
4173 const { owner: ownerName, repo: repoName } = c.req.param();
4174 const prNum = parseInt(c.req.param("number"), 10);
4175 const user = c.get("user")!;
4176 const resolved = await resolveRepo(ownerName, repoName);
4177 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4178
4179 const [pr] = await db
4180 .select()
4181 .from(pullRequests)
4182 .where(and(
4183 eq(pullRequests.repositoryId, resolved.repo.id),
4184 eq(pullRequests.number, prNum),
4185 ))
4186 .limit(1);
4187 if (!pr || pr.state !== "open") {
4188 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4189 }
4190 const canEdit = user.id === resolved.owner.id || user.id === pr.authorId;
4191 if (!canEdit) {
4192 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4193 }
4194
4195 const body = await c.req.parseBody();
4196 const newTitle = String(body.title || "").trim().slice(0, 256);
4197 if (!newTitle) {
4198 return c.redirect(
4199 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Title cannot be empty")}`
4200 );
4201 }
4202
4203 await db
4204 .update(pullRequests)
4205 .set({ title: newTitle, updatedAt: new Date() })
4206 .where(eq(pullRequests.id, pr.id));
4207
4208 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Title updated")}`);
4209 }
4210);
4211
cb5a796Claude4212// Add comment to PR.
4213//
4214// Permission model mirrors `issues.tsx`: any logged-in user with read
4215// access can submit; `decideInitialStatus` routes non-collaborators
4216// through the moderation queue. Slash commands only fire when the
4217// comment is auto-approved — we don't want a banned/pending comment to
4218// silently trigger AI work on the PR.
0074234Claude4219pulls.post(
4220 "/:owner/:repo/pulls/:number/comment",
4221 softAuth,
4222 requireAuth,
cb5a796Claude4223 requireRepoAccess("read"),
0074234Claude4224 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 body = await c.req.parseBody();
4229 const commentBody = String(body.body || "").trim();
47a7a0aClaude4230 const filePathRaw = String(body.file_path || "").trim();
4231 const lineNumberRaw = parseInt(String(body.line_number || ""), 10);
4232 const inlineFilePath = filePathRaw || undefined;
4233 const inlineLineNumber = Number.isFinite(lineNumberRaw) && lineNumberRaw > 0 ? lineNumberRaw : undefined;
0074234Claude4234
4235 if (!commentBody) {
4236 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4237 }
4238
4239 const resolved = await resolveRepo(ownerName, repoName);
4240 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4241
4242 const [pr] = await db
4243 .select()
4244 .from(pullRequests)
4245 .where(
4246 and(
4247 eq(pullRequests.repositoryId, resolved.repo.id),
4248 eq(pullRequests.number, prNum)
4249 )
4250 )
4251 .limit(1);
4252
4253 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4254
cb5a796Claude4255 const decision = await decideInitialStatus({
4256 commenterUserId: user.id,
4257 repositoryId: resolved.repo.id,
4258 kind: "pr",
4259 threadId: pr.id,
4260 });
4261
d4ac5c3Claude4262 const [inserted] = await db
4263 .insert(prComments)
4264 .values({
4265 pullRequestId: pr.id,
4266 authorId: user.id,
4267 body: commentBody,
cb5a796Claude4268 moderationStatus: decision.status,
47a7a0aClaude4269 filePath: inlineFilePath,
4270 lineNumber: inlineLineNumber,
d4ac5c3Claude4271 })
4272 .returning();
4273
cb5a796Claude4274 // Live update: only when the comment is actually visible.
4275 if (inserted && decision.status === "approved") {
d4ac5c3Claude4276 try {
4277 const { publish } = await import("../lib/sse");
4278 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
4279 event: "pr-comment",
4280 data: {
4281 pullRequestId: pr.id,
4282 commentId: inserted.id,
4283 authorId: user.id,
4284 authorUsername: user.username,
4285 },
4286 });
4287 } catch {
4288 /* SSE is best-effort */
4289 }
4290 }
0074234Claude4291
cb5a796Claude4292 if (decision.status === "pending") {
4293 void notifyOwnerOfPendingComment({
4294 repositoryId: resolved.repo.id,
4295 commenterUsername: user.username,
4296 kind: "pr",
4297 threadNumber: prNum,
4298 ownerUsername: ownerName,
4299 repoName,
4300 });
4301 return c.redirect(
4302 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4303 );
4304 }
4305 if (decision.status === "rejected") {
4306 // Silent ban path — same UX as 'pending' so we don't leak the gate.
4307 return c.redirect(
4308 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Comment awaiting author approval")}`
4309 );
4310 }
4311
15db0e0Claude4312 // Slash-command handoff. We always store the original comment above
4313 // first so free-form text that happens to start with `/` is preserved
4314 // verbatim; only recognised commands trigger a follow-up bot comment.
cb5a796Claude4315 // (Only reachable when decision.status === 'approved'.)
15db0e0Claude4316 const parsed = parseSlashCommand(commentBody);
4317 if (parsed) {
4318 try {
4319 const result = await executeSlashCommand({
4320 command: parsed.command,
4321 args: parsed.args,
4322 prId: pr.id,
4323 userId: user.id,
4324 repositoryId: resolved.repo.id,
4325 });
4326 await db.insert(prComments).values({
4327 pullRequestId: pr.id,
4328 authorId: user.id,
4329 body: result.body,
4330 });
4331 } catch (err) {
4332 // Defence-in-depth — executeSlashCommand promises not to throw,
4333 // but if it ever does we want the PR thread to know.
4334 await db
4335 .insert(prComments)
4336 .values({
4337 pullRequestId: pr.id,
4338 authorId: user.id,
4339 body: `<!-- cmd:${parsed.command} -->\n\nSlash-command \`/${parsed.command}\` crashed: ${err instanceof Error ? err.message : String(err)}`,
4340 })
4341 .catch(() => {});
4342 }
4343 }
4344
47a7a0aClaude4345 // Inline comments go back to the files tab; conversation comments to the conversation tab
4346 const redirectTab = inlineFilePath ? "?tab=files" : "";
4347 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}${redirectTab}`);
0074234Claude4348 }
4349);
4350
b5dd694Claude4351// Apply a suggestion from a PR comment — commits the suggested code to the
4352// head branch on behalf of the logged-in user.
4353pulls.post(
4354 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
4355 softAuth,
4356 requireAuth,
4357 requireRepoAccess("read"),
4358 async (c) => {
4359 const { owner: ownerName, repo: repoName } = c.req.param();
4360 const prNum = parseInt(c.req.param("number"), 10);
4361 const commentId = c.req.param("commentId"); // UUID
4362 const user = c.get("user")!;
4363
4364 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
4365
4366 const resolved = await resolveRepo(ownerName, repoName);
4367 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4368
4369 const [pr] = await db
4370 .select()
4371 .from(pullRequests)
4372 .where(
4373 and(
4374 eq(pullRequests.repositoryId, resolved.repo.id),
4375 eq(pullRequests.number, prNum)
4376 )
4377 )
4378 .limit(1);
4379
4380 if (!pr || pr.state !== "open") {
4381 return c.redirect(`${backUrl}&error=pr_not_open`);
4382 }
4383
4384 // Only PR author or repo owner may apply suggestions.
4385 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
4386 return c.redirect(`${backUrl}&error=forbidden`);
4387 }
4388
4389 // Load the comment.
4390 const [comment] = await db
4391 .select()
4392 .from(prComments)
4393 .where(
4394 and(
4395 eq(prComments.id, commentId),
4396 eq(prComments.pullRequestId, pr.id)
4397 )
4398 )
4399 .limit(1);
4400
4401 if (!comment) {
4402 return c.redirect(`${backUrl}&error=comment_not_found`);
4403 }
4404
4405 // Parse suggestion block from comment body.
4406 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
4407 if (!m) {
4408 return c.redirect(`${backUrl}&error=no_suggestion`);
4409 }
4410 const suggestionCode = m[1];
4411
4412 // Get the commenter's details for the commit message co-author line.
4413 const [commenter] = await db
4414 .select()
4415 .from(users)
4416 .where(eq(users.id, comment.authorId))
4417 .limit(1);
4418
4419 // Fetch current file content from head branch.
4420 if (!comment.filePath) {
4421 return c.redirect(`${backUrl}&error=file_not_found`);
4422 }
4423 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
4424 if (!blob) {
4425 return c.redirect(`${backUrl}&error=file_not_found`);
4426 }
4427
4428 // Apply the patch — replace the target line(s) with suggestion lines.
4429 const lines = blob.content.split('\n');
4430 const lineIdx = (comment.lineNumber ?? 1) - 1;
4431 if (lineIdx < 0 || lineIdx >= lines.length) {
4432 return c.redirect(`${backUrl}&error=line_out_of_range`);
4433 }
4434 const suggestionLines = suggestionCode.split('\n');
4435 lines.splice(lineIdx, 1, ...suggestionLines);
4436 const newContent = lines.join('\n');
4437
4438 // Commit the change.
4439 const coAuthorLine = commenter
4440 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
4441 : "";
4442 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
4443
4444 const result = await createOrUpdateFileOnBranch({
4445 owner: ownerName,
4446 name: repoName,
4447 branch: pr.headBranch,
4448 filePath: comment.filePath,
4449 bytes: new TextEncoder().encode(newContent),
4450 message: commitMessage,
4451 authorName: user.username,
4452 authorEmail: `${user.username}@users.noreply.gluecron.com`,
4453 });
4454
4455 if ("error" in result) {
4456 return c.redirect(`${backUrl}&error=apply_failed`);
4457 }
4458
4459 // Post a follow-up comment noting the suggestion was applied.
4460 await db.insert(prComments).values({
4461 pullRequestId: pr.id,
4462 authorId: user.id,
4463 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
4464 });
4465
4466 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
4467 }
4468);
4469
0a67773Claude4470// Formal review — Approve / Request Changes / Comment
4471pulls.post(
4472 "/:owner/:repo/pulls/:number/review",
4473 softAuth,
4474 requireAuth,
4475 requireRepoAccess("read"),
4476 async (c) => {
4477 const { owner: ownerName, repo: repoName } = c.req.param();
4478 const prNum = parseInt(c.req.param("number"), 10);
4479 const user = c.get("user")!;
4480 const body = await c.req.parseBody();
4481 const reviewBody = String(body.body || "").trim();
4482 const reviewState = String(body.review_state || "commented");
4483
4484 const validStates = ["approved", "changes_requested", "commented"];
4485 if (!validStates.includes(reviewState)) {
4486 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4487 }
4488
4489 const resolved = await resolveRepo(ownerName, repoName);
4490 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4491
4492 const [pr] = await db
4493 .select()
4494 .from(pullRequests)
4495 .where(
4496 and(
4497 eq(pullRequests.repositoryId, resolved.repo.id),
4498 eq(pullRequests.number, prNum)
4499 )
4500 )
4501 .limit(1);
4502 if (!pr || pr.state !== "open") {
4503 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4504 }
4505 // Authors can't review their own PR
4506 if (pr.authorId === user.id) {
4507 return c.redirect(
4508 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("You cannot review your own pull request")}`
4509 );
4510 }
4511
4512 await db.insert(prReviews).values({
4513 pullRequestId: pr.id,
4514 reviewerId: user.id,
4515 state: reviewState,
4516 body: reviewBody || null,
4517 });
4518
4519 const stateLabel =
4520 reviewState === "approved" ? "Approved"
4521 : reviewState === "changes_requested" ? "Changes requested"
4522 : "Commented";
4523 return c.redirect(
4524 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(stateLabel)}`
4525 );
4526 }
4527);
4528
e883329Claude4529// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude4530// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
4531// but we keep it at "write" for v1 so trusted collaborators can ship.
4532// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
4533// surface. Branch-protection rules (evaluated below) are the current mechanism
4534// for locking down merges further on specific branches.
0074234Claude4535pulls.post(
4536 "/:owner/:repo/pulls/:number/merge",
4537 softAuth,
4538 requireAuth,
04f6b7fClaude4539 requireRepoAccess("write"),
0074234Claude4540 async (c) => {
4541 const { owner: ownerName, repo: repoName } = c.req.param();
4542 const prNum = parseInt(c.req.param("number"), 10);
4543 const user = c.get("user")!;
4544
a164a6dClaude4545 // Read merge strategy from form (default: merge commit)
4546 let mergeStrategy = "merge";
4547 try {
4548 const body = await c.req.parseBody();
4549 const s = body.merge_strategy;
4550 if (s === "squash" || s === "ff" || s === "merge") mergeStrategy = s as string;
4551 } catch { /* ignore parse errors — default to merge commit */ }
4552
0074234Claude4553 const resolved = await resolveRepo(ownerName, repoName);
4554 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4555
4556 const [pr] = await db
4557 .select()
4558 .from(pullRequests)
4559 .where(
4560 and(
4561 eq(pullRequests.repositoryId, resolved.repo.id),
4562 eq(pullRequests.number, prNum)
4563 )
4564 )
4565 .limit(1);
4566
4567 if (!pr || pr.state !== "open") {
4568 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4569 }
4570
6fc53bdClaude4571 // Draft PRs cannot be merged — must be marked ready first.
4572 if (pr.isDraft) {
4573 return c.redirect(
4574 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4575 "This PR is a draft. Mark it as ready for review before merging."
4576 )}`
4577 );
4578 }
4579
e883329Claude4580 // Resolve head SHA
4581 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4582 if (!headSha) {
4583 return c.redirect(
4584 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
4585 );
4586 }
4587
4588 // Check if AI review approved this PR
4589 const aiComments = await db
4590 .select()
4591 .from(prComments)
4592 .where(
4593 and(
4594 eq(prComments.pullRequestId, pr.id),
4595 eq(prComments.isAiReview, true)
4596 )
4597 );
4598 const aiApproved = aiComments.length === 0 || aiComments.some(
4599 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude4600 );
e883329Claude4601
4602 // Run all green gate checks (GateTest + mergeability + AI review)
4603 const gateResult = await runAllGateChecks(
4604 ownerName,
4605 repoName,
4606 pr.baseBranch,
4607 pr.headBranch,
4608 headSha,
4609 aiApproved
0074234Claude4610 );
4611
e883329Claude4612 // If GateTest or AI review failed (hard blocks), reject the merge
4613 const hardFailures = gateResult.checks.filter(
4614 (check) => !check.passed && check.name !== "Merge check"
4615 );
4616 if (hardFailures.length > 0) {
4617 const errorMsg = hardFailures
4618 .map((f) => `${f.name}: ${f.details}`)
4619 .join("; ");
0074234Claude4620 return c.redirect(
e883329Claude4621 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude4622 );
4623 }
4624
1e162a8Claude4625 // D5 — Branch-protection enforcement. Looks up the matching rule for the
4626 // base branch and blocks the merge if requireAiApproval / requireGreenGates
4627 // / requireHumanReview / requiredApprovals are not satisfied. Independent
4628 // of repo-global settings, so owners can lock specific branches down
4629 // further than the repo default.
4630 const protectionRule = await matchProtection(
4631 resolved.repo.id,
4632 pr.baseBranch
4633 );
4634 if (protectionRule) {
4635 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude4636 const required = await listRequiredChecks(protectionRule.id);
4637 const passingNames = required.length > 0
4638 ? await passingCheckNames(resolved.repo.id, headSha)
4639 : [];
4640 const decision = evaluateProtection(
4641 protectionRule,
4642 {
4643 aiApproved,
4644 humanApprovalCount: humanApprovals,
4645 gateResultGreen: hardFailures.length === 0,
4646 hasFailedGates: hardFailures.length > 0,
4647 passingCheckNames: passingNames,
4648 },
4649 required.map((r) => r.checkName)
4650 );
1e162a8Claude4651 if (!decision.allowed) {
4652 return c.redirect(
4653 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
4654 decision.reasons.join(" ")
4655 )}`
4656 );
4657 }
4658 }
4659
e883329Claude4660 // Attempt the merge — with auto conflict resolution if needed
4661 const repoDir = getRepoPath(ownerName, repoName);
4662 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
4663 const hasConflicts = mergeCheck && !mergeCheck.passed;
4664
4665 if (hasConflicts && isAiReviewEnabled()) {
4666 // Use Claude to auto-resolve conflicts
4667 const mergeResult = await mergeWithAutoResolve(
4668 ownerName,
4669 repoName,
4670 pr.baseBranch,
4671 pr.headBranch,
4672 `Merge pull request #${pr.number}: ${pr.title}`
4673 );
4674
4675 if (!mergeResult.success) {
4676 return c.redirect(
4677 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
4678 );
4679 }
4680
4681 // Post a comment about the auto-resolution
4682 if (mergeResult.resolvedFiles.length > 0) {
4683 await db.insert(prComments).values({
4684 pullRequestId: pr.id,
4685 authorId: user.id,
4686 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
4687 isAiReview: true,
4688 });
4689 }
4690 } else {
a164a6dClaude4691 // Worktree-based merge: supports merge-commit, squash, and fast-forward
4692 const wt = `${repoDir}/_merge_wt_${Date.now()}`;
4693 const gitEnv = {
4694 ...process.env,
4695 GIT_AUTHOR_NAME: user.displayName || user.username,
4696 GIT_AUTHOR_EMAIL: user.email,
4697 GIT_COMMITTER_NAME: user.displayName || user.username,
4698 GIT_COMMITTER_EMAIL: user.email,
4699 };
4700
4701 // Create linked worktree on the base branch
4702 const addWt = Bun.spawn(
4703 ["git", "worktree", "add", wt, pr.baseBranch],
e883329Claude4704 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4705 );
a164a6dClaude4706 if (await addWt.exited !== 0) {
4707 return c.redirect(
4708 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — could not create worktree")}`
4709 );
4710 }
4711
4712 const commitMsg = `Merge pull request #${pr.number}: ${pr.title}`;
4713 let mergeOk = false;
4714
4715 try {
4716 if (mergeStrategy === "squash") {
4717 // Squash: stage all changes without committing
4718 const squashProc = Bun.spawn(
4719 ["git", "merge", "--squash", headSha],
4720 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4721 );
4722 if (await squashProc.exited !== 0) {
4723 const errTxt = await new Response(squashProc.stderr).text();
4724 throw new Error(`Squash merge failed: ${errTxt.trim()}`);
4725 }
4726 // Commit the squashed changes
4727 const commitProc = Bun.spawn(
4728 ["git", "commit", "-m", commitMsg],
4729 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4730 );
4731 if (await commitProc.exited !== 0) {
4732 const errTxt = await new Response(commitProc.stderr).text();
4733 throw new Error(`Squash commit failed: ${errTxt.trim()}`);
4734 }
4735 mergeOk = true;
4736 } else if (mergeStrategy === "ff") {
4737 // Fast-forward only — fail if FF is not possible
4738 const ffProc = Bun.spawn(
4739 ["git", "merge", "--ff-only", headSha],
4740 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4741 );
4742 if (await ffProc.exited !== 0) {
4743 const errTxt = await new Response(ffProc.stderr).text();
4744 throw new Error(`Fast-forward not possible: ${errTxt.trim()}`);
4745 }
4746 mergeOk = true;
4747 } else {
4748 // Default: merge commit (--no-ff always creates a merge commit)
4749 const mergeProc = Bun.spawn(
4750 ["git", "merge", "--no-ff", "-m", commitMsg, headSha],
4751 { cwd: wt, stdout: "pipe", stderr: "pipe", env: gitEnv }
4752 );
4753 if (await mergeProc.exited !== 0) {
4754 const errTxt = await new Response(mergeProc.stderr).text();
4755 throw new Error(`Merge commit failed: ${errTxt.trim()}`);
4756 }
4757 mergeOk = true;
4758 }
4759 } catch (err) {
4760 // Always clean up the worktree before redirecting
4761 Bun.spawn(["git", "worktree", "remove", "--force", wt], { cwd: repoDir }).exited.catch(() => {});
4762 const msg = err instanceof Error ? err.message : "Merge failed";
4763 return c.redirect(
4764 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(msg)}`
4765 );
4766 }
4767
4768 // Clean up worktree (changes are now in the bare repo via linked worktree)
4769 await Bun.spawn(
4770 ["git", "worktree", "remove", "--force", wt],
4771 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
4772 ).exited.catch(() => {});
e883329Claude4773
a164a6dClaude4774 if (!mergeOk) {
e883329Claude4775 return c.redirect(
a164a6dClaude4776 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed")}`
e883329Claude4777 );
4778 }
4779 }
4780
0074234Claude4781 await db
4782 .update(pullRequests)
4783 .set({
4784 state: "merged",
4785 mergedAt: new Date(),
4786 mergedBy: user.id,
4787 updatedAt: new Date(),
4788 })
4789 .where(eq(pullRequests.id, pr.id));
4790
8809b87Claude4791 // Chat notifier — fan out merge event to Slack/Discord/Teams.
4792 import("../lib/chat-notifier")
4793 .then((m) =>
4794 m.notifyChatChannels({
4795 ownerUserId: resolved.repo.ownerId,
4796 repositoryId: resolved.repo.id,
4797 event: {
4798 event: "pr.merged",
4799 repo: `${ownerName}/${repoName}`,
4800 title: `#${pr.number} ${pr.title}`,
4801 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
4802 actor: user.username,
4803 },
4804 })
4805 )
4806 .catch((err) =>
4807 console.warn(`[chat-notifier] PR merge notify failed:`, err)
4808 );
4809
d62fb36Claude4810 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
4811 // and auto-close each matching open issue with a back-link comment. Bounded
4812 // to the same repo for v1 (cross-repo refs ignored). Failures never block
4813 // the merge redirect.
4814 try {
4815 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
4816 const refs = extractClosingRefsMulti([pr.title, pr.body]);
4817 for (const n of refs) {
4818 const [issue] = await db
4819 .select()
4820 .from(issues)
4821 .where(
4822 and(
4823 eq(issues.repositoryId, resolved.repo.id),
4824 eq(issues.number, n)
4825 )
4826 )
4827 .limit(1);
4828 if (!issue || issue.state !== "open") continue;
4829 await db
4830 .update(issues)
4831 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
4832 .where(eq(issues.id, issue.id));
4833 await db.insert(issueComments).values({
4834 issueId: issue.id,
4835 authorId: user.id,
4836 body: `Closed by pull request #${pr.number}.`,
4837 });
4838 }
4839 } catch {
4840 // Never block the merge on close-keyword failures.
4841 }
4842
0074234Claude4843 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4844 }
4845);
4846
6fc53bdClaude4847// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
4848// hasn't run yet on this PR.
4849pulls.post(
4850 "/:owner/:repo/pulls/:number/ready",
4851 softAuth,
4852 requireAuth,
04f6b7fClaude4853 requireRepoAccess("write"),
6fc53bdClaude4854 async (c) => {
4855 const { owner: ownerName, repo: repoName } = c.req.param();
4856 const prNum = parseInt(c.req.param("number"), 10);
4857 const user = c.get("user")!;
4858
4859 const resolved = await resolveRepo(ownerName, repoName);
4860 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4861
4862 const [pr] = await db
4863 .select()
4864 .from(pullRequests)
4865 .where(
4866 and(
4867 eq(pullRequests.repositoryId, resolved.repo.id),
4868 eq(pullRequests.number, prNum)
4869 )
4870 )
4871 .limit(1);
4872 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4873
4874 // Only the author or repo owner can toggle draft state.
4875 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
4876 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4877 }
4878
4879 if (pr.state === "open" && pr.isDraft) {
4880 await db
4881 .update(pullRequests)
4882 .set({ isDraft: false, updatedAt: new Date() })
4883 .where(eq(pullRequests.id, pr.id));
4884
4885 if (isAiReviewEnabled()) {
4886 triggerAiReview(
4887 ownerName,
4888 repoName,
4889 pr.id,
4890 pr.title,
0316dbbClaude4891 pr.body || "",
6fc53bdClaude4892 pr.baseBranch,
4893 pr.headBranch
4894 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
4895 }
4896 }
4897
4898 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4899 }
4900);
4901
4902// Convert a PR back to draft.
4903pulls.post(
4904 "/:owner/:repo/pulls/:number/draft",
4905 softAuth,
4906 requireAuth,
04f6b7fClaude4907 requireRepoAccess("write"),
6fc53bdClaude4908 async (c) => {
4909 const { owner: ownerName, repo: repoName } = c.req.param();
4910 const prNum = parseInt(c.req.param("number"), 10);
4911 const user = c.get("user")!;
4912
4913 const resolved = await resolveRepo(ownerName, repoName);
4914 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4915
4916 const [pr] = await db
4917 .select()
4918 .from(pullRequests)
4919 .where(
4920 and(
4921 eq(pullRequests.repositoryId, resolved.repo.id),
4922 eq(pullRequests.number, prNum)
4923 )
4924 )
4925 .limit(1);
4926 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
4927
4928 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
4929 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4930 }
4931
4932 if (pr.state === "open" && !pr.isDraft) {
4933 await db
4934 .update(pullRequests)
4935 .set({ isDraft: true, updatedAt: new Date() })
4936 .where(eq(pullRequests.id, pr.id));
4937 }
4938
4939 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4940 }
4941);
4942
0074234Claude4943// Close PR
4944pulls.post(
4945 "/:owner/:repo/pulls/:number/close",
4946 softAuth,
4947 requireAuth,
04f6b7fClaude4948 requireRepoAccess("write"),
0074234Claude4949 async (c) => {
4950 const { owner: ownerName, repo: repoName } = c.req.param();
4951 const prNum = parseInt(c.req.param("number"), 10);
4952
4953 const resolved = await resolveRepo(ownerName, repoName);
4954 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4955
4956 await db
4957 .update(pullRequests)
4958 .set({
4959 state: "closed",
4960 closedAt: new Date(),
4961 updatedAt: new Date(),
4962 })
4963 .where(
4964 and(
4965 eq(pullRequests.repositoryId, resolved.repo.id),
4966 eq(pullRequests.number, prNum)
4967 )
4968 );
4969
4970 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
4971 }
4972);
4973
c3e0c07Claude4974// Re-run AI review on demand (e.g. after a force-push). Bypasses the
4975// idempotency marker via { force: true }. Write-access only.
4976pulls.post(
4977 "/:owner/:repo/pulls/:number/ai-rereview",
4978 softAuth,
4979 requireAuth,
4980 requireRepoAccess("write"),
4981 async (c) => {
4982 const { owner: ownerName, repo: repoName } = c.req.param();
4983 const prNum = parseInt(c.req.param("number"), 10);
4984 const resolved = await resolveRepo(ownerName, repoName);
4985 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
4986
4987 const [pr] = await db
4988 .select()
4989 .from(pullRequests)
4990 .where(
4991 and(
4992 eq(pullRequests.repositoryId, resolved.repo.id),
4993 eq(pullRequests.number, prNum)
4994 )
4995 )
4996 .limit(1);
4997 if (!pr) {
4998 return c.redirect(`/${ownerName}/${repoName}/pulls`);
4999 }
5000
5001 if (!isAiReviewEnabled()) {
5002 return c.redirect(
5003 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5004 "AI review is not configured (ANTHROPIC_API_KEY)."
5005 )}`
5006 );
5007 }
5008
5009 // Fire-and-forget but with { force: true } to bypass the
5010 // already-reviewed marker. The function still never throws.
5011 triggerAiReview(
5012 ownerName,
5013 repoName,
5014 pr.id,
5015 pr.title || "",
5016 pr.body || "",
5017 pr.baseBranch,
5018 pr.headBranch,
5019 { force: true }
a28cedeClaude5020 ).catch((err) => {
5021 console.warn(
5022 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
5023 err instanceof Error ? err.message : err
5024 );
5025 });
c3e0c07Claude5026
5027 return c.redirect(
5028 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5029 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
5030 )}`
5031 );
5032 }
5033);
5034
1d4ff60Claude5035// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
5036// the PR's head branch carrying just the new test files. Write-access only.
5037// Idempotent — if `ai:added-tests` was previously applied we redirect with
5038// an `info` banner instead of re-firing.
5039pulls.post(
5040 "/:owner/:repo/pulls/:number/generate-tests",
5041 softAuth,
5042 requireAuth,
5043 requireRepoAccess("write"),
5044 async (c) => {
5045 const { owner: ownerName, repo: repoName } = c.req.param();
5046 const prNum = parseInt(c.req.param("number"), 10);
5047 const resolved = await resolveRepo(ownerName, repoName);
5048 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
5049
5050 const [pr] = await db
5051 .select()
5052 .from(pullRequests)
5053 .where(
5054 and(
5055 eq(pullRequests.repositoryId, resolved.repo.id),
5056 eq(pullRequests.number, prNum)
5057 )
5058 )
5059 .limit(1);
5060 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
5061
5062 if (!isAiReviewEnabled()) {
5063 return c.redirect(
5064 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
5065 "AI test generation is not configured (ANTHROPIC_API_KEY)."
5066 )}`
5067 );
5068 }
5069
5070 // Fire-and-forget. The lib never throws.
5071 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
5072 .then((res) => {
5073 if (!res.ok) {
5074 console.warn(
5075 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
5076 );
5077 }
5078 })
5079 .catch((err) => {
5080 console.warn(
5081 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
5082 err instanceof Error ? err.message : err
5083 );
5084 });
5085
5086 return c.redirect(
5087 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
5088 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
5089 )}`
5090 );
5091 }
5092);
5093
0074234Claude5094export default pulls;