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.tsxBlame973 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";
bb0f894Claude31import {
32 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 )}
bb0f894Claude212 <Form action={`/${ownerName}/${repoName}/pulls/new`} method="POST">
213 <Flex gap={12} align="center" style="margin-bottom:16px">
214 <Select name="base" value={defaultBase}>
0074234Claude215 {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>
0074234Claude511 </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" && (
bb0f894Claude545 <div style="margin-top:20px">
546 <Form
0074234Claude547 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
bb0f894Claude548 method="POST"
0074234Claude549 >
bb0f894Claude550 <FormGroup>
551 <TextArea
0074234Claude552 name="body"
553 rows={6}
554 required
555 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude556 mono
0074234Claude557 />
bb0f894Claude558 </FormGroup>
559 <Flex gap={8}>
560 <Button type="submit" variant="primary">
0074234Claude561 Comment
bb0f894Claude562 </Button>
0074234Claude563 {canManage && (
564 <>
565 <button
566 type="submit"
567 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
568 class="btn"
bb0f894Claude569 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
0074234Claude570 >
571 Merge pull request
572 </button>
bb0f894Claude573 <Button
0074234Claude574 type="submit"
bb0f894Claude575 variant="danger"
0074234Claude576 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
577 >
578 Close
bb0f894Claude579 </Button>
0074234Claude580 </>
581 )}
bb0f894Claude582 </Flex>
583 </Form>
0074234Claude584 </div>
585 )}
586 </>
587 )}
588 </div>
589 </Layout>
590 );
591});
592
593// Add comment to PR
594pulls.post(
595 "/:owner/:repo/pulls/:number/comment",
596 softAuth,
597 requireAuth,
598 async (c) => {
599 const { owner: ownerName, repo: repoName } = c.req.param();
600 const prNum = parseInt(c.req.param("number"), 10);
601 const user = c.get("user")!;
602 const body = await c.req.parseBody();
603 const commentBody = String(body.body || "").trim();
604
605 if (!commentBody) {
606 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
607 }
608
609 const resolved = await resolveRepo(ownerName, repoName);
610 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
611
612 const [pr] = await db
613 .select()
614 .from(pullRequests)
615 .where(
616 and(
617 eq(pullRequests.repositoryId, resolved.repo.id),
618 eq(pullRequests.number, prNum)
619 )
620 )
621 .limit(1);
622
623 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
624
625 await db.insert(prComments).values({
626 pullRequestId: pr.id,
627 authorId: user.id,
628 body: commentBody,
629 });
630
631 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
632 }
633);
634
e883329Claude635// Merge PR — with green gate enforcement and auto conflict resolution
0074234Claude636pulls.post(
637 "/:owner/:repo/pulls/:number/merge",
638 softAuth,
639 requireAuth,
640 async (c) => {
641 const { owner: ownerName, repo: repoName } = c.req.param();
642 const prNum = parseInt(c.req.param("number"), 10);
643 const user = c.get("user")!;
644
645 const resolved = await resolveRepo(ownerName, repoName);
646 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
647
648 const [pr] = await db
649 .select()
650 .from(pullRequests)
651 .where(
652 and(
653 eq(pullRequests.repositoryId, resolved.repo.id),
654 eq(pullRequests.number, prNum)
655 )
656 )
657 .limit(1);
658
659 if (!pr || pr.state !== "open") {
660 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
661 }
662
6fc53bdClaude663 // Draft PRs cannot be merged — must be marked ready first.
664 if (pr.isDraft) {
665 return c.redirect(
666 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
667 "This PR is a draft. Mark it as ready for review before merging."
668 )}`
669 );
670 }
671
e883329Claude672 // Resolve head SHA
673 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
674 if (!headSha) {
675 return c.redirect(
676 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
677 );
678 }
679
680 // Check if AI review approved this PR
681 const aiComments = await db
682 .select()
683 .from(prComments)
684 .where(
685 and(
686 eq(prComments.pullRequestId, pr.id),
687 eq(prComments.isAiReview, true)
688 )
689 );
690 const aiApproved = aiComments.length === 0 || aiComments.some(
691 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude692 );
e883329Claude693
694 // Run all green gate checks (GateTest + mergeability + AI review)
695 const gateResult = await runAllGateChecks(
696 ownerName,
697 repoName,
698 pr.baseBranch,
699 pr.headBranch,
700 headSha,
701 aiApproved
0074234Claude702 );
703
e883329Claude704 // If GateTest or AI review failed (hard blocks), reject the merge
705 const hardFailures = gateResult.checks.filter(
706 (check) => !check.passed && check.name !== "Merge check"
707 );
708 if (hardFailures.length > 0) {
709 const errorMsg = hardFailures
710 .map((f) => `${f.name}: ${f.details}`)
711 .join("; ");
0074234Claude712 return c.redirect(
e883329Claude713 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude714 );
715 }
716
1e162a8Claude717 // D5 — Branch-protection enforcement. Looks up the matching rule for the
718 // base branch and blocks the merge if requireAiApproval / requireGreenGates
719 // / requireHumanReview / requiredApprovals are not satisfied. Independent
720 // of repo-global settings, so owners can lock specific branches down
721 // further than the repo default.
722 const protectionRule = await matchProtection(
723 resolved.repo.id,
724 pr.baseBranch
725 );
726 if (protectionRule) {
727 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude728 const required = await listRequiredChecks(protectionRule.id);
729 const passingNames = required.length > 0
730 ? await passingCheckNames(resolved.repo.id, headSha)
731 : [];
732 const decision = evaluateProtection(
733 protectionRule,
734 {
735 aiApproved,
736 humanApprovalCount: humanApprovals,
737 gateResultGreen: hardFailures.length === 0,
738 hasFailedGates: hardFailures.length > 0,
739 passingCheckNames: passingNames,
740 },
741 required.map((r) => r.checkName)
742 );
1e162a8Claude743 if (!decision.allowed) {
744 return c.redirect(
745 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
746 decision.reasons.join(" ")
747 )}`
748 );
749 }
750 }
751
e883329Claude752 // Attempt the merge — with auto conflict resolution if needed
753 const repoDir = getRepoPath(ownerName, repoName);
754 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
755 const hasConflicts = mergeCheck && !mergeCheck.passed;
756
757 if (hasConflicts && isAiReviewEnabled()) {
758 // Use Claude to auto-resolve conflicts
759 const mergeResult = await mergeWithAutoResolve(
760 ownerName,
761 repoName,
762 pr.baseBranch,
763 pr.headBranch,
764 `Merge pull request #${pr.number}: ${pr.title}`
765 );
766
767 if (!mergeResult.success) {
768 return c.redirect(
769 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
770 );
771 }
772
773 // Post a comment about the auto-resolution
774 if (mergeResult.resolvedFiles.length > 0) {
775 await db.insert(prComments).values({
776 pullRequestId: pr.id,
777 authorId: user.id,
778 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
779 isAiReview: true,
780 });
781 }
782 } else {
783 // Standard merge — fast-forward or clean merge
784 const ffProc = Bun.spawn(
785 [
786 "git",
787 "update-ref",
788 `refs/heads/${pr.baseBranch}`,
789 `refs/heads/${pr.headBranch}`,
790 ],
791 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
792 );
793 const ffExit = await ffProc.exited;
794
795 if (ffExit !== 0) {
796 return c.redirect(
797 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
798 );
799 }
800 }
801
0074234Claude802 await db
803 .update(pullRequests)
804 .set({
805 state: "merged",
806 mergedAt: new Date(),
807 mergedBy: user.id,
808 updatedAt: new Date(),
809 })
810 .where(eq(pullRequests.id, pr.id));
811
d62fb36Claude812 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
813 // and auto-close each matching open issue with a back-link comment. Bounded
814 // to the same repo for v1 (cross-repo refs ignored). Failures never block
815 // the merge redirect.
816 try {
817 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
818 const refs = extractClosingRefsMulti([pr.title, pr.body]);
819 for (const n of refs) {
820 const [issue] = await db
821 .select()
822 .from(issues)
823 .where(
824 and(
825 eq(issues.repositoryId, resolved.repo.id),
826 eq(issues.number, n)
827 )
828 )
829 .limit(1);
830 if (!issue || issue.state !== "open") continue;
831 await db
832 .update(issues)
833 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
834 .where(eq(issues.id, issue.id));
835 await db.insert(issueComments).values({
836 issueId: issue.id,
837 authorId: user.id,
838 body: `Closed by pull request #${pr.number}.`,
839 });
840 }
841 } catch {
842 // Never block the merge on close-keyword failures.
843 }
844
0074234Claude845 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
846 }
847);
848
6fc53bdClaude849// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
850// hasn't run yet on this PR.
851pulls.post(
852 "/:owner/:repo/pulls/:number/ready",
853 softAuth,
854 requireAuth,
855 async (c) => {
856 const { owner: ownerName, repo: repoName } = c.req.param();
857 const prNum = parseInt(c.req.param("number"), 10);
858 const user = c.get("user")!;
859
860 const resolved = await resolveRepo(ownerName, repoName);
861 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
862
863 const [pr] = await db
864 .select()
865 .from(pullRequests)
866 .where(
867 and(
868 eq(pullRequests.repositoryId, resolved.repo.id),
869 eq(pullRequests.number, prNum)
870 )
871 )
872 .limit(1);
873 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
874
875 // Only the author or repo owner can toggle draft state.
876 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
877 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
878 }
879
880 if (pr.state === "open" && pr.isDraft) {
881 await db
882 .update(pullRequests)
883 .set({ isDraft: false, updatedAt: new Date() })
884 .where(eq(pullRequests.id, pr.id));
885
886 if (isAiReviewEnabled()) {
887 triggerAiReview(
888 ownerName,
889 repoName,
890 pr.id,
891 pr.title,
892 pr.body,
893 pr.baseBranch,
894 pr.headBranch
895 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
896 }
897 }
898
899 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
900 }
901);
902
903// Convert a PR back to draft.
904pulls.post(
905 "/:owner/:repo/pulls/:number/draft",
906 softAuth,
907 requireAuth,
908 async (c) => {
909 const { owner: ownerName, repo: repoName } = c.req.param();
910 const prNum = parseInt(c.req.param("number"), 10);
911 const user = c.get("user")!;
912
913 const resolved = await resolveRepo(ownerName, repoName);
914 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
915
916 const [pr] = await db
917 .select()
918 .from(pullRequests)
919 .where(
920 and(
921 eq(pullRequests.repositoryId, resolved.repo.id),
922 eq(pullRequests.number, prNum)
923 )
924 )
925 .limit(1);
926 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
927
928 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
929 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
930 }
931
932 if (pr.state === "open" && !pr.isDraft) {
933 await db
934 .update(pullRequests)
935 .set({ isDraft: true, updatedAt: new Date() })
936 .where(eq(pullRequests.id, pr.id));
937 }
938
0074234Claude939 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
940 }
941);
942
943// Close PR
944pulls.post(
945 "/:owner/:repo/pulls/:number/close",
946 softAuth,
947 requireAuth,
948 async (c) => {
949 const { owner: ownerName, repo: repoName } = c.req.param();
950 const prNum = parseInt(c.req.param("number"), 10);
951
952 const resolved = await resolveRepo(ownerName, repoName);
953 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
954
955 await db
956 .update(pullRequests)
957 .set({
958 state: "closed",
959 closedAt: new Date(),
960 updatedAt: new Date(),
961 })
962 .where(
963 and(
964 eq(pullRequests.repositoryId, resolved.repo.id),
965 eq(pullRequests.number, prNum)
966 )
967 );
968
969 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
970 }
971);
972
973export default pulls;