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