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.tsxBlame998 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
641 await db.insert(prComments).values({
642 pullRequestId: pr.id,
643 authorId: user.id,
644 body: commentBody,
645 });
646
647 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
648 }
649);
650
e883329Claude651// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude652// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
653// but we keep it at "write" for v1 so trusted collaborators can ship.
654// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
655// surface. Branch-protection rules (evaluated below) are the current mechanism
656// for locking down merges further on specific branches.
0074234Claude657pulls.post(
658 "/:owner/:repo/pulls/:number/merge",
659 softAuth,
660 requireAuth,
04f6b7fClaude661 requireRepoAccess("write"),
0074234Claude662 async (c) => {
663 const { owner: ownerName, repo: repoName } = c.req.param();
664 const prNum = parseInt(c.req.param("number"), 10);
665 const user = c.get("user")!;
666
667 const resolved = await resolveRepo(ownerName, repoName);
668 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
669
670 const [pr] = await db
671 .select()
672 .from(pullRequests)
673 .where(
674 and(
675 eq(pullRequests.repositoryId, resolved.repo.id),
676 eq(pullRequests.number, prNum)
677 )
678 )
679 .limit(1);
680
681 if (!pr || pr.state !== "open") {
682 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
683 }
684
6fc53bdClaude685 // Draft PRs cannot be merged — must be marked ready first.
686 if (pr.isDraft) {
687 return c.redirect(
688 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
689 "This PR is a draft. Mark it as ready for review before merging."
690 )}`
691 );
692 }
693
e883329Claude694 // Resolve head SHA
695 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
696 if (!headSha) {
697 return c.redirect(
698 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
699 );
700 }
701
702 // Check if AI review approved this PR
703 const aiComments = await db
704 .select()
705 .from(prComments)
706 .where(
707 and(
708 eq(prComments.pullRequestId, pr.id),
709 eq(prComments.isAiReview, true)
710 )
711 );
712 const aiApproved = aiComments.length === 0 || aiComments.some(
713 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude714 );
e883329Claude715
716 // Run all green gate checks (GateTest + mergeability + AI review)
717 const gateResult = await runAllGateChecks(
718 ownerName,
719 repoName,
720 pr.baseBranch,
721 pr.headBranch,
722 headSha,
723 aiApproved
0074234Claude724 );
725
e883329Claude726 // If GateTest or AI review failed (hard blocks), reject the merge
727 const hardFailures = gateResult.checks.filter(
728 (check) => !check.passed && check.name !== "Merge check"
729 );
730 if (hardFailures.length > 0) {
731 const errorMsg = hardFailures
732 .map((f) => `${f.name}: ${f.details}`)
733 .join("; ");
0074234Claude734 return c.redirect(
e883329Claude735 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude736 );
737 }
738
1e162a8Claude739 // D5 — Branch-protection enforcement. Looks up the matching rule for the
740 // base branch and blocks the merge if requireAiApproval / requireGreenGates
741 // / requireHumanReview / requiredApprovals are not satisfied. Independent
742 // of repo-global settings, so owners can lock specific branches down
743 // further than the repo default.
744 const protectionRule = await matchProtection(
745 resolved.repo.id,
746 pr.baseBranch
747 );
748 if (protectionRule) {
749 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude750 const required = await listRequiredChecks(protectionRule.id);
751 const passingNames = required.length > 0
752 ? await passingCheckNames(resolved.repo.id, headSha)
753 : [];
754 const decision = evaluateProtection(
755 protectionRule,
756 {
757 aiApproved,
758 humanApprovalCount: humanApprovals,
759 gateResultGreen: hardFailures.length === 0,
760 hasFailedGates: hardFailures.length > 0,
761 passingCheckNames: passingNames,
762 },
763 required.map((r) => r.checkName)
764 );
1e162a8Claude765 if (!decision.allowed) {
766 return c.redirect(
767 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
768 decision.reasons.join(" ")
769 )}`
770 );
771 }
772 }
773
e883329Claude774 // Attempt the merge — with auto conflict resolution if needed
775 const repoDir = getRepoPath(ownerName, repoName);
776 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
777 const hasConflicts = mergeCheck && !mergeCheck.passed;
778
779 if (hasConflicts && isAiReviewEnabled()) {
780 // Use Claude to auto-resolve conflicts
781 const mergeResult = await mergeWithAutoResolve(
782 ownerName,
783 repoName,
784 pr.baseBranch,
785 pr.headBranch,
786 `Merge pull request #${pr.number}: ${pr.title}`
787 );
788
789 if (!mergeResult.success) {
790 return c.redirect(
791 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
792 );
793 }
794
795 // Post a comment about the auto-resolution
796 if (mergeResult.resolvedFiles.length > 0) {
797 await db.insert(prComments).values({
798 pullRequestId: pr.id,
799 authorId: user.id,
800 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
801 isAiReview: true,
802 });
803 }
804 } else {
805 // Standard merge — fast-forward or clean merge
806 const ffProc = Bun.spawn(
807 [
808 "git",
809 "update-ref",
810 `refs/heads/${pr.baseBranch}`,
811 `refs/heads/${pr.headBranch}`,
812 ],
813 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
814 );
815 const ffExit = await ffProc.exited;
816
817 if (ffExit !== 0) {
818 return c.redirect(
819 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
820 );
821 }
822 }
823
0074234Claude824 await db
825 .update(pullRequests)
826 .set({
827 state: "merged",
828 mergedAt: new Date(),
829 mergedBy: user.id,
830 updatedAt: new Date(),
831 })
832 .where(eq(pullRequests.id, pr.id));
833
d62fb36Claude834 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
835 // and auto-close each matching open issue with a back-link comment. Bounded
836 // to the same repo for v1 (cross-repo refs ignored). Failures never block
837 // the merge redirect.
838 try {
839 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
840 const refs = extractClosingRefsMulti([pr.title, pr.body]);
841 for (const n of refs) {
842 const [issue] = await db
843 .select()
844 .from(issues)
845 .where(
846 and(
847 eq(issues.repositoryId, resolved.repo.id),
848 eq(issues.number, n)
849 )
850 )
851 .limit(1);
852 if (!issue || issue.state !== "open") continue;
853 await db
854 .update(issues)
855 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
856 .where(eq(issues.id, issue.id));
857 await db.insert(issueComments).values({
858 issueId: issue.id,
859 authorId: user.id,
860 body: `Closed by pull request #${pr.number}.`,
861 });
862 }
863 } catch {
864 // Never block the merge on close-keyword failures.
865 }
866
0074234Claude867 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
868 }
869);
870
6fc53bdClaude871// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
872// hasn't run yet on this PR.
873pulls.post(
874 "/:owner/:repo/pulls/:number/ready",
875 softAuth,
876 requireAuth,
04f6b7fClaude877 requireRepoAccess("write"),
6fc53bdClaude878 async (c) => {
879 const { owner: ownerName, repo: repoName } = c.req.param();
880 const prNum = parseInt(c.req.param("number"), 10);
881 const user = c.get("user")!;
882
883 const resolved = await resolveRepo(ownerName, repoName);
884 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
885
886 const [pr] = await db
887 .select()
888 .from(pullRequests)
889 .where(
890 and(
891 eq(pullRequests.repositoryId, resolved.repo.id),
892 eq(pullRequests.number, prNum)
893 )
894 )
895 .limit(1);
896 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
897
898 // Only the author or repo owner can toggle draft state.
899 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
900 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
901 }
902
903 if (pr.state === "open" && pr.isDraft) {
904 await db
905 .update(pullRequests)
906 .set({ isDraft: false, updatedAt: new Date() })
907 .where(eq(pullRequests.id, pr.id));
908
909 if (isAiReviewEnabled()) {
910 triggerAiReview(
911 ownerName,
912 repoName,
913 pr.id,
914 pr.title,
0316dbbClaude915 pr.body || "",
6fc53bdClaude916 pr.baseBranch,
917 pr.headBranch
918 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
919 }
920 }
921
922 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
923 }
924);
925
926// Convert a PR back to draft.
927pulls.post(
928 "/:owner/:repo/pulls/:number/draft",
929 softAuth,
930 requireAuth,
04f6b7fClaude931 requireRepoAccess("write"),
6fc53bdClaude932 async (c) => {
933 const { owner: ownerName, repo: repoName } = c.req.param();
934 const prNum = parseInt(c.req.param("number"), 10);
935 const user = c.get("user")!;
936
937 const resolved = await resolveRepo(ownerName, repoName);
938 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
939
940 const [pr] = await db
941 .select()
942 .from(pullRequests)
943 .where(
944 and(
945 eq(pullRequests.repositoryId, resolved.repo.id),
946 eq(pullRequests.number, prNum)
947 )
948 )
949 .limit(1);
950 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
951
952 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
953 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
954 }
955
956 if (pr.state === "open" && !pr.isDraft) {
957 await db
958 .update(pullRequests)
959 .set({ isDraft: true, updatedAt: new Date() })
960 .where(eq(pullRequests.id, pr.id));
961 }
962
963 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
964 }
965);
966
0074234Claude967// Close PR
968pulls.post(
969 "/:owner/:repo/pulls/:number/close",
970 softAuth,
971 requireAuth,
04f6b7fClaude972 requireRepoAccess("write"),
0074234Claude973 async (c) => {
974 const { owner: ownerName, repo: repoName } = c.req.param();
975 const prNum = parseInt(c.req.param("number"), 10);
976
977 const resolved = await resolveRepo(ownerName, repoName);
978 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
979
980 await db
981 .update(pullRequests)
982 .set({
983 state: "closed",
984 closedAt: new Date(),
985 updatedAt: new Date(),
986 })
987 .where(
988 and(
989 eq(pullRequests.repositoryId, resolved.repo.id),
990 eq(pullRequests.number, prNum)
991 )
992 );
993
994 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
995 }
996);
997
998export default pulls;