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.tsxBlame887 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";
16import { renderMarkdown } from "../lib/markdown";
17import { softAuth, requireAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import {
20 listBranches,
21 getRepoPath,
e883329Claude22 resolveRef,
0074234Claude23} from "../git/repository";
24import type { GitDiffFile } from "../git/repository";
25import { html } from "hono/html";
e883329Claude26import { reviewDiff, isAiReviewEnabled } from "../lib/ai-review";
27import { mergeWithAutoResolve } from "../lib/merge-resolver";
28import { runAllGateChecks, type GateCheckResult } from "../lib/gate";
0074234Claude29
30const pulls = new Hono<AuthEnv>();
31
32async function resolveRepo(ownerName: string, repoName: string) {
33 const [owner] = await db
34 .select()
35 .from(users)
36 .where(eq(users.username, ownerName))
37 .limit(1);
38 if (!owner) return null;
39 const [repo] = await db
40 .select()
41 .from(repositories)
42 .where(
43 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
44 )
45 .limit(1);
46 if (!repo) return null;
47 return { owner, repo };
48}
49
50// PR Nav helper
51const PrNav = ({
52 owner,
53 repo,
54 active,
55}: {
56 owner: string;
57 repo: string;
58 active: "code" | "issues" | "pulls" | "commits";
59}) => (
60 <div class="repo-nav">
61 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
62 Code
63 </a>
64 <a
65 href={`/${owner}/${repo}/issues`}
66 class={active === "issues" ? "active" : ""}
67 >
68 Issues
69 </a>
70 <a
71 href={`/${owner}/${repo}/pulls`}
72 class={active === "pulls" ? "active" : ""}
73 >
74 Pull Requests
75 </a>
76 <a
77 href={`/${owner}/${repo}/commits`}
78 class={active === "commits" ? "active" : ""}
79 >
80 Commits
81 </a>
82 </div>
83);
84
85// List PRs
86pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
87 const { owner: ownerName, repo: repoName } = c.req.param();
88 const user = c.get("user");
89 const state = c.req.query("state") || "open";
90
91 const resolved = await resolveRepo(ownerName, repoName);
92 if (!resolved) return c.notFound();
93
94 const prList = await db
95 .select({
96 pr: pullRequests,
97 author: { username: users.username },
98 })
99 .from(pullRequests)
100 .innerJoin(users, eq(pullRequests.authorId, users.id))
101 .where(
102 and(
103 eq(pullRequests.repositoryId, resolved.repo.id),
104 eq(pullRequests.state, state)
105 )
106 )
107 .orderBy(desc(pullRequests.createdAt));
108
109 const [counts] = await db
110 .select({
111 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
112 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
113 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
114 })
115 .from(pullRequests)
116 .where(eq(pullRequests.repositoryId, resolved.repo.id));
117
118 return c.html(
119 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
120 <RepoHeader owner={ownerName} repo={repoName} />
121 <PrNav owner={ownerName} repo={repoName} active="pulls" />
122 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
123 <div class="issue-tabs">
124 <a
125 href={`/${ownerName}/${repoName}/pulls?state=open`}
126 class={state === "open" ? "active" : ""}
127 >
128 {counts?.open ?? 0} Open
129 </a>
130 <a
131 href={`/${ownerName}/${repoName}/pulls?state=merged`}
132 class={state === "merged" ? "active" : ""}
133 >
134 {counts?.merged ?? 0} Merged
135 </a>
136 <a
137 href={`/${ownerName}/${repoName}/pulls?state=closed`}
138 class={state === "closed" ? "active" : ""}
139 >
140 {counts?.closed ?? 0} Closed
141 </a>
142 </div>
143 {user && (
144 <a
145 href={`/${ownerName}/${repoName}/pulls/new`}
146 class="btn btn-primary"
147 >
148 New pull request
149 </a>
150 )}
151 </div>
152 {prList.length === 0 ? (
153 <div class="empty-state">
154 <p>No {state} pull requests.</p>
155 </div>
156 ) : (
157 <div class="issue-list">
158 {prList.map(({ pr, author }) => (
159 <div class="issue-item">
160 <div
161 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
162 >
163 {pr.state === "open"
164 ? "\u25CB"
165 : pr.state === "merged"
166 ? "\u2B8C"
167 : "\u2713"}
168 </div>
169 <div>
170 <div class="issue-title">
171 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
172 {pr.title}
173 </a>
174 </div>
175 <div class="issue-meta">
176 #{pr.number}{" "}
177 {pr.headBranch} → {pr.baseBranch}{" "}
178 by {author.username}{" "}
179 {formatRelative(pr.createdAt)}
180 </div>
181 </div>
182 </div>
183 ))}
184 </div>
185 )}
186 </Layout>
187 );
188});
189
190// New PR form
191pulls.get(
192 "/:owner/:repo/pulls/new",
193 softAuth,
194 requireAuth,
195 async (c) => {
196 const { owner: ownerName, repo: repoName } = c.req.param();
197 const user = c.get("user")!;
198 const branches = await listBranches(ownerName, repoName);
199 const error = c.req.query("error");
200 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
201
202 return c.html(
203 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
204 <RepoHeader owner={ownerName} repo={repoName} />
205 <PrNav owner={ownerName} repo={repoName} active="pulls" />
206 <div style="max-width: 800px">
207 <h2 style="margin-bottom: 16px">Open a pull request</h2>
208 {error && (
209 <div class="auth-error">{decodeURIComponent(error)}</div>
210 )}
211 <form method="POST" action={`/${ownerName}/${repoName}/pulls/new`}>
212 <div style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px">
213 <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">
214 {branches.map((b) => (
215 <option value={b} selected={b === defaultBase}>
216 {b}
217 </option>
218 ))}
219 </select>
220 <span style="color: var(--text-muted)">&larr;</span>
221 <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">
222 {branches
223 .filter((b) => b !== defaultBase)
224 .concat(defaultBase === branches[0] ? [] : [branches[0]])
225 .map((b) => (
226 <option value={b}>{b}</option>
227 ))}
228 </select>
229 </div>
230 <div class="form-group">
231 <input
232 type="text"
233 name="title"
234 required
235 placeholder="Title"
236 style="font-size: 16px; padding: 10px 14px"
237 />
238 </div>
239 <div class="form-group">
240 <textarea
241 name="body"
242 rows={8}
243 placeholder="Description (Markdown supported)"
244 style="font-family: var(--font-mono); font-size: 13px"
245 />
246 </div>
247 <button type="submit" class="btn btn-primary">
248 Create pull request
249 </button>
250 </form>
251 </div>
252 </Layout>
253 );
254 }
255);
256
257// Create PR
258pulls.post(
259 "/:owner/:repo/pulls/new",
260 softAuth,
261 requireAuth,
262 async (c) => {
263 const { owner: ownerName, repo: repoName } = c.req.param();
264 const user = c.get("user")!;
265 const body = await c.req.parseBody();
266 const title = String(body.title || "").trim();
267 const prBody = String(body.body || "").trim();
268 const baseBranch = String(body.base || "main");
269 const headBranch = String(body.head || "");
270
271 if (!title || !headBranch) {
272 return c.redirect(
273 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
274 );
275 }
276
277 if (baseBranch === headBranch) {
278 return c.redirect(
279 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
280 );
281 }
282
283 const resolved = await resolveRepo(ownerName, repoName);
284 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
285
286 const [pr] = await db
287 .insert(pullRequests)
288 .values({
289 repositoryId: resolved.repo.id,
290 authorId: user.id,
291 title,
292 body: prBody || null,
293 baseBranch,
294 headBranch,
295 })
296 .returning();
297
e883329Claude298 // Trigger AI code review asynchronously
299 if (isAiReviewEnabled()) {
300 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
301 (err) => console.error("[ai-review] Failed:", err)
302 );
303 }
304
0074234Claude305 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
306 }
307);
308
309// View single PR
310pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
311 const { owner: ownerName, repo: repoName } = c.req.param();
312 const prNum = parseInt(c.req.param("number"), 10);
313 const user = c.get("user");
314 const tab = c.req.query("tab") || "conversation";
315
316 const resolved = await resolveRepo(ownerName, repoName);
317 if (!resolved) return c.notFound();
318
319 const [pr] = await db
320 .select()
321 .from(pullRequests)
322 .where(
323 and(
324 eq(pullRequests.repositoryId, resolved.repo.id),
325 eq(pullRequests.number, prNum)
326 )
327 )
328 .limit(1);
329
330 if (!pr) return c.notFound();
331
332 const [author] = await db
333 .select()
334 .from(users)
335 .where(eq(users.id, pr.authorId))
336 .limit(1);
337
338 const comments = await db
339 .select({
340 comment: prComments,
341 author: { username: users.username },
342 })
343 .from(prComments)
344 .innerJoin(users, eq(prComments.authorId, users.id))
345 .where(eq(prComments.pullRequestId, pr.id))
346 .orderBy(asc(prComments.createdAt));
347
348 const canManage =
349 user &&
350 (user.id === resolved.owner.id || user.id === pr.authorId);
351
e883329Claude352 const error = c.req.query("error");
353
354 // Get gate check status for open PRs
355 let gateChecks: GateCheckResult[] = [];
356 if (pr.state === "open") {
357 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
358 if (headSha) {
359 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
360 const aiApproved = aiComments.length === 0 || aiComments.some(
361 ({ comment }) => comment.body.includes("**Approved**")
362 );
363 const gateResult = await runAllGateChecks(
364 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
365 );
366 gateChecks = gateResult.checks;
367 }
368 }
369
0074234Claude370 // Get diff for "Files changed" tab
371 let diffRaw = "";
372 let diffFiles: GitDiffFile[] = [];
373 if (tab === "files") {
374 const repoDir = getRepoPath(ownerName, repoName);
375 const proc = Bun.spawn(
376 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
377 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
378 );
379 diffRaw = await new Response(proc.stdout).text();
380 await proc.exited;
381
382 const statProc = Bun.spawn(
383 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
384 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
385 );
386 const stat = await new Response(statProc.stdout).text();
387 await statProc.exited;
388
389 diffFiles = stat
390 .trim()
391 .split("\n")
392 .filter(Boolean)
393 .map((line) => {
394 const [add, del, filePath] = line.split("\t");
395 return {
396 path: filePath,
397 status: "modified",
398 additions: add === "-" ? 0 : parseInt(add, 10),
399 deletions: del === "-" ? 0 : parseInt(del, 10),
400 patch: "",
401 };
402 });
403 }
404
405 return c.html(
406 <Layout
407 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
408 user={user}
409 >
410 <RepoHeader owner={ownerName} repo={repoName} />
411 <PrNav owner={ownerName} repo={repoName} active="pulls" />
412 <div class="issue-detail">
413 <h2>
414 {pr.title}{" "}
415 <span style="color: var(--text-muted); font-weight: 400">
416 #{pr.number}
417 </span>
418 </h2>
419 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
420 <span
421 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
422 >
423 {pr.state === "open"
424 ? "\u25CB Open"
425 : pr.state === "merged"
426 ? "\u2B8C Merged"
427 : "\u2713 Closed"}
428 </span>
429 <span style="color: var(--text-muted); font-size: 14px">
430 <strong style="color: var(--text)">
431 {author?.username}
432 </strong>{" "}
433 wants to merge <code>{pr.headBranch}</code> into{" "}
434 <code>{pr.baseBranch}</code>
435 </span>
436 </div>
437
438 <div class="issue-tabs" style="margin-bottom: 20px">
439 <a
440 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
441 class={tab === "conversation" ? "active" : ""}
442 >
443 Conversation
444 </a>
445 <a
446 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
447 class={tab === "files" ? "active" : ""}
448 >
449 Files changed
450 </a>
451 </div>
452
453 {tab === "files" ? (
454 <DiffView raw={diffRaw} files={diffFiles} />
455 ) : (
456 <>
457 {pr.body && (
458 <div class="issue-comment-box">
459 <div class="comment-header">
460 <strong>{author?.username}</strong> commented{" "}
461 {formatRelative(pr.createdAt)}
462 </div>
463 <div class="markdown-body">
464 {html([renderMarkdown(pr.body)] as unknown as TemplateStringsArray)}
465 </div>
466 </div>
467 )}
468
469 {comments.map(({ comment, author: commentAuthor }) => (
470 <div
471 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
472 >
473 <div class="comment-header">
474 <strong>{commentAuthor.username}</strong>
475 {comment.isAiReview && (
476 <span class="badge" style="margin-left: 8px; background: rgba(31, 111, 235, 0.15); color: var(--text-link); border-color: var(--accent)">
477 AI Review
478 </span>
479 )}
480 {" "}
481 commented {formatRelative(comment.createdAt)}
482 {comment.filePath && (
483 <span style="margin-left: 8px; font-family: var(--font-mono); font-size: 11px">
484 {comment.filePath}
485 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
486 </span>
487 )}
488 </div>
489 <div class="markdown-body">
490 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
491 </div>
492 </div>
493 ))}
494
e883329Claude495 {error && (
496 <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)">
497 {decodeURIComponent(error)}
498 </div>
499 )}
500
501 {pr.state === "open" && gateChecks.length > 0 && (
502 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
503 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
504 {gateChecks.map((check) => (
505 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
506 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
507 {check.passed ? "\u2713" : "\u2717"}
508 </span>
509 <strong style="font-size: 13px">{check.name}</strong>
510 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
511 </div>
512 ))}
513 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
514 {gateChecks.every((c) => c.passed)
515 ? "All checks passed — ready to merge"
516 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
517 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
518 : "Some checks failed — resolve issues before merging"}
519 </div>
520 </div>
521 )}
522
0074234Claude523 {user && pr.state === "open" && (
524 <div style="margin-top: 20px">
525 <form
526 method="POST"
527 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
528 >
529 <div class="form-group">
530 <textarea
531 name="body"
532 rows={6}
533 required
534 placeholder="Leave a comment... (Markdown supported)"
535 style="font-family: var(--font-mono); font-size: 13px"
536 />
537 </div>
538 <div style="display: flex; gap: 8px">
539 <button type="submit" class="btn btn-primary">
540 Comment
541 </button>
542 {canManage && (
543 <>
544 <button
545 type="submit"
546 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
547 class="btn"
e883329Claude548 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)"}`}
0074234Claude549 >
e883329Claude550 {gateChecks.every((c) => c.passed)
551 ? "Merge pull request"
552 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
553 ? "Merge with auto-resolve"
554 : "Merge pull request"}
0074234Claude555 </button>
556 <button
557 type="submit"
558 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
559 class="btn btn-danger"
560 >
561 Close
562 </button>
563 </>
564 )}
565 </div>
566 </form>
567 </div>
568 )}
569 </>
570 )}
571 </div>
572 </Layout>
573 );
574});
575
576// Add comment to PR
577pulls.post(
578 "/:owner/:repo/pulls/:number/comment",
579 softAuth,
580 requireAuth,
581 async (c) => {
582 const { owner: ownerName, repo: repoName } = c.req.param();
583 const prNum = parseInt(c.req.param("number"), 10);
584 const user = c.get("user")!;
585 const body = await c.req.parseBody();
586 const commentBody = String(body.body || "").trim();
587
588 if (!commentBody) {
589 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
590 }
591
592 const resolved = await resolveRepo(ownerName, repoName);
593 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
594
595 const [pr] = await db
596 .select()
597 .from(pullRequests)
598 .where(
599 and(
600 eq(pullRequests.repositoryId, resolved.repo.id),
601 eq(pullRequests.number, prNum)
602 )
603 )
604 .limit(1);
605
606 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
607
608 await db.insert(prComments).values({
609 pullRequestId: pr.id,
610 authorId: user.id,
611 body: commentBody,
612 });
613
614 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
615 }
616);
617
e883329Claude618// Merge PR — with green gate enforcement and auto conflict resolution
0074234Claude619pulls.post(
620 "/:owner/:repo/pulls/:number/merge",
621 softAuth,
622 requireAuth,
623 async (c) => {
624 const { owner: ownerName, repo: repoName } = c.req.param();
625 const prNum = parseInt(c.req.param("number"), 10);
626 const user = c.get("user")!;
627
628 const resolved = await resolveRepo(ownerName, repoName);
629 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
630
631 const [pr] = await db
632 .select()
633 .from(pullRequests)
634 .where(
635 and(
636 eq(pullRequests.repositoryId, resolved.repo.id),
637 eq(pullRequests.number, prNum)
638 )
639 )
640 .limit(1);
641
642 if (!pr || pr.state !== "open") {
643 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
644 }
645
e883329Claude646 // Resolve head SHA
647 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
648 if (!headSha) {
649 return c.redirect(
650 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
651 );
652 }
653
654 // Check if AI review approved this PR
655 const aiComments = await db
656 .select()
657 .from(prComments)
658 .where(
659 and(
660 eq(prComments.pullRequestId, pr.id),
661 eq(prComments.isAiReview, true)
662 )
663 );
664 const aiApproved = aiComments.length === 0 || aiComments.some(
665 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude666 );
e883329Claude667
668 // Run all green gate checks (GateTest + mergeability + AI review)
669 const gateResult = await runAllGateChecks(
670 ownerName,
671 repoName,
672 pr.baseBranch,
673 pr.headBranch,
674 headSha,
675 aiApproved
0074234Claude676 );
677
e883329Claude678 // If GateTest or AI review failed (hard blocks), reject the merge
679 const hardFailures = gateResult.checks.filter(
680 (check) => !check.passed && check.name !== "Merge check"
681 );
682 if (hardFailures.length > 0) {
683 const errorMsg = hardFailures
684 .map((f) => `${f.name}: ${f.details}`)
685 .join("; ");
0074234Claude686 return c.redirect(
e883329Claude687 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude688 );
689 }
690
e883329Claude691 // Attempt the merge — with auto conflict resolution if needed
692 const repoDir = getRepoPath(ownerName, repoName);
693 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
694 const hasConflicts = mergeCheck && !mergeCheck.passed;
695
696 if (hasConflicts && isAiReviewEnabled()) {
697 // Use Claude to auto-resolve conflicts
698 const mergeResult = await mergeWithAutoResolve(
699 ownerName,
700 repoName,
701 pr.baseBranch,
702 pr.headBranch,
703 `Merge pull request #${pr.number}: ${pr.title}`
704 );
705
706 if (!mergeResult.success) {
707 return c.redirect(
708 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
709 );
710 }
711
712 // Post a comment about the auto-resolution
713 if (mergeResult.resolvedFiles.length > 0) {
714 await db.insert(prComments).values({
715 pullRequestId: pr.id,
716 authorId: user.id,
717 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
718 isAiReview: true,
719 });
720 }
721 } else {
722 // Standard merge — fast-forward or clean merge
723 const ffProc = Bun.spawn(
724 [
725 "git",
726 "update-ref",
727 `refs/heads/${pr.baseBranch}`,
728 `refs/heads/${pr.headBranch}`,
729 ],
730 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
731 );
732 const ffExit = await ffProc.exited;
733
734 if (ffExit !== 0) {
735 return c.redirect(
736 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
737 );
738 }
739 }
740
0074234Claude741 await db
742 .update(pullRequests)
743 .set({
744 state: "merged",
745 mergedAt: new Date(),
746 mergedBy: user.id,
747 updatedAt: new Date(),
748 })
749 .where(eq(pullRequests.id, pr.id));
750
751 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
752 }
753);
754
755// Close PR
756pulls.post(
757 "/:owner/:repo/pulls/:number/close",
758 softAuth,
759 requireAuth,
760 async (c) => {
761 const { owner: ownerName, repo: repoName } = c.req.param();
762 const prNum = parseInt(c.req.param("number"), 10);
763
764 const resolved = await resolveRepo(ownerName, repoName);
765 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
766
767 await db
768 .update(pullRequests)
769 .set({
770 state: "closed",
771 closedAt: new Date(),
772 updatedAt: new Date(),
773 })
774 .where(
775 and(
776 eq(pullRequests.repositoryId, resolved.repo.id),
777 eq(pullRequests.number, prNum)
778 )
779 );
780
781 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
782 }
783);
784
e883329Claude785/**
786 * Trigger AI code review asynchronously after PR creation.
787 * Runs the diff through Claude and posts review comments.
788 */
789async function triggerAiReview(
790 ownerName: string,
791 repoName: string,
792 prId: string,
793 title: string,
794 body: string | null,
795 baseBranch: string,
796 headBranch: string
797): Promise<void> {
798 const repoDir = getRepoPath(ownerName, repoName);
799
800 // Get the diff between branches
801 const proc = Bun.spawn(
802 ["git", "diff", `${baseBranch}...${headBranch}`],
803 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
804 );
805 const diffText = await new Response(proc.stdout).text();
806 await proc.exited;
807
808 if (!diffText.trim()) return;
809
810 const result = await reviewDiff(
811 `${ownerName}/${repoName}`,
812 title,
813 body,
814 baseBranch,
815 headBranch,
816 diffText
817 );
818
819 // We need a system user for AI reviews — use the PR author for now
820 // Get the PR to find the author
821 const [pr] = await db
822 .select()
823 .from(pullRequests)
824 .where(eq(pullRequests.id, prId))
825 .limit(1);
826
827 if (!pr) return;
828
829 // Post summary comment
830 const statusEmoji = result.approved ? "**Approved**" : "**Changes Requested**";
831 let commentBody = `## AI Code Review ${statusEmoji}\n\n${result.summary}`;
832
833 if (result.comments.length > 0) {
834 commentBody += "\n\n### Issues Found\n";
835 for (const comment of result.comments) {
836 const location = comment.filePath
837 ? `\`${comment.filePath}${comment.lineNumber ? `:${comment.lineNumber}` : ""}\``
838 : "";
839 commentBody += `\n---\n${location}\n\n${comment.body}\n`;
840 }
841 }
842
843 await db.insert(prComments).values({
844 pullRequestId: prId,
845 authorId: pr.authorId,
846 body: commentBody,
847 isAiReview: true,
848 });
849
850 // Post individual file-level comments
851 for (const comment of result.comments) {
852 if (comment.filePath) {
853 await db.insert(prComments).values({
854 pullRequestId: prId,
855 authorId: pr.authorId,
856 body: comment.body,
857 isAiReview: true,
858 filePath: comment.filePath,
859 lineNumber: comment.lineNumber,
860 });
861 }
862 }
863
864 console.log(
865 `[ai-review] Review posted for PR ${prId}: ${result.approved ? "approved" : "changes requested"}, ${result.comments.length} comments`
866 );
867}
868
0074234Claude869function formatRelative(date: Date | string): string {
870 const d = typeof date === "string" ? new Date(date) : date;
871 const now = new Date();
872 const diffMs = now.getTime() - d.getTime();
873 const diffMins = Math.floor(diffMs / 60000);
874 if (diffMins < 1) return "just now";
875 if (diffMins < 60) return `${diffMins}m ago`;
876 const diffHours = Math.floor(diffMins / 60);
877 if (diffHours < 24) return `${diffHours}h ago`;
878 const diffDays = Math.floor(diffHours / 24);
879 if (diffDays < 30) return `${diffDays}d ago`;
880 return d.toLocaleDateString("en-US", {
881 month: "short",
882 day: "numeric",
883 year: "numeric",
884 });
885}
886
887export default pulls;