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