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