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.tsxBlame1019 lines · 1 contributor
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"
0074234Claude251 />
bb0f894Claude252 </FormGroup>
253 <FormGroup>
254 <TextArea
0074234Claude255 name="body"
256 rows={8}
257 placeholder="Description (Markdown supported)"
bb0f894Claude258 mono
0074234Claude259 />
bb0f894Claude260 </FormGroup>
261 <Button type="submit" variant="primary">
0074234Claude262 Create pull request
bb0f894Claude263 </Button>
264 </Form>
265 </Container>
0074234Claude266 </Layout>
267 );
268 }
269);
270
271// Create PR
272pulls.post(
273 "/:owner/:repo/pulls/new",
274 softAuth,
275 requireAuth,
04f6b7fClaude276 requireRepoAccess("write"),
0074234Claude277 async (c) => {
278 const { owner: ownerName, repo: repoName } = c.req.param();
279 const user = c.get("user")!;
280 const body = await c.req.parseBody();
281 const title = String(body.title || "").trim();
282 const prBody = String(body.body || "").trim();
283 const baseBranch = String(body.base || "main");
284 const headBranch = String(body.head || "");
285
286 if (!title || !headBranch) {
287 return c.redirect(
288 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
289 );
290 }
291
292 if (baseBranch === headBranch) {
293 return c.redirect(
294 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
295 );
296 }
297
298 const resolved = await resolveRepo(ownerName, repoName);
299 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
300
6fc53bdClaude301 const isDraft = String(body.draft || "") === "1";
302
0074234Claude303 const [pr] = await db
304 .insert(pullRequests)
305 .values({
306 repositoryId: resolved.repo.id,
307 authorId: user.id,
308 title,
309 body: prBody || null,
310 baseBranch,
311 headBranch,
6fc53bdClaude312 isDraft,
0074234Claude313 })
314 .returning();
315
6fc53bdClaude316 // Skip AI review on drafts — it runs again when the PR is marked ready.
317 if (!isDraft && isAiReviewEnabled()) {
e883329Claude318 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
319 (err) => console.error("[ai-review] Failed:", err)
320 );
321 }
322
3cbe3d6Claude323 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
324 triggerPrTriage({
325 ownerName,
326 repoName,
327 repositoryId: resolved.repo.id,
328 prId: pr.id,
329 prAuthorId: user.id,
330 title,
331 body: prBody,
332 baseBranch,
333 headBranch,
334 }).catch((err) => console.error("[pr-triage] Failed:", err));
335
0074234Claude336 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
337 }
338);
339
340// View single PR
04f6b7fClaude341pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude342 const { owner: ownerName, repo: repoName } = c.req.param();
343 const prNum = parseInt(c.req.param("number"), 10);
344 const user = c.get("user");
345 const tab = c.req.query("tab") || "conversation";
346
347 const resolved = await resolveRepo(ownerName, repoName);
348 if (!resolved) return c.notFound();
349
350 const [pr] = await db
351 .select()
352 .from(pullRequests)
353 .where(
354 and(
355 eq(pullRequests.repositoryId, resolved.repo.id),
356 eq(pullRequests.number, prNum)
357 )
358 )
359 .limit(1);
360
361 if (!pr) return c.notFound();
362
363 const [author] = await db
364 .select()
365 .from(users)
366 .where(eq(users.id, pr.authorId))
367 .limit(1);
368
369 const comments = await db
370 .select({
371 comment: prComments,
372 author: { username: users.username },
373 })
374 .from(prComments)
375 .innerJoin(users, eq(prComments.authorId, users.id))
376 .where(eq(prComments.pullRequestId, pr.id))
377 .orderBy(asc(prComments.createdAt));
378
6fc53bdClaude379 // Reactions for the PR body + each comment, in parallel.
380 const [prReactions, ...prCommentReactions] = await Promise.all([
381 summariseReactions("pr", pr.id, user?.id),
382 ...comments.map((row) =>
383 summariseReactions("pr_comment", row.comment.id, user?.id)
384 ),
385 ]);
386
0074234Claude387 const canManage =
388 user &&
389 (user.id === resolved.owner.id || user.id === pr.authorId);
390
e883329Claude391 const error = c.req.query("error");
392
393 // Get gate check status for open PRs
394 let gateChecks: GateCheckResult[] = [];
395 if (pr.state === "open") {
396 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
397 if (headSha) {
398 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
399 const aiApproved = aiComments.length === 0 || aiComments.some(
400 ({ comment }) => comment.body.includes("**Approved**")
401 );
402 const gateResult = await runAllGateChecks(
403 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
404 );
405 gateChecks = gateResult.checks;
406 }
407 }
408
0074234Claude409 // Get diff for "Files changed" tab
410 let diffRaw = "";
411 let diffFiles: GitDiffFile[] = [];
412 if (tab === "files") {
413 const repoDir = getRepoPath(ownerName, repoName);
414 const proc = Bun.spawn(
415 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
416 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
417 );
418 diffRaw = await new Response(proc.stdout).text();
419 await proc.exited;
420
421 const statProc = Bun.spawn(
422 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
423 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
424 );
425 const stat = await new Response(statProc.stdout).text();
426 await statProc.exited;
427
428 diffFiles = stat
429 .trim()
430 .split("\n")
431 .filter(Boolean)
432 .map((line) => {
433 const [add, del, filePath] = line.split("\t");
434 return {
435 path: filePath,
436 status: "modified",
437 additions: add === "-" ? 0 : parseInt(add, 10),
438 deletions: del === "-" ? 0 : parseInt(del, 10),
439 patch: "",
440 };
441 });
442 }
443
444 return c.html(
445 <Layout
446 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
447 user={user}
448 >
449 <RepoHeader owner={ownerName} repo={repoName} />
450 <PrNav owner={ownerName} repo={repoName} active="pulls" />
451 <div class="issue-detail">
452 <h2>
453 {pr.title}{" "}
bb0f894Claude454 <Text color="var(--text-muted)" weight={400}>
0074234Claude455 #{pr.number}
bb0f894Claude456 </Text>
0074234Claude457 </h2>
bb0f894Claude458 <Flex align="center" gap={8} style="margin:8px 0 20px">
459 <Badge
460 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
0074234Claude461 >
462 {pr.state === "open"
463 ? "\u25CB Open"
464 : pr.state === "merged"
465 ? "\u2B8C Merged"
466 : "\u2713 Closed"}
bb0f894Claude467 </Badge>
468 <Text size={14} muted>
469 <strong style="color:var(--text)">
0074234Claude470 {author?.username}
471 </strong>{" "}
472 wants to merge <code>{pr.headBranch}</code> into{" "}
473 <code>{pr.baseBranch}</code>
bb0f894Claude474 </Text>
475 </Flex>
476
477 <FilterTabs
478 tabs={[
479 {
480 label: "Conversation",
481 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
482 active: tab === "conversation",
483 },
484 {
485 label: "Files changed",
486 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
487 active: tab === "files",
488 },
489 ]}
490 />
0074234Claude491
492 {tab === "files" ? (
493 <DiffView raw={diffRaw} files={diffFiles} />
494 ) : (
495 <>
496 {pr.body && (
bb0f894Claude497 <CommentBox
498 author={author?.username ?? "unknown"}
499 date={pr.createdAt}
500 body={renderMarkdown(pr.body)}
501 />
0074234Claude502 )}
503
6fc53bdClaude504 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude505 <div
506 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
507 >
508 <div class="comment-header">
bb0f894Claude509 <Flex gap={8} align="center">
510 <strong>{commentAuthor.username}</strong>
511 {comment.isAiReview && (
512 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
513 AI Review
514 </Badge>
515 )}
516 <Text size={13} muted>
517 commented {formatRelative(comment.createdAt)}
518 </Text>
519 {comment.filePath && (
520 <Text size={11} mono style="margin-left:8px">
521 {comment.filePath}
522 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
523 </Text>
524 )}
525 </Flex>
6fc53bdClaude526 </div>
bb0f894Claude527 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude528 </div>
529 ))}
530
e883329Claude531 {error && (
532 <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)">
533 {decodeURIComponent(error)}
534 </div>
535 )}
536
537 {pr.state === "open" && gateChecks.length > 0 && (
538 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
539 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
540 {gateChecks.map((check) => (
541 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
542 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
543 {check.passed ? "\u2713" : "\u2717"}
544 </span>
545 <strong style="font-size: 13px">{check.name}</strong>
546 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
547 </div>
548 ))}
549 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
550 {gateChecks.every((c) => c.passed)
551 ? "All checks passed — ready to merge"
552 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
553 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
554 : "Some checks failed — resolve issues before merging"}
555 </div>
556 </div>
557 )}
558
0074234Claude559 {user && pr.state === "open" && (
560 <div style="margin-top: 20px">
0316dbbClaude561 <Form
e7e240eClaude562 method="post"
0074234Claude563 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
564 >
bb0f894Claude565 <FormGroup>
566 <TextArea
0074234Claude567 name="body"
568 rows={6}
569 required
570 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude571 mono
0074234Claude572 />
bb0f894Claude573 </FormGroup>
574 <Flex gap={8}>
575 <Button type="submit" variant="primary">
0074234Claude576 Comment
bb0f894Claude577 </Button>
0074234Claude578 {canManage && (
579 <>
580 <button
581 type="submit"
582 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
583 class="btn"
bb0f894Claude584 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
0074234Claude585 >
586 Merge pull request
587 </button>
bb0f894Claude588 <Button
0074234Claude589 type="submit"
bb0f894Claude590 variant="danger"
0074234Claude591 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
592 >
593 Close
bb0f894Claude594 </Button>
0074234Claude595 </>
596 )}
bb0f894Claude597 </Flex>
598 </Form>
0074234Claude599 </div>
600 )}
601 </>
602 )}
603 </div>
604 </Layout>
605 );
606});
607
608// Add comment to PR
609pulls.post(
610 "/:owner/:repo/pulls/:number/comment",
611 softAuth,
612 requireAuth,
04f6b7fClaude613 requireRepoAccess("write"),
0074234Claude614 async (c) => {
615 const { owner: ownerName, repo: repoName } = c.req.param();
616 const prNum = parseInt(c.req.param("number"), 10);
617 const user = c.get("user")!;
618 const body = await c.req.parseBody();
619 const commentBody = String(body.body || "").trim();
620
621 if (!commentBody) {
622 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
623 }
624
625 const resolved = await resolveRepo(ownerName, repoName);
626 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
627
628 const [pr] = await db
629 .select()
630 .from(pullRequests)
631 .where(
632 and(
633 eq(pullRequests.repositoryId, resolved.repo.id),
634 eq(pullRequests.number, prNum)
635 )
636 )
637 .limit(1);
638
639 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
640
d4ac5c3Claude641 const [inserted] = await db
642 .insert(prComments)
643 .values({
644 pullRequestId: pr.id,
645 authorId: user.id,
646 body: commentBody,
647 })
648 .returning();
649
650 // Live update: nudge any browser tabs subscribed to this PR.
651 if (inserted) {
652 try {
653 const { publish } = await import("../lib/sse");
654 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
655 event: "pr-comment",
656 data: {
657 pullRequestId: pr.id,
658 commentId: inserted.id,
659 authorId: user.id,
660 authorUsername: user.username,
661 },
662 });
663 } catch {
664 /* SSE is best-effort */
665 }
666 }
0074234Claude667
668 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
669 }
670);
671
e883329Claude672// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude673// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
674// but we keep it at "write" for v1 so trusted collaborators can ship.
675// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
676// surface. Branch-protection rules (evaluated below) are the current mechanism
677// for locking down merges further on specific branches.
0074234Claude678pulls.post(
679 "/:owner/:repo/pulls/:number/merge",
680 softAuth,
681 requireAuth,
04f6b7fClaude682 requireRepoAccess("write"),
0074234Claude683 async (c) => {
684 const { owner: ownerName, repo: repoName } = c.req.param();
685 const prNum = parseInt(c.req.param("number"), 10);
686 const user = c.get("user")!;
687
688 const resolved = await resolveRepo(ownerName, repoName);
689 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
690
691 const [pr] = await db
692 .select()
693 .from(pullRequests)
694 .where(
695 and(
696 eq(pullRequests.repositoryId, resolved.repo.id),
697 eq(pullRequests.number, prNum)
698 )
699 )
700 .limit(1);
701
702 if (!pr || pr.state !== "open") {
703 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
704 }
705
6fc53bdClaude706 // Draft PRs cannot be merged — must be marked ready first.
707 if (pr.isDraft) {
708 return c.redirect(
709 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
710 "This PR is a draft. Mark it as ready for review before merging."
711 )}`
712 );
713 }
714
e883329Claude715 // Resolve head SHA
716 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
717 if (!headSha) {
718 return c.redirect(
719 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
720 );
721 }
722
723 // Check if AI review approved this PR
724 const aiComments = await db
725 .select()
726 .from(prComments)
727 .where(
728 and(
729 eq(prComments.pullRequestId, pr.id),
730 eq(prComments.isAiReview, true)
731 )
732 );
733 const aiApproved = aiComments.length === 0 || aiComments.some(
734 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude735 );
e883329Claude736
737 // Run all green gate checks (GateTest + mergeability + AI review)
738 const gateResult = await runAllGateChecks(
739 ownerName,
740 repoName,
741 pr.baseBranch,
742 pr.headBranch,
743 headSha,
744 aiApproved
0074234Claude745 );
746
e883329Claude747 // If GateTest or AI review failed (hard blocks), reject the merge
748 const hardFailures = gateResult.checks.filter(
749 (check) => !check.passed && check.name !== "Merge check"
750 );
751 if (hardFailures.length > 0) {
752 const errorMsg = hardFailures
753 .map((f) => `${f.name}: ${f.details}`)
754 .join("; ");
0074234Claude755 return c.redirect(
e883329Claude756 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude757 );
758 }
759
1e162a8Claude760 // D5 — Branch-protection enforcement. Looks up the matching rule for the
761 // base branch and blocks the merge if requireAiApproval / requireGreenGates
762 // / requireHumanReview / requiredApprovals are not satisfied. Independent
763 // of repo-global settings, so owners can lock specific branches down
764 // further than the repo default.
765 const protectionRule = await matchProtection(
766 resolved.repo.id,
767 pr.baseBranch
768 );
769 if (protectionRule) {
770 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude771 const required = await listRequiredChecks(protectionRule.id);
772 const passingNames = required.length > 0
773 ? await passingCheckNames(resolved.repo.id, headSha)
774 : [];
775 const decision = evaluateProtection(
776 protectionRule,
777 {
778 aiApproved,
779 humanApprovalCount: humanApprovals,
780 gateResultGreen: hardFailures.length === 0,
781 hasFailedGates: hardFailures.length > 0,
782 passingCheckNames: passingNames,
783 },
784 required.map((r) => r.checkName)
785 );
1e162a8Claude786 if (!decision.allowed) {
787 return c.redirect(
788 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
789 decision.reasons.join(" ")
790 )}`
791 );
792 }
793 }
794
e883329Claude795 // Attempt the merge — with auto conflict resolution if needed
796 const repoDir = getRepoPath(ownerName, repoName);
797 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
798 const hasConflicts = mergeCheck && !mergeCheck.passed;
799
800 if (hasConflicts && isAiReviewEnabled()) {
801 // Use Claude to auto-resolve conflicts
802 const mergeResult = await mergeWithAutoResolve(
803 ownerName,
804 repoName,
805 pr.baseBranch,
806 pr.headBranch,
807 `Merge pull request #${pr.number}: ${pr.title}`
808 );
809
810 if (!mergeResult.success) {
811 return c.redirect(
812 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
813 );
814 }
815
816 // Post a comment about the auto-resolution
817 if (mergeResult.resolvedFiles.length > 0) {
818 await db.insert(prComments).values({
819 pullRequestId: pr.id,
820 authorId: user.id,
821 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
822 isAiReview: true,
823 });
824 }
825 } else {
826 // Standard merge — fast-forward or clean merge
827 const ffProc = Bun.spawn(
828 [
829 "git",
830 "update-ref",
831 `refs/heads/${pr.baseBranch}`,
832 `refs/heads/${pr.headBranch}`,
833 ],
834 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
835 );
836 const ffExit = await ffProc.exited;
837
838 if (ffExit !== 0) {
839 return c.redirect(
840 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
841 );
842 }
843 }
844
0074234Claude845 await db
846 .update(pullRequests)
847 .set({
848 state: "merged",
849 mergedAt: new Date(),
850 mergedBy: user.id,
851 updatedAt: new Date(),
852 })
853 .where(eq(pullRequests.id, pr.id));
854
d62fb36Claude855 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
856 // and auto-close each matching open issue with a back-link comment. Bounded
857 // to the same repo for v1 (cross-repo refs ignored). Failures never block
858 // the merge redirect.
859 try {
860 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
861 const refs = extractClosingRefsMulti([pr.title, pr.body]);
862 for (const n of refs) {
863 const [issue] = await db
864 .select()
865 .from(issues)
866 .where(
867 and(
868 eq(issues.repositoryId, resolved.repo.id),
869 eq(issues.number, n)
870 )
871 )
872 .limit(1);
873 if (!issue || issue.state !== "open") continue;
874 await db
875 .update(issues)
876 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
877 .where(eq(issues.id, issue.id));
878 await db.insert(issueComments).values({
879 issueId: issue.id,
880 authorId: user.id,
881 body: `Closed by pull request #${pr.number}.`,
882 });
883 }
884 } catch {
885 // Never block the merge on close-keyword failures.
886 }
887
0074234Claude888 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
889 }
890);
891
6fc53bdClaude892// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
893// hasn't run yet on this PR.
894pulls.post(
895 "/:owner/:repo/pulls/:number/ready",
896 softAuth,
897 requireAuth,
04f6b7fClaude898 requireRepoAccess("write"),
6fc53bdClaude899 async (c) => {
900 const { owner: ownerName, repo: repoName } = c.req.param();
901 const prNum = parseInt(c.req.param("number"), 10);
902 const user = c.get("user")!;
903
904 const resolved = await resolveRepo(ownerName, repoName);
905 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
906
907 const [pr] = await db
908 .select()
909 .from(pullRequests)
910 .where(
911 and(
912 eq(pullRequests.repositoryId, resolved.repo.id),
913 eq(pullRequests.number, prNum)
914 )
915 )
916 .limit(1);
917 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
918
919 // Only the author or repo owner can toggle draft state.
920 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
921 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
922 }
923
924 if (pr.state === "open" && pr.isDraft) {
925 await db
926 .update(pullRequests)
927 .set({ isDraft: false, updatedAt: new Date() })
928 .where(eq(pullRequests.id, pr.id));
929
930 if (isAiReviewEnabled()) {
931 triggerAiReview(
932 ownerName,
933 repoName,
934 pr.id,
935 pr.title,
0316dbbClaude936 pr.body || "",
6fc53bdClaude937 pr.baseBranch,
938 pr.headBranch
939 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
940 }
941 }
942
943 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
944 }
945);
946
947// Convert a PR back to draft.
948pulls.post(
949 "/:owner/:repo/pulls/:number/draft",
950 softAuth,
951 requireAuth,
04f6b7fClaude952 requireRepoAccess("write"),
6fc53bdClaude953 async (c) => {
954 const { owner: ownerName, repo: repoName } = c.req.param();
955 const prNum = parseInt(c.req.param("number"), 10);
956 const user = c.get("user")!;
957
958 const resolved = await resolveRepo(ownerName, repoName);
959 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
960
961 const [pr] = await db
962 .select()
963 .from(pullRequests)
964 .where(
965 and(
966 eq(pullRequests.repositoryId, resolved.repo.id),
967 eq(pullRequests.number, prNum)
968 )
969 )
970 .limit(1);
971 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
972
973 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
974 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
975 }
976
977 if (pr.state === "open" && !pr.isDraft) {
978 await db
979 .update(pullRequests)
980 .set({ isDraft: true, updatedAt: new Date() })
981 .where(eq(pullRequests.id, pr.id));
982 }
983
984 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
985 }
986);
987
0074234Claude988// Close PR
989pulls.post(
990 "/:owner/:repo/pulls/:number/close",
991 softAuth,
992 requireAuth,
04f6b7fClaude993 requireRepoAccess("write"),
0074234Claude994 async (c) => {
995 const { owner: ownerName, repo: repoName } = c.req.param();
996 const prNum = parseInt(c.req.param("number"), 10);
997
998 const resolved = await resolveRepo(ownerName, repoName);
999 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1000
1001 await db
1002 .update(pullRequests)
1003 .set({
1004 state: "closed",
1005 closedAt: new Date(),
1006 updatedAt: new Date(),
1007 })
1008 .where(
1009 and(
1010 eq(pullRequests.repositoryId, resolved.repo.id),
1011 eq(pullRequests.number, prNum)
1012 )
1013 );
1014
1015 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1016 }
1017);
1018
1019export default pulls;