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.tsxBlame1413 lines · 3 contributors
0074234Claude1/**
2 * Pull request routes — create, list, view, merge, close, comment.
3 */
4
5import { Hono } from "hono";
6import { eq, and, desc, asc, sql } from "drizzle-orm";
7import { db } from "../db";
8import {
9 pullRequests,
10 prComments,
11 repositories,
12 users,
d62fb36Claude13 issues,
14 issueComments,
0074234Claude15} from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader, DiffView } from "../views/components";
6fc53bdClaude18import { ReactionsBar } from "../views/reactions";
19import { summariseReactions } from "../lib/reactions";
24cf2caClaude20import { loadPrTemplate } from "../lib/templates";
0074234Claude21import { renderMarkdown } from "../lib/markdown";
b584e52Claude22import { liveCommentBannerScript } from "../lib/sse-client";
0074234Claude23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude25import { requireRepoAccess } from "../middleware/repo-access";
0316dbbClaude26import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
27import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude28import { generatePrSummary } from "../lib/ai-generators";
29import { isAiAvailable } from "../lib/ai-client";
534f04aClaude30import {
31 computePrRiskForPullRequest,
32 getCachedPrRisk,
33 type PrRiskScore,
34} from "../lib/pr-risk";
0316dbbClaude35import { runAllGateChecks } from "../lib/gate";
36import type { GateCheckResult } from "../lib/gate";
37import {
38 matchProtection,
39 countHumanApprovals,
40 listRequiredChecks,
41 passingCheckNames,
42 evaluateProtection,
43} from "../lib/branch-protection";
44import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude45import {
46 listBranches,
47 getRepoPath,
e883329Claude48 resolveRef,
0074234Claude49} from "../git/repository";
50import type { GitDiffFile } from "../git/repository";
51import { html } from "hono/html";
1e162a8Claude52import {
bb0f894Claude53 Flex,
54 Container,
55 Badge,
56 Button,
57 LinkButton,
58 Form,
59 FormGroup,
60 Input,
61 TextArea,
62 Select,
63 EmptyState,
64 FilterTabs,
65 TabNav,
66 List,
67 ListItem,
68 Text,
69 Alert,
70 MarkdownContent,
71 CommentBox,
72 formatRelative,
73} from "../views/ui";
0074234Claude74
75const pulls = new Hono<AuthEnv>();
76
81c73c1Claude77/**
78 * Tiny inline JS that drives the "Suggest description with AI" button.
79 * On click, gathers form values, POSTs JSON to the given endpoint, and
80 * pipes the response into the #pr-body textarea. All DOM lookups are
81 * defensive — element absence is a silent no-op.
82 *
83 * Built as a string template so it lives next to its server-side caller
84 * and there is no bundler dependency. The endpoint URL is JSON-escaped
85 * to avoid </script> breakouts.
86 */
87function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
88 const url = JSON.stringify(endpointUrl)
89 .split("<").join("\\u003C")
90 .split(">").join("\\u003E")
91 .split("&").join("\\u0026");
92 return (
93 "(function(){try{" +
94 "var btn=document.getElementById('ai-suggest-desc');" +
95 "var status=document.getElementById('ai-suggest-status');" +
96 "var body=document.getElementById('pr-body');" +
97 "var form=btn&&btn.closest&&btn.closest('form');" +
98 "if(!btn||!body||!form)return;" +
99 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
100 "var fd=new FormData(form);" +
101 "var title=String(fd.get('title')||'').trim();" +
102 "var base=String(fd.get('base')||'').trim();" +
103 "var head=String(fd.get('head')||'').trim();" +
104 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
105 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
106 "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'})" +
107 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
108 ".then(function(j){btn.disabled=false;" +
109 "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;}}" +
110 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
111 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
112 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
113 "});" +
114 "}catch(e){}})();"
115 );
116}
117
0074234Claude118async function resolveRepo(ownerName: string, repoName: string) {
119 const [owner] = await db
120 .select()
121 .from(users)
122 .where(eq(users.username, ownerName))
123 .limit(1);
124 if (!owner) return null;
125 const [repo] = await db
126 .select()
127 .from(repositories)
128 .where(
129 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
130 )
131 .limit(1);
132 if (!repo) return null;
133 return { owner, repo };
134}
135
136// PR Nav helper
137const PrNav = ({
138 owner,
139 repo,
140 active,
141}: {
142 owner: string;
143 repo: string;
144 active: "code" | "issues" | "pulls" | "commits";
145}) => (
bb0f894Claude146 <TabNav
147 tabs={[
148 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
149 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
150 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
151 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
152 ]}
153 />
0074234Claude154);
155
534f04aClaude156/**
157 * Block M3 — pre-merge risk score card. Pure presentational helper.
158 * Rendered in the conversation tab above the gate checks block. Hidden
159 * entirely when the PR is closed/merged or there is nothing cached and
160 * nothing in-flight.
161 */
162function PrRiskCard({
163 risk,
164 calculating,
165}: {
166 risk: PrRiskScore | null;
167 calculating: boolean;
168}) {
169 if (!risk) {
170 return (
171 <div
172 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
173 >
174 <strong style="font-size: 13px; color: var(--text)">
175 Risk score: calculating…
176 </strong>
177 <div style="font-size: 12px; margin-top: 4px">
178 Refresh in a moment to see the pre-merge risk score for this PR.
179 </div>
180 </div>
181 );
182 }
183
184 const palette = riskBandPalette(risk.band);
185 const label = riskBandLabel(risk.band);
186
187 return (
188 <div
189 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
190 >
191 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
192 <strong>Risk score:</strong>
193 <span style={`color:${palette.border};font-weight:600`}>
194 {palette.icon} {label} ({risk.score}/10)
195 </span>
196 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
197 {risk.commitSha.slice(0, 7)}
198 </span>
199 </div>
200 {risk.aiSummary && (
201 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
202 {risk.aiSummary}
203 </div>
204 )}
205 <details style="margin-top:10px">
206 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
207 See full signal breakdown
208 </summary>
209 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
210 <li>files changed: {risk.signals.filesChanged}</li>
211 <li>
212 lines added/removed: {risk.signals.linesAdded} /{" "}
213 {risk.signals.linesRemoved}
214 </li>
215 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
216 <li>
217 schema migration touched:{" "}
218 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
219 </li>
220 <li>
221 locked / sensitive path touched:{" "}
222 {risk.signals.lockedPathTouched ? "yes" : "no"}
223 </li>
224 <li>
225 adds new dependency:{" "}
226 {risk.signals.addsNewDependency ? "yes" : "no"}
227 </li>
228 <li>
229 bumps major dependency:{" "}
230 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
231 </li>
232 <li>
233 tests added for new code:{" "}
234 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
235 </li>
236 <li>
237 diff-minus-test ratio:{" "}
238 {risk.signals.diffMinusTestRatio.toFixed(2)}
239 </li>
240 </ul>
241 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
242 How is this calculated? The score is a transparent sum of
243 weighted signals — see <code>src/lib/pr-risk.ts</code>
244 {" "}<code>computePrRiskScore</code>.
245 </div>
246 </details>
247 {calculating && (
248 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
249 (recomputing for the latest commit — refresh to update)
250 </div>
251 )}
252 </div>
253 );
254}
255
256function riskBandPalette(band: PrRiskScore["band"]): {
257 border: string;
258 icon: string;
259} {
260 switch (band) {
261 case "low":
262 return { border: "var(--green)", icon: "" };
263 case "medium":
264 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
265 case "high":
266 return { border: "var(--orange, #db6d28)", icon: "⚠" };
267 case "critical":
268 return { border: "var(--red)", icon: "\u{1F6D1}" };
269 }
270}
271
272function riskBandLabel(band: PrRiskScore["band"]): string {
273 switch (band) {
274 case "low":
275 return "LOW";
276 case "medium":
277 return "MEDIUM";
278 case "high":
279 return "HIGH";
280 case "critical":
281 return "CRITICAL";
282 }
283}
284
0074234Claude285// List PRs
04f6b7fClaude286pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude287 const { owner: ownerName, repo: repoName } = c.req.param();
288 const user = c.get("user");
289 const state = c.req.query("state") || "open";
290
291 const resolved = await resolveRepo(ownerName, repoName);
292 if (!resolved) return c.notFound();
293
6fc53bdClaude294 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
295 const stateFilter =
296 state === "draft"
297 ? and(
298 eq(pullRequests.state, "open"),
299 eq(pullRequests.isDraft, true)
300 )
301 : eq(pullRequests.state, state);
302
0074234Claude303 const prList = await db
304 .select({
305 pr: pullRequests,
306 author: { username: users.username },
307 })
308 .from(pullRequests)
309 .innerJoin(users, eq(pullRequests.authorId, users.id))
310 .where(
6fc53bdClaude311 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude312 )
313 .orderBy(desc(pullRequests.createdAt));
314
315 const [counts] = await db
316 .select({
317 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude318 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude319 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
320 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
321 })
322 .from(pullRequests)
323 .where(eq(pullRequests.repositoryId, resolved.repo.id));
324
325 return c.html(
326 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
327 <RepoHeader owner={ownerName} repo={repoName} />
328 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude329 <Flex justify="space-between" align="center" style="margin-bottom:16px">
330 <FilterTabs
331 tabs={[
332 { label: `${counts?.open ?? 0} Open`, href: `/${ownerName}/${repoName}/pulls?state=open`, active: state === "open" },
333 { label: `${counts?.merged ?? 0} Merged`, href: `/${ownerName}/${repoName}/pulls?state=merged`, active: state === "merged" },
334 { label: `${counts?.closed ?? 0} Closed`, href: `/${ownerName}/${repoName}/pulls?state=closed`, active: state === "closed" },
335 ]}
336 />
0074234Claude337 {user && (
bb0f894Claude338 <LinkButton href={`/${ownerName}/${repoName}/pulls/new`} variant="primary">
0074234Claude339 New pull request
bb0f894Claude340 </LinkButton>
0074234Claude341 )}
bb0f894Claude342 </Flex>
0074234Claude343 {prList.length === 0 ? (
bb0f894Claude344 <EmptyState>
0074234Claude345 <p>No {state} pull requests.</p>
bb0f894Claude346 </EmptyState>
0074234Claude347 ) : (
bb0f894Claude348 <List>
0074234Claude349 {prList.map(({ pr, author }) => (
bb0f894Claude350 <ListItem>
0074234Claude351 <div
352 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
353 >
354 {pr.state === "open"
355 ? "\u25CB"
356 : pr.state === "merged"
357 ? "\u2B8C"
358 : "\u2713"}
359 </div>
360 <div>
361 <div class="issue-title">
362 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
363 {pr.title}
364 </a>
365 </div>
366 <div class="issue-meta">
367 #{pr.number}{" "}
368 {pr.headBranch} → {pr.baseBranch}{" "}
369 by {author.username}{" "}
370 {formatRelative(pr.createdAt)}
371 </div>
372 </div>
bb0f894Claude373 </ListItem>
0074234Claude374 ))}
bb0f894Claude375 </List>
0074234Claude376 )}
377 </Layout>
378 );
379});
380
381// New PR form
382pulls.get(
383 "/:owner/:repo/pulls/new",
384 softAuth,
385 requireAuth,
04f6b7fClaude386 requireRepoAccess("write"),
0074234Claude387 async (c) => {
388 const { owner: ownerName, repo: repoName } = c.req.param();
389 const user = c.get("user")!;
390 const branches = await listBranches(ownerName, repoName);
391 const error = c.req.query("error");
392 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude393 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude394
395 return c.html(
396 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
397 <RepoHeader owner={ownerName} repo={repoName} />
398 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude399 <Container maxWidth={800}>
400 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude401 {error && (
bb0f894Claude402 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude403 )}
0316dbbClaude404 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
405 <Flex gap={12} align="center" style="margin-bottom: 16px">
406 <Select name="base">
0074234Claude407 {branches.map((b) => (
408 <option value={b} selected={b === defaultBase}>
409 {b}
410 </option>
411 ))}
bb0f894Claude412 </Select>
413 <Text muted>&larr;</Text>
414 <Select name="head">
0074234Claude415 {branches
416 .filter((b) => b !== defaultBase)
417 .concat(defaultBase === branches[0] ? [] : [branches[0]])
418 .map((b) => (
419 <option value={b}>{b}</option>
420 ))}
bb0f894Claude421 </Select>
422 </Flex>
423 <FormGroup>
424 <Input
0074234Claude425 name="title"
426 required
427 placeholder="Title"
bb0f894Claude428 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]429 aria-label="Pull request title"
0074234Claude430 />
bb0f894Claude431 </FormGroup>
432 <FormGroup>
433 <TextArea
0074234Claude434 name="body"
81c73c1Claude435 id="pr-body"
0074234Claude436 rows={8}
437 placeholder="Description (Markdown supported)"
bb0f894Claude438 mono
0074234Claude439 />
bb0f894Claude440 </FormGroup>
81c73c1Claude441 <Flex gap={8} align="center">
442 <Button type="submit" variant="primary">
443 Create pull request
444 </Button>
445 <button
446 type="button"
447 id="ai-suggest-desc"
448 class="btn"
449 style="font-weight:500"
450 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
451 >
452 Suggest description with AI
453 </button>
454 <span
455 id="ai-suggest-status"
456 style="color:var(--text-muted);font-size:13px"
457 />
458 </Flex>
bb0f894Claude459 </Form>
81c73c1Claude460 <script
461 dangerouslySetInnerHTML={{
462 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
463 }}
464 />
bb0f894Claude465 </Container>
0074234Claude466 </Layout>
467 );
468 }
469);
470
81c73c1Claude471// AI-suggested PR description — JSON endpoint driven by the form button.
472// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
473// 200; the inline script reads `ok` to decide what to do.
474pulls.post(
475 "/:owner/:repo/ai/pr-description",
476 softAuth,
477 requireAuth,
478 requireRepoAccess("write"),
479 async (c) => {
480 const { owner: ownerName, repo: repoName } = c.req.param();
481 if (!isAiAvailable()) {
482 return c.json({
483 ok: false,
484 error: "AI is not available — set ANTHROPIC_API_KEY.",
485 });
486 }
487 const body = await c.req.parseBody();
488 const title = String(body.title || "").trim();
489 const baseBranch = String(body.base || "").trim();
490 const headBranch = String(body.head || "").trim();
491 if (!baseBranch || !headBranch) {
492 return c.json({ ok: false, error: "Pick base + head branches first." });
493 }
494 if (baseBranch === headBranch) {
495 return c.json({ ok: false, error: "Base and head must differ." });
496 }
497
498 let diff = "";
499 try {
500 const cwd = getRepoPath(ownerName, repoName);
501 const proc = Bun.spawn(
502 [
503 "git",
504 "diff",
505 `${baseBranch}...${headBranch}`,
506 "--",
507 ],
508 { cwd, stdout: "pipe", stderr: "pipe" }
509 );
510 diff = await new Response(proc.stdout).text();
511 await proc.exited;
512 } catch {
513 diff = "";
514 }
515 if (!diff.trim()) {
516 return c.json({
517 ok: false,
518 error: "No diff between branches — nothing to summarise.",
519 });
520 }
521
522 let summary = "";
523 try {
524 summary = await generatePrSummary(title || "(untitled)", diff);
525 } catch (err) {
526 const msg = err instanceof Error ? err.message : "AI request failed.";
527 return c.json({ ok: false, error: msg });
528 }
529 if (!summary.trim()) {
530 return c.json({ ok: false, error: "AI returned an empty draft." });
531 }
532 return c.json({ ok: true, body: summary });
533 }
534);
535
0074234Claude536// Create PR
537pulls.post(
538 "/:owner/:repo/pulls/new",
539 softAuth,
540 requireAuth,
04f6b7fClaude541 requireRepoAccess("write"),
0074234Claude542 async (c) => {
543 const { owner: ownerName, repo: repoName } = c.req.param();
544 const user = c.get("user")!;
545 const body = await c.req.parseBody();
546 const title = String(body.title || "").trim();
547 const prBody = String(body.body || "").trim();
548 const baseBranch = String(body.base || "main");
549 const headBranch = String(body.head || "");
550
551 if (!title || !headBranch) {
552 return c.redirect(
553 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
554 );
555 }
556
557 if (baseBranch === headBranch) {
558 return c.redirect(
559 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
560 );
561 }
562
563 const resolved = await resolveRepo(ownerName, repoName);
564 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
565
6fc53bdClaude566 const isDraft = String(body.draft || "") === "1";
567
0074234Claude568 const [pr] = await db
569 .insert(pullRequests)
570 .values({
571 repositoryId: resolved.repo.id,
572 authorId: user.id,
573 title,
574 body: prBody || null,
575 baseBranch,
576 headBranch,
6fc53bdClaude577 isDraft,
0074234Claude578 })
579 .returning();
580
6fc53bdClaude581 // Skip AI review on drafts — it runs again when the PR is marked ready.
582 if (!isDraft && isAiReviewEnabled()) {
e883329Claude583 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
584 (err) => console.error("[ai-review] Failed:", err)
585 );
586 }
587
3cbe3d6Claude588 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
589 triggerPrTriage({
590 ownerName,
591 repoName,
592 repositoryId: resolved.repo.id,
593 prId: pr.id,
594 prAuthorId: user.id,
595 title,
596 body: prBody,
597 baseBranch,
598 headBranch,
599 }).catch((err) => console.error("[pr-triage] Failed:", err));
600
9dd96b9Test User601 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
a28cedeClaude602 import("../lib/auto-merge")
603 .then((m) => m.tryAutoMergeNow(pr.id))
604 .catch((err) => {
605 console.warn(
606 `[auto-merge] tryAutoMergeNow failed for PR ${pr.id}:`,
607 err instanceof Error ? err.message : err
608 );
609 });
9dd96b9Test User610
0074234Claude611 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
612 }
613);
614
615// View single PR
04f6b7fClaude616pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude617 const { owner: ownerName, repo: repoName } = c.req.param();
618 const prNum = parseInt(c.req.param("number"), 10);
619 const user = c.get("user");
620 const tab = c.req.query("tab") || "conversation";
621
622 const resolved = await resolveRepo(ownerName, repoName);
623 if (!resolved) return c.notFound();
624
625 const [pr] = await db
626 .select()
627 .from(pullRequests)
628 .where(
629 and(
630 eq(pullRequests.repositoryId, resolved.repo.id),
631 eq(pullRequests.number, prNum)
632 )
633 )
634 .limit(1);
635
636 if (!pr) return c.notFound();
637
638 const [author] = await db
639 .select()
640 .from(users)
641 .where(eq(users.id, pr.authorId))
642 .limit(1);
643
644 const comments = await db
645 .select({
646 comment: prComments,
647 author: { username: users.username },
648 })
649 .from(prComments)
650 .innerJoin(users, eq(prComments.authorId, users.id))
651 .where(eq(prComments.pullRequestId, pr.id))
652 .orderBy(asc(prComments.createdAt));
653
6fc53bdClaude654 // Reactions for the PR body + each comment, in parallel.
655 const [prReactions, ...prCommentReactions] = await Promise.all([
656 summariseReactions("pr", pr.id, user?.id),
657 ...comments.map((row) =>
658 summariseReactions("pr_comment", row.comment.id, user?.id)
659 ),
660 ]);
661
0074234Claude662 const canManage =
663 user &&
664 (user.id === resolved.owner.id || user.id === pr.authorId);
665
e883329Claude666 const error = c.req.query("error");
c3e0c07Claude667 const info = c.req.query("info");
e883329Claude668
669 // Get gate check status for open PRs
670 let gateChecks: GateCheckResult[] = [];
671 if (pr.state === "open") {
672 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
673 if (headSha) {
674 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
675 const aiApproved = aiComments.length === 0 || aiComments.some(
676 ({ comment }) => comment.body.includes("**Approved**")
677 );
678 const gateResult = await runAllGateChecks(
679 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
680 );
681 gateChecks = gateResult.checks;
682 }
683 }
684
534f04aClaude685 // Block M3 — pre-merge risk score. Cache-only on the request path so
686 // the page never waits on Haiku. On a cache miss for an open PR we
687 // kick off the computation fire-and-forget; the next refresh shows it.
688 let prRisk: PrRiskScore | null = null;
689 let prRiskCalculating = false;
690 if (pr.state === "open") {
691 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
692 if (!prRisk) {
693 prRiskCalculating = true;
a28cedeClaude694 void computePrRiskForPullRequest(pr.id).catch((err) => {
695 console.warn(
696 `[pr-risk] computePrRiskForPullRequest failed for PR ${pr.id}:`,
697 err instanceof Error ? err.message : err
698 );
699 });
534f04aClaude700 }
701 }
702
0074234Claude703 // Get diff for "Files changed" tab
704 let diffRaw = "";
705 let diffFiles: GitDiffFile[] = [];
706 if (tab === "files") {
707 const repoDir = getRepoPath(ownerName, repoName);
708 const proc = Bun.spawn(
709 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
710 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
711 );
712 diffRaw = await new Response(proc.stdout).text();
713 await proc.exited;
714
715 const statProc = Bun.spawn(
716 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
717 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
718 );
719 const stat = await new Response(statProc.stdout).text();
720 await statProc.exited;
721
722 diffFiles = stat
723 .trim()
724 .split("\n")
725 .filter(Boolean)
726 .map((line) => {
727 const [add, del, filePath] = line.split("\t");
728 return {
729 path: filePath,
730 status: "modified",
731 additions: add === "-" ? 0 : parseInt(add, 10),
732 deletions: del === "-" ? 0 : parseInt(del, 10),
733 patch: "",
734 };
735 });
736 }
737
738 return c.html(
739 <Layout
740 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
741 user={user}
742 >
743 <RepoHeader owner={ownerName} repo={repoName} />
744 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b584e52Claude745 <div
746 id="live-comment-banner"
747 class="alert"
748 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
749 >
750 <strong class="js-live-count">0</strong> new comment(s) —{" "}
751 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
752 reload to view
753 </a>
754 </div>
755 <script
756 dangerouslySetInnerHTML={{
757 __html: liveCommentBannerScript({
758 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
759 bannerElementId: "live-comment-banner",
760 }),
761 }}
762 />
0074234Claude763 <div class="issue-detail">
764 <h2>
765 {pr.title}{" "}
bb0f894Claude766 <Text color="var(--text-muted)" weight={400}>
0074234Claude767 #{pr.number}
bb0f894Claude768 </Text>
0074234Claude769 </h2>
bb0f894Claude770 <Flex align="center" gap={8} style="margin:8px 0 20px">
771 <Badge
772 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
0074234Claude773 >
774 {pr.state === "open"
775 ? "\u25CB Open"
776 : pr.state === "merged"
777 ? "\u2B8C Merged"
778 : "\u2713 Closed"}
bb0f894Claude779 </Badge>
780 <Text size={14} muted>
781 <strong style="color:var(--text)">
0074234Claude782 {author?.username}
783 </strong>{" "}
784 wants to merge <code>{pr.headBranch}</code> into{" "}
785 <code>{pr.baseBranch}</code>
bb0f894Claude786 </Text>
787 </Flex>
788
789 <FilterTabs
790 tabs={[
791 {
792 label: "Conversation",
793 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
794 active: tab === "conversation",
795 },
796 {
797 label: "Files changed",
798 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
799 active: tab === "files",
800 },
801 ]}
802 />
0074234Claude803
804 {tab === "files" ? (
805 <DiffView raw={diffRaw} files={diffFiles} />
806 ) : (
807 <>
808 {pr.body && (
bb0f894Claude809 <CommentBox
810 author={author?.username ?? "unknown"}
811 date={pr.createdAt}
812 body={renderMarkdown(pr.body)}
813 />
0074234Claude814 )}
815
6fc53bdClaude816 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude817 <div
818 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
819 >
820 <div class="comment-header">
bb0f894Claude821 <Flex gap={8} align="center">
822 <strong>{commentAuthor.username}</strong>
823 {comment.isAiReview && (
824 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
825 AI Review
826 </Badge>
827 )}
828 <Text size={13} muted>
829 commented {formatRelative(comment.createdAt)}
830 </Text>
831 {comment.filePath && (
832 <Text size={11} mono style="margin-left:8px">
833 {comment.filePath}
834 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
835 </Text>
836 )}
837 </Flex>
6fc53bdClaude838 </div>
bb0f894Claude839 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude840 </div>
841 ))}
842
e883329Claude843 {error && (
844 <div class="auth-error" 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)">
845 {decodeURIComponent(error)}
846 </div>
847 )}
848
c3e0c07Claude849 {info && (
850 <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)">
851 {decodeURIComponent(info)}
852 </div>
853 )}
854
534f04aClaude855 {pr.state === "open" && (prRisk || prRiskCalculating) && (
856 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
857 )}
858
e883329Claude859 {pr.state === "open" && gateChecks.length > 0 && (
860 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
861 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
862 {gateChecks.map((check) => (
863 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
864 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
865 {check.passed ? "\u2713" : "\u2717"}
866 </span>
867 <strong style="font-size: 13px">{check.name}</strong>
868 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
869 </div>
870 ))}
871 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
872 {gateChecks.every((c) => c.passed)
873 ? "All checks passed — ready to merge"
874 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
875 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
876 : "Some checks failed — resolve issues before merging"}
877 </div>
878 </div>
879 )}
880
0074234Claude881 {user && pr.state === "open" && (
882 <div style="margin-top: 20px">
0316dbbClaude883 <Form
e7e240eClaude884 method="post"
0074234Claude885 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
886 >
bb0f894Claude887 <FormGroup>
888 <TextArea
0074234Claude889 name="body"
890 rows={6}
891 required
892 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude893 mono
0074234Claude894 />
bb0f894Claude895 </FormGroup>
896 <Flex gap={8}>
897 <Button type="submit" variant="primary">
0074234Claude898 Comment
bb0f894Claude899 </Button>
0074234Claude900 {canManage && (
901 <>
902 <button
903 type="submit"
904 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
905 class="btn"
bb0f894Claude906 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
0074234Claude907 >
908 Merge pull request
909 </button>
c3e0c07Claude910 {isAiReviewEnabled() && (
911 <button
912 type="submit"
913 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
914 formnovalidate
915 class="btn"
916 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
917 >
918 Re-run AI review
919 </button>
920 )}
bb0f894Claude921 <Button
0074234Claude922 type="submit"
bb0f894Claude923 variant="danger"
0074234Claude924 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
925 >
926 Close
bb0f894Claude927 </Button>
0074234Claude928 </>
929 )}
bb0f894Claude930 </Flex>
931 </Form>
0074234Claude932 </div>
933 )}
934 </>
935 )}
936 </div>
937 </Layout>
938 );
939});
940
941// Add comment to PR
942pulls.post(
943 "/:owner/:repo/pulls/:number/comment",
944 softAuth,
945 requireAuth,
04f6b7fClaude946 requireRepoAccess("write"),
0074234Claude947 async (c) => {
948 const { owner: ownerName, repo: repoName } = c.req.param();
949 const prNum = parseInt(c.req.param("number"), 10);
950 const user = c.get("user")!;
951 const body = await c.req.parseBody();
952 const commentBody = String(body.body || "").trim();
953
954 if (!commentBody) {
955 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
956 }
957
958 const resolved = await resolveRepo(ownerName, repoName);
959 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
960
961 const [pr] = await db
962 .select()
963 .from(pullRequests)
964 .where(
965 and(
966 eq(pullRequests.repositoryId, resolved.repo.id),
967 eq(pullRequests.number, prNum)
968 )
969 )
970 .limit(1);
971
972 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
973
d4ac5c3Claude974 const [inserted] = await db
975 .insert(prComments)
976 .values({
977 pullRequestId: pr.id,
978 authorId: user.id,
979 body: commentBody,
980 })
981 .returning();
982
983 // Live update: nudge any browser tabs subscribed to this PR.
984 if (inserted) {
985 try {
986 const { publish } = await import("../lib/sse");
987 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
988 event: "pr-comment",
989 data: {
990 pullRequestId: pr.id,
991 commentId: inserted.id,
992 authorId: user.id,
993 authorUsername: user.username,
994 },
995 });
996 } catch {
997 /* SSE is best-effort */
998 }
999 }
0074234Claude1000
1001 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1002 }
1003);
1004
e883329Claude1005// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude1006// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
1007// but we keep it at "write" for v1 so trusted collaborators can ship.
1008// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
1009// surface. Branch-protection rules (evaluated below) are the current mechanism
1010// for locking down merges further on specific branches.
0074234Claude1011pulls.post(
1012 "/:owner/:repo/pulls/:number/merge",
1013 softAuth,
1014 requireAuth,
04f6b7fClaude1015 requireRepoAccess("write"),
0074234Claude1016 async (c) => {
1017 const { owner: ownerName, repo: repoName } = c.req.param();
1018 const prNum = parseInt(c.req.param("number"), 10);
1019 const user = c.get("user")!;
1020
1021 const resolved = await resolveRepo(ownerName, repoName);
1022 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1023
1024 const [pr] = await db
1025 .select()
1026 .from(pullRequests)
1027 .where(
1028 and(
1029 eq(pullRequests.repositoryId, resolved.repo.id),
1030 eq(pullRequests.number, prNum)
1031 )
1032 )
1033 .limit(1);
1034
1035 if (!pr || pr.state !== "open") {
1036 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1037 }
1038
6fc53bdClaude1039 // Draft PRs cannot be merged — must be marked ready first.
1040 if (pr.isDraft) {
1041 return c.redirect(
1042 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1043 "This PR is a draft. Mark it as ready for review before merging."
1044 )}`
1045 );
1046 }
1047
e883329Claude1048 // Resolve head SHA
1049 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
1050 if (!headSha) {
1051 return c.redirect(
1052 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
1053 );
1054 }
1055
1056 // Check if AI review approved this PR
1057 const aiComments = await db
1058 .select()
1059 .from(prComments)
1060 .where(
1061 and(
1062 eq(prComments.pullRequestId, pr.id),
1063 eq(prComments.isAiReview, true)
1064 )
1065 );
1066 const aiApproved = aiComments.length === 0 || aiComments.some(
1067 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude1068 );
e883329Claude1069
1070 // Run all green gate checks (GateTest + mergeability + AI review)
1071 const gateResult = await runAllGateChecks(
1072 ownerName,
1073 repoName,
1074 pr.baseBranch,
1075 pr.headBranch,
1076 headSha,
1077 aiApproved
0074234Claude1078 );
1079
e883329Claude1080 // If GateTest or AI review failed (hard blocks), reject the merge
1081 const hardFailures = gateResult.checks.filter(
1082 (check) => !check.passed && check.name !== "Merge check"
1083 );
1084 if (hardFailures.length > 0) {
1085 const errorMsg = hardFailures
1086 .map((f) => `${f.name}: ${f.details}`)
1087 .join("; ");
0074234Claude1088 return c.redirect(
e883329Claude1089 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude1090 );
1091 }
1092
1e162a8Claude1093 // D5 — Branch-protection enforcement. Looks up the matching rule for the
1094 // base branch and blocks the merge if requireAiApproval / requireGreenGates
1095 // / requireHumanReview / requiredApprovals are not satisfied. Independent
1096 // of repo-global settings, so owners can lock specific branches down
1097 // further than the repo default.
1098 const protectionRule = await matchProtection(
1099 resolved.repo.id,
1100 pr.baseBranch
1101 );
1102 if (protectionRule) {
1103 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude1104 const required = await listRequiredChecks(protectionRule.id);
1105 const passingNames = required.length > 0
1106 ? await passingCheckNames(resolved.repo.id, headSha)
1107 : [];
1108 const decision = evaluateProtection(
1109 protectionRule,
1110 {
1111 aiApproved,
1112 humanApprovalCount: humanApprovals,
1113 gateResultGreen: hardFailures.length === 0,
1114 hasFailedGates: hardFailures.length > 0,
1115 passingCheckNames: passingNames,
1116 },
1117 required.map((r) => r.checkName)
1118 );
1e162a8Claude1119 if (!decision.allowed) {
1120 return c.redirect(
1121 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1122 decision.reasons.join(" ")
1123 )}`
1124 );
1125 }
1126 }
1127
e883329Claude1128 // Attempt the merge — with auto conflict resolution if needed
1129 const repoDir = getRepoPath(ownerName, repoName);
1130 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
1131 const hasConflicts = mergeCheck && !mergeCheck.passed;
1132
1133 if (hasConflicts && isAiReviewEnabled()) {
1134 // Use Claude to auto-resolve conflicts
1135 const mergeResult = await mergeWithAutoResolve(
1136 ownerName,
1137 repoName,
1138 pr.baseBranch,
1139 pr.headBranch,
1140 `Merge pull request #${pr.number}: ${pr.title}`
1141 );
1142
1143 if (!mergeResult.success) {
1144 return c.redirect(
1145 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
1146 );
1147 }
1148
1149 // Post a comment about the auto-resolution
1150 if (mergeResult.resolvedFiles.length > 0) {
1151 await db.insert(prComments).values({
1152 pullRequestId: pr.id,
1153 authorId: user.id,
1154 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1155 isAiReview: true,
1156 });
1157 }
1158 } else {
1159 // Standard merge — fast-forward or clean merge
1160 const ffProc = Bun.spawn(
1161 [
1162 "git",
1163 "update-ref",
1164 `refs/heads/${pr.baseBranch}`,
1165 `refs/heads/${pr.headBranch}`,
1166 ],
1167 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1168 );
1169 const ffExit = await ffProc.exited;
1170
1171 if (ffExit !== 0) {
1172 return c.redirect(
1173 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
1174 );
1175 }
1176 }
1177
0074234Claude1178 await db
1179 .update(pullRequests)
1180 .set({
1181 state: "merged",
1182 mergedAt: new Date(),
1183 mergedBy: user.id,
1184 updatedAt: new Date(),
1185 })
1186 .where(eq(pullRequests.id, pr.id));
1187
d62fb36Claude1188 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
1189 // and auto-close each matching open issue with a back-link comment. Bounded
1190 // to the same repo for v1 (cross-repo refs ignored). Failures never block
1191 // the merge redirect.
1192 try {
1193 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
1194 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1195 for (const n of refs) {
1196 const [issue] = await db
1197 .select()
1198 .from(issues)
1199 .where(
1200 and(
1201 eq(issues.repositoryId, resolved.repo.id),
1202 eq(issues.number, n)
1203 )
1204 )
1205 .limit(1);
1206 if (!issue || issue.state !== "open") continue;
1207 await db
1208 .update(issues)
1209 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1210 .where(eq(issues.id, issue.id));
1211 await db.insert(issueComments).values({
1212 issueId: issue.id,
1213 authorId: user.id,
1214 body: `Closed by pull request #${pr.number}.`,
1215 });
1216 }
1217 } catch {
1218 // Never block the merge on close-keyword failures.
1219 }
1220
0074234Claude1221 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1222 }
1223);
1224
6fc53bdClaude1225// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
1226// hasn't run yet on this PR.
1227pulls.post(
1228 "/:owner/:repo/pulls/:number/ready",
1229 softAuth,
1230 requireAuth,
04f6b7fClaude1231 requireRepoAccess("write"),
6fc53bdClaude1232 async (c) => {
1233 const { owner: ownerName, repo: repoName } = c.req.param();
1234 const prNum = parseInt(c.req.param("number"), 10);
1235 const user = c.get("user")!;
1236
1237 const resolved = await resolveRepo(ownerName, repoName);
1238 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1239
1240 const [pr] = await db
1241 .select()
1242 .from(pullRequests)
1243 .where(
1244 and(
1245 eq(pullRequests.repositoryId, resolved.repo.id),
1246 eq(pullRequests.number, prNum)
1247 )
1248 )
1249 .limit(1);
1250 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1251
1252 // Only the author or repo owner can toggle draft state.
1253 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1254 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1255 }
1256
1257 if (pr.state === "open" && pr.isDraft) {
1258 await db
1259 .update(pullRequests)
1260 .set({ isDraft: false, updatedAt: new Date() })
1261 .where(eq(pullRequests.id, pr.id));
1262
1263 if (isAiReviewEnabled()) {
1264 triggerAiReview(
1265 ownerName,
1266 repoName,
1267 pr.id,
1268 pr.title,
0316dbbClaude1269 pr.body || "",
6fc53bdClaude1270 pr.baseBranch,
1271 pr.headBranch
1272 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
1273 }
1274 }
1275
1276 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1277 }
1278);
1279
1280// Convert a PR back to draft.
1281pulls.post(
1282 "/:owner/:repo/pulls/:number/draft",
1283 softAuth,
1284 requireAuth,
04f6b7fClaude1285 requireRepoAccess("write"),
6fc53bdClaude1286 async (c) => {
1287 const { owner: ownerName, repo: repoName } = c.req.param();
1288 const prNum = parseInt(c.req.param("number"), 10);
1289 const user = c.get("user")!;
1290
1291 const resolved = await resolveRepo(ownerName, repoName);
1292 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1293
1294 const [pr] = await db
1295 .select()
1296 .from(pullRequests)
1297 .where(
1298 and(
1299 eq(pullRequests.repositoryId, resolved.repo.id),
1300 eq(pullRequests.number, prNum)
1301 )
1302 )
1303 .limit(1);
1304 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1305
1306 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1307 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1308 }
1309
1310 if (pr.state === "open" && !pr.isDraft) {
1311 await db
1312 .update(pullRequests)
1313 .set({ isDraft: true, updatedAt: new Date() })
1314 .where(eq(pullRequests.id, pr.id));
1315 }
1316
1317 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1318 }
1319);
1320
0074234Claude1321// Close PR
1322pulls.post(
1323 "/:owner/:repo/pulls/:number/close",
1324 softAuth,
1325 requireAuth,
04f6b7fClaude1326 requireRepoAccess("write"),
0074234Claude1327 async (c) => {
1328 const { owner: ownerName, repo: repoName } = c.req.param();
1329 const prNum = parseInt(c.req.param("number"), 10);
1330
1331 const resolved = await resolveRepo(ownerName, repoName);
1332 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1333
1334 await db
1335 .update(pullRequests)
1336 .set({
1337 state: "closed",
1338 closedAt: new Date(),
1339 updatedAt: new Date(),
1340 })
1341 .where(
1342 and(
1343 eq(pullRequests.repositoryId, resolved.repo.id),
1344 eq(pullRequests.number, prNum)
1345 )
1346 );
1347
1348 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1349 }
1350);
1351
c3e0c07Claude1352// Re-run AI review on demand (e.g. after a force-push). Bypasses the
1353// idempotency marker via { force: true }. Write-access only.
1354pulls.post(
1355 "/:owner/:repo/pulls/:number/ai-rereview",
1356 softAuth,
1357 requireAuth,
1358 requireRepoAccess("write"),
1359 async (c) => {
1360 const { owner: ownerName, repo: repoName } = c.req.param();
1361 const prNum = parseInt(c.req.param("number"), 10);
1362 const resolved = await resolveRepo(ownerName, repoName);
1363 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1364
1365 const [pr] = await db
1366 .select()
1367 .from(pullRequests)
1368 .where(
1369 and(
1370 eq(pullRequests.repositoryId, resolved.repo.id),
1371 eq(pullRequests.number, prNum)
1372 )
1373 )
1374 .limit(1);
1375 if (!pr) {
1376 return c.redirect(`/${ownerName}/${repoName}/pulls`);
1377 }
1378
1379 if (!isAiReviewEnabled()) {
1380 return c.redirect(
1381 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1382 "AI review is not configured (ANTHROPIC_API_KEY)."
1383 )}`
1384 );
1385 }
1386
1387 // Fire-and-forget but with { force: true } to bypass the
1388 // already-reviewed marker. The function still never throws.
1389 triggerAiReview(
1390 ownerName,
1391 repoName,
1392 pr.id,
1393 pr.title || "",
1394 pr.body || "",
1395 pr.baseBranch,
1396 pr.headBranch,
1397 { force: true }
a28cedeClaude1398 ).catch((err) => {
1399 console.warn(
1400 `[ai-rereview] triggerAiReview failed for PR ${pr.id}:`,
1401 err instanceof Error ? err.message : err
1402 );
1403 });
c3e0c07Claude1404
1405 return c.redirect(
1406 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
1407 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
1408 )}`
1409 );
1410 }
1411);
1412
0074234Claude1413export default pulls;