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.tsxBlame1085 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,
13} from "../db/schema";
14import { Layout } from "../views/layout";
15import { RepoHeader, DiffView } from "../views/components";
6fc53bdClaude16import { ReactionsBar } from "../views/reactions";
17import { summariseReactions } from "../lib/reactions";
0074234Claude18import { renderMarkdown } from "../lib/markdown";
19import { softAuth, requireAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21import {
22 listBranches,
23 getRepoPath,
e883329Claude24 resolveRef,
0074234Claude25} from "../git/repository";
26import type { GitDiffFile } from "../git/repository";
27import { html } from "hono/html";
e883329Claude28import { reviewDiff, isAiReviewEnabled } from "../lib/ai-review";
29import { mergeWithAutoResolve } from "../lib/merge-resolver";
30import { runAllGateChecks, type GateCheckResult } from "../lib/gate";
0074234Claude31
32const pulls = new Hono<AuthEnv>();
33
34async function resolveRepo(ownerName: string, repoName: string) {
35 const [owner] = await db
36 .select()
37 .from(users)
38 .where(eq(users.username, ownerName))
39 .limit(1);
40 if (!owner) return null;
41 const [repo] = await db
42 .select()
43 .from(repositories)
44 .where(
45 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
46 )
47 .limit(1);
48 if (!repo) return null;
49 return { owner, repo };
50}
51
52// PR Nav helper
53const PrNav = ({
54 owner,
55 repo,
56 active,
57}: {
58 owner: string;
59 repo: string;
60 active: "code" | "issues" | "pulls" | "commits";
61}) => (
62 <div class="repo-nav">
63 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
64 Code
65 </a>
66 <a
67 href={`/${owner}/${repo}/issues`}
68 class={active === "issues" ? "active" : ""}
69 >
70 Issues
71 </a>
72 <a
73 href={`/${owner}/${repo}/pulls`}
74 class={active === "pulls" ? "active" : ""}
75 >
76 Pull Requests
77 </a>
78 <a
79 href={`/${owner}/${repo}/commits`}
80 class={active === "commits" ? "active" : ""}
81 >
82 Commits
83 </a>
84 </div>
85);
86
87// List PRs
88pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
89 const { owner: ownerName, repo: repoName } = c.req.param();
90 const user = c.get("user");
91 const state = c.req.query("state") || "open";
92
93 const resolved = await resolveRepo(ownerName, repoName);
94 if (!resolved) return c.notFound();
95
6fc53bdClaude96 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
97 const stateFilter =
98 state === "draft"
99 ? and(
100 eq(pullRequests.state, "open"),
101 eq(pullRequests.isDraft, true)
102 )
103 : eq(pullRequests.state, state);
104
0074234Claude105 const prList = await db
106 .select({
107 pr: pullRequests,
108 author: { username: users.username },
109 })
110 .from(pullRequests)
111 .innerJoin(users, eq(pullRequests.authorId, users.id))
112 .where(
6fc53bdClaude113 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude114 )
115 .orderBy(desc(pullRequests.createdAt));
116
117 const [counts] = await db
118 .select({
119 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude120 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude121 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
122 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
123 })
124 .from(pullRequests)
125 .where(eq(pullRequests.repositoryId, resolved.repo.id));
126
127 return c.html(
128 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
129 <RepoHeader owner={ownerName} repo={repoName} />
130 <PrNav owner={ownerName} repo={repoName} active="pulls" />
131 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
132 <div class="issue-tabs">
133 <a
134 href={`/${ownerName}/${repoName}/pulls?state=open`}
135 class={state === "open" ? "active" : ""}
136 >
137 {counts?.open ?? 0} Open
138 </a>
6fc53bdClaude139 <a
140 href={`/${ownerName}/${repoName}/pulls?state=draft`}
141 class={state === "draft" ? "active" : ""}
142 >
143 {counts?.draft ?? 0} Draft
144 </a>
0074234Claude145 <a
146 href={`/${ownerName}/${repoName}/pulls?state=merged`}
147 class={state === "merged" ? "active" : ""}
148 >
149 {counts?.merged ?? 0} Merged
150 </a>
151 <a
152 href={`/${ownerName}/${repoName}/pulls?state=closed`}
153 class={state === "closed" ? "active" : ""}
154 >
155 {counts?.closed ?? 0} Closed
156 </a>
157 </div>
158 {user && (
159 <a
160 href={`/${ownerName}/${repoName}/pulls/new`}
161 class="btn btn-primary"
162 >
163 New pull request
164 </a>
165 )}
166 </div>
167 {prList.length === 0 ? (
168 <div class="empty-state">
169 <p>No {state} pull requests.</p>
170 </div>
171 ) : (
172 <div class="issue-list">
6fc53bdClaude173 {prList.map(({ pr, author }) => {
174 const isDraft = pr.state === "open" && pr.isDraft;
175 const stateClass = isDraft
176 ? "state-draft"
177 : pr.state === "open"
178 ? "state-open"
179 : pr.state === "merged"
180 ? "state-merged"
181 : "state-closed";
182 const stateIcon = isDraft
183 ? "\u270E"
184 : pr.state === "open"
185 ? "\u25CB"
186 : pr.state === "merged"
187 ? "\u2B8C"
188 : "\u2713";
189 return (
190 <div class="issue-item">
191 <div class={`issue-state-icon ${stateClass}`}>{stateIcon}</div>
192 <div>
193 <div class="issue-title">
194 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
195 {pr.title}
196 </a>
197 {isDraft && (
198 <span class="issue-badge draft-badge" style="margin-left: 8px; font-size: 11px; padding: 2px 8px">
199 Draft
200 </span>
201 )}
202 </div>
203 <div class="issue-meta">
204 #{pr.number}{" "}
205 {pr.headBranch} → {pr.baseBranch}{" "}
206 by {author.username}{" "}
207 {formatRelative(pr.createdAt)}
208 </div>
0074234Claude209 </div>
210 </div>
6fc53bdClaude211 );
212 })}
0074234Claude213 </div>
214 )}
215 </Layout>
216 );
217});
218
219// New PR form
220pulls.get(
221 "/:owner/:repo/pulls/new",
222 softAuth,
223 requireAuth,
224 async (c) => {
225 const { owner: ownerName, repo: repoName } = c.req.param();
226 const user = c.get("user")!;
227 const branches = await listBranches(ownerName, repoName);
228 const error = c.req.query("error");
229 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
230
231 return c.html(
232 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
233 <RepoHeader owner={ownerName} repo={repoName} />
234 <PrNav owner={ownerName} repo={repoName} active="pulls" />
235 <div style="max-width: 800px">
236 <h2 style="margin-bottom: 16px">Open a pull request</h2>
237 {error && (
238 <div class="auth-error">{decodeURIComponent(error)}</div>
239 )}
240 <form method="POST" action={`/${ownerName}/${repoName}/pulls/new`}>
241 <div style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px">
242 <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">
243 {branches.map((b) => (
244 <option value={b} selected={b === defaultBase}>
245 {b}
246 </option>
247 ))}
248 </select>
249 <span style="color: var(--text-muted)">&larr;</span>
250 <select name="head" style="padding: 6px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px">
251 {branches
252 .filter((b) => b !== defaultBase)
253 .concat(defaultBase === branches[0] ? [] : [branches[0]])
254 .map((b) => (
255 <option value={b}>{b}</option>
256 ))}
257 </select>
258 </div>
259 <div class="form-group">
260 <input
261 type="text"
262 name="title"
263 required
264 placeholder="Title"
265 style="font-size: 16px; padding: 10px 14px"
266 />
267 </div>
268 <div class="form-group">
269 <textarea
270 name="body"
271 rows={8}
272 placeholder="Description (Markdown supported)"
273 style="font-family: var(--font-mono); font-size: 13px"
274 />
275 </div>
6fc53bdClaude276 <div style="display: flex; gap: 8px">
277 <button type="submit" class="btn btn-primary">
278 Create pull request
279 </button>
280 <button
281 type="submit"
282 name="draft"
283 value="1"
284 class="btn"
285 title="Create a draft PR — skips AI review and cannot be merged until marked ready"
286 >
287 Create draft
288 </button>
289 </div>
0074234Claude290 </form>
291 </div>
292 </Layout>
293 );
294 }
295);
296
297// Create PR
298pulls.post(
299 "/:owner/:repo/pulls/new",
300 softAuth,
301 requireAuth,
302 async (c) => {
303 const { owner: ownerName, repo: repoName } = c.req.param();
304 const user = c.get("user")!;
305 const body = await c.req.parseBody();
306 const title = String(body.title || "").trim();
307 const prBody = String(body.body || "").trim();
308 const baseBranch = String(body.base || "main");
309 const headBranch = String(body.head || "");
310
311 if (!title || !headBranch) {
312 return c.redirect(
313 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
314 );
315 }
316
317 if (baseBranch === headBranch) {
318 return c.redirect(
319 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
320 );
321 }
322
323 const resolved = await resolveRepo(ownerName, repoName);
324 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
325
6fc53bdClaude326 const isDraft = String(body.draft || "") === "1";
327
0074234Claude328 const [pr] = await db
329 .insert(pullRequests)
330 .values({
331 repositoryId: resolved.repo.id,
332 authorId: user.id,
333 title,
334 body: prBody || null,
335 baseBranch,
336 headBranch,
6fc53bdClaude337 isDraft,
0074234Claude338 })
339 .returning();
340
6fc53bdClaude341 // Skip AI review on drafts — it runs again when the PR is marked ready.
342 if (!isDraft && isAiReviewEnabled()) {
e883329Claude343 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
344 (err) => console.error("[ai-review] Failed:", err)
345 );
346 }
347
0074234Claude348 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
349 }
350);
351
352// View single PR
353pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
354 const { owner: ownerName, repo: repoName } = c.req.param();
355 const prNum = parseInt(c.req.param("number"), 10);
356 const user = c.get("user");
357 const tab = c.req.query("tab") || "conversation";
358
359 const resolved = await resolveRepo(ownerName, repoName);
360 if (!resolved) return c.notFound();
361
362 const [pr] = await db
363 .select()
364 .from(pullRequests)
365 .where(
366 and(
367 eq(pullRequests.repositoryId, resolved.repo.id),
368 eq(pullRequests.number, prNum)
369 )
370 )
371 .limit(1);
372
373 if (!pr) return c.notFound();
374
375 const [author] = await db
376 .select()
377 .from(users)
378 .where(eq(users.id, pr.authorId))
379 .limit(1);
380
381 const comments = await db
382 .select({
383 comment: prComments,
384 author: { username: users.username },
385 })
386 .from(prComments)
387 .innerJoin(users, eq(prComments.authorId, users.id))
388 .where(eq(prComments.pullRequestId, pr.id))
389 .orderBy(asc(prComments.createdAt));
390
6fc53bdClaude391 // Reactions for the PR body + each comment, in parallel.
392 const [prReactions, ...prCommentReactions] = await Promise.all([
393 summariseReactions("pr", pr.id, user?.id),
394 ...comments.map((row) =>
395 summariseReactions("pr_comment", row.comment.id, user?.id)
396 ),
397 ]);
398
0074234Claude399 const canManage =
400 user &&
401 (user.id === resolved.owner.id || user.id === pr.authorId);
402
e883329Claude403 const error = c.req.query("error");
404
405 // Get gate check status for open PRs
406 let gateChecks: GateCheckResult[] = [];
407 if (pr.state === "open") {
408 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
409 if (headSha) {
410 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
411 const aiApproved = aiComments.length === 0 || aiComments.some(
412 ({ comment }) => comment.body.includes("**Approved**")
413 );
414 const gateResult = await runAllGateChecks(
415 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
416 );
417 gateChecks = gateResult.checks;
418 }
419 }
420
0074234Claude421 // Get diff for "Files changed" tab
422 let diffRaw = "";
423 let diffFiles: GitDiffFile[] = [];
424 if (tab === "files") {
425 const repoDir = getRepoPath(ownerName, repoName);
426 const proc = Bun.spawn(
427 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
428 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
429 );
430 diffRaw = await new Response(proc.stdout).text();
431 await proc.exited;
432
433 const statProc = Bun.spawn(
434 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
435 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
436 );
437 const stat = await new Response(statProc.stdout).text();
438 await statProc.exited;
439
440 diffFiles = stat
441 .trim()
442 .split("\n")
443 .filter(Boolean)
444 .map((line) => {
445 const [add, del, filePath] = line.split("\t");
446 return {
447 path: filePath,
448 status: "modified",
449 additions: add === "-" ? 0 : parseInt(add, 10),
450 deletions: del === "-" ? 0 : parseInt(del, 10),
451 patch: "",
452 };
453 });
454 }
455
456 return c.html(
457 <Layout
458 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
459 user={user}
460 >
461 <RepoHeader owner={ownerName} repo={repoName} />
462 <PrNav owner={ownerName} repo={repoName} active="pulls" />
463 <div class="issue-detail">
464 <h2>
465 {pr.title}{" "}
466 <span style="color: var(--text-muted); font-weight: 400">
467 #{pr.number}
468 </span>
469 </h2>
470 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
6fc53bdClaude471 {pr.state === "open" && pr.isDraft ? (
472 <span class="issue-badge draft-badge">
473 {"\u270E Draft"}
474 </span>
475 ) : (
476 <span
477 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
478 >
479 {pr.state === "open"
480 ? "\u25CB Open"
481 : pr.state === "merged"
482 ? "\u2B8C Merged"
483 : "\u2713 Closed"}
484 </span>
485 )}
0074234Claude486 <span style="color: var(--text-muted); font-size: 14px">
487 <strong style="color: var(--text)">
488 {author?.username}
489 </strong>{" "}
490 wants to merge <code>{pr.headBranch}</code> into{" "}
491 <code>{pr.baseBranch}</code>
492 </span>
493 </div>
494
495 <div class="issue-tabs" style="margin-bottom: 20px">
496 <a
497 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
498 class={tab === "conversation" ? "active" : ""}
499 >
500 Conversation
501 </a>
502 <a
503 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
504 class={tab === "files" ? "active" : ""}
505 >
506 Files changed
507 </a>
508 </div>
509
510 {tab === "files" ? (
511 <DiffView raw={diffRaw} files={diffFiles} />
512 ) : (
513 <>
514 {pr.body && (
515 <div class="issue-comment-box">
516 <div class="comment-header">
517 <strong>{author?.username}</strong> commented{" "}
518 {formatRelative(pr.createdAt)}
519 </div>
520 <div class="markdown-body">
521 {html([renderMarkdown(pr.body)] as unknown as TemplateStringsArray)}
522 </div>
6fc53bdClaude523 <div style="padding: 0 16px 12px">
524 <ReactionsBar
525 targetType="pr"
526 targetId={pr.id}
527 summaries={prReactions}
528 canReact={!!user}
529 />
530 </div>
0074234Claude531 </div>
532 )}
533
6fc53bdClaude534 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude535 <div
536 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
537 >
538 <div class="comment-header">
539 <strong>{commentAuthor.username}</strong>
540 {comment.isAiReview && (
541 <span class="badge" style="margin-left: 8px; background: rgba(31, 111, 235, 0.15); color: var(--text-link); border-color: var(--accent)">
542 AI Review
543 </span>
544 )}
545 {" "}
546 commented {formatRelative(comment.createdAt)}
547 {comment.filePath && (
548 <span style="margin-left: 8px; font-family: var(--font-mono); font-size: 11px">
549 {comment.filePath}
550 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
551 </span>
552 )}
553 </div>
554 <div class="markdown-body">
555 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
556 </div>
6fc53bdClaude557 <div style="padding: 0 16px 12px">
558 <ReactionsBar
559 targetType="pr_comment"
560 targetId={comment.id}
561 summaries={prCommentReactions[i] || []}
562 canReact={!!user}
563 />
564 </div>
0074234Claude565 </div>
566 ))}
567
e883329Claude568 {error && (
569 <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)">
570 {decodeURIComponent(error)}
571 </div>
572 )}
573
574 {pr.state === "open" && gateChecks.length > 0 && (
575 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
576 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
577 {gateChecks.map((check) => (
578 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
579 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
580 {check.passed ? "\u2713" : "\u2717"}
581 </span>
582 <strong style="font-size: 13px">{check.name}</strong>
583 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
584 </div>
585 ))}
586 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
587 {gateChecks.every((c) => c.passed)
588 ? "All checks passed — ready to merge"
589 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
590 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
591 : "Some checks failed — resolve issues before merging"}
592 </div>
593 </div>
594 )}
595
0074234Claude596 {user && pr.state === "open" && (
597 <div style="margin-top: 20px">
598 <form
599 method="POST"
600 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
601 >
602 <div class="form-group">
603 <textarea
604 name="body"
605 rows={6}
606 required
607 placeholder="Leave a comment... (Markdown supported)"
608 style="font-family: var(--font-mono); font-size: 13px"
609 />
610 </div>
611 <div style="display: flex; gap: 8px">
612 <button type="submit" class="btn btn-primary">
613 Comment
614 </button>
615 {canManage && (
616 <>
6fc53bdClaude617 {pr.isDraft ? (
618 <button
619 type="submit"
620 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
621 class="btn"
622 style="background: rgba(63, 185, 80, 0.15); border-color: var(--green); color: var(--green)"
623 title="Mark this draft PR as ready for review — triggers AI review"
624 >
625 Ready for review
626 </button>
627 ) : (
628 <>
629 <button
630 type="submit"
631 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
632 class="btn"
633 style={`background: ${gateChecks.every((c) => c.passed) ? "rgba(63, 185, 80, 0.15)" : "rgba(248, 81, 73, 0.1)"}; border-color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}; color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}`}
634 >
635 {gateChecks.every((c) => c.passed)
636 ? "Merge pull request"
637 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
638 ? "Merge with auto-resolve"
639 : "Merge pull request"}
640 </button>
641 <button
642 type="submit"
643 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
644 class="btn"
645 title="Convert back to draft"
646 >
647 Convert to draft
648 </button>
649 </>
650 )}
0074234Claude651 <button
652 type="submit"
653 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
654 class="btn btn-danger"
655 >
656 Close
657 </button>
658 </>
659 )}
660 </div>
661 </form>
662 </div>
663 )}
664 </>
665 )}
666 </div>
667 </Layout>
668 );
669});
670
671// Add comment to PR
672pulls.post(
673 "/:owner/:repo/pulls/:number/comment",
674 softAuth,
675 requireAuth,
676 async (c) => {
677 const { owner: ownerName, repo: repoName } = c.req.param();
678 const prNum = parseInt(c.req.param("number"), 10);
679 const user = c.get("user")!;
680 const body = await c.req.parseBody();
681 const commentBody = String(body.body || "").trim();
682
683 if (!commentBody) {
684 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
685 }
686
687 const resolved = await resolveRepo(ownerName, repoName);
688 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
689
690 const [pr] = await db
691 .select()
692 .from(pullRequests)
693 .where(
694 and(
695 eq(pullRequests.repositoryId, resolved.repo.id),
696 eq(pullRequests.number, prNum)
697 )
698 )
699 .limit(1);
700
701 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
702
703 await db.insert(prComments).values({
704 pullRequestId: pr.id,
705 authorId: user.id,
706 body: commentBody,
707 });
708
709 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
710 }
711);
712
e883329Claude713// Merge PR — with green gate enforcement and auto conflict resolution
0074234Claude714pulls.post(
715 "/:owner/:repo/pulls/:number/merge",
716 softAuth,
717 requireAuth,
718 async (c) => {
719 const { owner: ownerName, repo: repoName } = c.req.param();
720 const prNum = parseInt(c.req.param("number"), 10);
721 const user = c.get("user")!;
722
723 const resolved = await resolveRepo(ownerName, repoName);
724 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
725
726 const [pr] = await db
727 .select()
728 .from(pullRequests)
729 .where(
730 and(
731 eq(pullRequests.repositoryId, resolved.repo.id),
732 eq(pullRequests.number, prNum)
733 )
734 )
735 .limit(1);
736
737 if (!pr || pr.state !== "open") {
738 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
739 }
740
6fc53bdClaude741 // Draft PRs cannot be merged — must be marked ready first.
742 if (pr.isDraft) {
743 return c.redirect(
744 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
745 "This PR is a draft. Mark it as ready for review before merging."
746 )}`
747 );
748 }
749
e883329Claude750 // Resolve head SHA
751 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
752 if (!headSha) {
753 return c.redirect(
754 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
755 );
756 }
757
758 // Check if AI review approved this PR
759 const aiComments = await db
760 .select()
761 .from(prComments)
762 .where(
763 and(
764 eq(prComments.pullRequestId, pr.id),
765 eq(prComments.isAiReview, true)
766 )
767 );
768 const aiApproved = aiComments.length === 0 || aiComments.some(
769 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude770 );
e883329Claude771
772 // Run all green gate checks (GateTest + mergeability + AI review)
773 const gateResult = await runAllGateChecks(
774 ownerName,
775 repoName,
776 pr.baseBranch,
777 pr.headBranch,
778 headSha,
779 aiApproved
0074234Claude780 );
781
e883329Claude782 // If GateTest or AI review failed (hard blocks), reject the merge
783 const hardFailures = gateResult.checks.filter(
784 (check) => !check.passed && check.name !== "Merge check"
785 );
786 if (hardFailures.length > 0) {
787 const errorMsg = hardFailures
788 .map((f) => `${f.name}: ${f.details}`)
789 .join("; ");
0074234Claude790 return c.redirect(
e883329Claude791 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude792 );
793 }
794
e883329Claude795 // Attempt the merge — with auto conflict resolution if needed
796 const repoDir = getRepoPath(ownerName, repoName);
797 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
798 const hasConflicts = mergeCheck && !mergeCheck.passed;
799
800 if (hasConflicts && isAiReviewEnabled()) {
801 // Use Claude to auto-resolve conflicts
802 const mergeResult = await mergeWithAutoResolve(
803 ownerName,
804 repoName,
805 pr.baseBranch,
806 pr.headBranch,
807 `Merge pull request #${pr.number}: ${pr.title}`
808 );
809
810 if (!mergeResult.success) {
811 return c.redirect(
812 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
813 );
814 }
815
816 // Post a comment about the auto-resolution
817 if (mergeResult.resolvedFiles.length > 0) {
818 await db.insert(prComments).values({
819 pullRequestId: pr.id,
820 authorId: user.id,
821 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
822 isAiReview: true,
823 });
824 }
825 } else {
826 // Standard merge — fast-forward or clean merge
827 const ffProc = Bun.spawn(
828 [
829 "git",
830 "update-ref",
831 `refs/heads/${pr.baseBranch}`,
832 `refs/heads/${pr.headBranch}`,
833 ],
834 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
835 );
836 const ffExit = await ffProc.exited;
837
838 if (ffExit !== 0) {
839 return c.redirect(
840 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
841 );
842 }
843 }
844
0074234Claude845 await db
846 .update(pullRequests)
847 .set({
848 state: "merged",
849 mergedAt: new Date(),
850 mergedBy: user.id,
851 updatedAt: new Date(),
852 })
853 .where(eq(pullRequests.id, pr.id));
854
855 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
856 }
857);
858
6fc53bdClaude859// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
860// hasn't run yet on this PR.
861pulls.post(
862 "/:owner/:repo/pulls/:number/ready",
863 softAuth,
864 requireAuth,
865 async (c) => {
866 const { owner: ownerName, repo: repoName } = c.req.param();
867 const prNum = parseInt(c.req.param("number"), 10);
868 const user = c.get("user")!;
869
870 const resolved = await resolveRepo(ownerName, repoName);
871 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
872
873 const [pr] = await db
874 .select()
875 .from(pullRequests)
876 .where(
877 and(
878 eq(pullRequests.repositoryId, resolved.repo.id),
879 eq(pullRequests.number, prNum)
880 )
881 )
882 .limit(1);
883 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
884
885 // Only the author or repo owner can toggle draft state.
886 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
887 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
888 }
889
890 if (pr.state === "open" && pr.isDraft) {
891 await db
892 .update(pullRequests)
893 .set({ isDraft: false, updatedAt: new Date() })
894 .where(eq(pullRequests.id, pr.id));
895
896 if (isAiReviewEnabled()) {
897 triggerAiReview(
898 ownerName,
899 repoName,
900 pr.id,
901 pr.title,
902 pr.body,
903 pr.baseBranch,
904 pr.headBranch
905 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
906 }
907 }
908
909 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
910 }
911);
912
913// Convert a PR back to draft.
914pulls.post(
915 "/:owner/:repo/pulls/:number/draft",
916 softAuth,
917 requireAuth,
918 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 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
939 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
940 }
941
942 if (pr.state === "open" && !pr.isDraft) {
943 await db
944 .update(pullRequests)
945 .set({ isDraft: true, updatedAt: new Date() })
946 .where(eq(pullRequests.id, pr.id));
947 }
948
949 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
950 }
951);
952
0074234Claude953// Close PR
954pulls.post(
955 "/:owner/:repo/pulls/:number/close",
956 softAuth,
957 requireAuth,
958 async (c) => {
959 const { owner: ownerName, repo: repoName } = c.req.param();
960 const prNum = parseInt(c.req.param("number"), 10);
961
962 const resolved = await resolveRepo(ownerName, repoName);
963 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
964
965 await db
966 .update(pullRequests)
967 .set({
968 state: "closed",
969 closedAt: new Date(),
970 updatedAt: new Date(),
971 })
972 .where(
973 and(
974 eq(pullRequests.repositoryId, resolved.repo.id),
975 eq(pullRequests.number, prNum)
976 )
977 );
978
979 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
980 }
981);
982
e883329Claude983/**
984 * Trigger AI code review asynchronously after PR creation.
985 * Runs the diff through Claude and posts review comments.
986 */
987async function triggerAiReview(
988 ownerName: string,
989 repoName: string,
990 prId: string,
991 title: string,
992 body: string | null,
993 baseBranch: string,
994 headBranch: string
995): Promise<void> {
996 const repoDir = getRepoPath(ownerName, repoName);
997
998 // Get the diff between branches
999 const proc = Bun.spawn(
1000 ["git", "diff", `${baseBranch}...${headBranch}`],
1001 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1002 );
1003 const diffText = await new Response(proc.stdout).text();
1004 await proc.exited;
1005
1006 if (!diffText.trim()) return;
1007
1008 const result = await reviewDiff(
1009 `${ownerName}/${repoName}`,
1010 title,
1011 body,
1012 baseBranch,
1013 headBranch,
1014 diffText
1015 );
1016
1017 // We need a system user for AI reviews — use the PR author for now
1018 // Get the PR to find the author
1019 const [pr] = await db
1020 .select()
1021 .from(pullRequests)
1022 .where(eq(pullRequests.id, prId))
1023 .limit(1);
1024
1025 if (!pr) return;
1026
1027 // Post summary comment
1028 const statusEmoji = result.approved ? "**Approved**" : "**Changes Requested**";
1029 let commentBody = `## AI Code Review ${statusEmoji}\n\n${result.summary}`;
1030
1031 if (result.comments.length > 0) {
1032 commentBody += "\n\n### Issues Found\n";
1033 for (const comment of result.comments) {
1034 const location = comment.filePath
1035 ? `\`${comment.filePath}${comment.lineNumber ? `:${comment.lineNumber}` : ""}\``
1036 : "";
1037 commentBody += `\n---\n${location}\n\n${comment.body}\n`;
1038 }
1039 }
1040
1041 await db.insert(prComments).values({
1042 pullRequestId: prId,
1043 authorId: pr.authorId,
1044 body: commentBody,
1045 isAiReview: true,
1046 });
1047
1048 // Post individual file-level comments
1049 for (const comment of result.comments) {
1050 if (comment.filePath) {
1051 await db.insert(prComments).values({
1052 pullRequestId: prId,
1053 authorId: pr.authorId,
1054 body: comment.body,
1055 isAiReview: true,
1056 filePath: comment.filePath,
1057 lineNumber: comment.lineNumber,
1058 });
1059 }
1060 }
1061
1062 console.log(
1063 `[ai-review] Review posted for PR ${prId}: ${result.approved ? "approved" : "changes requested"}, ${result.comments.length} comments`
1064 );
1065}
1066
0074234Claude1067function formatRelative(date: Date | string): string {
1068 const d = typeof date === "string" ? new Date(date) : date;
1069 const now = new Date();
1070 const diffMs = now.getTime() - d.getTime();
1071 const diffMins = Math.floor(diffMs / 60000);
1072 if (diffMins < 1) return "just now";
1073 if (diffMins < 60) return `${diffMins}m ago`;
1074 const diffHours = Math.floor(diffMins / 60);
1075 if (diffHours < 24) return `${diffHours}h ago`;
1076 const diffDays = Math.floor(diffHours / 24);
1077 if (diffDays < 30) return `${diffDays}d ago`;
1078 return d.toLocaleDateString("en-US", {
1079 month: "short",
1080 day: "numeric",
1081 year: "numeric",
1082 });
1083}
1084
1085export default pulls;