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.tsxBlame985 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";
0316dbbClaude24import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
25import { triggerPrTriage } from "../lib/pr-triage";
26import { runAllGateChecks } from "../lib/gate";
27import type { GateCheckResult } from "../lib/gate";
28import {
29 matchProtection,
30 countHumanApprovals,
31 listRequiredChecks,
32 passingCheckNames,
33 evaluateProtection,
34} from "../lib/branch-protection";
35import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude36import {
37 listBranches,
38 getRepoPath,
e883329Claude39 resolveRef,
0074234Claude40} from "../git/repository";
41import type { GitDiffFile } from "../git/repository";
42import { html } from "hono/html";
1e162a8Claude43import {
bb0f894Claude44 Flex,
45 Container,
46 Badge,
47 Button,
48 LinkButton,
49 Form,
50 FormGroup,
51 Input,
52 TextArea,
53 Select,
54 EmptyState,
55 FilterTabs,
56 TabNav,
57 List,
58 ListItem,
59 Text,
60 Alert,
61 MarkdownContent,
62 CommentBox,
63 formatRelative,
64} from "../views/ui";
0074234Claude65
66const pulls = new Hono<AuthEnv>();
67
68async function resolveRepo(ownerName: string, repoName: string) {
69 const [owner] = await db
70 .select()
71 .from(users)
72 .where(eq(users.username, ownerName))
73 .limit(1);
74 if (!owner) return null;
75 const [repo] = await db
76 .select()
77 .from(repositories)
78 .where(
79 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
80 )
81 .limit(1);
82 if (!repo) return null;
83 return { owner, repo };
84}
85
86// PR Nav helper
87const PrNav = ({
88 owner,
89 repo,
90 active,
91}: {
92 owner: string;
93 repo: string;
94 active: "code" | "issues" | "pulls" | "commits";
95}) => (
bb0f894Claude96 <TabNav
97 tabs={[
98 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
99 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
100 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
101 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
102 ]}
103 />
0074234Claude104);
105
106// List PRs
107pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
108 const { owner: ownerName, repo: repoName } = c.req.param();
109 const user = c.get("user");
110 const state = c.req.query("state") || "open";
111
112 const resolved = await resolveRepo(ownerName, repoName);
113 if (!resolved) return c.notFound();
114
6fc53bdClaude115 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
116 const stateFilter =
117 state === "draft"
118 ? and(
119 eq(pullRequests.state, "open"),
120 eq(pullRequests.isDraft, true)
121 )
122 : eq(pullRequests.state, state);
123
0074234Claude124 const prList = await db
125 .select({
126 pr: pullRequests,
127 author: { username: users.username },
128 })
129 .from(pullRequests)
130 .innerJoin(users, eq(pullRequests.authorId, users.id))
131 .where(
6fc53bdClaude132 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude133 )
134 .orderBy(desc(pullRequests.createdAt));
135
136 const [counts] = await db
137 .select({
138 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude139 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude140 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
141 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
142 })
143 .from(pullRequests)
144 .where(eq(pullRequests.repositoryId, resolved.repo.id));
145
146 return c.html(
147 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
148 <RepoHeader owner={ownerName} repo={repoName} />
149 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude150 <Flex justify="space-between" align="center" style="margin-bottom:16px">
151 <FilterTabs
152 tabs={[
153 { label: `${counts?.open ?? 0} Open`, href: `/${ownerName}/${repoName}/pulls?state=open`, active: state === "open" },
154 { label: `${counts?.merged ?? 0} Merged`, href: `/${ownerName}/${repoName}/pulls?state=merged`, active: state === "merged" },
155 { label: `${counts?.closed ?? 0} Closed`, href: `/${ownerName}/${repoName}/pulls?state=closed`, active: state === "closed" },
156 ]}
157 />
0074234Claude158 {user && (
bb0f894Claude159 <LinkButton href={`/${ownerName}/${repoName}/pulls/new`} variant="primary">
0074234Claude160 New pull request
bb0f894Claude161 </LinkButton>
0074234Claude162 )}
bb0f894Claude163 </Flex>
0074234Claude164 {prList.length === 0 ? (
bb0f894Claude165 <EmptyState>
0074234Claude166 <p>No {state} pull requests.</p>
bb0f894Claude167 </EmptyState>
0074234Claude168 ) : (
bb0f894Claude169 <List>
0074234Claude170 {prList.map(({ pr, author }) => (
bb0f894Claude171 <ListItem>
0074234Claude172 <div
173 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
174 >
175 {pr.state === "open"
176 ? "\u25CB"
177 : pr.state === "merged"
178 ? "\u2B8C"
179 : "\u2713"}
180 </div>
181 <div>
182 <div class="issue-title">
183 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
184 {pr.title}
185 </a>
186 </div>
187 <div class="issue-meta">
188 #{pr.number}{" "}
189 {pr.headBranch} → {pr.baseBranch}{" "}
190 by {author.username}{" "}
191 {formatRelative(pr.createdAt)}
192 </div>
193 </div>
bb0f894Claude194 </ListItem>
0074234Claude195 ))}
bb0f894Claude196 </List>
0074234Claude197 )}
198 </Layout>
199 );
200});
201
202// New PR form
203pulls.get(
204 "/:owner/:repo/pulls/new",
205 softAuth,
206 requireAuth,
207 async (c) => {
208 const { owner: ownerName, repo: repoName } = c.req.param();
209 const user = c.get("user")!;
210 const branches = await listBranches(ownerName, repoName);
211 const error = c.req.query("error");
212 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude213 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude214
215 return c.html(
216 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
217 <RepoHeader owner={ownerName} repo={repoName} />
218 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude219 <Container maxWidth={800}>
220 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude221 {error && (
bb0f894Claude222 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude223 )}
0316dbbClaude224 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
225 <Flex gap={12} align="center" style="margin-bottom: 16px">
226 <Select name="base">
0074234Claude227 {branches.map((b) => (
228 <option value={b} selected={b === defaultBase}>
229 {b}
230 </option>
231 ))}
bb0f894Claude232 </Select>
233 <Text muted>&larr;</Text>
234 <Select name="head">
0074234Claude235 {branches
236 .filter((b) => b !== defaultBase)
237 .concat(defaultBase === branches[0] ? [] : [branches[0]])
238 .map((b) => (
239 <option value={b}>{b}</option>
240 ))}
bb0f894Claude241 </Select>
242 </Flex>
243 <FormGroup>
244 <Input
0074234Claude245 name="title"
246 required
247 placeholder="Title"
bb0f894Claude248 style="font-size:16px;padding:10px 14px"
0074234Claude249 />
bb0f894Claude250 </FormGroup>
251 <FormGroup>
252 <TextArea
0074234Claude253 name="body"
254 rows={8}
255 placeholder="Description (Markdown supported)"
bb0f894Claude256 mono
0074234Claude257 />
bb0f894Claude258 </FormGroup>
259 <Button type="submit" variant="primary">
0074234Claude260 Create pull request
bb0f894Claude261 </Button>
262 </Form>
263 </Container>
0074234Claude264 </Layout>
265 );
266 }
267);
268
269// Create PR
270pulls.post(
271 "/:owner/:repo/pulls/new",
272 softAuth,
273 requireAuth,
274 async (c) => {
275 const { owner: ownerName, repo: repoName } = c.req.param();
276 const user = c.get("user")!;
277 const body = await c.req.parseBody();
278 const title = String(body.title || "").trim();
279 const prBody = String(body.body || "").trim();
280 const baseBranch = String(body.base || "main");
281 const headBranch = String(body.head || "");
282
283 if (!title || !headBranch) {
284 return c.redirect(
285 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
286 );
287 }
288
289 if (baseBranch === headBranch) {
290 return c.redirect(
291 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
292 );
293 }
294
295 const resolved = await resolveRepo(ownerName, repoName);
296 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
297
6fc53bdClaude298 const isDraft = String(body.draft || "") === "1";
299
0074234Claude300 const [pr] = await db
301 .insert(pullRequests)
302 .values({
303 repositoryId: resolved.repo.id,
304 authorId: user.id,
305 title,
306 body: prBody || null,
307 baseBranch,
308 headBranch,
6fc53bdClaude309 isDraft,
0074234Claude310 })
311 .returning();
312
6fc53bdClaude313 // Skip AI review on drafts — it runs again when the PR is marked ready.
314 if (!isDraft && isAiReviewEnabled()) {
e883329Claude315 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
316 (err) => console.error("[ai-review] Failed:", err)
317 );
318 }
319
3cbe3d6Claude320 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
321 triggerPrTriage({
322 ownerName,
323 repoName,
324 repositoryId: resolved.repo.id,
325 prId: pr.id,
326 prAuthorId: user.id,
327 title,
328 body: prBody,
329 baseBranch,
330 headBranch,
331 }).catch((err) => console.error("[pr-triage] Failed:", err));
332
0074234Claude333 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
334 }
335);
336
337// View single PR
338pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
339 const { owner: ownerName, repo: repoName } = c.req.param();
340 const prNum = parseInt(c.req.param("number"), 10);
341 const user = c.get("user");
342 const tab = c.req.query("tab") || "conversation";
343
344 const resolved = await resolveRepo(ownerName, repoName);
345 if (!resolved) return c.notFound();
346
347 const [pr] = await db
348 .select()
349 .from(pullRequests)
350 .where(
351 and(
352 eq(pullRequests.repositoryId, resolved.repo.id),
353 eq(pullRequests.number, prNum)
354 )
355 )
356 .limit(1);
357
358 if (!pr) return c.notFound();
359
360 const [author] = await db
361 .select()
362 .from(users)
363 .where(eq(users.id, pr.authorId))
364 .limit(1);
365
366 const comments = await db
367 .select({
368 comment: prComments,
369 author: { username: users.username },
370 })
371 .from(prComments)
372 .innerJoin(users, eq(prComments.authorId, users.id))
373 .where(eq(prComments.pullRequestId, pr.id))
374 .orderBy(asc(prComments.createdAt));
375
6fc53bdClaude376 // Reactions for the PR body + each comment, in parallel.
377 const [prReactions, ...prCommentReactions] = await Promise.all([
378 summariseReactions("pr", pr.id, user?.id),
379 ...comments.map((row) =>
380 summariseReactions("pr_comment", row.comment.id, user?.id)
381 ),
382 ]);
383
0074234Claude384 const canManage =
385 user &&
386 (user.id === resolved.owner.id || user.id === pr.authorId);
387
e883329Claude388 const error = c.req.query("error");
389
390 // Get gate check status for open PRs
391 let gateChecks: GateCheckResult[] = [];
392 if (pr.state === "open") {
393 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
394 if (headSha) {
395 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
396 const aiApproved = aiComments.length === 0 || aiComments.some(
397 ({ comment }) => comment.body.includes("**Approved**")
398 );
399 const gateResult = await runAllGateChecks(
400 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
401 );
402 gateChecks = gateResult.checks;
403 }
404 }
405
0074234Claude406 // Get diff for "Files changed" tab
407 let diffRaw = "";
408 let diffFiles: GitDiffFile[] = [];
409 if (tab === "files") {
410 const repoDir = getRepoPath(ownerName, repoName);
411 const proc = Bun.spawn(
412 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
413 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
414 );
415 diffRaw = await new Response(proc.stdout).text();
416 await proc.exited;
417
418 const statProc = Bun.spawn(
419 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
420 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
421 );
422 const stat = await new Response(statProc.stdout).text();
423 await statProc.exited;
424
425 diffFiles = stat
426 .trim()
427 .split("\n")
428 .filter(Boolean)
429 .map((line) => {
430 const [add, del, filePath] = line.split("\t");
431 return {
432 path: filePath,
433 status: "modified",
434 additions: add === "-" ? 0 : parseInt(add, 10),
435 deletions: del === "-" ? 0 : parseInt(del, 10),
436 patch: "",
437 };
438 });
439 }
440
441 return c.html(
442 <Layout
443 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
444 user={user}
445 >
446 <RepoHeader owner={ownerName} repo={repoName} />
447 <PrNav owner={ownerName} repo={repoName} active="pulls" />
448 <div class="issue-detail">
449 <h2>
450 {pr.title}{" "}
bb0f894Claude451 <Text color="var(--text-muted)" weight={400}>
0074234Claude452 #{pr.number}
bb0f894Claude453 </Text>
0074234Claude454 </h2>
bb0f894Claude455 <Flex align="center" gap={8} style="margin:8px 0 20px">
456 <Badge
457 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
0074234Claude458 >
459 {pr.state === "open"
460 ? "\u25CB Open"
461 : pr.state === "merged"
462 ? "\u2B8C Merged"
463 : "\u2713 Closed"}
bb0f894Claude464 </Badge>
465 <Text size={14} muted>
466 <strong style="color:var(--text)">
0074234Claude467 {author?.username}
468 </strong>{" "}
469 wants to merge <code>{pr.headBranch}</code> into{" "}
470 <code>{pr.baseBranch}</code>
bb0f894Claude471 </Text>
472 </Flex>
473
474 <FilterTabs
475 tabs={[
476 {
477 label: "Conversation",
478 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
479 active: tab === "conversation",
480 },
481 {
482 label: "Files changed",
483 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
484 active: tab === "files",
485 },
486 ]}
487 />
0074234Claude488
489 {tab === "files" ? (
490 <DiffView raw={diffRaw} files={diffFiles} />
491 ) : (
492 <>
493 {pr.body && (
bb0f894Claude494 <CommentBox
495 author={author?.username ?? "unknown"}
496 date={pr.createdAt}
497 body={renderMarkdown(pr.body)}
498 />
0074234Claude499 )}
500
6fc53bdClaude501 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude502 <div
503 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
504 >
505 <div class="comment-header">
bb0f894Claude506 <Flex gap={8} align="center">
507 <strong>{commentAuthor.username}</strong>
508 {comment.isAiReview && (
509 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
510 AI Review
511 </Badge>
512 )}
513 <Text size={13} muted>
514 commented {formatRelative(comment.createdAt)}
515 </Text>
516 {comment.filePath && (
517 <Text size={11} mono style="margin-left:8px">
518 {comment.filePath}
519 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
520 </Text>
521 )}
522 </Flex>
6fc53bdClaude523 </div>
bb0f894Claude524 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude525 </div>
526 ))}
527
e883329Claude528 {error && (
529 <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)">
530 {decodeURIComponent(error)}
531 </div>
532 )}
533
534 {pr.state === "open" && gateChecks.length > 0 && (
535 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
536 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
537 {gateChecks.map((check) => (
538 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
539 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
540 {check.passed ? "\u2713" : "\u2717"}
541 </span>
542 <strong style="font-size: 13px">{check.name}</strong>
543 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
544 </div>
545 ))}
546 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
547 {gateChecks.every((c) => c.passed)
548 ? "All checks passed — ready to merge"
549 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
550 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
551 : "Some checks failed — resolve issues before merging"}
552 </div>
553 </div>
554 )}
555
0074234Claude556 {user && pr.state === "open" && (
557 <div style="margin-top: 20px">
0316dbbClaude558 <Form
e7e240eClaude559 method="post"
0074234Claude560 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
561 >
bb0f894Claude562 <FormGroup>
563 <TextArea
0074234Claude564 name="body"
565 rows={6}
566 required
567 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude568 mono
0074234Claude569 />
bb0f894Claude570 </FormGroup>
571 <Flex gap={8}>
572 <Button type="submit" variant="primary">
0074234Claude573 Comment
bb0f894Claude574 </Button>
0074234Claude575 {canManage && (
576 <>
577 <button
578 type="submit"
579 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
580 class="btn"
bb0f894Claude581 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
0074234Claude582 >
583 Merge pull request
584 </button>
bb0f894Claude585 <Button
0074234Claude586 type="submit"
bb0f894Claude587 variant="danger"
0074234Claude588 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
589 >
590 Close
bb0f894Claude591 </Button>
0074234Claude592 </>
593 )}
bb0f894Claude594 </Flex>
595 </Form>
0074234Claude596 </div>
597 )}
598 </>
599 )}
600 </div>
601 </Layout>
602 );
603});
604
605// Add comment to PR
606pulls.post(
607 "/:owner/:repo/pulls/:number/comment",
608 softAuth,
609 requireAuth,
610 async (c) => {
611 const { owner: ownerName, repo: repoName } = c.req.param();
612 const prNum = parseInt(c.req.param("number"), 10);
613 const user = c.get("user")!;
614 const body = await c.req.parseBody();
615 const commentBody = String(body.body || "").trim();
616
617 if (!commentBody) {
618 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
619 }
620
621 const resolved = await resolveRepo(ownerName, repoName);
622 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
623
624 const [pr] = await db
625 .select()
626 .from(pullRequests)
627 .where(
628 and(
629 eq(pullRequests.repositoryId, resolved.repo.id),
630 eq(pullRequests.number, prNum)
631 )
632 )
633 .limit(1);
634
635 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
636
637 await db.insert(prComments).values({
638 pullRequestId: pr.id,
639 authorId: user.id,
640 body: commentBody,
641 });
642
643 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
644 }
645);
646
e883329Claude647// Merge PR — with green gate enforcement and auto conflict resolution
0074234Claude648pulls.post(
649 "/:owner/:repo/pulls/:number/merge",
650 softAuth,
651 requireAuth,
652 async (c) => {
653 const { owner: ownerName, repo: repoName } = c.req.param();
654 const prNum = parseInt(c.req.param("number"), 10);
655 const user = c.get("user")!;
656
657 const resolved = await resolveRepo(ownerName, repoName);
658 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
659
660 const [pr] = await db
661 .select()
662 .from(pullRequests)
663 .where(
664 and(
665 eq(pullRequests.repositoryId, resolved.repo.id),
666 eq(pullRequests.number, prNum)
667 )
668 )
669 .limit(1);
670
671 if (!pr || pr.state !== "open") {
672 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
673 }
674
6fc53bdClaude675 // Draft PRs cannot be merged — must be marked ready first.
676 if (pr.isDraft) {
677 return c.redirect(
678 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
679 "This PR is a draft. Mark it as ready for review before merging."
680 )}`
681 );
682 }
683
e883329Claude684 // Resolve head SHA
685 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
686 if (!headSha) {
687 return c.redirect(
688 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
689 );
690 }
691
692 // Check if AI review approved this PR
693 const aiComments = await db
694 .select()
695 .from(prComments)
696 .where(
697 and(
698 eq(prComments.pullRequestId, pr.id),
699 eq(prComments.isAiReview, true)
700 )
701 );
702 const aiApproved = aiComments.length === 0 || aiComments.some(
703 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude704 );
e883329Claude705
706 // Run all green gate checks (GateTest + mergeability + AI review)
707 const gateResult = await runAllGateChecks(
708 ownerName,
709 repoName,
710 pr.baseBranch,
711 pr.headBranch,
712 headSha,
713 aiApproved
0074234Claude714 );
715
e883329Claude716 // If GateTest or AI review failed (hard blocks), reject the merge
717 const hardFailures = gateResult.checks.filter(
718 (check) => !check.passed && check.name !== "Merge check"
719 );
720 if (hardFailures.length > 0) {
721 const errorMsg = hardFailures
722 .map((f) => `${f.name}: ${f.details}`)
723 .join("; ");
0074234Claude724 return c.redirect(
e883329Claude725 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude726 );
727 }
728
1e162a8Claude729 // D5 — Branch-protection enforcement. Looks up the matching rule for the
730 // base branch and blocks the merge if requireAiApproval / requireGreenGates
731 // / requireHumanReview / requiredApprovals are not satisfied. Independent
732 // of repo-global settings, so owners can lock specific branches down
733 // further than the repo default.
734 const protectionRule = await matchProtection(
735 resolved.repo.id,
736 pr.baseBranch
737 );
738 if (protectionRule) {
739 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude740 const required = await listRequiredChecks(protectionRule.id);
741 const passingNames = required.length > 0
742 ? await passingCheckNames(resolved.repo.id, headSha)
743 : [];
744 const decision = evaluateProtection(
745 protectionRule,
746 {
747 aiApproved,
748 humanApprovalCount: humanApprovals,
749 gateResultGreen: hardFailures.length === 0,
750 hasFailedGates: hardFailures.length > 0,
751 passingCheckNames: passingNames,
752 },
753 required.map((r) => r.checkName)
754 );
1e162a8Claude755 if (!decision.allowed) {
756 return c.redirect(
757 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
758 decision.reasons.join(" ")
759 )}`
760 );
761 }
762 }
763
e883329Claude764 // Attempt the merge — with auto conflict resolution if needed
765 const repoDir = getRepoPath(ownerName, repoName);
766 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
767 const hasConflicts = mergeCheck && !mergeCheck.passed;
768
769 if (hasConflicts && isAiReviewEnabled()) {
770 // Use Claude to auto-resolve conflicts
771 const mergeResult = await mergeWithAutoResolve(
772 ownerName,
773 repoName,
774 pr.baseBranch,
775 pr.headBranch,
776 `Merge pull request #${pr.number}: ${pr.title}`
777 );
778
779 if (!mergeResult.success) {
780 return c.redirect(
781 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
782 );
783 }
784
785 // Post a comment about the auto-resolution
786 if (mergeResult.resolvedFiles.length > 0) {
787 await db.insert(prComments).values({
788 pullRequestId: pr.id,
789 authorId: user.id,
790 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
791 isAiReview: true,
792 });
793 }
794 } else {
795 // Standard merge — fast-forward or clean merge
796 const ffProc = Bun.spawn(
797 [
798 "git",
799 "update-ref",
800 `refs/heads/${pr.baseBranch}`,
801 `refs/heads/${pr.headBranch}`,
802 ],
803 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
804 );
805 const ffExit = await ffProc.exited;
806
807 if (ffExit !== 0) {
808 return c.redirect(
809 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
810 );
811 }
812 }
813
0074234Claude814 await db
815 .update(pullRequests)
816 .set({
817 state: "merged",
818 mergedAt: new Date(),
819 mergedBy: user.id,
820 updatedAt: new Date(),
821 })
822 .where(eq(pullRequests.id, pr.id));
823
d62fb36Claude824 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
825 // and auto-close each matching open issue with a back-link comment. Bounded
826 // to the same repo for v1 (cross-repo refs ignored). Failures never block
827 // the merge redirect.
828 try {
829 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
830 const refs = extractClosingRefsMulti([pr.title, pr.body]);
831 for (const n of refs) {
832 const [issue] = await db
833 .select()
834 .from(issues)
835 .where(
836 and(
837 eq(issues.repositoryId, resolved.repo.id),
838 eq(issues.number, n)
839 )
840 )
841 .limit(1);
842 if (!issue || issue.state !== "open") continue;
843 await db
844 .update(issues)
845 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
846 .where(eq(issues.id, issue.id));
847 await db.insert(issueComments).values({
848 issueId: issue.id,
849 authorId: user.id,
850 body: `Closed by pull request #${pr.number}.`,
851 });
852 }
853 } catch {
854 // Never block the merge on close-keyword failures.
855 }
856
0074234Claude857 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
858 }
859);
860
6fc53bdClaude861// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
862// hasn't run yet on this PR.
863pulls.post(
864 "/:owner/:repo/pulls/:number/ready",
865 softAuth,
866 requireAuth,
867 async (c) => {
868 const { owner: ownerName, repo: repoName } = c.req.param();
869 const prNum = parseInt(c.req.param("number"), 10);
870 const user = c.get("user")!;
871
872 const resolved = await resolveRepo(ownerName, repoName);
873 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
874
875 const [pr] = await db
876 .select()
877 .from(pullRequests)
878 .where(
879 and(
880 eq(pullRequests.repositoryId, resolved.repo.id),
881 eq(pullRequests.number, prNum)
882 )
883 )
884 .limit(1);
885 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
886
887 // Only the author or repo owner can toggle draft state.
888 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
889 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
890 }
891
892 if (pr.state === "open" && pr.isDraft) {
893 await db
894 .update(pullRequests)
895 .set({ isDraft: false, updatedAt: new Date() })
896 .where(eq(pullRequests.id, pr.id));
897
898 if (isAiReviewEnabled()) {
899 triggerAiReview(
900 ownerName,
901 repoName,
902 pr.id,
903 pr.title,
0316dbbClaude904 pr.body || "",
6fc53bdClaude905 pr.baseBranch,
906 pr.headBranch
907 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
908 }
909 }
910
911 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
912 }
913);
914
915// Convert a PR back to draft.
916pulls.post(
917 "/:owner/:repo/pulls/:number/draft",
918 softAuth,
919 requireAuth,
920 async (c) => {
921 const { owner: ownerName, repo: repoName } = c.req.param();
922 const prNum = parseInt(c.req.param("number"), 10);
923 const user = c.get("user")!;
924
925 const resolved = await resolveRepo(ownerName, repoName);
926 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
927
928 const [pr] = await db
929 .select()
930 .from(pullRequests)
931 .where(
932 and(
933 eq(pullRequests.repositoryId, resolved.repo.id),
934 eq(pullRequests.number, prNum)
935 )
936 )
937 .limit(1);
938 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
939
940 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
941 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
942 }
943
944 if (pr.state === "open" && !pr.isDraft) {
945 await db
946 .update(pullRequests)
947 .set({ isDraft: true, updatedAt: new Date() })
948 .where(eq(pullRequests.id, pr.id));
949 }
950
951 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
952 }
953);
954
0074234Claude955// Close PR
956pulls.post(
957 "/:owner/:repo/pulls/:number/close",
958 softAuth,
959 requireAuth,
960 async (c) => {
961 const { owner: ownerName, repo: repoName } = c.req.param();
962 const prNum = parseInt(c.req.param("number"), 10);
963
964 const resolved = await resolveRepo(ownerName, repoName);
965 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
966
967 await db
968 .update(pullRequests)
969 .set({
970 state: "closed",
971 closedAt: new Date(),
972 updatedAt: new Date(),
973 })
974 .where(
975 and(
976 eq(pullRequests.repositoryId, resolved.repo.id),
977 eq(pullRequests.number, prNum)
978 )
979 );
980
981 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
982 }
983);
984
985export default pulls;