Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame999 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";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude24import { requireRepoAccess } from "../middleware/repo-access";
0316dbbClaude25import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
26import { triggerPrTriage } from "../lib/pr-triage";
27import { runAllGateChecks } from "../lib/gate";
28import type { GateCheckResult } from "../lib/gate";
29import {
30 matchProtection,
31 countHumanApprovals,
32 listRequiredChecks,
33 passingCheckNames,
34 evaluateProtection,
35} from "../lib/branch-protection";
36import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude37import {
38 listBranches,
39 getRepoPath,
e883329Claude40 resolveRef,
0074234Claude41} from "../git/repository";
42import type { GitDiffFile } from "../git/repository";
43import { html } from "hono/html";
1e162a8Claude44import {
bb0f894Claude45 Flex,
46 Container,
47 Badge,
48 Button,
49 LinkButton,
50 Form,
51 FormGroup,
52 Input,
53 TextArea,
54 Select,
55 EmptyState,
56 FilterTabs,
57 TabNav,
58 List,
59 ListItem,
60 Text,
61 Alert,
62 MarkdownContent,
63 CommentBox,
64 formatRelative,
65} from "../views/ui";
0074234Claude66
67const pulls = new Hono<AuthEnv>();
68
69async function resolveRepo(ownerName: string, repoName: string) {
70 const [owner] = await db
71 .select()
72 .from(users)
73 .where(eq(users.username, ownerName))
74 .limit(1);
75 if (!owner) return null;
76 const [repo] = await db
77 .select()
78 .from(repositories)
79 .where(
80 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
81 )
82 .limit(1);
83 if (!repo) return null;
84 return { owner, repo };
85}
86
87// PR Nav helper
88const PrNav = ({
89 owner,
90 repo,
91 active,
92}: {
93 owner: string;
94 repo: string;
95 active: "code" | "issues" | "pulls" | "commits";
96}) => (
bb0f894Claude97 <TabNav
98 tabs={[
99 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
100 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
101 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
102 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
103 ]}
104 />
0074234Claude105);
106
107// List PRs
04f6b7fClaude108pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude109 const { owner: ownerName, repo: repoName } = c.req.param();
110 const user = c.get("user");
111 const state = c.req.query("state") || "open";
112
113 const resolved = await resolveRepo(ownerName, repoName);
114 if (!resolved) return c.notFound();
115
6fc53bdClaude116 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
117 const stateFilter =
118 state === "draft"
119 ? and(
120 eq(pullRequests.state, "open"),
121 eq(pullRequests.isDraft, true)
122 )
123 : eq(pullRequests.state, state);
124
0074234Claude125 const prList = await db
126 .select({
127 pr: pullRequests,
128 author: { username: users.username },
129 })
130 .from(pullRequests)
131 .innerJoin(users, eq(pullRequests.authorId, users.id))
132 .where(
6fc53bdClaude133 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude134 )
135 .orderBy(desc(pullRequests.createdAt));
136
137 const [counts] = await db
138 .select({
139 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude140 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude141 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
142 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
143 })
144 .from(pullRequests)
145 .where(eq(pullRequests.repositoryId, resolved.repo.id));
146
147 return c.html(
148 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
149 <RepoHeader owner={ownerName} repo={repoName} />
150 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude151 <Flex justify="space-between" align="center" style="margin-bottom:16px">
152 <FilterTabs
153 tabs={[
154 { label: `${counts?.open ?? 0} Open`, href: `/${ownerName}/${repoName}/pulls?state=open`, active: state === "open" },
155 { label: `${counts?.merged ?? 0} Merged`, href: `/${ownerName}/${repoName}/pulls?state=merged`, active: state === "merged" },
156 { label: `${counts?.closed ?? 0} Closed`, href: `/${ownerName}/${repoName}/pulls?state=closed`, active: state === "closed" },
157 ]}
158 />
0074234Claude159 {user && (
bb0f894Claude160 <LinkButton href={`/${ownerName}/${repoName}/pulls/new`} variant="primary">
0074234Claude161 New pull request
bb0f894Claude162 </LinkButton>
0074234Claude163 )}
bb0f894Claude164 </Flex>
0074234Claude165 {prList.length === 0 ? (
bb0f894Claude166 <EmptyState>
0074234Claude167 <p>No {state} pull requests.</p>
bb0f894Claude168 </EmptyState>
0074234Claude169 ) : (
bb0f894Claude170 <List>
0074234Claude171 {prList.map(({ pr, author }) => (
bb0f894Claude172 <ListItem>
0074234Claude173 <div
174 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
175 >
176 {pr.state === "open"
177 ? "\u25CB"
178 : pr.state === "merged"
179 ? "\u2B8C"
180 : "\u2713"}
181 </div>
182 <div>
183 <div class="issue-title">
184 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
185 {pr.title}
186 </a>
187 </div>
188 <div class="issue-meta">
189 #{pr.number}{" "}
190 {pr.headBranch} → {pr.baseBranch}{" "}
191 by {author.username}{" "}
192 {formatRelative(pr.createdAt)}
193 </div>
194 </div>
bb0f894Claude195 </ListItem>
0074234Claude196 ))}
bb0f894Claude197 </List>
0074234Claude198 )}
199 </Layout>
200 );
201});
202
203// New PR form
204pulls.get(
205 "/:owner/:repo/pulls/new",
206 softAuth,
207 requireAuth,
04f6b7fClaude208 requireRepoAccess("write"),
0074234Claude209 async (c) => {
210 const { owner: ownerName, repo: repoName } = c.req.param();
211 const user = c.get("user")!;
212 const branches = await listBranches(ownerName, repoName);
213 const error = c.req.query("error");
214 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude215 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude216
217 return c.html(
218 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
219 <RepoHeader owner={ownerName} repo={repoName} />
220 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude221 <Container maxWidth={800}>
222 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude223 {error && (
bb0f894Claude224 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude225 )}
0316dbbClaude226 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
227 <Flex gap={12} align="center" style="margin-bottom: 16px">
228 <Select name="base">
0074234Claude229 {branches.map((b) => (
230 <option value={b} selected={b === defaultBase}>
231 {b}
232 </option>
233 ))}
bb0f894Claude234 </Select>
235 <Text muted>&larr;</Text>
236 <Select name="head">
0074234Claude237 {branches
238 .filter((b) => b !== defaultBase)
239 .concat(defaultBase === branches[0] ? [] : [branches[0]])
240 .map((b) => (
241 <option value={b}>{b}</option>
242 ))}
bb0f894Claude243 </Select>
244 </Flex>
245 <FormGroup>
246 <Input
0074234Claude247 name="title"
248 required
249 placeholder="Title"
bb0f894Claude250 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]251 aria-label="Pull request title"
0074234Claude252 />
bb0f894Claude253 </FormGroup>
254 <FormGroup>
255 <TextArea
0074234Claude256 name="body"
257 rows={8}
258 placeholder="Description (Markdown supported)"
bb0f894Claude259 mono
0074234Claude260 />
bb0f894Claude261 </FormGroup>
262 <Button type="submit" variant="primary">
0074234Claude263 Create pull request
bb0f894Claude264 </Button>
265 </Form>
266 </Container>
0074234Claude267 </Layout>
268 );
269 }
270);
271
272// Create PR
273pulls.post(
274 "/:owner/:repo/pulls/new",
275 softAuth,
276 requireAuth,
04f6b7fClaude277 requireRepoAccess("write"),
0074234Claude278 async (c) => {
279 const { owner: ownerName, repo: repoName } = c.req.param();
280 const user = c.get("user")!;
281 const body = await c.req.parseBody();
282 const title = String(body.title || "").trim();
283 const prBody = String(body.body || "").trim();
284 const baseBranch = String(body.base || "main");
285 const headBranch = String(body.head || "");
286
287 if (!title || !headBranch) {
288 return c.redirect(
289 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
290 );
291 }
292
293 if (baseBranch === headBranch) {
294 return c.redirect(
295 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
296 );
297 }
298
299 const resolved = await resolveRepo(ownerName, repoName);
300 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
301
6fc53bdClaude302 const isDraft = String(body.draft || "") === "1";
303
0074234Claude304 const [pr] = await db
305 .insert(pullRequests)
306 .values({
307 repositoryId: resolved.repo.id,
308 authorId: user.id,
309 title,
310 body: prBody || null,
311 baseBranch,
312 headBranch,
6fc53bdClaude313 isDraft,
0074234Claude314 })
315 .returning();
316
6fc53bdClaude317 // Skip AI review on drafts — it runs again when the PR is marked ready.
318 if (!isDraft && isAiReviewEnabled()) {
e883329Claude319 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
320 (err) => console.error("[ai-review] Failed:", err)
321 );
322 }
323
3cbe3d6Claude324 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
325 triggerPrTriage({
326 ownerName,
327 repoName,
328 repositoryId: resolved.repo.id,
329 prId: pr.id,
330 prAuthorId: user.id,
331 title,
332 body: prBody,
333 baseBranch,
334 headBranch,
335 }).catch((err) => console.error("[pr-triage] Failed:", err));
336
0074234Claude337 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
338 }
339);
340
341// View single PR
04f6b7fClaude342pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude343 const { owner: ownerName, repo: repoName } = c.req.param();
344 const prNum = parseInt(c.req.param("number"), 10);
345 const user = c.get("user");
346 const tab = c.req.query("tab") || "conversation";
347
348 const resolved = await resolveRepo(ownerName, repoName);
349 if (!resolved) return c.notFound();
350
351 const [pr] = await db
352 .select()
353 .from(pullRequests)
354 .where(
355 and(
356 eq(pullRequests.repositoryId, resolved.repo.id),
357 eq(pullRequests.number, prNum)
358 )
359 )
360 .limit(1);
361
362 if (!pr) return c.notFound();
363
364 const [author] = await db
365 .select()
366 .from(users)
367 .where(eq(users.id, pr.authorId))
368 .limit(1);
369
370 const comments = await db
371 .select({
372 comment: prComments,
373 author: { username: users.username },
374 })
375 .from(prComments)
376 .innerJoin(users, eq(prComments.authorId, users.id))
377 .where(eq(prComments.pullRequestId, pr.id))
378 .orderBy(asc(prComments.createdAt));
379
6fc53bdClaude380 // Reactions for the PR body + each comment, in parallel.
381 const [prReactions, ...prCommentReactions] = await Promise.all([
382 summariseReactions("pr", pr.id, user?.id),
383 ...comments.map((row) =>
384 summariseReactions("pr_comment", row.comment.id, user?.id)
385 ),
386 ]);
387
0074234Claude388 const canManage =
389 user &&
390 (user.id === resolved.owner.id || user.id === pr.authorId);
391
e883329Claude392 const error = c.req.query("error");
393
394 // Get gate check status for open PRs
395 let gateChecks: GateCheckResult[] = [];
396 if (pr.state === "open") {
397 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
398 if (headSha) {
399 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
400 const aiApproved = aiComments.length === 0 || aiComments.some(
401 ({ comment }) => comment.body.includes("**Approved**")
402 );
403 const gateResult = await runAllGateChecks(
404 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
405 );
406 gateChecks = gateResult.checks;
407 }
408 }
409
0074234Claude410 // Get diff for "Files changed" tab
411 let diffRaw = "";
412 let diffFiles: GitDiffFile[] = [];
413 if (tab === "files") {
414 const repoDir = getRepoPath(ownerName, repoName);
415 const proc = Bun.spawn(
416 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
417 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
418 );
419 diffRaw = await new Response(proc.stdout).text();
420 await proc.exited;
421
422 const statProc = Bun.spawn(
423 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
424 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
425 );
426 const stat = await new Response(statProc.stdout).text();
427 await statProc.exited;
428
429 diffFiles = stat
430 .trim()
431 .split("\n")
432 .filter(Boolean)
433 .map((line) => {
434 const [add, del, filePath] = line.split("\t");
435 return {
436 path: filePath,
437 status: "modified",
438 additions: add === "-" ? 0 : parseInt(add, 10),
439 deletions: del === "-" ? 0 : parseInt(del, 10),
440 patch: "",
441 };
442 });
443 }
444
445 return c.html(
446 <Layout
447 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
448 user={user}
449 >
450 <RepoHeader owner={ownerName} repo={repoName} />
451 <PrNav owner={ownerName} repo={repoName} active="pulls" />
452 <div class="issue-detail">
453 <h2>
454 {pr.title}{" "}
bb0f894Claude455 <Text color="var(--text-muted)" weight={400}>
0074234Claude456 #{pr.number}
bb0f894Claude457 </Text>
0074234Claude458 </h2>
bb0f894Claude459 <Flex align="center" gap={8} style="margin:8px 0 20px">
460 <Badge
461 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
0074234Claude462 >
463 {pr.state === "open"
464 ? "\u25CB Open"
465 : pr.state === "merged"
466 ? "\u2B8C Merged"
467 : "\u2713 Closed"}
bb0f894Claude468 </Badge>
469 <Text size={14} muted>
470 <strong style="color:var(--text)">
0074234Claude471 {author?.username}
472 </strong>{" "}
473 wants to merge <code>{pr.headBranch}</code> into{" "}
474 <code>{pr.baseBranch}</code>
bb0f894Claude475 </Text>
476 </Flex>
477
478 <FilterTabs
479 tabs={[
480 {
481 label: "Conversation",
482 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
483 active: tab === "conversation",
484 },
485 {
486 label: "Files changed",
487 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
488 active: tab === "files",
489 },
490 ]}
491 />
0074234Claude492
493 {tab === "files" ? (
494 <DiffView raw={diffRaw} files={diffFiles} />
495 ) : (
496 <>
497 {pr.body && (
bb0f894Claude498 <CommentBox
499 author={author?.username ?? "unknown"}
500 date={pr.createdAt}
501 body={renderMarkdown(pr.body)}
502 />
0074234Claude503 )}
504
6fc53bdClaude505 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude506 <div
507 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
508 >
509 <div class="comment-header">
bb0f894Claude510 <Flex gap={8} align="center">
511 <strong>{commentAuthor.username}</strong>
512 {comment.isAiReview && (
513 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
514 AI Review
515 </Badge>
516 )}
517 <Text size={13} muted>
518 commented {formatRelative(comment.createdAt)}
519 </Text>
520 {comment.filePath && (
521 <Text size={11} mono style="margin-left:8px">
522 {comment.filePath}
523 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
524 </Text>
525 )}
526 </Flex>
6fc53bdClaude527 </div>
bb0f894Claude528 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude529 </div>
530 ))}
531
e883329Claude532 {error && (
533 <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)">
534 {decodeURIComponent(error)}
535 </div>
536 )}
537
538 {pr.state === "open" && gateChecks.length > 0 && (
539 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
540 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
541 {gateChecks.map((check) => (
542 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
543 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
544 {check.passed ? "\u2713" : "\u2717"}
545 </span>
546 <strong style="font-size: 13px">{check.name}</strong>
547 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
548 </div>
549 ))}
550 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
551 {gateChecks.every((c) => c.passed)
552 ? "All checks passed — ready to merge"
553 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
554 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
555 : "Some checks failed — resolve issues before merging"}
556 </div>
557 </div>
558 )}
559
0074234Claude560 {user && pr.state === "open" && (
561 <div style="margin-top: 20px">
0316dbbClaude562 <Form
e7e240eClaude563 method="post"
0074234Claude564 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
565 >
bb0f894Claude566 <FormGroup>
567 <TextArea
0074234Claude568 name="body"
569 rows={6}
570 required
571 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude572 mono
0074234Claude573 />
bb0f894Claude574 </FormGroup>
575 <Flex gap={8}>
576 <Button type="submit" variant="primary">
0074234Claude577 Comment
bb0f894Claude578 </Button>
0074234Claude579 {canManage && (
580 <>
581 <button
582 type="submit"
583 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
584 class="btn"
bb0f894Claude585 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
0074234Claude586 >
587 Merge pull request
588 </button>
bb0f894Claude589 <Button
0074234Claude590 type="submit"
bb0f894Claude591 variant="danger"
0074234Claude592 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
593 >
594 Close
bb0f894Claude595 </Button>
0074234Claude596 </>
597 )}
bb0f894Claude598 </Flex>
599 </Form>
0074234Claude600 </div>
601 )}
602 </>
603 )}
604 </div>
605 </Layout>
606 );
607});
608
609// Add comment to PR
610pulls.post(
611 "/:owner/:repo/pulls/:number/comment",
612 softAuth,
613 requireAuth,
04f6b7fClaude614 requireRepoAccess("write"),
0074234Claude615 async (c) => {
616 const { owner: ownerName, repo: repoName } = c.req.param();
617 const prNum = parseInt(c.req.param("number"), 10);
618 const user = c.get("user")!;
619 const body = await c.req.parseBody();
620 const commentBody = String(body.body || "").trim();
621
622 if (!commentBody) {
623 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
624 }
625
626 const resolved = await resolveRepo(ownerName, repoName);
627 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
628
629 const [pr] = await db
630 .select()
631 .from(pullRequests)
632 .where(
633 and(
634 eq(pullRequests.repositoryId, resolved.repo.id),
635 eq(pullRequests.number, prNum)
636 )
637 )
638 .limit(1);
639
640 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
641
642 await db.insert(prComments).values({
643 pullRequestId: pr.id,
644 authorId: user.id,
645 body: commentBody,
646 });
647
648 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
649 }
650);
651
e883329Claude652// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude653// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
654// but we keep it at "write" for v1 so trusted collaborators can ship.
655// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
656// surface. Branch-protection rules (evaluated below) are the current mechanism
657// for locking down merges further on specific branches.
0074234Claude658pulls.post(
659 "/:owner/:repo/pulls/:number/merge",
660 softAuth,
661 requireAuth,
04f6b7fClaude662 requireRepoAccess("write"),
0074234Claude663 async (c) => {
664 const { owner: ownerName, repo: repoName } = c.req.param();
665 const prNum = parseInt(c.req.param("number"), 10);
666 const user = c.get("user")!;
667
668 const resolved = await resolveRepo(ownerName, repoName);
669 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
670
671 const [pr] = await db
672 .select()
673 .from(pullRequests)
674 .where(
675 and(
676 eq(pullRequests.repositoryId, resolved.repo.id),
677 eq(pullRequests.number, prNum)
678 )
679 )
680 .limit(1);
681
682 if (!pr || pr.state !== "open") {
683 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
684 }
685
6fc53bdClaude686 // Draft PRs cannot be merged — must be marked ready first.
687 if (pr.isDraft) {
688 return c.redirect(
689 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
690 "This PR is a draft. Mark it as ready for review before merging."
691 )}`
692 );
693 }
694
e883329Claude695 // Resolve head SHA
696 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
697 if (!headSha) {
698 return c.redirect(
699 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
700 );
701 }
702
703 // Check if AI review approved this PR
704 const aiComments = await db
705 .select()
706 .from(prComments)
707 .where(
708 and(
709 eq(prComments.pullRequestId, pr.id),
710 eq(prComments.isAiReview, true)
711 )
712 );
713 const aiApproved = aiComments.length === 0 || aiComments.some(
714 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude715 );
e883329Claude716
717 // Run all green gate checks (GateTest + mergeability + AI review)
718 const gateResult = await runAllGateChecks(
719 ownerName,
720 repoName,
721 pr.baseBranch,
722 pr.headBranch,
723 headSha,
724 aiApproved
0074234Claude725 );
726
e883329Claude727 // If GateTest or AI review failed (hard blocks), reject the merge
728 const hardFailures = gateResult.checks.filter(
729 (check) => !check.passed && check.name !== "Merge check"
730 );
731 if (hardFailures.length > 0) {
732 const errorMsg = hardFailures
733 .map((f) => `${f.name}: ${f.details}`)
734 .join("; ");
0074234Claude735 return c.redirect(
e883329Claude736 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude737 );
738 }
739
1e162a8Claude740 // D5 — Branch-protection enforcement. Looks up the matching rule for the
741 // base branch and blocks the merge if requireAiApproval / requireGreenGates
742 // / requireHumanReview / requiredApprovals are not satisfied. Independent
743 // of repo-global settings, so owners can lock specific branches down
744 // further than the repo default.
745 const protectionRule = await matchProtection(
746 resolved.repo.id,
747 pr.baseBranch
748 );
749 if (protectionRule) {
750 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude751 const required = await listRequiredChecks(protectionRule.id);
752 const passingNames = required.length > 0
753 ? await passingCheckNames(resolved.repo.id, headSha)
754 : [];
755 const decision = evaluateProtection(
756 protectionRule,
757 {
758 aiApproved,
759 humanApprovalCount: humanApprovals,
760 gateResultGreen: hardFailures.length === 0,
761 hasFailedGates: hardFailures.length > 0,
762 passingCheckNames: passingNames,
763 },
764 required.map((r) => r.checkName)
765 );
1e162a8Claude766 if (!decision.allowed) {
767 return c.redirect(
768 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
769 decision.reasons.join(" ")
770 )}`
771 );
772 }
773 }
774
e883329Claude775 // Attempt the merge — with auto conflict resolution if needed
776 const repoDir = getRepoPath(ownerName, repoName);
777 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
778 const hasConflicts = mergeCheck && !mergeCheck.passed;
779
780 if (hasConflicts && isAiReviewEnabled()) {
781 // Use Claude to auto-resolve conflicts
782 const mergeResult = await mergeWithAutoResolve(
783 ownerName,
784 repoName,
785 pr.baseBranch,
786 pr.headBranch,
787 `Merge pull request #${pr.number}: ${pr.title}`
788 );
789
790 if (!mergeResult.success) {
791 return c.redirect(
792 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
793 );
794 }
795
796 // Post a comment about the auto-resolution
797 if (mergeResult.resolvedFiles.length > 0) {
798 await db.insert(prComments).values({
799 pullRequestId: pr.id,
800 authorId: user.id,
801 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
802 isAiReview: true,
803 });
804 }
805 } else {
806 // Standard merge — fast-forward or clean merge
807 const ffProc = Bun.spawn(
808 [
809 "git",
810 "update-ref",
811 `refs/heads/${pr.baseBranch}`,
812 `refs/heads/${pr.headBranch}`,
813 ],
814 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
815 );
816 const ffExit = await ffProc.exited;
817
818 if (ffExit !== 0) {
819 return c.redirect(
820 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
821 );
822 }
823 }
824
0074234Claude825 await db
826 .update(pullRequests)
827 .set({
828 state: "merged",
829 mergedAt: new Date(),
830 mergedBy: user.id,
831 updatedAt: new Date(),
832 })
833 .where(eq(pullRequests.id, pr.id));
834
d62fb36Claude835 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
836 // and auto-close each matching open issue with a back-link comment. Bounded
837 // to the same repo for v1 (cross-repo refs ignored). Failures never block
838 // the merge redirect.
839 try {
840 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
841 const refs = extractClosingRefsMulti([pr.title, pr.body]);
842 for (const n of refs) {
843 const [issue] = await db
844 .select()
845 .from(issues)
846 .where(
847 and(
848 eq(issues.repositoryId, resolved.repo.id),
849 eq(issues.number, n)
850 )
851 )
852 .limit(1);
853 if (!issue || issue.state !== "open") continue;
854 await db
855 .update(issues)
856 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
857 .where(eq(issues.id, issue.id));
858 await db.insert(issueComments).values({
859 issueId: issue.id,
860 authorId: user.id,
861 body: `Closed by pull request #${pr.number}.`,
862 });
863 }
864 } catch {
865 // Never block the merge on close-keyword failures.
866 }
867
0074234Claude868 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
869 }
870);
871
6fc53bdClaude872// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
873// hasn't run yet on this PR.
874pulls.post(
875 "/:owner/:repo/pulls/:number/ready",
876 softAuth,
877 requireAuth,
04f6b7fClaude878 requireRepoAccess("write"),
6fc53bdClaude879 async (c) => {
880 const { owner: ownerName, repo: repoName } = c.req.param();
881 const prNum = parseInt(c.req.param("number"), 10);
882 const user = c.get("user")!;
883
884 const resolved = await resolveRepo(ownerName, repoName);
885 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
886
887 const [pr] = await db
888 .select()
889 .from(pullRequests)
890 .where(
891 and(
892 eq(pullRequests.repositoryId, resolved.repo.id),
893 eq(pullRequests.number, prNum)
894 )
895 )
896 .limit(1);
897 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
898
899 // Only the author or repo owner can toggle draft state.
900 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
901 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
902 }
903
904 if (pr.state === "open" && pr.isDraft) {
905 await db
906 .update(pullRequests)
907 .set({ isDraft: false, updatedAt: new Date() })
908 .where(eq(pullRequests.id, pr.id));
909
910 if (isAiReviewEnabled()) {
911 triggerAiReview(
912 ownerName,
913 repoName,
914 pr.id,
915 pr.title,
0316dbbClaude916 pr.body || "",
6fc53bdClaude917 pr.baseBranch,
918 pr.headBranch
919 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
920 }
921 }
922
923 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
924 }
925);
926
927// Convert a PR back to draft.
928pulls.post(
929 "/:owner/:repo/pulls/:number/draft",
930 softAuth,
931 requireAuth,
04f6b7fClaude932 requireRepoAccess("write"),
6fc53bdClaude933 async (c) => {
934 const { owner: ownerName, repo: repoName } = c.req.param();
935 const prNum = parseInt(c.req.param("number"), 10);
936 const user = c.get("user")!;
937
938 const resolved = await resolveRepo(ownerName, repoName);
939 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
940
941 const [pr] = await db
942 .select()
943 .from(pullRequests)
944 .where(
945 and(
946 eq(pullRequests.repositoryId, resolved.repo.id),
947 eq(pullRequests.number, prNum)
948 )
949 )
950 .limit(1);
951 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
952
953 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
954 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
955 }
956
957 if (pr.state === "open" && !pr.isDraft) {
958 await db
959 .update(pullRequests)
960 .set({ isDraft: true, updatedAt: new Date() })
961 .where(eq(pullRequests.id, pr.id));
962 }
963
964 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
965 }
966);
967
0074234Claude968// Close PR
969pulls.post(
970 "/:owner/:repo/pulls/:number/close",
971 softAuth,
972 requireAuth,
04f6b7fClaude973 requireRepoAccess("write"),
0074234Claude974 async (c) => {
975 const { owner: ownerName, repo: repoName } = c.req.param();
976 const prNum = parseInt(c.req.param("number"), 10);
977
978 const resolved = await resolveRepo(ownerName, repoName);
979 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
980
981 await db
982 .update(pullRequests)
983 .set({
984 state: "closed",
985 closedAt: new Date(),
986 updatedAt: new Date(),
987 })
988 .where(
989 and(
990 eq(pullRequests.repositoryId, resolved.repo.id),
991 eq(pullRequests.number, prNum)
992 )
993 );
994
995 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
996 }
997);
998
999export default pulls;