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.tsxBlame1248 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";
3cbe3d6Claude30import { triagePullRequest } from "../lib/ai-generators";
e883329Claude31import { mergeWithAutoResolve } from "../lib/merge-resolver";
32import { runAllGateChecks, type GateCheckResult } from "../lib/gate";
3cbe3d6Claude33import { labels as labelsTable } from "../db/schema";
0074234Claude34
35const pulls = new Hono<AuthEnv>();
36
37async function resolveRepo(ownerName: string, repoName: string) {
38 const [owner] = await db
39 .select()
40 .from(users)
41 .where(eq(users.username, ownerName))
42 .limit(1);
43 if (!owner) return null;
44 const [repo] = await db
45 .select()
46 .from(repositories)
47 .where(
48 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
49 )
50 .limit(1);
51 if (!repo) return null;
52 return { owner, repo };
53}
54
55// PR Nav helper
56const PrNav = ({
57 owner,
58 repo,
59 active,
60}: {
61 owner: string;
62 repo: string;
63 active: "code" | "issues" | "pulls" | "commits";
64}) => (
65 <div class="repo-nav">
66 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
67 Code
68 </a>
69 <a
70 href={`/${owner}/${repo}/issues`}
71 class={active === "issues" ? "active" : ""}
72 >
73 Issues
74 </a>
75 <a
76 href={`/${owner}/${repo}/pulls`}
77 class={active === "pulls" ? "active" : ""}
78 >
79 Pull Requests
80 </a>
81 <a
82 href={`/${owner}/${repo}/commits`}
83 class={active === "commits" ? "active" : ""}
84 >
85 Commits
86 </a>
87 </div>
88);
89
90// List PRs
91pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
92 const { owner: ownerName, repo: repoName } = c.req.param();
93 const user = c.get("user");
94 const state = c.req.query("state") || "open";
95
96 const resolved = await resolveRepo(ownerName, repoName);
97 if (!resolved) return c.notFound();
98
6fc53bdClaude99 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
100 const stateFilter =
101 state === "draft"
102 ? and(
103 eq(pullRequests.state, "open"),
104 eq(pullRequests.isDraft, true)
105 )
106 : eq(pullRequests.state, state);
107
0074234Claude108 const prList = await db
109 .select({
110 pr: pullRequests,
111 author: { username: users.username },
112 })
113 .from(pullRequests)
114 .innerJoin(users, eq(pullRequests.authorId, users.id))
115 .where(
6fc53bdClaude116 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude117 )
118 .orderBy(desc(pullRequests.createdAt));
119
120 const [counts] = await db
121 .select({
122 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude123 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude124 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
125 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
126 })
127 .from(pullRequests)
128 .where(eq(pullRequests.repositoryId, resolved.repo.id));
129
130 return c.html(
131 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
132 <RepoHeader owner={ownerName} repo={repoName} />
133 <PrNav owner={ownerName} repo={repoName} active="pulls" />
134 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
135 <div class="issue-tabs">
136 <a
137 href={`/${ownerName}/${repoName}/pulls?state=open`}
138 class={state === "open" ? "active" : ""}
139 >
140 {counts?.open ?? 0} Open
141 </a>
6fc53bdClaude142 <a
143 href={`/${ownerName}/${repoName}/pulls?state=draft`}
144 class={state === "draft" ? "active" : ""}
145 >
146 {counts?.draft ?? 0} Draft
147 </a>
0074234Claude148 <a
149 href={`/${ownerName}/${repoName}/pulls?state=merged`}
150 class={state === "merged" ? "active" : ""}
151 >
152 {counts?.merged ?? 0} Merged
153 </a>
154 <a
155 href={`/${ownerName}/${repoName}/pulls?state=closed`}
156 class={state === "closed" ? "active" : ""}
157 >
158 {counts?.closed ?? 0} Closed
159 </a>
160 </div>
161 {user && (
162 <a
163 href={`/${ownerName}/${repoName}/pulls/new`}
164 class="btn btn-primary"
165 >
166 New pull request
167 </a>
168 )}
169 </div>
170 {prList.length === 0 ? (
171 <div class="empty-state">
172 <p>No {state} pull requests.</p>
173 </div>
174 ) : (
175 <div class="issue-list">
6fc53bdClaude176 {prList.map(({ pr, author }) => {
177 const isDraft = pr.state === "open" && pr.isDraft;
178 const stateClass = isDraft
179 ? "state-draft"
180 : pr.state === "open"
181 ? "state-open"
182 : pr.state === "merged"
183 ? "state-merged"
184 : "state-closed";
185 const stateIcon = isDraft
186 ? "\u270E"
187 : pr.state === "open"
188 ? "\u25CB"
189 : pr.state === "merged"
190 ? "\u2B8C"
191 : "\u2713";
192 return (
193 <div class="issue-item">
194 <div class={`issue-state-icon ${stateClass}`}>{stateIcon}</div>
195 <div>
196 <div class="issue-title">
197 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
198 {pr.title}
199 </a>
200 {isDraft && (
201 <span class="issue-badge draft-badge" style="margin-left: 8px; font-size: 11px; padding: 2px 8px">
202 Draft
203 </span>
204 )}
205 </div>
206 <div class="issue-meta">
207 #{pr.number}{" "}
208 {pr.headBranch} → {pr.baseBranch}{" "}
209 by {author.username}{" "}
210 {formatRelative(pr.createdAt)}
211 </div>
0074234Claude212 </div>
213 </div>
6fc53bdClaude214 );
215 })}
0074234Claude216 </div>
217 )}
218 </Layout>
219 );
220});
221
222// New PR form
223pulls.get(
224 "/:owner/:repo/pulls/new",
225 softAuth,
226 requireAuth,
227 async (c) => {
228 const { owner: ownerName, repo: repoName } = c.req.param();
229 const user = c.get("user")!;
230 const branches = await listBranches(ownerName, repoName);
231 const error = c.req.query("error");
232 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude233 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude234
235 return c.html(
236 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
237 <RepoHeader owner={ownerName} repo={repoName} />
238 <PrNav owner={ownerName} repo={repoName} active="pulls" />
239 <div style="max-width: 800px">
240 <h2 style="margin-bottom: 16px">Open a pull request</h2>
24cf2caClaude241 {template && (
242 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
243 Using <code>PULL_REQUEST_TEMPLATE.md</code> from the default branch.
244 </div>
245 )}
0074234Claude246 {error && (
247 <div class="auth-error">{decodeURIComponent(error)}</div>
248 )}
249 <form method="POST" action={`/${ownerName}/${repoName}/pulls/new`}>
250 <div style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px">
251 <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">
252 {branches.map((b) => (
253 <option value={b} selected={b === defaultBase}>
254 {b}
255 </option>
256 ))}
257 </select>
258 <span style="color: var(--text-muted)">&larr;</span>
259 <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">
260 {branches
261 .filter((b) => b !== defaultBase)
262 .concat(defaultBase === branches[0] ? [] : [branches[0]])
263 .map((b) => (
264 <option value={b}>{b}</option>
265 ))}
266 </select>
267 </div>
268 <div class="form-group">
269 <input
270 type="text"
271 name="title"
272 required
273 placeholder="Title"
274 style="font-size: 16px; padding: 10px 14px"
275 />
276 </div>
277 <div class="form-group">
278 <textarea
279 name="body"
280 rows={8}
281 placeholder="Description (Markdown supported)"
282 style="font-family: var(--font-mono); font-size: 13px"
24cf2caClaude283 >
284 {template || ""}
285 </textarea>
0074234Claude286 </div>
6fc53bdClaude287 <div style="display: flex; gap: 8px">
288 <button type="submit" class="btn btn-primary">
289 Create pull request
290 </button>
291 <button
292 type="submit"
293 name="draft"
294 value="1"
295 class="btn"
296 title="Create a draft PR — skips AI review and cannot be merged until marked ready"
297 >
298 Create draft
299 </button>
300 </div>
0074234Claude301 </form>
302 </div>
303 </Layout>
304 );
305 }
306);
307
308// Create PR
309pulls.post(
310 "/:owner/:repo/pulls/new",
311 softAuth,
312 requireAuth,
313 async (c) => {
314 const { owner: ownerName, repo: repoName } = c.req.param();
315 const user = c.get("user")!;
316 const body = await c.req.parseBody();
317 const title = String(body.title || "").trim();
318 const prBody = String(body.body || "").trim();
319 const baseBranch = String(body.base || "main");
320 const headBranch = String(body.head || "");
321
322 if (!title || !headBranch) {
323 return c.redirect(
324 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
325 );
326 }
327
328 if (baseBranch === headBranch) {
329 return c.redirect(
330 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
331 );
332 }
333
334 const resolved = await resolveRepo(ownerName, repoName);
335 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
336
6fc53bdClaude337 const isDraft = String(body.draft || "") === "1";
338
0074234Claude339 const [pr] = await db
340 .insert(pullRequests)
341 .values({
342 repositoryId: resolved.repo.id,
343 authorId: user.id,
344 title,
345 body: prBody || null,
346 baseBranch,
347 headBranch,
6fc53bdClaude348 isDraft,
0074234Claude349 })
350 .returning();
351
6fc53bdClaude352 // Skip AI review on drafts — it runs again when the PR is marked ready.
353 if (!isDraft && isAiReviewEnabled()) {
e883329Claude354 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
355 (err) => console.error("[ai-review] Failed:", err)
356 );
357 }
358
3cbe3d6Claude359 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
360 triggerPrTriage({
361 ownerName,
362 repoName,
363 repositoryId: resolved.repo.id,
364 prId: pr.id,
365 prAuthorId: user.id,
366 title,
367 body: prBody,
368 baseBranch,
369 headBranch,
370 }).catch((err) => console.error("[pr-triage] Failed:", err));
371
0074234Claude372 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
373 }
374);
375
376// View single PR
377pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
378 const { owner: ownerName, repo: repoName } = c.req.param();
379 const prNum = parseInt(c.req.param("number"), 10);
380 const user = c.get("user");
381 const tab = c.req.query("tab") || "conversation";
382
383 const resolved = await resolveRepo(ownerName, repoName);
384 if (!resolved) return c.notFound();
385
386 const [pr] = await db
387 .select()
388 .from(pullRequests)
389 .where(
390 and(
391 eq(pullRequests.repositoryId, resolved.repo.id),
392 eq(pullRequests.number, prNum)
393 )
394 )
395 .limit(1);
396
397 if (!pr) return c.notFound();
398
399 const [author] = await db
400 .select()
401 .from(users)
402 .where(eq(users.id, pr.authorId))
403 .limit(1);
404
405 const comments = await db
406 .select({
407 comment: prComments,
408 author: { username: users.username },
409 })
410 .from(prComments)
411 .innerJoin(users, eq(prComments.authorId, users.id))
412 .where(eq(prComments.pullRequestId, pr.id))
413 .orderBy(asc(prComments.createdAt));
414
6fc53bdClaude415 // Reactions for the PR body + each comment, in parallel.
416 const [prReactions, ...prCommentReactions] = await Promise.all([
417 summariseReactions("pr", pr.id, user?.id),
418 ...comments.map((row) =>
419 summariseReactions("pr_comment", row.comment.id, user?.id)
420 ),
421 ]);
422
0074234Claude423 const canManage =
424 user &&
425 (user.id === resolved.owner.id || user.id === pr.authorId);
426
e883329Claude427 const error = c.req.query("error");
428
429 // Get gate check status for open PRs
430 let gateChecks: GateCheckResult[] = [];
431 if (pr.state === "open") {
432 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
433 if (headSha) {
434 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
435 const aiApproved = aiComments.length === 0 || aiComments.some(
436 ({ comment }) => comment.body.includes("**Approved**")
437 );
438 const gateResult = await runAllGateChecks(
439 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
440 );
441 gateChecks = gateResult.checks;
442 }
443 }
444
0074234Claude445 // Get diff for "Files changed" tab
446 let diffRaw = "";
447 let diffFiles: GitDiffFile[] = [];
448 if (tab === "files") {
449 const repoDir = getRepoPath(ownerName, repoName);
450 const proc = Bun.spawn(
451 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
452 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
453 );
454 diffRaw = await new Response(proc.stdout).text();
455 await proc.exited;
456
457 const statProc = Bun.spawn(
458 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
459 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
460 );
461 const stat = await new Response(statProc.stdout).text();
462 await statProc.exited;
463
464 diffFiles = stat
465 .trim()
466 .split("\n")
467 .filter(Boolean)
468 .map((line) => {
469 const [add, del, filePath] = line.split("\t");
470 return {
471 path: filePath,
472 status: "modified",
473 additions: add === "-" ? 0 : parseInt(add, 10),
474 deletions: del === "-" ? 0 : parseInt(del, 10),
475 patch: "",
476 };
477 });
478 }
479
480 return c.html(
481 <Layout
482 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
483 user={user}
484 >
485 <RepoHeader owner={ownerName} repo={repoName} />
486 <PrNav owner={ownerName} repo={repoName} active="pulls" />
487 <div class="issue-detail">
488 <h2>
489 {pr.title}{" "}
490 <span style="color: var(--text-muted); font-weight: 400">
491 #{pr.number}
492 </span>
493 </h2>
494 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
6fc53bdClaude495 {pr.state === "open" && pr.isDraft ? (
496 <span class="issue-badge draft-badge">
497 {"\u270E Draft"}
498 </span>
499 ) : (
500 <span
501 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
502 >
503 {pr.state === "open"
504 ? "\u25CB Open"
505 : pr.state === "merged"
506 ? "\u2B8C Merged"
507 : "\u2713 Closed"}
508 </span>
509 )}
0074234Claude510 <span style="color: var(--text-muted); font-size: 14px">
511 <strong style="color: var(--text)">
512 {author?.username}
513 </strong>{" "}
514 wants to merge <code>{pr.headBranch}</code> into{" "}
515 <code>{pr.baseBranch}</code>
516 </span>
517 </div>
518
519 <div class="issue-tabs" style="margin-bottom: 20px">
520 <a
521 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
522 class={tab === "conversation" ? "active" : ""}
523 >
524 Conversation
525 </a>
526 <a
527 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
528 class={tab === "files" ? "active" : ""}
529 >
530 Files changed
531 </a>
532 </div>
533
534 {tab === "files" ? (
535 <DiffView raw={diffRaw} files={diffFiles} />
536 ) : (
537 <>
538 {pr.body && (
539 <div class="issue-comment-box">
540 <div class="comment-header">
541 <strong>{author?.username}</strong> commented{" "}
542 {formatRelative(pr.createdAt)}
543 </div>
544 <div class="markdown-body">
545 {html([renderMarkdown(pr.body)] as unknown as TemplateStringsArray)}
546 </div>
6fc53bdClaude547 <div style="padding: 0 16px 12px">
548 <ReactionsBar
549 targetType="pr"
550 targetId={pr.id}
551 summaries={prReactions}
552 canReact={!!user}
553 />
554 </div>
0074234Claude555 </div>
556 )}
557
6fc53bdClaude558 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude559 <div
560 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
561 >
562 <div class="comment-header">
563 <strong>{commentAuthor.username}</strong>
564 {comment.isAiReview && (
565 <span class="badge" style="margin-left: 8px; background: rgba(31, 111, 235, 0.15); color: var(--text-link); border-color: var(--accent)">
566 AI Review
567 </span>
568 )}
569 {" "}
570 commented {formatRelative(comment.createdAt)}
571 {comment.filePath && (
572 <span style="margin-left: 8px; font-family: var(--font-mono); font-size: 11px">
573 {comment.filePath}
574 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
575 </span>
576 )}
577 </div>
578 <div class="markdown-body">
579 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
580 </div>
6fc53bdClaude581 <div style="padding: 0 16px 12px">
582 <ReactionsBar
583 targetType="pr_comment"
584 targetId={comment.id}
585 summaries={prCommentReactions[i] || []}
586 canReact={!!user}
587 />
588 </div>
0074234Claude589 </div>
590 ))}
591
e883329Claude592 {error && (
593 <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)">
594 {decodeURIComponent(error)}
595 </div>
596 )}
597
598 {pr.state === "open" && gateChecks.length > 0 && (
599 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
600 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
601 {gateChecks.map((check) => (
602 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
603 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
604 {check.passed ? "\u2713" : "\u2717"}
605 </span>
606 <strong style="font-size: 13px">{check.name}</strong>
607 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
608 </div>
609 ))}
610 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
611 {gateChecks.every((c) => c.passed)
612 ? "All checks passed — ready to merge"
613 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
614 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
615 : "Some checks failed — resolve issues before merging"}
616 </div>
617 </div>
618 )}
619
0074234Claude620 {user && pr.state === "open" && (
621 <div style="margin-top: 20px">
622 <form
623 method="POST"
624 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
625 >
626 <div class="form-group">
627 <textarea
628 name="body"
629 rows={6}
630 required
631 placeholder="Leave a comment... (Markdown supported)"
632 style="font-family: var(--font-mono); font-size: 13px"
633 />
634 </div>
635 <div style="display: flex; gap: 8px">
636 <button type="submit" class="btn btn-primary">
637 Comment
638 </button>
639 {canManage && (
640 <>
6fc53bdClaude641 {pr.isDraft ? (
642 <button
643 type="submit"
644 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
645 class="btn"
646 style="background: rgba(63, 185, 80, 0.15); border-color: var(--green); color: var(--green)"
647 title="Mark this draft PR as ready for review — triggers AI review"
648 >
649 Ready for review
650 </button>
651 ) : (
652 <>
653 <button
654 type="submit"
655 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
656 class="btn"
657 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)"}`}
658 >
659 {gateChecks.every((c) => c.passed)
660 ? "Merge pull request"
661 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
662 ? "Merge with auto-resolve"
663 : "Merge pull request"}
664 </button>
665 <button
666 type="submit"
667 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
668 class="btn"
669 title="Convert back to draft"
670 >
671 Convert to draft
672 </button>
673 </>
674 )}
0074234Claude675 <button
676 type="submit"
677 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
678 class="btn btn-danger"
679 >
680 Close
681 </button>
682 </>
683 )}
684 </div>
685 </form>
686 </div>
687 )}
688 </>
689 )}
690 </div>
691 </Layout>
692 );
693});
694
695// Add comment to PR
696pulls.post(
697 "/:owner/:repo/pulls/:number/comment",
698 softAuth,
699 requireAuth,
700 async (c) => {
701 const { owner: ownerName, repo: repoName } = c.req.param();
702 const prNum = parseInt(c.req.param("number"), 10);
703 const user = c.get("user")!;
704 const body = await c.req.parseBody();
705 const commentBody = String(body.body || "").trim();
706
707 if (!commentBody) {
708 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
709 }
710
711 const resolved = await resolveRepo(ownerName, repoName);
712 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
713
714 const [pr] = await db
715 .select()
716 .from(pullRequests)
717 .where(
718 and(
719 eq(pullRequests.repositoryId, resolved.repo.id),
720 eq(pullRequests.number, prNum)
721 )
722 )
723 .limit(1);
724
725 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
726
727 await db.insert(prComments).values({
728 pullRequestId: pr.id,
729 authorId: user.id,
730 body: commentBody,
731 });
732
733 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
734 }
735);
736
e883329Claude737// Merge PR — with green gate enforcement and auto conflict resolution
0074234Claude738pulls.post(
739 "/:owner/:repo/pulls/:number/merge",
740 softAuth,
741 requireAuth,
742 async (c) => {
743 const { owner: ownerName, repo: repoName } = c.req.param();
744 const prNum = parseInt(c.req.param("number"), 10);
745 const user = c.get("user")!;
746
747 const resolved = await resolveRepo(ownerName, repoName);
748 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
749
750 const [pr] = await db
751 .select()
752 .from(pullRequests)
753 .where(
754 and(
755 eq(pullRequests.repositoryId, resolved.repo.id),
756 eq(pullRequests.number, prNum)
757 )
758 )
759 .limit(1);
760
761 if (!pr || pr.state !== "open") {
762 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
763 }
764
6fc53bdClaude765 // Draft PRs cannot be merged — must be marked ready first.
766 if (pr.isDraft) {
767 return c.redirect(
768 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
769 "This PR is a draft. Mark it as ready for review before merging."
770 )}`
771 );
772 }
773
e883329Claude774 // Resolve head SHA
775 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
776 if (!headSha) {
777 return c.redirect(
778 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
779 );
780 }
781
782 // Check if AI review approved this PR
783 const aiComments = await db
784 .select()
785 .from(prComments)
786 .where(
787 and(
788 eq(prComments.pullRequestId, pr.id),
789 eq(prComments.isAiReview, true)
790 )
791 );
792 const aiApproved = aiComments.length === 0 || aiComments.some(
793 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude794 );
e883329Claude795
796 // Run all green gate checks (GateTest + mergeability + AI review)
797 const gateResult = await runAllGateChecks(
798 ownerName,
799 repoName,
800 pr.baseBranch,
801 pr.headBranch,
802 headSha,
803 aiApproved
0074234Claude804 );
805
e883329Claude806 // If GateTest or AI review failed (hard blocks), reject the merge
807 const hardFailures = gateResult.checks.filter(
808 (check) => !check.passed && check.name !== "Merge check"
809 );
810 if (hardFailures.length > 0) {
811 const errorMsg = hardFailures
812 .map((f) => `${f.name}: ${f.details}`)
813 .join("; ");
0074234Claude814 return c.redirect(
e883329Claude815 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude816 );
817 }
818
e883329Claude819 // Attempt the merge — with auto conflict resolution if needed
820 const repoDir = getRepoPath(ownerName, repoName);
821 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
822 const hasConflicts = mergeCheck && !mergeCheck.passed;
823
824 if (hasConflicts && isAiReviewEnabled()) {
825 // Use Claude to auto-resolve conflicts
826 const mergeResult = await mergeWithAutoResolve(
827 ownerName,
828 repoName,
829 pr.baseBranch,
830 pr.headBranch,
831 `Merge pull request #${pr.number}: ${pr.title}`
832 );
833
834 if (!mergeResult.success) {
835 return c.redirect(
836 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
837 );
838 }
839
840 // Post a comment about the auto-resolution
841 if (mergeResult.resolvedFiles.length > 0) {
842 await db.insert(prComments).values({
843 pullRequestId: pr.id,
844 authorId: user.id,
845 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
846 isAiReview: true,
847 });
848 }
849 } else {
850 // Standard merge — fast-forward or clean merge
851 const ffProc = Bun.spawn(
852 [
853 "git",
854 "update-ref",
855 `refs/heads/${pr.baseBranch}`,
856 `refs/heads/${pr.headBranch}`,
857 ],
858 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
859 );
860 const ffExit = await ffProc.exited;
861
862 if (ffExit !== 0) {
863 return c.redirect(
864 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
865 );
866 }
867 }
868
0074234Claude869 await db
870 .update(pullRequests)
871 .set({
872 state: "merged",
873 mergedAt: new Date(),
874 mergedBy: user.id,
875 updatedAt: new Date(),
876 })
877 .where(eq(pullRequests.id, pr.id));
878
879 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
880 }
881);
882
6fc53bdClaude883// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
884// hasn't run yet on this PR.
885pulls.post(
886 "/:owner/:repo/pulls/:number/ready",
887 softAuth,
888 requireAuth,
889 async (c) => {
890 const { owner: ownerName, repo: repoName } = c.req.param();
891 const prNum = parseInt(c.req.param("number"), 10);
892 const user = c.get("user")!;
893
894 const resolved = await resolveRepo(ownerName, repoName);
895 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
896
897 const [pr] = await db
898 .select()
899 .from(pullRequests)
900 .where(
901 and(
902 eq(pullRequests.repositoryId, resolved.repo.id),
903 eq(pullRequests.number, prNum)
904 )
905 )
906 .limit(1);
907 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
908
909 // Only the author or repo owner can toggle draft state.
910 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
911 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
912 }
913
914 if (pr.state === "open" && pr.isDraft) {
915 await db
916 .update(pullRequests)
917 .set({ isDraft: false, updatedAt: new Date() })
918 .where(eq(pullRequests.id, pr.id));
919
920 if (isAiReviewEnabled()) {
921 triggerAiReview(
922 ownerName,
923 repoName,
924 pr.id,
925 pr.title,
926 pr.body,
927 pr.baseBranch,
928 pr.headBranch
929 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
930 }
931 }
932
933 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
934 }
935);
936
937// Convert a PR back to draft.
938pulls.post(
939 "/:owner/:repo/pulls/:number/draft",
940 softAuth,
941 requireAuth,
942 async (c) => {
943 const { owner: ownerName, repo: repoName } = c.req.param();
944 const prNum = parseInt(c.req.param("number"), 10);
945 const user = c.get("user")!;
946
947 const resolved = await resolveRepo(ownerName, repoName);
948 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
949
950 const [pr] = await db
951 .select()
952 .from(pullRequests)
953 .where(
954 and(
955 eq(pullRequests.repositoryId, resolved.repo.id),
956 eq(pullRequests.number, prNum)
957 )
958 )
959 .limit(1);
960 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
961
962 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
963 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
964 }
965
966 if (pr.state === "open" && !pr.isDraft) {
967 await db
968 .update(pullRequests)
969 .set({ isDraft: true, updatedAt: new Date() })
970 .where(eq(pullRequests.id, pr.id));
971 }
972
973 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
974 }
975);
976
0074234Claude977// Close PR
978pulls.post(
979 "/:owner/:repo/pulls/:number/close",
980 softAuth,
981 requireAuth,
982 async (c) => {
983 const { owner: ownerName, repo: repoName } = c.req.param();
984 const prNum = parseInt(c.req.param("number"), 10);
985
986 const resolved = await resolveRepo(ownerName, repoName);
987 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
988
989 await db
990 .update(pullRequests)
991 .set({
992 state: "closed",
993 closedAt: new Date(),
994 updatedAt: new Date(),
995 })
996 .where(
997 and(
998 eq(pullRequests.repositoryId, resolved.repo.id),
999 eq(pullRequests.number, prNum)
1000 )
1001 );
1002
1003 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1004 }
1005);
1006
e883329Claude1007/**
1008 * Trigger AI code review asynchronously after PR creation.
1009 * Runs the diff through Claude and posts review comments.
1010 */
1011async function triggerAiReview(
1012 ownerName: string,
1013 repoName: string,
1014 prId: string,
1015 title: string,
1016 body: string | null,
1017 baseBranch: string,
1018 headBranch: string
1019): Promise<void> {
1020 const repoDir = getRepoPath(ownerName, repoName);
1021
1022 // Get the diff between branches
1023 const proc = Bun.spawn(
1024 ["git", "diff", `${baseBranch}...${headBranch}`],
1025 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1026 );
1027 const diffText = await new Response(proc.stdout).text();
1028 await proc.exited;
1029
1030 if (!diffText.trim()) return;
1031
1032 const result = await reviewDiff(
1033 `${ownerName}/${repoName}`,
1034 title,
1035 body,
1036 baseBranch,
1037 headBranch,
1038 diffText
1039 );
1040
1041 // We need a system user for AI reviews — use the PR author for now
1042 // Get the PR to find the author
1043 const [pr] = await db
1044 .select()
1045 .from(pullRequests)
1046 .where(eq(pullRequests.id, prId))
1047 .limit(1);
1048
1049 if (!pr) return;
1050
1051 // Post summary comment
1052 const statusEmoji = result.approved ? "**Approved**" : "**Changes Requested**";
1053 let commentBody = `## AI Code Review ${statusEmoji}\n\n${result.summary}`;
1054
1055 if (result.comments.length > 0) {
1056 commentBody += "\n\n### Issues Found\n";
1057 for (const comment of result.comments) {
1058 const location = comment.filePath
1059 ? `\`${comment.filePath}${comment.lineNumber ? `:${comment.lineNumber}` : ""}\``
1060 : "";
1061 commentBody += `\n---\n${location}\n\n${comment.body}\n`;
1062 }
1063 }
1064
1065 await db.insert(prComments).values({
1066 pullRequestId: prId,
1067 authorId: pr.authorId,
1068 body: commentBody,
1069 isAiReview: true,
1070 });
1071
1072 // Post individual file-level comments
1073 for (const comment of result.comments) {
1074 if (comment.filePath) {
1075 await db.insert(prComments).values({
1076 pullRequestId: prId,
1077 authorId: pr.authorId,
1078 body: comment.body,
1079 isAiReview: true,
1080 filePath: comment.filePath,
1081 lineNumber: comment.lineNumber,
1082 });
1083 }
1084 }
1085
1086 console.log(
1087 `[ai-review] Review posted for PR ${prId}: ${result.approved ? "approved" : "changes requested"}, ${result.comments.length} comments`
1088 );
1089}
1090
3cbe3d6Claude1091/**
1092 * D3 — AI PR triage. Runs Claude Haiku on the PR title/body + diff summary and
1093 * posts an AI-authored comment suggesting labels, reviewers, and priority.
1094 * Nothing is auto-applied — the PR author remains in control.
1095 */
1096async function triggerPrTriage(args: {
1097 ownerName: string;
1098 repoName: string;
1099 repositoryId: string;
1100 prId: string;
1101 prAuthorId: string;
1102 title: string;
1103 body: string;
1104 baseBranch: string;
1105 headBranch: string;
1106}): Promise<void> {
1107 try {
1108 // Gather candidate reviewers (top contributors from recent commits).
1109 const repoDir = getRepoPath(args.ownerName, args.repoName);
1110 const shortlogProc = Bun.spawn(
1111 ["git", "shortlog", "-sn", "--no-merges", "-50", args.baseBranch],
1112 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1113 );
1114 const shortlogOut = await new Response(shortlogProc.stdout).text();
1115 await shortlogProc.exited;
1116 const authorNames = shortlogOut
1117 .trim()
1118 .split("\n")
1119 .map((l) => l.trim().split(/\s+/).slice(1).join(" "))
1120 .filter(Boolean)
1121 .slice(0, 10);
1122
1123 // Look up usernames matching these author names (best-effort match).
1124 const candidateUsernames: string[] = [];
1125 if (authorNames.length > 0) {
1126 try {
1127 const matches = await db
1128 .select({ username: users.username, displayName: users.displayName })
1129 .from(users)
1130 .limit(100);
1131 for (const u of matches) {
1132 if (
1133 authorNames.some(
1134 (n) =>
1135 u.username === n ||
1136 (u.displayName && u.displayName === n)
1137 )
1138 ) {
1139 candidateUsernames.push(u.username);
1140 }
1141 }
1142 } catch {
1143 /* ignore */
1144 }
1145 }
1146 // Always include the repo owner as a candidate reviewer.
1147 try {
1148 const [ownerRow] = await db
1149 .select({ username: users.username })
1150 .from(users)
1151 .where(eq(users.username, args.ownerName))
1152 .limit(1);
1153 if (ownerRow && !candidateUsernames.includes(ownerRow.username)) {
1154 candidateUsernames.push(ownerRow.username);
1155 }
1156 } catch {
1157 /* ignore */
1158 }
1159
1160 // Load repo labels.
1161 const availableLabels = await db
1162 .select({ name: labelsTable.name })
1163 .from(labelsTable)
1164 .where(eq(labelsTable.repositoryId, args.repositoryId))
1165 .then((rows) => rows.map((r) => r.name))
1166 .catch(() => [] as string[]);
1167
1168 // Short diff summary (numstat only to keep prompt small).
1169 const statProc = Bun.spawn(
1170 ["git", "diff", "--numstat", `${args.baseBranch}...${args.headBranch}`],
1171 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1172 );
1173 const diffSummary = await new Response(statProc.stdout).text();
1174 await statProc.exited;
1175
1176 const result = await triagePullRequest(
1177 args.title,
1178 args.body,
1179 diffSummary,
1180 availableLabels,
1181 candidateUsernames
1182 );
1183
1184 // Skip posting if we have absolutely nothing useful to say.
1185 if (
1186 !result.summary &&
1187 result.suggestedLabels.length === 0 &&
1188 result.suggestedReviewerUsernames.length === 0
1189 ) {
1190 return;
1191 }
1192
1193 const priorityEmoji =
1194 result.priority === "critical"
1195 ? "**Critical**"
1196 : result.priority === "high"
1197 ? "**High**"
1198 : result.priority === "low"
1199 ? "**Low**"
1200 : "**Medium**";
1201 const parts: string[] = [`## AI Triage\n`];
1202 if (result.summary) parts.push(`${result.summary}\n`);
1203 parts.push(`- **Priority:** ${priorityEmoji}`);
1204 parts.push(`- **Risk area:** ${result.riskArea}`);
1205 if (result.suggestedLabels.length > 0) {
1206 parts.push(
1207 `- **Suggested labels:** ${result.suggestedLabels.map((l) => `\`${l}\``).join(", ")}`
1208 );
1209 }
1210 if (result.suggestedReviewerUsernames.length > 0) {
1211 parts.push(
1212 `- **Suggested reviewers:** ${result.suggestedReviewerUsernames.map((u) => `@${u}`).join(", ")}`
1213 );
1214 }
1215 parts.push(
1216 `\n_Suggestions only — nothing was auto-applied. The PR author remains in control._`
1217 );
1218
1219 await db.insert(prComments).values({
1220 pullRequestId: args.prId,
1221 authorId: args.prAuthorId,
1222 body: parts.join("\n"),
1223 isAiReview: true,
1224 });
1225 } catch (err) {
1226 console.error("[pr-triage]", err);
1227 }
1228}
1229
0074234Claude1230function formatRelative(date: Date | string): string {
1231 const d = typeof date === "string" ? new Date(date) : date;
1232 const now = new Date();
1233 const diffMs = now.getTime() - d.getTime();
1234 const diffMins = Math.floor(diffMs / 60000);
1235 if (diffMins < 1) return "just now";
1236 if (diffMins < 60) return `${diffMins}m ago`;
1237 const diffHours = Math.floor(diffMins / 60);
1238 if (diffHours < 24) return `${diffHours}h ago`;
1239 const diffDays = Math.floor(diffHours / 24);
1240 if (diffDays < 30) return `${diffDays}d ago`;
1241 return d.toLocaleDateString("en-US", {
1242 month: "short",
1243 day: "numeric",
1244 year: "numeric",
1245 });
1246}
1247
1248export default pulls;