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