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.tsxBlame1038 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";
b584e52Claude22import { liveCommentBannerScript } from "../lib/sse-client";
0074234Claude23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude25import { requireRepoAccess } from "../middleware/repo-access";
0316dbbClaude26import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
27import { triggerPrTriage } from "../lib/pr-triage";
28import { runAllGateChecks } from "../lib/gate";
29import type { GateCheckResult } from "../lib/gate";
30import {
31 matchProtection,
32 countHumanApprovals,
33 listRequiredChecks,
34 passingCheckNames,
35 evaluateProtection,
36} from "../lib/branch-protection";
37import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude38import {
39 listBranches,
40 getRepoPath,
e883329Claude41 resolveRef,
0074234Claude42} from "../git/repository";
43import type { GitDiffFile } from "../git/repository";
44import { html } from "hono/html";
1e162a8Claude45import {
bb0f894Claude46 Flex,
47 Container,
48 Badge,
49 Button,
50 LinkButton,
51 Form,
52 FormGroup,
53 Input,
54 TextArea,
55 Select,
56 EmptyState,
57 FilterTabs,
58 TabNav,
59 List,
60 ListItem,
61 Text,
62 Alert,
63 MarkdownContent,
64 CommentBox,
65 formatRelative,
66} from "../views/ui";
0074234Claude67
68const pulls = new Hono<AuthEnv>();
69
70async function resolveRepo(ownerName: string, repoName: string) {
71 const [owner] = await db
72 .select()
73 .from(users)
74 .where(eq(users.username, ownerName))
75 .limit(1);
76 if (!owner) return null;
77 const [repo] = await db
78 .select()
79 .from(repositories)
80 .where(
81 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
82 )
83 .limit(1);
84 if (!repo) return null;
85 return { owner, repo };
86}
87
88// PR Nav helper
89const PrNav = ({
90 owner,
91 repo,
92 active,
93}: {
94 owner: string;
95 repo: string;
96 active: "code" | "issues" | "pulls" | "commits";
97}) => (
bb0f894Claude98 <TabNav
99 tabs={[
100 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
101 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
102 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
103 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
104 ]}
105 />
0074234Claude106);
107
108// List PRs
04f6b7fClaude109pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude110 const { owner: ownerName, repo: repoName } = c.req.param();
111 const user = c.get("user");
112 const state = c.req.query("state") || "open";
113
114 const resolved = await resolveRepo(ownerName, repoName);
115 if (!resolved) return c.notFound();
116
6fc53bdClaude117 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
118 const stateFilter =
119 state === "draft"
120 ? and(
121 eq(pullRequests.state, "open"),
122 eq(pullRequests.isDraft, true)
123 )
124 : eq(pullRequests.state, state);
125
0074234Claude126 const prList = await db
127 .select({
128 pr: pullRequests,
129 author: { username: users.username },
130 })
131 .from(pullRequests)
132 .innerJoin(users, eq(pullRequests.authorId, users.id))
133 .where(
6fc53bdClaude134 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude135 )
136 .orderBy(desc(pullRequests.createdAt));
137
138 const [counts] = await db
139 .select({
140 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude141 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude142 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
143 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
144 })
145 .from(pullRequests)
146 .where(eq(pullRequests.repositoryId, resolved.repo.id));
147
148 return c.html(
149 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
150 <RepoHeader owner={ownerName} repo={repoName} />
151 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude152 <Flex justify="space-between" align="center" style="margin-bottom:16px">
153 <FilterTabs
154 tabs={[
155 { label: `${counts?.open ?? 0} Open`, href: `/${ownerName}/${repoName}/pulls?state=open`, active: state === "open" },
156 { label: `${counts?.merged ?? 0} Merged`, href: `/${ownerName}/${repoName}/pulls?state=merged`, active: state === "merged" },
157 { label: `${counts?.closed ?? 0} Closed`, href: `/${ownerName}/${repoName}/pulls?state=closed`, active: state === "closed" },
158 ]}
159 />
0074234Claude160 {user && (
bb0f894Claude161 <LinkButton href={`/${ownerName}/${repoName}/pulls/new`} variant="primary">
0074234Claude162 New pull request
bb0f894Claude163 </LinkButton>
0074234Claude164 )}
bb0f894Claude165 </Flex>
0074234Claude166 {prList.length === 0 ? (
bb0f894Claude167 <EmptyState>
0074234Claude168 <p>No {state} pull requests.</p>
bb0f894Claude169 </EmptyState>
0074234Claude170 ) : (
bb0f894Claude171 <List>
0074234Claude172 {prList.map(({ pr, author }) => (
bb0f894Claude173 <ListItem>
0074234Claude174 <div
175 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
176 >
177 {pr.state === "open"
178 ? "\u25CB"
179 : pr.state === "merged"
180 ? "\u2B8C"
181 : "\u2713"}
182 </div>
183 <div>
184 <div class="issue-title">
185 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
186 {pr.title}
187 </a>
188 </div>
189 <div class="issue-meta">
190 #{pr.number}{" "}
191 {pr.headBranch} → {pr.baseBranch}{" "}
192 by {author.username}{" "}
193 {formatRelative(pr.createdAt)}
194 </div>
195 </div>
bb0f894Claude196 </ListItem>
0074234Claude197 ))}
bb0f894Claude198 </List>
0074234Claude199 )}
200 </Layout>
201 );
202});
203
204// New PR form
205pulls.get(
206 "/:owner/:repo/pulls/new",
207 softAuth,
208 requireAuth,
04f6b7fClaude209 requireRepoAccess("write"),
0074234Claude210 async (c) => {
211 const { owner: ownerName, repo: repoName } = c.req.param();
212 const user = c.get("user")!;
213 const branches = await listBranches(ownerName, repoName);
214 const error = c.req.query("error");
215 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude216 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude217
218 return c.html(
219 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
220 <RepoHeader owner={ownerName} repo={repoName} />
221 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude222 <Container maxWidth={800}>
223 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude224 {error && (
bb0f894Claude225 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude226 )}
0316dbbClaude227 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
228 <Flex gap={12} align="center" style="margin-bottom: 16px">
229 <Select name="base">
0074234Claude230 {branches.map((b) => (
231 <option value={b} selected={b === defaultBase}>
232 {b}
233 </option>
234 ))}
bb0f894Claude235 </Select>
236 <Text muted>&larr;</Text>
237 <Select name="head">
0074234Claude238 {branches
239 .filter((b) => b !== defaultBase)
240 .concat(defaultBase === branches[0] ? [] : [branches[0]])
241 .map((b) => (
242 <option value={b}>{b}</option>
243 ))}
bb0f894Claude244 </Select>
245 </Flex>
246 <FormGroup>
247 <Input
0074234Claude248 name="title"
249 required
250 placeholder="Title"
bb0f894Claude251 style="font-size:16px;padding:10px 14px"
0074234Claude252 />
bb0f894Claude253 </FormGroup>
254 <FormGroup>
255 <TextArea
0074234Claude256 name="body"
257 rows={8}
258 placeholder="Description (Markdown supported)"
bb0f894Claude259 mono
0074234Claude260 />
bb0f894Claude261 </FormGroup>
262 <Button type="submit" variant="primary">
0074234Claude263 Create pull request
bb0f894Claude264 </Button>
265 </Form>
266 </Container>
0074234Claude267 </Layout>
268 );
269 }
270);
271
272// Create PR
273pulls.post(
274 "/:owner/:repo/pulls/new",
275 softAuth,
276 requireAuth,
04f6b7fClaude277 requireRepoAccess("write"),
0074234Claude278 async (c) => {
279 const { owner: ownerName, repo: repoName } = c.req.param();
280 const user = c.get("user")!;
281 const body = await c.req.parseBody();
282 const title = String(body.title || "").trim();
283 const prBody = String(body.body || "").trim();
284 const baseBranch = String(body.base || "main");
285 const headBranch = String(body.head || "");
286
287 if (!title || !headBranch) {
288 return c.redirect(
289 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
290 );
291 }
292
293 if (baseBranch === headBranch) {
294 return c.redirect(
295 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
296 );
297 }
298
299 const resolved = await resolveRepo(ownerName, repoName);
300 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
301
6fc53bdClaude302 const isDraft = String(body.draft || "") === "1";
303
0074234Claude304 const [pr] = await db
305 .insert(pullRequests)
306 .values({
307 repositoryId: resolved.repo.id,
308 authorId: user.id,
309 title,
310 body: prBody || null,
311 baseBranch,
312 headBranch,
6fc53bdClaude313 isDraft,
0074234Claude314 })
315 .returning();
316
6fc53bdClaude317 // Skip AI review on drafts — it runs again when the PR is marked ready.
318 if (!isDraft && isAiReviewEnabled()) {
e883329Claude319 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
320 (err) => console.error("[ai-review] Failed:", err)
321 );
322 }
323
3cbe3d6Claude324 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
325 triggerPrTriage({
326 ownerName,
327 repoName,
328 repositoryId: resolved.repo.id,
329 prId: pr.id,
330 prAuthorId: user.id,
331 title,
332 body: prBody,
333 baseBranch,
334 headBranch,
335 }).catch((err) => console.error("[pr-triage] Failed:", err));
336
0074234Claude337 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
338 }
339);
340
341// View single PR
04f6b7fClaude342pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude343 const { owner: ownerName, repo: repoName } = c.req.param();
344 const prNum = parseInt(c.req.param("number"), 10);
345 const user = c.get("user");
346 const tab = c.req.query("tab") || "conversation";
347
348 const resolved = await resolveRepo(ownerName, repoName);
349 if (!resolved) return c.notFound();
350
351 const [pr] = await db
352 .select()
353 .from(pullRequests)
354 .where(
355 and(
356 eq(pullRequests.repositoryId, resolved.repo.id),
357 eq(pullRequests.number, prNum)
358 )
359 )
360 .limit(1);
361
362 if (!pr) return c.notFound();
363
364 const [author] = await db
365 .select()
366 .from(users)
367 .where(eq(users.id, pr.authorId))
368 .limit(1);
369
370 const comments = await db
371 .select({
372 comment: prComments,
373 author: { username: users.username },
374 })
375 .from(prComments)
376 .innerJoin(users, eq(prComments.authorId, users.id))
377 .where(eq(prComments.pullRequestId, pr.id))
378 .orderBy(asc(prComments.createdAt));
379
6fc53bdClaude380 // Reactions for the PR body + each comment, in parallel.
381 const [prReactions, ...prCommentReactions] = await Promise.all([
382 summariseReactions("pr", pr.id, user?.id),
383 ...comments.map((row) =>
384 summariseReactions("pr_comment", row.comment.id, user?.id)
385 ),
386 ]);
387
0074234Claude388 const canManage =
389 user &&
390 (user.id === resolved.owner.id || user.id === pr.authorId);
391
e883329Claude392 const error = c.req.query("error");
393
394 // Get gate check status for open PRs
395 let gateChecks: GateCheckResult[] = [];
396 if (pr.state === "open") {
397 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
398 if (headSha) {
399 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
400 const aiApproved = aiComments.length === 0 || aiComments.some(
401 ({ comment }) => comment.body.includes("**Approved**")
402 );
403 const gateResult = await runAllGateChecks(
404 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
405 );
406 gateChecks = gateResult.checks;
407 }
408 }
409
0074234Claude410 // Get diff for "Files changed" tab
411 let diffRaw = "";
412 let diffFiles: GitDiffFile[] = [];
413 if (tab === "files") {
414 const repoDir = getRepoPath(ownerName, repoName);
415 const proc = Bun.spawn(
416 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
417 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
418 );
419 diffRaw = await new Response(proc.stdout).text();
420 await proc.exited;
421
422 const statProc = Bun.spawn(
423 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
424 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
425 );
426 const stat = await new Response(statProc.stdout).text();
427 await statProc.exited;
428
429 diffFiles = stat
430 .trim()
431 .split("\n")
432 .filter(Boolean)
433 .map((line) => {
434 const [add, del, filePath] = line.split("\t");
435 return {
436 path: filePath,
437 status: "modified",
438 additions: add === "-" ? 0 : parseInt(add, 10),
439 deletions: del === "-" ? 0 : parseInt(del, 10),
440 patch: "",
441 };
442 });
443 }
444
445 return c.html(
446 <Layout
447 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
448 user={user}
449 >
450 <RepoHeader owner={ownerName} repo={repoName} />
451 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b584e52Claude452 <div
453 id="live-comment-banner"
454 class="alert"
455 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
456 >
457 <strong class="js-live-count">0</strong> new comment(s) —{" "}
458 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
459 reload to view
460 </a>
461 </div>
462 <script
463 dangerouslySetInnerHTML={{
464 __html: liveCommentBannerScript({
465 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
466 bannerElementId: "live-comment-banner",
467 }),
468 }}
469 />
0074234Claude470 <div class="issue-detail">
471 <h2>
472 {pr.title}{" "}
bb0f894Claude473 <Text color="var(--text-muted)" weight={400}>
0074234Claude474 #{pr.number}
bb0f894Claude475 </Text>
0074234Claude476 </h2>
bb0f894Claude477 <Flex align="center" gap={8} style="margin:8px 0 20px">
478 <Badge
479 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
0074234Claude480 >
481 {pr.state === "open"
482 ? "\u25CB Open"
483 : pr.state === "merged"
484 ? "\u2B8C Merged"
485 : "\u2713 Closed"}
bb0f894Claude486 </Badge>
487 <Text size={14} muted>
488 <strong style="color:var(--text)">
0074234Claude489 {author?.username}
490 </strong>{" "}
491 wants to merge <code>{pr.headBranch}</code> into{" "}
492 <code>{pr.baseBranch}</code>
bb0f894Claude493 </Text>
494 </Flex>
495
496 <FilterTabs
497 tabs={[
498 {
499 label: "Conversation",
500 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
501 active: tab === "conversation",
502 },
503 {
504 label: "Files changed",
505 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
506 active: tab === "files",
507 },
508 ]}
509 />
0074234Claude510
511 {tab === "files" ? (
512 <DiffView raw={diffRaw} files={diffFiles} />
513 ) : (
514 <>
515 {pr.body && (
bb0f894Claude516 <CommentBox
517 author={author?.username ?? "unknown"}
518 date={pr.createdAt}
519 body={renderMarkdown(pr.body)}
520 />
0074234Claude521 )}
522
6fc53bdClaude523 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude524 <div
525 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
526 >
527 <div class="comment-header">
bb0f894Claude528 <Flex gap={8} align="center">
529 <strong>{commentAuthor.username}</strong>
530 {comment.isAiReview && (
531 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
532 AI Review
533 </Badge>
534 )}
535 <Text size={13} muted>
536 commented {formatRelative(comment.createdAt)}
537 </Text>
538 {comment.filePath && (
539 <Text size={11} mono style="margin-left:8px">
540 {comment.filePath}
541 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
542 </Text>
543 )}
544 </Flex>
6fc53bdClaude545 </div>
bb0f894Claude546 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude547 </div>
548 ))}
549
e883329Claude550 {error && (
551 <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)">
552 {decodeURIComponent(error)}
553 </div>
554 )}
555
556 {pr.state === "open" && gateChecks.length > 0 && (
557 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
558 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
559 {gateChecks.map((check) => (
560 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
561 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
562 {check.passed ? "\u2713" : "\u2717"}
563 </span>
564 <strong style="font-size: 13px">{check.name}</strong>
565 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
566 </div>
567 ))}
568 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
569 {gateChecks.every((c) => c.passed)
570 ? "All checks passed — ready to merge"
571 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
572 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
573 : "Some checks failed — resolve issues before merging"}
574 </div>
575 </div>
576 )}
577
0074234Claude578 {user && pr.state === "open" && (
579 <div style="margin-top: 20px">
0316dbbClaude580 <Form
e7e240eClaude581 method="post"
0074234Claude582 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
583 >
bb0f894Claude584 <FormGroup>
585 <TextArea
0074234Claude586 name="body"
587 rows={6}
588 required
589 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude590 mono
0074234Claude591 />
bb0f894Claude592 </FormGroup>
593 <Flex gap={8}>
594 <Button type="submit" variant="primary">
0074234Claude595 Comment
bb0f894Claude596 </Button>
0074234Claude597 {canManage && (
598 <>
599 <button
600 type="submit"
601 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
602 class="btn"
bb0f894Claude603 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
0074234Claude604 >
605 Merge pull request
606 </button>
bb0f894Claude607 <Button
0074234Claude608 type="submit"
bb0f894Claude609 variant="danger"
0074234Claude610 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
611 >
612 Close
bb0f894Claude613 </Button>
0074234Claude614 </>
615 )}
bb0f894Claude616 </Flex>
617 </Form>
0074234Claude618 </div>
619 )}
620 </>
621 )}
622 </div>
623 </Layout>
624 );
625});
626
627// Add comment to PR
628pulls.post(
629 "/:owner/:repo/pulls/:number/comment",
630 softAuth,
631 requireAuth,
04f6b7fClaude632 requireRepoAccess("write"),
0074234Claude633 async (c) => {
634 const { owner: ownerName, repo: repoName } = c.req.param();
635 const prNum = parseInt(c.req.param("number"), 10);
636 const user = c.get("user")!;
637 const body = await c.req.parseBody();
638 const commentBody = String(body.body || "").trim();
639
640 if (!commentBody) {
641 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
642 }
643
644 const resolved = await resolveRepo(ownerName, repoName);
645 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
646
647 const [pr] = await db
648 .select()
649 .from(pullRequests)
650 .where(
651 and(
652 eq(pullRequests.repositoryId, resolved.repo.id),
653 eq(pullRequests.number, prNum)
654 )
655 )
656 .limit(1);
657
658 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
659
d4ac5c3Claude660 const [inserted] = await db
661 .insert(prComments)
662 .values({
663 pullRequestId: pr.id,
664 authorId: user.id,
665 body: commentBody,
666 })
667 .returning();
668
669 // Live update: nudge any browser tabs subscribed to this PR.
670 if (inserted) {
671 try {
672 const { publish } = await import("../lib/sse");
673 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
674 event: "pr-comment",
675 data: {
676 pullRequestId: pr.id,
677 commentId: inserted.id,
678 authorId: user.id,
679 authorUsername: user.username,
680 },
681 });
682 } catch {
683 /* SSE is best-effort */
684 }
685 }
0074234Claude686
687 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
688 }
689);
690
e883329Claude691// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude692// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
693// but we keep it at "write" for v1 so trusted collaborators can ship.
694// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
695// surface. Branch-protection rules (evaluated below) are the current mechanism
696// for locking down merges further on specific branches.
0074234Claude697pulls.post(
698 "/:owner/:repo/pulls/:number/merge",
699 softAuth,
700 requireAuth,
04f6b7fClaude701 requireRepoAccess("write"),
0074234Claude702 async (c) => {
703 const { owner: ownerName, repo: repoName } = c.req.param();
704 const prNum = parseInt(c.req.param("number"), 10);
705 const user = c.get("user")!;
706
707 const resolved = await resolveRepo(ownerName, repoName);
708 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
709
710 const [pr] = await db
711 .select()
712 .from(pullRequests)
713 .where(
714 and(
715 eq(pullRequests.repositoryId, resolved.repo.id),
716 eq(pullRequests.number, prNum)
717 )
718 )
719 .limit(1);
720
721 if (!pr || pr.state !== "open") {
722 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
723 }
724
6fc53bdClaude725 // Draft PRs cannot be merged — must be marked ready first.
726 if (pr.isDraft) {
727 return c.redirect(
728 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
729 "This PR is a draft. Mark it as ready for review before merging."
730 )}`
731 );
732 }
733
e883329Claude734 // Resolve head SHA
735 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
736 if (!headSha) {
737 return c.redirect(
738 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
739 );
740 }
741
742 // Check if AI review approved this PR
743 const aiComments = await db
744 .select()
745 .from(prComments)
746 .where(
747 and(
748 eq(prComments.pullRequestId, pr.id),
749 eq(prComments.isAiReview, true)
750 )
751 );
752 const aiApproved = aiComments.length === 0 || aiComments.some(
753 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude754 );
e883329Claude755
756 // Run all green gate checks (GateTest + mergeability + AI review)
757 const gateResult = await runAllGateChecks(
758 ownerName,
759 repoName,
760 pr.baseBranch,
761 pr.headBranch,
762 headSha,
763 aiApproved
0074234Claude764 );
765
e883329Claude766 // If GateTest or AI review failed (hard blocks), reject the merge
767 const hardFailures = gateResult.checks.filter(
768 (check) => !check.passed && check.name !== "Merge check"
769 );
770 if (hardFailures.length > 0) {
771 const errorMsg = hardFailures
772 .map((f) => `${f.name}: ${f.details}`)
773 .join("; ");
0074234Claude774 return c.redirect(
e883329Claude775 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude776 );
777 }
778
1e162a8Claude779 // D5 — Branch-protection enforcement. Looks up the matching rule for the
780 // base branch and blocks the merge if requireAiApproval / requireGreenGates
781 // / requireHumanReview / requiredApprovals are not satisfied. Independent
782 // of repo-global settings, so owners can lock specific branches down
783 // further than the repo default.
784 const protectionRule = await matchProtection(
785 resolved.repo.id,
786 pr.baseBranch
787 );
788 if (protectionRule) {
789 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude790 const required = await listRequiredChecks(protectionRule.id);
791 const passingNames = required.length > 0
792 ? await passingCheckNames(resolved.repo.id, headSha)
793 : [];
794 const decision = evaluateProtection(
795 protectionRule,
796 {
797 aiApproved,
798 humanApprovalCount: humanApprovals,
799 gateResultGreen: hardFailures.length === 0,
800 hasFailedGates: hardFailures.length > 0,
801 passingCheckNames: passingNames,
802 },
803 required.map((r) => r.checkName)
804 );
1e162a8Claude805 if (!decision.allowed) {
806 return c.redirect(
807 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
808 decision.reasons.join(" ")
809 )}`
810 );
811 }
812 }
813
e883329Claude814 // Attempt the merge — with auto conflict resolution if needed
815 const repoDir = getRepoPath(ownerName, repoName);
816 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
817 const hasConflicts = mergeCheck && !mergeCheck.passed;
818
819 if (hasConflicts && isAiReviewEnabled()) {
820 // Use Claude to auto-resolve conflicts
821 const mergeResult = await mergeWithAutoResolve(
822 ownerName,
823 repoName,
824 pr.baseBranch,
825 pr.headBranch,
826 `Merge pull request #${pr.number}: ${pr.title}`
827 );
828
829 if (!mergeResult.success) {
830 return c.redirect(
831 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
832 );
833 }
834
835 // Post a comment about the auto-resolution
836 if (mergeResult.resolvedFiles.length > 0) {
837 await db.insert(prComments).values({
838 pullRequestId: pr.id,
839 authorId: user.id,
840 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
841 isAiReview: true,
842 });
843 }
844 } else {
845 // Standard merge — fast-forward or clean merge
846 const ffProc = Bun.spawn(
847 [
848 "git",
849 "update-ref",
850 `refs/heads/${pr.baseBranch}`,
851 `refs/heads/${pr.headBranch}`,
852 ],
853 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
854 );
855 const ffExit = await ffProc.exited;
856
857 if (ffExit !== 0) {
858 return c.redirect(
859 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
860 );
861 }
862 }
863
0074234Claude864 await db
865 .update(pullRequests)
866 .set({
867 state: "merged",
868 mergedAt: new Date(),
869 mergedBy: user.id,
870 updatedAt: new Date(),
871 })
872 .where(eq(pullRequests.id, pr.id));
873
d62fb36Claude874 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
875 // and auto-close each matching open issue with a back-link comment. Bounded
876 // to the same repo for v1 (cross-repo refs ignored). Failures never block
877 // the merge redirect.
878 try {
879 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
880 const refs = extractClosingRefsMulti([pr.title, pr.body]);
881 for (const n of refs) {
882 const [issue] = await db
883 .select()
884 .from(issues)
885 .where(
886 and(
887 eq(issues.repositoryId, resolved.repo.id),
888 eq(issues.number, n)
889 )
890 )
891 .limit(1);
892 if (!issue || issue.state !== "open") continue;
893 await db
894 .update(issues)
895 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
896 .where(eq(issues.id, issue.id));
897 await db.insert(issueComments).values({
898 issueId: issue.id,
899 authorId: user.id,
900 body: `Closed by pull request #${pr.number}.`,
901 });
902 }
903 } catch {
904 // Never block the merge on close-keyword failures.
905 }
906
0074234Claude907 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
908 }
909);
910
6fc53bdClaude911// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
912// hasn't run yet on this PR.
913pulls.post(
914 "/:owner/:repo/pulls/:number/ready",
915 softAuth,
916 requireAuth,
04f6b7fClaude917 requireRepoAccess("write"),
6fc53bdClaude918 async (c) => {
919 const { owner: ownerName, repo: repoName } = c.req.param();
920 const prNum = parseInt(c.req.param("number"), 10);
921 const user = c.get("user")!;
922
923 const resolved = await resolveRepo(ownerName, repoName);
924 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
925
926 const [pr] = await db
927 .select()
928 .from(pullRequests)
929 .where(
930 and(
931 eq(pullRequests.repositoryId, resolved.repo.id),
932 eq(pullRequests.number, prNum)
933 )
934 )
935 .limit(1);
936 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
937
938 // Only the author or repo owner can toggle draft state.
939 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
940 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
941 }
942
943 if (pr.state === "open" && pr.isDraft) {
944 await db
945 .update(pullRequests)
946 .set({ isDraft: false, updatedAt: new Date() })
947 .where(eq(pullRequests.id, pr.id));
948
949 if (isAiReviewEnabled()) {
950 triggerAiReview(
951 ownerName,
952 repoName,
953 pr.id,
954 pr.title,
0316dbbClaude955 pr.body || "",
6fc53bdClaude956 pr.baseBranch,
957 pr.headBranch
958 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
959 }
960 }
961
962 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
963 }
964);
965
966// Convert a PR back to draft.
967pulls.post(
968 "/:owner/:repo/pulls/:number/draft",
969 softAuth,
970 requireAuth,
04f6b7fClaude971 requireRepoAccess("write"),
6fc53bdClaude972 async (c) => {
973 const { owner: ownerName, repo: repoName } = c.req.param();
974 const prNum = parseInt(c.req.param("number"), 10);
975 const user = c.get("user")!;
976
977 const resolved = await resolveRepo(ownerName, repoName);
978 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
979
980 const [pr] = await db
981 .select()
982 .from(pullRequests)
983 .where(
984 and(
985 eq(pullRequests.repositoryId, resolved.repo.id),
986 eq(pullRequests.number, prNum)
987 )
988 )
989 .limit(1);
990 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
991
992 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
993 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
994 }
995
996 if (pr.state === "open" && !pr.isDraft) {
997 await db
998 .update(pullRequests)
999 .set({ isDraft: true, updatedAt: new Date() })
1000 .where(eq(pullRequests.id, pr.id));
1001 }
1002
1003 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1004 }
1005);
1006
0074234Claude1007// Close PR
1008pulls.post(
1009 "/:owner/:repo/pulls/:number/close",
1010 softAuth,
1011 requireAuth,
04f6b7fClaude1012 requireRepoAccess("write"),
0074234Claude1013 async (c) => {
1014 const { owner: ownerName, repo: repoName } = c.req.param();
1015 const prNum = parseInt(c.req.param("number"), 10);
1016
1017 const resolved = await resolveRepo(ownerName, repoName);
1018 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1019
1020 await db
1021 .update(pullRequests)
1022 .set({
1023 state: "closed",
1024 closedAt: new Date(),
1025 updatedAt: new Date(),
1026 })
1027 .where(
1028 and(
1029 eq(pullRequests.repositoryId, resolved.repo.id),
1030 eq(pullRequests.number, prNum)
1031 )
1032 );
1033
1034 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1035 }
1036);
1037
1038export default pulls;