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