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.tsxBlame974 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";
24import {
25 listBranches,
26 getRepoPath,
e883329Claude27 resolveRef,
0074234Claude28} from "../git/repository";
29import type { GitDiffFile } from "../git/repository";
30import { html } from "hono/html";
1e162a8Claude31import {
bb0f894Claude32 Flex,
33 Container,
34 Badge,
35 Button,
36 LinkButton,
37 Form,
38 FormGroup,
39 Input,
40 TextArea,
41 Select,
42 EmptyState,
43 FilterTabs,
44 TabNav,
45 List,
46 ListItem,
47 Text,
48 Alert,
49 MarkdownContent,
50 CommentBox,
51 formatRelative,
52} from "../views/ui";
0074234Claude53
54const pulls = new Hono<AuthEnv>();
55
56async function resolveRepo(ownerName: string, repoName: string) {
57 const [owner] = await db
58 .select()
59 .from(users)
60 .where(eq(users.username, ownerName))
61 .limit(1);
62 if (!owner) return null;
63 const [repo] = await db
64 .select()
65 .from(repositories)
66 .where(
67 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
68 )
69 .limit(1);
70 if (!repo) return null;
71 return { owner, repo };
72}
73
74// PR Nav helper
75const PrNav = ({
76 owner,
77 repo,
78 active,
79}: {
80 owner: string;
81 repo: string;
82 active: "code" | "issues" | "pulls" | "commits";
83}) => (
bb0f894Claude84 <TabNav
85 tabs={[
86 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
87 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
88 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
89 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
90 ]}
91 />
0074234Claude92);
93
94// List PRs
95pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
96 const { owner: ownerName, repo: repoName } = c.req.param();
97 const user = c.get("user");
98 const state = c.req.query("state") || "open";
99
100 const resolved = await resolveRepo(ownerName, repoName);
101 if (!resolved) return c.notFound();
102
6fc53bdClaude103 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
104 const stateFilter =
105 state === "draft"
106 ? and(
107 eq(pullRequests.state, "open"),
108 eq(pullRequests.isDraft, true)
109 )
110 : eq(pullRequests.state, state);
111
0074234Claude112 const prList = await db
113 .select({
114 pr: pullRequests,
115 author: { username: users.username },
116 })
117 .from(pullRequests)
118 .innerJoin(users, eq(pullRequests.authorId, users.id))
119 .where(
6fc53bdClaude120 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude121 )
122 .orderBy(desc(pullRequests.createdAt));
123
124 const [counts] = await db
125 .select({
126 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude127 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude128 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
129 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
130 })
131 .from(pullRequests)
132 .where(eq(pullRequests.repositoryId, resolved.repo.id));
133
134 return c.html(
135 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
136 <RepoHeader owner={ownerName} repo={repoName} />
137 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude138 <Flex justify="space-between" align="center" style="margin-bottom:16px">
139 <FilterTabs
140 tabs={[
141 { label: `${counts?.open ?? 0} Open`, href: `/${ownerName}/${repoName}/pulls?state=open`, active: state === "open" },
142 { label: `${counts?.merged ?? 0} Merged`, href: `/${ownerName}/${repoName}/pulls?state=merged`, active: state === "merged" },
143 { label: `${counts?.closed ?? 0} Closed`, href: `/${ownerName}/${repoName}/pulls?state=closed`, active: state === "closed" },
144 ]}
145 />
0074234Claude146 {user && (
bb0f894Claude147 <LinkButton href={`/${ownerName}/${repoName}/pulls/new`} variant="primary">
0074234Claude148 New pull request
bb0f894Claude149 </LinkButton>
0074234Claude150 )}
bb0f894Claude151 </Flex>
0074234Claude152 {prList.length === 0 ? (
bb0f894Claude153 <EmptyState>
0074234Claude154 <p>No {state} pull requests.</p>
bb0f894Claude155 </EmptyState>
0074234Claude156 ) : (
bb0f894Claude157 <List>
0074234Claude158 {prList.map(({ pr, author }) => (
bb0f894Claude159 <ListItem>
0074234Claude160 <div
161 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
162 >
163 {pr.state === "open"
164 ? "\u25CB"
165 : pr.state === "merged"
166 ? "\u2B8C"
167 : "\u2713"}
168 </div>
169 <div>
170 <div class="issue-title">
171 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
172 {pr.title}
173 </a>
174 </div>
175 <div class="issue-meta">
176 #{pr.number}{" "}
177 {pr.headBranch} → {pr.baseBranch}{" "}
178 by {author.username}{" "}
179 {formatRelative(pr.createdAt)}
180 </div>
181 </div>
bb0f894Claude182 </ListItem>
0074234Claude183 ))}
bb0f894Claude184 </List>
0074234Claude185 )}
186 </Layout>
187 );
188});
189
190// New PR form
191pulls.get(
192 "/:owner/:repo/pulls/new",
193 softAuth,
194 requireAuth,
195 async (c) => {
196 const { owner: ownerName, repo: repoName } = c.req.param();
197 const user = c.get("user")!;
198 const branches = await listBranches(ownerName, repoName);
199 const error = c.req.query("error");
200 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude201 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude202
203 return c.html(
204 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
205 <RepoHeader owner={ownerName} repo={repoName} />
206 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude207 <Container maxWidth={800}>
208 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude209 {error && (
bb0f894Claude210 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude211 )}
e7e240eClaude212 <form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
0074234Claude213 <div style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px">
214 <select name="base" style="padding: 6px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px">
215 {branches.map((b) => (
216 <option value={b} selected={b === defaultBase}>
217 {b}
218 </option>
219 ))}
bb0f894Claude220 </Select>
221 <Text muted>&larr;</Text>
222 <Select name="head">
0074234Claude223 {branches
224 .filter((b) => b !== defaultBase)
225 .concat(defaultBase === branches[0] ? [] : [branches[0]])
226 .map((b) => (
227 <option value={b}>{b}</option>
228 ))}
bb0f894Claude229 </Select>
230 </Flex>
231 <FormGroup>
232 <Input
0074234Claude233 name="title"
234 required
235 placeholder="Title"
bb0f894Claude236 style="font-size:16px;padding:10px 14px"
0074234Claude237 />
bb0f894Claude238 </FormGroup>
239 <FormGroup>
240 <TextArea
0074234Claude241 name="body"
242 rows={8}
243 placeholder="Description (Markdown supported)"
bb0f894Claude244 mono
0074234Claude245 />
bb0f894Claude246 </FormGroup>
247 <Button type="submit" variant="primary">
0074234Claude248 Create pull request
bb0f894Claude249 </Button>
250 </Form>
251 </Container>
0074234Claude252 </Layout>
253 );
254 }
255);
256
257// Create PR
258pulls.post(
259 "/:owner/:repo/pulls/new",
260 softAuth,
261 requireAuth,
262 async (c) => {
263 const { owner: ownerName, repo: repoName } = c.req.param();
264 const user = c.get("user")!;
265 const body = await c.req.parseBody();
266 const title = String(body.title || "").trim();
267 const prBody = String(body.body || "").trim();
268 const baseBranch = String(body.base || "main");
269 const headBranch = String(body.head || "");
270
271 if (!title || !headBranch) {
272 return c.redirect(
273 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
274 );
275 }
276
277 if (baseBranch === headBranch) {
278 return c.redirect(
279 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
280 );
281 }
282
283 const resolved = await resolveRepo(ownerName, repoName);
284 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
285
6fc53bdClaude286 const isDraft = String(body.draft || "") === "1";
287
0074234Claude288 const [pr] = await db
289 .insert(pullRequests)
290 .values({
291 repositoryId: resolved.repo.id,
292 authorId: user.id,
293 title,
294 body: prBody || null,
295 baseBranch,
296 headBranch,
6fc53bdClaude297 isDraft,
0074234Claude298 })
299 .returning();
300
6fc53bdClaude301 // Skip AI review on drafts — it runs again when the PR is marked ready.
302 if (!isDraft && isAiReviewEnabled()) {
e883329Claude303 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
304 (err) => console.error("[ai-review] Failed:", err)
305 );
306 }
307
3cbe3d6Claude308 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
309 triggerPrTriage({
310 ownerName,
311 repoName,
312 repositoryId: resolved.repo.id,
313 prId: pr.id,
314 prAuthorId: user.id,
315 title,
316 body: prBody,
317 baseBranch,
318 headBranch,
319 }).catch((err) => console.error("[pr-triage] Failed:", err));
320
0074234Claude321 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
322 }
323);
324
325// View single PR
326pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
327 const { owner: ownerName, repo: repoName } = c.req.param();
328 const prNum = parseInt(c.req.param("number"), 10);
329 const user = c.get("user");
330 const tab = c.req.query("tab") || "conversation";
331
332 const resolved = await resolveRepo(ownerName, repoName);
333 if (!resolved) return c.notFound();
334
335 const [pr] = await db
336 .select()
337 .from(pullRequests)
338 .where(
339 and(
340 eq(pullRequests.repositoryId, resolved.repo.id),
341 eq(pullRequests.number, prNum)
342 )
343 )
344 .limit(1);
345
346 if (!pr) return c.notFound();
347
348 const [author] = await db
349 .select()
350 .from(users)
351 .where(eq(users.id, pr.authorId))
352 .limit(1);
353
354 const comments = await db
355 .select({
356 comment: prComments,
357 author: { username: users.username },
358 })
359 .from(prComments)
360 .innerJoin(users, eq(prComments.authorId, users.id))
361 .where(eq(prComments.pullRequestId, pr.id))
362 .orderBy(asc(prComments.createdAt));
363
6fc53bdClaude364 // Reactions for the PR body + each comment, in parallel.
365 const [prReactions, ...prCommentReactions] = await Promise.all([
366 summariseReactions("pr", pr.id, user?.id),
367 ...comments.map((row) =>
368 summariseReactions("pr_comment", row.comment.id, user?.id)
369 ),
370 ]);
371
0074234Claude372 const canManage =
373 user &&
374 (user.id === resolved.owner.id || user.id === pr.authorId);
375
e883329Claude376 const error = c.req.query("error");
377
378 // Get gate check status for open PRs
379 let gateChecks: GateCheckResult[] = [];
380 if (pr.state === "open") {
381 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
382 if (headSha) {
383 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
384 const aiApproved = aiComments.length === 0 || aiComments.some(
385 ({ comment }) => comment.body.includes("**Approved**")
386 );
387 const gateResult = await runAllGateChecks(
388 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
389 );
390 gateChecks = gateResult.checks;
391 }
392 }
393
0074234Claude394 // Get diff for "Files changed" tab
395 let diffRaw = "";
396 let diffFiles: GitDiffFile[] = [];
397 if (tab === "files") {
398 const repoDir = getRepoPath(ownerName, repoName);
399 const proc = Bun.spawn(
400 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
401 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
402 );
403 diffRaw = await new Response(proc.stdout).text();
404 await proc.exited;
405
406 const statProc = Bun.spawn(
407 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
408 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
409 );
410 const stat = await new Response(statProc.stdout).text();
411 await statProc.exited;
412
413 diffFiles = stat
414 .trim()
415 .split("\n")
416 .filter(Boolean)
417 .map((line) => {
418 const [add, del, filePath] = line.split("\t");
419 return {
420 path: filePath,
421 status: "modified",
422 additions: add === "-" ? 0 : parseInt(add, 10),
423 deletions: del === "-" ? 0 : parseInt(del, 10),
424 patch: "",
425 };
426 });
427 }
428
429 return c.html(
430 <Layout
431 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
432 user={user}
433 >
434 <RepoHeader owner={ownerName} repo={repoName} />
435 <PrNav owner={ownerName} repo={repoName} active="pulls" />
436 <div class="issue-detail">
437 <h2>
438 {pr.title}{" "}
bb0f894Claude439 <Text color="var(--text-muted)" weight={400}>
0074234Claude440 #{pr.number}
bb0f894Claude441 </Text>
0074234Claude442 </h2>
bb0f894Claude443 <Flex align="center" gap={8} style="margin:8px 0 20px">
444 <Badge
445 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
0074234Claude446 >
447 {pr.state === "open"
448 ? "\u25CB Open"
449 : pr.state === "merged"
450 ? "\u2B8C Merged"
451 : "\u2713 Closed"}
bb0f894Claude452 </Badge>
453 <Text size={14} muted>
454 <strong style="color:var(--text)">
0074234Claude455 {author?.username}
456 </strong>{" "}
457 wants to merge <code>{pr.headBranch}</code> into{" "}
458 <code>{pr.baseBranch}</code>
bb0f894Claude459 </Text>
460 </Flex>
461
462 <FilterTabs
463 tabs={[
464 {
465 label: "Conversation",
466 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
467 active: tab === "conversation",
468 },
469 {
470 label: "Files changed",
471 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
472 active: tab === "files",
473 },
474 ]}
475 />
0074234Claude476
477 {tab === "files" ? (
478 <DiffView raw={diffRaw} files={diffFiles} />
479 ) : (
480 <>
481 {pr.body && (
bb0f894Claude482 <CommentBox
483 author={author?.username ?? "unknown"}
484 date={pr.createdAt}
485 body={renderMarkdown(pr.body)}
486 />
0074234Claude487 )}
488
6fc53bdClaude489 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude490 <div
491 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
492 >
493 <div class="comment-header">
bb0f894Claude494 <Flex gap={8} align="center">
495 <strong>{commentAuthor.username}</strong>
496 {comment.isAiReview && (
497 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
498 AI Review
499 </Badge>
500 )}
501 <Text size={13} muted>
502 commented {formatRelative(comment.createdAt)}
503 </Text>
504 {comment.filePath && (
505 <Text size={11} mono style="margin-left:8px">
506 {comment.filePath}
507 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
508 </Text>
509 )}
510 </Flex>
6fc53bdClaude511 </div>
bb0f894Claude512 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude513 </div>
514 ))}
515
e883329Claude516 {error && (
517 <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)">
518 {decodeURIComponent(error)}
519 </div>
520 )}
521
522 {pr.state === "open" && gateChecks.length > 0 && (
523 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
524 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
525 {gateChecks.map((check) => (
526 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
527 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
528 {check.passed ? "\u2713" : "\u2717"}
529 </span>
530 <strong style="font-size: 13px">{check.name}</strong>
531 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
532 </div>
533 ))}
534 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
535 {gateChecks.every((c) => c.passed)
536 ? "All checks passed — ready to merge"
537 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
538 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
539 : "Some checks failed — resolve issues before merging"}
540 </div>
541 </div>
542 )}
543
0074234Claude544 {user && pr.state === "open" && (
545 <div style="margin-top: 20px">
546 <form
e7e240eClaude547 method="post"
0074234Claude548 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
bb0f894Claude549 method="POST"
0074234Claude550 >
bb0f894Claude551 <FormGroup>
552 <TextArea
0074234Claude553 name="body"
554 rows={6}
555 required
556 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude557 mono
0074234Claude558 />
bb0f894Claude559 </FormGroup>
560 <Flex gap={8}>
561 <Button type="submit" variant="primary">
0074234Claude562 Comment
bb0f894Claude563 </Button>
0074234Claude564 {canManage && (
565 <>
566 <button
567 type="submit"
568 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
569 class="btn"
bb0f894Claude570 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
0074234Claude571 >
572 Merge pull request
573 </button>
bb0f894Claude574 <Button
0074234Claude575 type="submit"
bb0f894Claude576 variant="danger"
0074234Claude577 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
578 >
579 Close
bb0f894Claude580 </Button>
0074234Claude581 </>
582 )}
bb0f894Claude583 </Flex>
584 </Form>
0074234Claude585 </div>
586 )}
587 </>
588 )}
589 </div>
590 </Layout>
591 );
592});
593
594// Add comment to PR
595pulls.post(
596 "/:owner/:repo/pulls/:number/comment",
597 softAuth,
598 requireAuth,
599 async (c) => {
600 const { owner: ownerName, repo: repoName } = c.req.param();
601 const prNum = parseInt(c.req.param("number"), 10);
602 const user = c.get("user")!;
603 const body = await c.req.parseBody();
604 const commentBody = String(body.body || "").trim();
605
606 if (!commentBody) {
607 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
608 }
609
610 const resolved = await resolveRepo(ownerName, repoName);
611 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
612
613 const [pr] = await db
614 .select()
615 .from(pullRequests)
616 .where(
617 and(
618 eq(pullRequests.repositoryId, resolved.repo.id),
619 eq(pullRequests.number, prNum)
620 )
621 )
622 .limit(1);
623
624 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
625
626 await db.insert(prComments).values({
627 pullRequestId: pr.id,
628 authorId: user.id,
629 body: commentBody,
630 });
631
632 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
633 }
634);
635
e883329Claude636// Merge PR — with green gate enforcement and auto conflict resolution
0074234Claude637pulls.post(
638 "/:owner/:repo/pulls/:number/merge",
639 softAuth,
640 requireAuth,
641 async (c) => {
642 const { owner: ownerName, repo: repoName } = c.req.param();
643 const prNum = parseInt(c.req.param("number"), 10);
644 const user = c.get("user")!;
645
646 const resolved = await resolveRepo(ownerName, repoName);
647 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
648
649 const [pr] = await db
650 .select()
651 .from(pullRequests)
652 .where(
653 and(
654 eq(pullRequests.repositoryId, resolved.repo.id),
655 eq(pullRequests.number, prNum)
656 )
657 )
658 .limit(1);
659
660 if (!pr || pr.state !== "open") {
661 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
662 }
663
6fc53bdClaude664 // Draft PRs cannot be merged — must be marked ready first.
665 if (pr.isDraft) {
666 return c.redirect(
667 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
668 "This PR is a draft. Mark it as ready for review before merging."
669 )}`
670 );
671 }
672
e883329Claude673 // Resolve head SHA
674 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
675 if (!headSha) {
676 return c.redirect(
677 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
678 );
679 }
680
681 // Check if AI review approved this PR
682 const aiComments = await db
683 .select()
684 .from(prComments)
685 .where(
686 and(
687 eq(prComments.pullRequestId, pr.id),
688 eq(prComments.isAiReview, true)
689 )
690 );
691 const aiApproved = aiComments.length === 0 || aiComments.some(
692 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude693 );
e883329Claude694
695 // Run all green gate checks (GateTest + mergeability + AI review)
696 const gateResult = await runAllGateChecks(
697 ownerName,
698 repoName,
699 pr.baseBranch,
700 pr.headBranch,
701 headSha,
702 aiApproved
0074234Claude703 );
704
e883329Claude705 // If GateTest or AI review failed (hard blocks), reject the merge
706 const hardFailures = gateResult.checks.filter(
707 (check) => !check.passed && check.name !== "Merge check"
708 );
709 if (hardFailures.length > 0) {
710 const errorMsg = hardFailures
711 .map((f) => `${f.name}: ${f.details}`)
712 .join("; ");
0074234Claude713 return c.redirect(
e883329Claude714 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude715 );
716 }
717
1e162a8Claude718 // D5 — Branch-protection enforcement. Looks up the matching rule for the
719 // base branch and blocks the merge if requireAiApproval / requireGreenGates
720 // / requireHumanReview / requiredApprovals are not satisfied. Independent
721 // of repo-global settings, so owners can lock specific branches down
722 // further than the repo default.
723 const protectionRule = await matchProtection(
724 resolved.repo.id,
725 pr.baseBranch
726 );
727 if (protectionRule) {
728 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude729 const required = await listRequiredChecks(protectionRule.id);
730 const passingNames = required.length > 0
731 ? await passingCheckNames(resolved.repo.id, headSha)
732 : [];
733 const decision = evaluateProtection(
734 protectionRule,
735 {
736 aiApproved,
737 humanApprovalCount: humanApprovals,
738 gateResultGreen: hardFailures.length === 0,
739 hasFailedGates: hardFailures.length > 0,
740 passingCheckNames: passingNames,
741 },
742 required.map((r) => r.checkName)
743 );
1e162a8Claude744 if (!decision.allowed) {
745 return c.redirect(
746 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
747 decision.reasons.join(" ")
748 )}`
749 );
750 }
751 }
752
e883329Claude753 // Attempt the merge — with auto conflict resolution if needed
754 const repoDir = getRepoPath(ownerName, repoName);
755 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
756 const hasConflicts = mergeCheck && !mergeCheck.passed;
757
758 if (hasConflicts && isAiReviewEnabled()) {
759 // Use Claude to auto-resolve conflicts
760 const mergeResult = await mergeWithAutoResolve(
761 ownerName,
762 repoName,
763 pr.baseBranch,
764 pr.headBranch,
765 `Merge pull request #${pr.number}: ${pr.title}`
766 );
767
768 if (!mergeResult.success) {
769 return c.redirect(
770 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
771 );
772 }
773
774 // Post a comment about the auto-resolution
775 if (mergeResult.resolvedFiles.length > 0) {
776 await db.insert(prComments).values({
777 pullRequestId: pr.id,
778 authorId: user.id,
779 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
780 isAiReview: true,
781 });
782 }
783 } else {
784 // Standard merge — fast-forward or clean merge
785 const ffProc = Bun.spawn(
786 [
787 "git",
788 "update-ref",
789 `refs/heads/${pr.baseBranch}`,
790 `refs/heads/${pr.headBranch}`,
791 ],
792 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
793 );
794 const ffExit = await ffProc.exited;
795
796 if (ffExit !== 0) {
797 return c.redirect(
798 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
799 );
800 }
801 }
802
0074234Claude803 await db
804 .update(pullRequests)
805 .set({
806 state: "merged",
807 mergedAt: new Date(),
808 mergedBy: user.id,
809 updatedAt: new Date(),
810 })
811 .where(eq(pullRequests.id, pr.id));
812
d62fb36Claude813 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
814 // and auto-close each matching open issue with a back-link comment. Bounded
815 // to the same repo for v1 (cross-repo refs ignored). Failures never block
816 // the merge redirect.
817 try {
818 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
819 const refs = extractClosingRefsMulti([pr.title, pr.body]);
820 for (const n of refs) {
821 const [issue] = await db
822 .select()
823 .from(issues)
824 .where(
825 and(
826 eq(issues.repositoryId, resolved.repo.id),
827 eq(issues.number, n)
828 )
829 )
830 .limit(1);
831 if (!issue || issue.state !== "open") continue;
832 await db
833 .update(issues)
834 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
835 .where(eq(issues.id, issue.id));
836 await db.insert(issueComments).values({
837 issueId: issue.id,
838 authorId: user.id,
839 body: `Closed by pull request #${pr.number}.`,
840 });
841 }
842 } catch {
843 // Never block the merge on close-keyword failures.
844 }
845
0074234Claude846 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
847 }
848);
849
6fc53bdClaude850// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
851// hasn't run yet on this PR.
852pulls.post(
853 "/:owner/:repo/pulls/:number/ready",
854 softAuth,
855 requireAuth,
856 async (c) => {
857 const { owner: ownerName, repo: repoName } = c.req.param();
858 const prNum = parseInt(c.req.param("number"), 10);
859 const user = c.get("user")!;
860
861 const resolved = await resolveRepo(ownerName, repoName);
862 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
863
864 const [pr] = await db
865 .select()
866 .from(pullRequests)
867 .where(
868 and(
869 eq(pullRequests.repositoryId, resolved.repo.id),
870 eq(pullRequests.number, prNum)
871 )
872 )
873 .limit(1);
874 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
875
876 // Only the author or repo owner can toggle draft state.
877 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
878 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
879 }
880
881 if (pr.state === "open" && pr.isDraft) {
882 await db
883 .update(pullRequests)
884 .set({ isDraft: false, updatedAt: new Date() })
885 .where(eq(pullRequests.id, pr.id));
886
887 if (isAiReviewEnabled()) {
888 triggerAiReview(
889 ownerName,
890 repoName,
891 pr.id,
892 pr.title,
893 pr.body,
894 pr.baseBranch,
895 pr.headBranch
896 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
897 }
898 }
899
900 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
901 }
902);
903
904// Convert a PR back to draft.
905pulls.post(
906 "/:owner/:repo/pulls/:number/draft",
907 softAuth,
908 requireAuth,
909 async (c) => {
910 const { owner: ownerName, repo: repoName } = c.req.param();
911 const prNum = parseInt(c.req.param("number"), 10);
912 const user = c.get("user")!;
913
914 const resolved = await resolveRepo(ownerName, repoName);
915 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
916
917 const [pr] = await db
918 .select()
919 .from(pullRequests)
920 .where(
921 and(
922 eq(pullRequests.repositoryId, resolved.repo.id),
923 eq(pullRequests.number, prNum)
924 )
925 )
926 .limit(1);
927 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
928
929 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
930 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
931 }
932
933 if (pr.state === "open" && !pr.isDraft) {
934 await db
935 .update(pullRequests)
936 .set({ isDraft: true, updatedAt: new Date() })
937 .where(eq(pullRequests.id, pr.id));
938 }
939
940 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
941 }
942);
943
0074234Claude944// Close PR
945pulls.post(
946 "/:owner/:repo/pulls/:number/close",
947 softAuth,
948 requireAuth,
949 async (c) => {
950 const { owner: ownerName, repo: repoName } = c.req.param();
951 const prNum = parseInt(c.req.param("number"), 10);
952
953 const resolved = await resolveRepo(ownerName, repoName);
954 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
955
956 await db
957 .update(pullRequests)
958 .set({
959 state: "closed",
960 closedAt: new Date(),
961 updatedAt: new Date(),
962 })
963 .where(
964 and(
965 eq(pullRequests.repositoryId, resolved.repo.id),
966 eq(pullRequests.number, prNum)
967 )
968 );
969
970 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
971 }
972);
973
974export default pulls;