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.tsxBlame1396 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.
602 import("../lib/auto-merge").then((m) => m.tryAutoMergeNow(pr.id)).catch(() => {});
603
0074234Claude604 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
605 }
606);
607
608// View single PR
04f6b7fClaude609pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude610 const { owner: ownerName, repo: repoName } = c.req.param();
611 const prNum = parseInt(c.req.param("number"), 10);
612 const user = c.get("user");
613 const tab = c.req.query("tab") || "conversation";
614
615 const resolved = await resolveRepo(ownerName, repoName);
616 if (!resolved) return c.notFound();
617
618 const [pr] = await db
619 .select()
620 .from(pullRequests)
621 .where(
622 and(
623 eq(pullRequests.repositoryId, resolved.repo.id),
624 eq(pullRequests.number, prNum)
625 )
626 )
627 .limit(1);
628
629 if (!pr) return c.notFound();
630
631 const [author] = await db
632 .select()
633 .from(users)
634 .where(eq(users.id, pr.authorId))
635 .limit(1);
636
637 const comments = await db
638 .select({
639 comment: prComments,
640 author: { username: users.username },
641 })
642 .from(prComments)
643 .innerJoin(users, eq(prComments.authorId, users.id))
644 .where(eq(prComments.pullRequestId, pr.id))
645 .orderBy(asc(prComments.createdAt));
646
6fc53bdClaude647 // Reactions for the PR body + each comment, in parallel.
648 const [prReactions, ...prCommentReactions] = await Promise.all([
649 summariseReactions("pr", pr.id, user?.id),
650 ...comments.map((row) =>
651 summariseReactions("pr_comment", row.comment.id, user?.id)
652 ),
653 ]);
654
0074234Claude655 const canManage =
656 user &&
657 (user.id === resolved.owner.id || user.id === pr.authorId);
658
e883329Claude659 const error = c.req.query("error");
c3e0c07Claude660 const info = c.req.query("info");
e883329Claude661
662 // Get gate check status for open PRs
663 let gateChecks: GateCheckResult[] = [];
664 if (pr.state === "open") {
665 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
666 if (headSha) {
667 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
668 const aiApproved = aiComments.length === 0 || aiComments.some(
669 ({ comment }) => comment.body.includes("**Approved**")
670 );
671 const gateResult = await runAllGateChecks(
672 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
673 );
674 gateChecks = gateResult.checks;
675 }
676 }
677
534f04aClaude678 // Block M3 — pre-merge risk score. Cache-only on the request path so
679 // the page never waits on Haiku. On a cache miss for an open PR we
680 // kick off the computation fire-and-forget; the next refresh shows it.
681 let prRisk: PrRiskScore | null = null;
682 let prRiskCalculating = false;
683 if (pr.state === "open") {
684 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
685 if (!prRisk) {
686 prRiskCalculating = true;
687 void computePrRiskForPullRequest(pr.id).catch(() => {});
688 }
689 }
690
0074234Claude691 // Get diff for "Files changed" tab
692 let diffRaw = "";
693 let diffFiles: GitDiffFile[] = [];
694 if (tab === "files") {
695 const repoDir = getRepoPath(ownerName, repoName);
696 const proc = Bun.spawn(
697 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
698 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
699 );
700 diffRaw = await new Response(proc.stdout).text();
701 await proc.exited;
702
703 const statProc = Bun.spawn(
704 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
705 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
706 );
707 const stat = await new Response(statProc.stdout).text();
708 await statProc.exited;
709
710 diffFiles = stat
711 .trim()
712 .split("\n")
713 .filter(Boolean)
714 .map((line) => {
715 const [add, del, filePath] = line.split("\t");
716 return {
717 path: filePath,
718 status: "modified",
719 additions: add === "-" ? 0 : parseInt(add, 10),
720 deletions: del === "-" ? 0 : parseInt(del, 10),
721 patch: "",
722 };
723 });
724 }
725
726 return c.html(
727 <Layout
728 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
729 user={user}
730 >
731 <RepoHeader owner={ownerName} repo={repoName} />
732 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b584e52Claude733 <div
734 id="live-comment-banner"
735 class="alert"
736 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
737 >
738 <strong class="js-live-count">0</strong> new comment(s) —{" "}
739 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
740 reload to view
741 </a>
742 </div>
743 <script
744 dangerouslySetInnerHTML={{
745 __html: liveCommentBannerScript({
746 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
747 bannerElementId: "live-comment-banner",
748 }),
749 }}
750 />
0074234Claude751 <div class="issue-detail">
752 <h2>
753 {pr.title}{" "}
bb0f894Claude754 <Text color="var(--text-muted)" weight={400}>
0074234Claude755 #{pr.number}
bb0f894Claude756 </Text>
0074234Claude757 </h2>
bb0f894Claude758 <Flex align="center" gap={8} style="margin:8px 0 20px">
759 <Badge
760 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
0074234Claude761 >
762 {pr.state === "open"
763 ? "\u25CB Open"
764 : pr.state === "merged"
765 ? "\u2B8C Merged"
766 : "\u2713 Closed"}
bb0f894Claude767 </Badge>
768 <Text size={14} muted>
769 <strong style="color:var(--text)">
0074234Claude770 {author?.username}
771 </strong>{" "}
772 wants to merge <code>{pr.headBranch}</code> into{" "}
773 <code>{pr.baseBranch}</code>
bb0f894Claude774 </Text>
775 </Flex>
776
777 <FilterTabs
778 tabs={[
779 {
780 label: "Conversation",
781 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
782 active: tab === "conversation",
783 },
784 {
785 label: "Files changed",
786 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
787 active: tab === "files",
788 },
789 ]}
790 />
0074234Claude791
792 {tab === "files" ? (
793 <DiffView raw={diffRaw} files={diffFiles} />
794 ) : (
795 <>
796 {pr.body && (
bb0f894Claude797 <CommentBox
798 author={author?.username ?? "unknown"}
799 date={pr.createdAt}
800 body={renderMarkdown(pr.body)}
801 />
0074234Claude802 )}
803
6fc53bdClaude804 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude805 <div
806 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
807 >
808 <div class="comment-header">
bb0f894Claude809 <Flex gap={8} align="center">
810 <strong>{commentAuthor.username}</strong>
811 {comment.isAiReview && (
812 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
813 AI Review
814 </Badge>
815 )}
816 <Text size={13} muted>
817 commented {formatRelative(comment.createdAt)}
818 </Text>
819 {comment.filePath && (
820 <Text size={11} mono style="margin-left:8px">
821 {comment.filePath}
822 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
823 </Text>
824 )}
825 </Flex>
6fc53bdClaude826 </div>
bb0f894Claude827 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude828 </div>
829 ))}
830
e883329Claude831 {error && (
832 <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)">
833 {decodeURIComponent(error)}
834 </div>
835 )}
836
c3e0c07Claude837 {info && (
838 <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)">
839 {decodeURIComponent(info)}
840 </div>
841 )}
842
534f04aClaude843 {pr.state === "open" && (prRisk || prRiskCalculating) && (
844 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
845 )}
846
e883329Claude847 {pr.state === "open" && gateChecks.length > 0 && (
848 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
849 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
850 {gateChecks.map((check) => (
851 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
852 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
853 {check.passed ? "\u2713" : "\u2717"}
854 </span>
855 <strong style="font-size: 13px">{check.name}</strong>
856 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
857 </div>
858 ))}
859 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
860 {gateChecks.every((c) => c.passed)
861 ? "All checks passed — ready to merge"
862 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
863 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
864 : "Some checks failed — resolve issues before merging"}
865 </div>
866 </div>
867 )}
868
0074234Claude869 {user && pr.state === "open" && (
870 <div style="margin-top: 20px">
0316dbbClaude871 <Form
e7e240eClaude872 method="post"
0074234Claude873 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
874 >
bb0f894Claude875 <FormGroup>
876 <TextArea
0074234Claude877 name="body"
878 rows={6}
879 required
880 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude881 mono
0074234Claude882 />
bb0f894Claude883 </FormGroup>
884 <Flex gap={8}>
885 <Button type="submit" variant="primary">
0074234Claude886 Comment
bb0f894Claude887 </Button>
0074234Claude888 {canManage && (
889 <>
890 <button
891 type="submit"
892 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
893 class="btn"
bb0f894Claude894 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
0074234Claude895 >
896 Merge pull request
897 </button>
c3e0c07Claude898 {isAiReviewEnabled() && (
899 <button
900 type="submit"
901 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
902 formnovalidate
903 class="btn"
904 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
905 >
906 Re-run AI review
907 </button>
908 )}
bb0f894Claude909 <Button
0074234Claude910 type="submit"
bb0f894Claude911 variant="danger"
0074234Claude912 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
913 >
914 Close
bb0f894Claude915 </Button>
0074234Claude916 </>
917 )}
bb0f894Claude918 </Flex>
919 </Form>
0074234Claude920 </div>
921 )}
922 </>
923 )}
924 </div>
925 </Layout>
926 );
927});
928
929// Add comment to PR
930pulls.post(
931 "/:owner/:repo/pulls/:number/comment",
932 softAuth,
933 requireAuth,
04f6b7fClaude934 requireRepoAccess("write"),
0074234Claude935 async (c) => {
936 const { owner: ownerName, repo: repoName } = c.req.param();
937 const prNum = parseInt(c.req.param("number"), 10);
938 const user = c.get("user")!;
939 const body = await c.req.parseBody();
940 const commentBody = String(body.body || "").trim();
941
942 if (!commentBody) {
943 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
944 }
945
946 const resolved = await resolveRepo(ownerName, repoName);
947 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
948
949 const [pr] = await db
950 .select()
951 .from(pullRequests)
952 .where(
953 and(
954 eq(pullRequests.repositoryId, resolved.repo.id),
955 eq(pullRequests.number, prNum)
956 )
957 )
958 .limit(1);
959
960 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
961
d4ac5c3Claude962 const [inserted] = await db
963 .insert(prComments)
964 .values({
965 pullRequestId: pr.id,
966 authorId: user.id,
967 body: commentBody,
968 })
969 .returning();
970
971 // Live update: nudge any browser tabs subscribed to this PR.
972 if (inserted) {
973 try {
974 const { publish } = await import("../lib/sse");
975 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
976 event: "pr-comment",
977 data: {
978 pullRequestId: pr.id,
979 commentId: inserted.id,
980 authorId: user.id,
981 authorUsername: user.username,
982 },
983 });
984 } catch {
985 /* SSE is best-effort */
986 }
987 }
0074234Claude988
989 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
990 }
991);
992
e883329Claude993// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude994// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
995// but we keep it at "write" for v1 so trusted collaborators can ship.
996// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
997// surface. Branch-protection rules (evaluated below) are the current mechanism
998// for locking down merges further on specific branches.
0074234Claude999pulls.post(
1000 "/:owner/:repo/pulls/:number/merge",
1001 softAuth,
1002 requireAuth,
04f6b7fClaude1003 requireRepoAccess("write"),
0074234Claude1004 async (c) => {
1005 const { owner: ownerName, repo: repoName } = c.req.param();
1006 const prNum = parseInt(c.req.param("number"), 10);
1007 const user = c.get("user")!;
1008
1009 const resolved = await resolveRepo(ownerName, repoName);
1010 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1011
1012 const [pr] = await db
1013 .select()
1014 .from(pullRequests)
1015 .where(
1016 and(
1017 eq(pullRequests.repositoryId, resolved.repo.id),
1018 eq(pullRequests.number, prNum)
1019 )
1020 )
1021 .limit(1);
1022
1023 if (!pr || pr.state !== "open") {
1024 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1025 }
1026
6fc53bdClaude1027 // Draft PRs cannot be merged — must be marked ready first.
1028 if (pr.isDraft) {
1029 return c.redirect(
1030 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1031 "This PR is a draft. Mark it as ready for review before merging."
1032 )}`
1033 );
1034 }
1035
e883329Claude1036 // Resolve head SHA
1037 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
1038 if (!headSha) {
1039 return c.redirect(
1040 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
1041 );
1042 }
1043
1044 // Check if AI review approved this PR
1045 const aiComments = await db
1046 .select()
1047 .from(prComments)
1048 .where(
1049 and(
1050 eq(prComments.pullRequestId, pr.id),
1051 eq(prComments.isAiReview, true)
1052 )
1053 );
1054 const aiApproved = aiComments.length === 0 || aiComments.some(
1055 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude1056 );
e883329Claude1057
1058 // Run all green gate checks (GateTest + mergeability + AI review)
1059 const gateResult = await runAllGateChecks(
1060 ownerName,
1061 repoName,
1062 pr.baseBranch,
1063 pr.headBranch,
1064 headSha,
1065 aiApproved
0074234Claude1066 );
1067
e883329Claude1068 // If GateTest or AI review failed (hard blocks), reject the merge
1069 const hardFailures = gateResult.checks.filter(
1070 (check) => !check.passed && check.name !== "Merge check"
1071 );
1072 if (hardFailures.length > 0) {
1073 const errorMsg = hardFailures
1074 .map((f) => `${f.name}: ${f.details}`)
1075 .join("; ");
0074234Claude1076 return c.redirect(
e883329Claude1077 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude1078 );
1079 }
1080
1e162a8Claude1081 // D5 — Branch-protection enforcement. Looks up the matching rule for the
1082 // base branch and blocks the merge if requireAiApproval / requireGreenGates
1083 // / requireHumanReview / requiredApprovals are not satisfied. Independent
1084 // of repo-global settings, so owners can lock specific branches down
1085 // further than the repo default.
1086 const protectionRule = await matchProtection(
1087 resolved.repo.id,
1088 pr.baseBranch
1089 );
1090 if (protectionRule) {
1091 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude1092 const required = await listRequiredChecks(protectionRule.id);
1093 const passingNames = required.length > 0
1094 ? await passingCheckNames(resolved.repo.id, headSha)
1095 : [];
1096 const decision = evaluateProtection(
1097 protectionRule,
1098 {
1099 aiApproved,
1100 humanApprovalCount: humanApprovals,
1101 gateResultGreen: hardFailures.length === 0,
1102 hasFailedGates: hardFailures.length > 0,
1103 passingCheckNames: passingNames,
1104 },
1105 required.map((r) => r.checkName)
1106 );
1e162a8Claude1107 if (!decision.allowed) {
1108 return c.redirect(
1109 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1110 decision.reasons.join(" ")
1111 )}`
1112 );
1113 }
1114 }
1115
e883329Claude1116 // Attempt the merge — with auto conflict resolution if needed
1117 const repoDir = getRepoPath(ownerName, repoName);
1118 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
1119 const hasConflicts = mergeCheck && !mergeCheck.passed;
1120
1121 if (hasConflicts && isAiReviewEnabled()) {
1122 // Use Claude to auto-resolve conflicts
1123 const mergeResult = await mergeWithAutoResolve(
1124 ownerName,
1125 repoName,
1126 pr.baseBranch,
1127 pr.headBranch,
1128 `Merge pull request #${pr.number}: ${pr.title}`
1129 );
1130
1131 if (!mergeResult.success) {
1132 return c.redirect(
1133 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
1134 );
1135 }
1136
1137 // Post a comment about the auto-resolution
1138 if (mergeResult.resolvedFiles.length > 0) {
1139 await db.insert(prComments).values({
1140 pullRequestId: pr.id,
1141 authorId: user.id,
1142 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1143 isAiReview: true,
1144 });
1145 }
1146 } else {
1147 // Standard merge — fast-forward or clean merge
1148 const ffProc = Bun.spawn(
1149 [
1150 "git",
1151 "update-ref",
1152 `refs/heads/${pr.baseBranch}`,
1153 `refs/heads/${pr.headBranch}`,
1154 ],
1155 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1156 );
1157 const ffExit = await ffProc.exited;
1158
1159 if (ffExit !== 0) {
1160 return c.redirect(
1161 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
1162 );
1163 }
1164 }
1165
0074234Claude1166 await db
1167 .update(pullRequests)
1168 .set({
1169 state: "merged",
1170 mergedAt: new Date(),
1171 mergedBy: user.id,
1172 updatedAt: new Date(),
1173 })
1174 .where(eq(pullRequests.id, pr.id));
1175
d62fb36Claude1176 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
1177 // and auto-close each matching open issue with a back-link comment. Bounded
1178 // to the same repo for v1 (cross-repo refs ignored). Failures never block
1179 // the merge redirect.
1180 try {
1181 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
1182 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1183 for (const n of refs) {
1184 const [issue] = await db
1185 .select()
1186 .from(issues)
1187 .where(
1188 and(
1189 eq(issues.repositoryId, resolved.repo.id),
1190 eq(issues.number, n)
1191 )
1192 )
1193 .limit(1);
1194 if (!issue || issue.state !== "open") continue;
1195 await db
1196 .update(issues)
1197 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1198 .where(eq(issues.id, issue.id));
1199 await db.insert(issueComments).values({
1200 issueId: issue.id,
1201 authorId: user.id,
1202 body: `Closed by pull request #${pr.number}.`,
1203 });
1204 }
1205 } catch {
1206 // Never block the merge on close-keyword failures.
1207 }
1208
0074234Claude1209 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1210 }
1211);
1212
6fc53bdClaude1213// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
1214// hasn't run yet on this PR.
1215pulls.post(
1216 "/:owner/:repo/pulls/:number/ready",
1217 softAuth,
1218 requireAuth,
04f6b7fClaude1219 requireRepoAccess("write"),
6fc53bdClaude1220 async (c) => {
1221 const { owner: ownerName, repo: repoName } = c.req.param();
1222 const prNum = parseInt(c.req.param("number"), 10);
1223 const user = c.get("user")!;
1224
1225 const resolved = await resolveRepo(ownerName, repoName);
1226 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1227
1228 const [pr] = await db
1229 .select()
1230 .from(pullRequests)
1231 .where(
1232 and(
1233 eq(pullRequests.repositoryId, resolved.repo.id),
1234 eq(pullRequests.number, prNum)
1235 )
1236 )
1237 .limit(1);
1238 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1239
1240 // Only the author or repo owner can toggle draft state.
1241 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1242 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1243 }
1244
1245 if (pr.state === "open" && pr.isDraft) {
1246 await db
1247 .update(pullRequests)
1248 .set({ isDraft: false, updatedAt: new Date() })
1249 .where(eq(pullRequests.id, pr.id));
1250
1251 if (isAiReviewEnabled()) {
1252 triggerAiReview(
1253 ownerName,
1254 repoName,
1255 pr.id,
1256 pr.title,
0316dbbClaude1257 pr.body || "",
6fc53bdClaude1258 pr.baseBranch,
1259 pr.headBranch
1260 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
1261 }
1262 }
1263
1264 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1265 }
1266);
1267
1268// Convert a PR back to draft.
1269pulls.post(
1270 "/:owner/:repo/pulls/:number/draft",
1271 softAuth,
1272 requireAuth,
04f6b7fClaude1273 requireRepoAccess("write"),
6fc53bdClaude1274 async (c) => {
1275 const { owner: ownerName, repo: repoName } = c.req.param();
1276 const prNum = parseInt(c.req.param("number"), 10);
1277 const user = c.get("user")!;
1278
1279 const resolved = await resolveRepo(ownerName, repoName);
1280 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1281
1282 const [pr] = await db
1283 .select()
1284 .from(pullRequests)
1285 .where(
1286 and(
1287 eq(pullRequests.repositoryId, resolved.repo.id),
1288 eq(pullRequests.number, prNum)
1289 )
1290 )
1291 .limit(1);
1292 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1293
1294 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1295 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1296 }
1297
1298 if (pr.state === "open" && !pr.isDraft) {
1299 await db
1300 .update(pullRequests)
1301 .set({ isDraft: true, updatedAt: new Date() })
1302 .where(eq(pullRequests.id, pr.id));
1303 }
1304
1305 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1306 }
1307);
1308
0074234Claude1309// Close PR
1310pulls.post(
1311 "/:owner/:repo/pulls/:number/close",
1312 softAuth,
1313 requireAuth,
04f6b7fClaude1314 requireRepoAccess("write"),
0074234Claude1315 async (c) => {
1316 const { owner: ownerName, repo: repoName } = c.req.param();
1317 const prNum = parseInt(c.req.param("number"), 10);
1318
1319 const resolved = await resolveRepo(ownerName, repoName);
1320 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1321
1322 await db
1323 .update(pullRequests)
1324 .set({
1325 state: "closed",
1326 closedAt: new Date(),
1327 updatedAt: new Date(),
1328 })
1329 .where(
1330 and(
1331 eq(pullRequests.repositoryId, resolved.repo.id),
1332 eq(pullRequests.number, prNum)
1333 )
1334 );
1335
1336 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1337 }
1338);
1339
c3e0c07Claude1340// Re-run AI review on demand (e.g. after a force-push). Bypasses the
1341// idempotency marker via { force: true }. Write-access only.
1342pulls.post(
1343 "/:owner/:repo/pulls/:number/ai-rereview",
1344 softAuth,
1345 requireAuth,
1346 requireRepoAccess("write"),
1347 async (c) => {
1348 const { owner: ownerName, repo: repoName } = c.req.param();
1349 const prNum = parseInt(c.req.param("number"), 10);
1350 const resolved = await resolveRepo(ownerName, repoName);
1351 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1352
1353 const [pr] = await db
1354 .select()
1355 .from(pullRequests)
1356 .where(
1357 and(
1358 eq(pullRequests.repositoryId, resolved.repo.id),
1359 eq(pullRequests.number, prNum)
1360 )
1361 )
1362 .limit(1);
1363 if (!pr) {
1364 return c.redirect(`/${ownerName}/${repoName}/pulls`);
1365 }
1366
1367 if (!isAiReviewEnabled()) {
1368 return c.redirect(
1369 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1370 "AI review is not configured (ANTHROPIC_API_KEY)."
1371 )}`
1372 );
1373 }
1374
1375 // Fire-and-forget but with { force: true } to bypass the
1376 // already-reviewed marker. The function still never throws.
1377 triggerAiReview(
1378 ownerName,
1379 repoName,
1380 pr.id,
1381 pr.title || "",
1382 pr.body || "",
1383 pr.baseBranch,
1384 pr.headBranch,
1385 { force: true }
1386 ).catch(() => {});
1387
1388 return c.redirect(
1389 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
1390 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
1391 )}`
1392 );
1393 }
1394);
1395
0074234Claude1396export default pulls;