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.tsxBlame1393 lines · 2 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
0074234Claude601 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
602 }
603);
604
605// View single PR
04f6b7fClaude606pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude607 const { owner: ownerName, repo: repoName } = c.req.param();
608 const prNum = parseInt(c.req.param("number"), 10);
609 const user = c.get("user");
610 const tab = c.req.query("tab") || "conversation";
611
612 const resolved = await resolveRepo(ownerName, repoName);
613 if (!resolved) return c.notFound();
614
615 const [pr] = await db
616 .select()
617 .from(pullRequests)
618 .where(
619 and(
620 eq(pullRequests.repositoryId, resolved.repo.id),
621 eq(pullRequests.number, prNum)
622 )
623 )
624 .limit(1);
625
626 if (!pr) return c.notFound();
627
628 const [author] = await db
629 .select()
630 .from(users)
631 .where(eq(users.id, pr.authorId))
632 .limit(1);
633
634 const comments = await db
635 .select({
636 comment: prComments,
637 author: { username: users.username },
638 })
639 .from(prComments)
640 .innerJoin(users, eq(prComments.authorId, users.id))
641 .where(eq(prComments.pullRequestId, pr.id))
642 .orderBy(asc(prComments.createdAt));
643
6fc53bdClaude644 // Reactions for the PR body + each comment, in parallel.
645 const [prReactions, ...prCommentReactions] = await Promise.all([
646 summariseReactions("pr", pr.id, user?.id),
647 ...comments.map((row) =>
648 summariseReactions("pr_comment", row.comment.id, user?.id)
649 ),
650 ]);
651
0074234Claude652 const canManage =
653 user &&
654 (user.id === resolved.owner.id || user.id === pr.authorId);
655
e883329Claude656 const error = c.req.query("error");
c3e0c07Claude657 const info = c.req.query("info");
e883329Claude658
659 // Get gate check status for open PRs
660 let gateChecks: GateCheckResult[] = [];
661 if (pr.state === "open") {
662 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
663 if (headSha) {
664 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
665 const aiApproved = aiComments.length === 0 || aiComments.some(
666 ({ comment }) => comment.body.includes("**Approved**")
667 );
668 const gateResult = await runAllGateChecks(
669 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
670 );
671 gateChecks = gateResult.checks;
672 }
673 }
674
534f04aClaude675 // Block M3 — pre-merge risk score. Cache-only on the request path so
676 // the page never waits on Haiku. On a cache miss for an open PR we
677 // kick off the computation fire-and-forget; the next refresh shows it.
678 let prRisk: PrRiskScore | null = null;
679 let prRiskCalculating = false;
680 if (pr.state === "open") {
681 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
682 if (!prRisk) {
683 prRiskCalculating = true;
684 void computePrRiskForPullRequest(pr.id).catch(() => {});
685 }
686 }
687
0074234Claude688 // Get diff for "Files changed" tab
689 let diffRaw = "";
690 let diffFiles: GitDiffFile[] = [];
691 if (tab === "files") {
692 const repoDir = getRepoPath(ownerName, repoName);
693 const proc = Bun.spawn(
694 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
695 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
696 );
697 diffRaw = await new Response(proc.stdout).text();
698 await proc.exited;
699
700 const statProc = Bun.spawn(
701 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
702 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
703 );
704 const stat = await new Response(statProc.stdout).text();
705 await statProc.exited;
706
707 diffFiles = stat
708 .trim()
709 .split("\n")
710 .filter(Boolean)
711 .map((line) => {
712 const [add, del, filePath] = line.split("\t");
713 return {
714 path: filePath,
715 status: "modified",
716 additions: add === "-" ? 0 : parseInt(add, 10),
717 deletions: del === "-" ? 0 : parseInt(del, 10),
718 patch: "",
719 };
720 });
721 }
722
723 return c.html(
724 <Layout
725 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
726 user={user}
727 >
728 <RepoHeader owner={ownerName} repo={repoName} />
729 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b584e52Claude730 <div
731 id="live-comment-banner"
732 class="alert"
733 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
734 >
735 <strong class="js-live-count">0</strong> new comment(s) —{" "}
736 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
737 reload to view
738 </a>
739 </div>
740 <script
741 dangerouslySetInnerHTML={{
742 __html: liveCommentBannerScript({
743 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
744 bannerElementId: "live-comment-banner",
745 }),
746 }}
747 />
0074234Claude748 <div class="issue-detail">
749 <h2>
750 {pr.title}{" "}
bb0f894Claude751 <Text color="var(--text-muted)" weight={400}>
0074234Claude752 #{pr.number}
bb0f894Claude753 </Text>
0074234Claude754 </h2>
bb0f894Claude755 <Flex align="center" gap={8} style="margin:8px 0 20px">
756 <Badge
757 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
0074234Claude758 >
759 {pr.state === "open"
760 ? "\u25CB Open"
761 : pr.state === "merged"
762 ? "\u2B8C Merged"
763 : "\u2713 Closed"}
bb0f894Claude764 </Badge>
765 <Text size={14} muted>
766 <strong style="color:var(--text)">
0074234Claude767 {author?.username}
768 </strong>{" "}
769 wants to merge <code>{pr.headBranch}</code> into{" "}
770 <code>{pr.baseBranch}</code>
bb0f894Claude771 </Text>
772 </Flex>
773
774 <FilterTabs
775 tabs={[
776 {
777 label: "Conversation",
778 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
779 active: tab === "conversation",
780 },
781 {
782 label: "Files changed",
783 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
784 active: tab === "files",
785 },
786 ]}
787 />
0074234Claude788
789 {tab === "files" ? (
790 <DiffView raw={diffRaw} files={diffFiles} />
791 ) : (
792 <>
793 {pr.body && (
bb0f894Claude794 <CommentBox
795 author={author?.username ?? "unknown"}
796 date={pr.createdAt}
797 body={renderMarkdown(pr.body)}
798 />
0074234Claude799 )}
800
6fc53bdClaude801 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude802 <div
803 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
804 >
805 <div class="comment-header">
bb0f894Claude806 <Flex gap={8} align="center">
807 <strong>{commentAuthor.username}</strong>
808 {comment.isAiReview && (
809 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
810 AI Review
811 </Badge>
812 )}
813 <Text size={13} muted>
814 commented {formatRelative(comment.createdAt)}
815 </Text>
816 {comment.filePath && (
817 <Text size={11} mono style="margin-left:8px">
818 {comment.filePath}
819 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
820 </Text>
821 )}
822 </Flex>
6fc53bdClaude823 </div>
bb0f894Claude824 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude825 </div>
826 ))}
827
e883329Claude828 {error && (
829 <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)">
830 {decodeURIComponent(error)}
831 </div>
832 )}
833
c3e0c07Claude834 {info && (
835 <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)">
836 {decodeURIComponent(info)}
837 </div>
838 )}
839
534f04aClaude840 {pr.state === "open" && (prRisk || prRiskCalculating) && (
841 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
842 )}
843
e883329Claude844 {pr.state === "open" && gateChecks.length > 0 && (
845 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
846 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
847 {gateChecks.map((check) => (
848 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
849 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
850 {check.passed ? "\u2713" : "\u2717"}
851 </span>
852 <strong style="font-size: 13px">{check.name}</strong>
853 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
854 </div>
855 ))}
856 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
857 {gateChecks.every((c) => c.passed)
858 ? "All checks passed — ready to merge"
859 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
860 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
861 : "Some checks failed — resolve issues before merging"}
862 </div>
863 </div>
864 )}
865
0074234Claude866 {user && pr.state === "open" && (
867 <div style="margin-top: 20px">
0316dbbClaude868 <Form
e7e240eClaude869 method="post"
0074234Claude870 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
871 >
bb0f894Claude872 <FormGroup>
873 <TextArea
0074234Claude874 name="body"
875 rows={6}
876 required
877 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude878 mono
0074234Claude879 />
bb0f894Claude880 </FormGroup>
881 <Flex gap={8}>
882 <Button type="submit" variant="primary">
0074234Claude883 Comment
bb0f894Claude884 </Button>
0074234Claude885 {canManage && (
886 <>
887 <button
888 type="submit"
889 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
890 class="btn"
bb0f894Claude891 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
0074234Claude892 >
893 Merge pull request
894 </button>
c3e0c07Claude895 {isAiReviewEnabled() && (
896 <button
897 type="submit"
898 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
899 formnovalidate
900 class="btn"
901 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
902 >
903 Re-run AI review
904 </button>
905 )}
bb0f894Claude906 <Button
0074234Claude907 type="submit"
bb0f894Claude908 variant="danger"
0074234Claude909 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
910 >
911 Close
bb0f894Claude912 </Button>
0074234Claude913 </>
914 )}
bb0f894Claude915 </Flex>
916 </Form>
0074234Claude917 </div>
918 )}
919 </>
920 )}
921 </div>
922 </Layout>
923 );
924});
925
926// Add comment to PR
927pulls.post(
928 "/:owner/:repo/pulls/:number/comment",
929 softAuth,
930 requireAuth,
04f6b7fClaude931 requireRepoAccess("write"),
0074234Claude932 async (c) => {
933 const { owner: ownerName, repo: repoName } = c.req.param();
934 const prNum = parseInt(c.req.param("number"), 10);
935 const user = c.get("user")!;
936 const body = await c.req.parseBody();
937 const commentBody = String(body.body || "").trim();
938
939 if (!commentBody) {
940 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
941 }
942
943 const resolved = await resolveRepo(ownerName, repoName);
944 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
945
946 const [pr] = await db
947 .select()
948 .from(pullRequests)
949 .where(
950 and(
951 eq(pullRequests.repositoryId, resolved.repo.id),
952 eq(pullRequests.number, prNum)
953 )
954 )
955 .limit(1);
956
957 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
958
d4ac5c3Claude959 const [inserted] = await db
960 .insert(prComments)
961 .values({
962 pullRequestId: pr.id,
963 authorId: user.id,
964 body: commentBody,
965 })
966 .returning();
967
968 // Live update: nudge any browser tabs subscribed to this PR.
969 if (inserted) {
970 try {
971 const { publish } = await import("../lib/sse");
972 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
973 event: "pr-comment",
974 data: {
975 pullRequestId: pr.id,
976 commentId: inserted.id,
977 authorId: user.id,
978 authorUsername: user.username,
979 },
980 });
981 } catch {
982 /* SSE is best-effort */
983 }
984 }
0074234Claude985
986 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
987 }
988);
989
e883329Claude990// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude991// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
992// but we keep it at "write" for v1 so trusted collaborators can ship.
993// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
994// surface. Branch-protection rules (evaluated below) are the current mechanism
995// for locking down merges further on specific branches.
0074234Claude996pulls.post(
997 "/:owner/:repo/pulls/:number/merge",
998 softAuth,
999 requireAuth,
04f6b7fClaude1000 requireRepoAccess("write"),
0074234Claude1001 async (c) => {
1002 const { owner: ownerName, repo: repoName } = c.req.param();
1003 const prNum = parseInt(c.req.param("number"), 10);
1004 const user = c.get("user")!;
1005
1006 const resolved = await resolveRepo(ownerName, repoName);
1007 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1008
1009 const [pr] = await db
1010 .select()
1011 .from(pullRequests)
1012 .where(
1013 and(
1014 eq(pullRequests.repositoryId, resolved.repo.id),
1015 eq(pullRequests.number, prNum)
1016 )
1017 )
1018 .limit(1);
1019
1020 if (!pr || pr.state !== "open") {
1021 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1022 }
1023
6fc53bdClaude1024 // Draft PRs cannot be merged — must be marked ready first.
1025 if (pr.isDraft) {
1026 return c.redirect(
1027 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1028 "This PR is a draft. Mark it as ready for review before merging."
1029 )}`
1030 );
1031 }
1032
e883329Claude1033 // Resolve head SHA
1034 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
1035 if (!headSha) {
1036 return c.redirect(
1037 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
1038 );
1039 }
1040
1041 // Check if AI review approved this PR
1042 const aiComments = await db
1043 .select()
1044 .from(prComments)
1045 .where(
1046 and(
1047 eq(prComments.pullRequestId, pr.id),
1048 eq(prComments.isAiReview, true)
1049 )
1050 );
1051 const aiApproved = aiComments.length === 0 || aiComments.some(
1052 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude1053 );
e883329Claude1054
1055 // Run all green gate checks (GateTest + mergeability + AI review)
1056 const gateResult = await runAllGateChecks(
1057 ownerName,
1058 repoName,
1059 pr.baseBranch,
1060 pr.headBranch,
1061 headSha,
1062 aiApproved
0074234Claude1063 );
1064
e883329Claude1065 // If GateTest or AI review failed (hard blocks), reject the merge
1066 const hardFailures = gateResult.checks.filter(
1067 (check) => !check.passed && check.name !== "Merge check"
1068 );
1069 if (hardFailures.length > 0) {
1070 const errorMsg = hardFailures
1071 .map((f) => `${f.name}: ${f.details}`)
1072 .join("; ");
0074234Claude1073 return c.redirect(
e883329Claude1074 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude1075 );
1076 }
1077
1e162a8Claude1078 // D5 — Branch-protection enforcement. Looks up the matching rule for the
1079 // base branch and blocks the merge if requireAiApproval / requireGreenGates
1080 // / requireHumanReview / requiredApprovals are not satisfied. Independent
1081 // of repo-global settings, so owners can lock specific branches down
1082 // further than the repo default.
1083 const protectionRule = await matchProtection(
1084 resolved.repo.id,
1085 pr.baseBranch
1086 );
1087 if (protectionRule) {
1088 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude1089 const required = await listRequiredChecks(protectionRule.id);
1090 const passingNames = required.length > 0
1091 ? await passingCheckNames(resolved.repo.id, headSha)
1092 : [];
1093 const decision = evaluateProtection(
1094 protectionRule,
1095 {
1096 aiApproved,
1097 humanApprovalCount: humanApprovals,
1098 gateResultGreen: hardFailures.length === 0,
1099 hasFailedGates: hardFailures.length > 0,
1100 passingCheckNames: passingNames,
1101 },
1102 required.map((r) => r.checkName)
1103 );
1e162a8Claude1104 if (!decision.allowed) {
1105 return c.redirect(
1106 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1107 decision.reasons.join(" ")
1108 )}`
1109 );
1110 }
1111 }
1112
e883329Claude1113 // Attempt the merge — with auto conflict resolution if needed
1114 const repoDir = getRepoPath(ownerName, repoName);
1115 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
1116 const hasConflicts = mergeCheck && !mergeCheck.passed;
1117
1118 if (hasConflicts && isAiReviewEnabled()) {
1119 // Use Claude to auto-resolve conflicts
1120 const mergeResult = await mergeWithAutoResolve(
1121 ownerName,
1122 repoName,
1123 pr.baseBranch,
1124 pr.headBranch,
1125 `Merge pull request #${pr.number}: ${pr.title}`
1126 );
1127
1128 if (!mergeResult.success) {
1129 return c.redirect(
1130 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
1131 );
1132 }
1133
1134 // Post a comment about the auto-resolution
1135 if (mergeResult.resolvedFiles.length > 0) {
1136 await db.insert(prComments).values({
1137 pullRequestId: pr.id,
1138 authorId: user.id,
1139 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
1140 isAiReview: true,
1141 });
1142 }
1143 } else {
1144 // Standard merge — fast-forward or clean merge
1145 const ffProc = Bun.spawn(
1146 [
1147 "git",
1148 "update-ref",
1149 `refs/heads/${pr.baseBranch}`,
1150 `refs/heads/${pr.headBranch}`,
1151 ],
1152 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1153 );
1154 const ffExit = await ffProc.exited;
1155
1156 if (ffExit !== 0) {
1157 return c.redirect(
1158 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
1159 );
1160 }
1161 }
1162
0074234Claude1163 await db
1164 .update(pullRequests)
1165 .set({
1166 state: "merged",
1167 mergedAt: new Date(),
1168 mergedBy: user.id,
1169 updatedAt: new Date(),
1170 })
1171 .where(eq(pullRequests.id, pr.id));
1172
d62fb36Claude1173 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
1174 // and auto-close each matching open issue with a back-link comment. Bounded
1175 // to the same repo for v1 (cross-repo refs ignored). Failures never block
1176 // the merge redirect.
1177 try {
1178 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
1179 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1180 for (const n of refs) {
1181 const [issue] = await db
1182 .select()
1183 .from(issues)
1184 .where(
1185 and(
1186 eq(issues.repositoryId, resolved.repo.id),
1187 eq(issues.number, n)
1188 )
1189 )
1190 .limit(1);
1191 if (!issue || issue.state !== "open") continue;
1192 await db
1193 .update(issues)
1194 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1195 .where(eq(issues.id, issue.id));
1196 await db.insert(issueComments).values({
1197 issueId: issue.id,
1198 authorId: user.id,
1199 body: `Closed by pull request #${pr.number}.`,
1200 });
1201 }
1202 } catch {
1203 // Never block the merge on close-keyword failures.
1204 }
1205
0074234Claude1206 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1207 }
1208);
1209
6fc53bdClaude1210// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
1211// hasn't run yet on this PR.
1212pulls.post(
1213 "/:owner/:repo/pulls/:number/ready",
1214 softAuth,
1215 requireAuth,
04f6b7fClaude1216 requireRepoAccess("write"),
6fc53bdClaude1217 async (c) => {
1218 const { owner: ownerName, repo: repoName } = c.req.param();
1219 const prNum = parseInt(c.req.param("number"), 10);
1220 const user = c.get("user")!;
1221
1222 const resolved = await resolveRepo(ownerName, repoName);
1223 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1224
1225 const [pr] = await db
1226 .select()
1227 .from(pullRequests)
1228 .where(
1229 and(
1230 eq(pullRequests.repositoryId, resolved.repo.id),
1231 eq(pullRequests.number, prNum)
1232 )
1233 )
1234 .limit(1);
1235 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1236
1237 // Only the author or repo owner can toggle draft state.
1238 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1239 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1240 }
1241
1242 if (pr.state === "open" && pr.isDraft) {
1243 await db
1244 .update(pullRequests)
1245 .set({ isDraft: false, updatedAt: new Date() })
1246 .where(eq(pullRequests.id, pr.id));
1247
1248 if (isAiReviewEnabled()) {
1249 triggerAiReview(
1250 ownerName,
1251 repoName,
1252 pr.id,
1253 pr.title,
0316dbbClaude1254 pr.body || "",
6fc53bdClaude1255 pr.baseBranch,
1256 pr.headBranch
1257 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
1258 }
1259 }
1260
1261 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1262 }
1263);
1264
1265// Convert a PR back to draft.
1266pulls.post(
1267 "/:owner/:repo/pulls/:number/draft",
1268 softAuth,
1269 requireAuth,
04f6b7fClaude1270 requireRepoAccess("write"),
6fc53bdClaude1271 async (c) => {
1272 const { owner: ownerName, repo: repoName } = c.req.param();
1273 const prNum = parseInt(c.req.param("number"), 10);
1274 const user = c.get("user")!;
1275
1276 const resolved = await resolveRepo(ownerName, repoName);
1277 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1278
1279 const [pr] = await db
1280 .select()
1281 .from(pullRequests)
1282 .where(
1283 and(
1284 eq(pullRequests.repositoryId, resolved.repo.id),
1285 eq(pullRequests.number, prNum)
1286 )
1287 )
1288 .limit(1);
1289 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1290
1291 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1292 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1293 }
1294
1295 if (pr.state === "open" && !pr.isDraft) {
1296 await db
1297 .update(pullRequests)
1298 .set({ isDraft: true, updatedAt: new Date() })
1299 .where(eq(pullRequests.id, pr.id));
1300 }
1301
1302 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1303 }
1304);
1305
0074234Claude1306// Close PR
1307pulls.post(
1308 "/:owner/:repo/pulls/:number/close",
1309 softAuth,
1310 requireAuth,
04f6b7fClaude1311 requireRepoAccess("write"),
0074234Claude1312 async (c) => {
1313 const { owner: ownerName, repo: repoName } = c.req.param();
1314 const prNum = parseInt(c.req.param("number"), 10);
1315
1316 const resolved = await resolveRepo(ownerName, repoName);
1317 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1318
1319 await db
1320 .update(pullRequests)
1321 .set({
1322 state: "closed",
1323 closedAt: new Date(),
1324 updatedAt: new Date(),
1325 })
1326 .where(
1327 and(
1328 eq(pullRequests.repositoryId, resolved.repo.id),
1329 eq(pullRequests.number, prNum)
1330 )
1331 );
1332
1333 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1334 }
1335);
1336
c3e0c07Claude1337// Re-run AI review on demand (e.g. after a force-push). Bypasses the
1338// idempotency marker via { force: true }. Write-access only.
1339pulls.post(
1340 "/:owner/:repo/pulls/:number/ai-rereview",
1341 softAuth,
1342 requireAuth,
1343 requireRepoAccess("write"),
1344 async (c) => {
1345 const { owner: ownerName, repo: repoName } = c.req.param();
1346 const prNum = parseInt(c.req.param("number"), 10);
1347 const resolved = await resolveRepo(ownerName, repoName);
1348 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1349
1350 const [pr] = await db
1351 .select()
1352 .from(pullRequests)
1353 .where(
1354 and(
1355 eq(pullRequests.repositoryId, resolved.repo.id),
1356 eq(pullRequests.number, prNum)
1357 )
1358 )
1359 .limit(1);
1360 if (!pr) {
1361 return c.redirect(`/${ownerName}/${repoName}/pulls`);
1362 }
1363
1364 if (!isAiReviewEnabled()) {
1365 return c.redirect(
1366 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1367 "AI review is not configured (ANTHROPIC_API_KEY)."
1368 )}`
1369 );
1370 }
1371
1372 // Fire-and-forget but with { force: true } to bypass the
1373 // already-reviewed marker. The function still never throws.
1374 triggerAiReview(
1375 ownerName,
1376 repoName,
1377 pr.id,
1378 pr.title || "",
1379 pr.body || "",
1380 pr.baseBranch,
1381 pr.headBranch,
1382 { force: true }
1383 ).catch(() => {});
1384
1385 return c.redirect(
1386 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
1387 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
1388 )}`
1389 );
1390 }
1391);
1392
0074234Claude1393export default pulls;