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