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.tsxBlame1333 lines · 1 contributor
0074234Claude1/**
2 * Pull request routes — create, list, view, merge, close, comment.
3 */
4
5import { Hono } from "hono";
6import { eq, and, desc, asc, sql } from "drizzle-orm";
7import { db } from "../db";
8import {
9 pullRequests,
10 prComments,
11 repositories,
12 users,
d62fb36Claude13 issues,
14 issueComments,
0074234Claude15} from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader, DiffView } from "../views/components";
6fc53bdClaude18import { ReactionsBar } from "../views/reactions";
19import { summariseReactions } from "../lib/reactions";
24cf2caClaude20import { loadPrTemplate } from "../lib/templates";
0074234Claude21import { renderMarkdown } from "../lib/markdown";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import {
25 listBranches,
26 getRepoPath,
e883329Claude27 resolveRef,
0074234Claude28} from "../git/repository";
29import type { GitDiffFile } from "../git/repository";
30import { html } from "hono/html";
e883329Claude31import { reviewDiff, isAiReviewEnabled } from "../lib/ai-review";
3cbe3d6Claude32import { triagePullRequest } from "../lib/ai-generators";
e883329Claude33import { mergeWithAutoResolve } from "../lib/merge-resolver";
34import { runAllGateChecks, type GateCheckResult } from "../lib/gate";
3cbe3d6Claude35import { labels as labelsTable } from "../db/schema";
1e162a8Claude36import {
37 matchProtection,
38 evaluateProtection,
39 countHumanApprovals,
a79a9edClaude40 listRequiredChecks,
41 passingCheckNames,
1e162a8Claude42} from "../lib/branch-protection";
0074234Claude43
44const pulls = new Hono<AuthEnv>();
45
46async function resolveRepo(ownerName: string, repoName: string) {
47 const [owner] = await db
48 .select()
49 .from(users)
50 .where(eq(users.username, ownerName))
51 .limit(1);
52 if (!owner) return null;
53 const [repo] = await db
54 .select()
55 .from(repositories)
56 .where(
57 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
58 )
59 .limit(1);
60 if (!repo) return null;
61 return { owner, repo };
62}
63
64// PR Nav helper
65const PrNav = ({
66 owner,
67 repo,
68 active,
69}: {
70 owner: string;
71 repo: string;
72 active: "code" | "issues" | "pulls" | "commits";
73}) => (
74 <div class="repo-nav">
75 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
76 Code
77 </a>
78 <a
79 href={`/${owner}/${repo}/issues`}
80 class={active === "issues" ? "active" : ""}
81 >
82 Issues
83 </a>
84 <a
85 href={`/${owner}/${repo}/pulls`}
86 class={active === "pulls" ? "active" : ""}
87 >
88 Pull Requests
89 </a>
90 <a
91 href={`/${owner}/${repo}/commits`}
92 class={active === "commits" ? "active" : ""}
93 >
94 Commits
95 </a>
96 </div>
97);
98
99// List PRs
100pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
101 const { owner: ownerName, repo: repoName } = c.req.param();
102 const user = c.get("user");
103 const state = c.req.query("state") || "open";
104
105 const resolved = await resolveRepo(ownerName, repoName);
106 if (!resolved) return c.notFound();
107
6fc53bdClaude108 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
109 const stateFilter =
110 state === "draft"
111 ? and(
112 eq(pullRequests.state, "open"),
113 eq(pullRequests.isDraft, true)
114 )
115 : eq(pullRequests.state, state);
116
0074234Claude117 const prList = await db
118 .select({
119 pr: pullRequests,
120 author: { username: users.username },
121 })
122 .from(pullRequests)
123 .innerJoin(users, eq(pullRequests.authorId, users.id))
124 .where(
6fc53bdClaude125 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude126 )
127 .orderBy(desc(pullRequests.createdAt));
128
129 const [counts] = await db
130 .select({
131 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude132 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude133 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
134 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
135 })
136 .from(pullRequests)
137 .where(eq(pullRequests.repositoryId, resolved.repo.id));
138
139 return c.html(
140 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
141 <RepoHeader owner={ownerName} repo={repoName} />
142 <PrNav owner={ownerName} repo={repoName} active="pulls" />
143 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
144 <div class="issue-tabs">
145 <a
146 href={`/${ownerName}/${repoName}/pulls?state=open`}
147 class={state === "open" ? "active" : ""}
148 >
149 {counts?.open ?? 0} Open
150 </a>
6fc53bdClaude151 <a
152 href={`/${ownerName}/${repoName}/pulls?state=draft`}
153 class={state === "draft" ? "active" : ""}
154 >
155 {counts?.draft ?? 0} Draft
156 </a>
0074234Claude157 <a
158 href={`/${ownerName}/${repoName}/pulls?state=merged`}
159 class={state === "merged" ? "active" : ""}
160 >
161 {counts?.merged ?? 0} Merged
162 </a>
163 <a
164 href={`/${ownerName}/${repoName}/pulls?state=closed`}
165 class={state === "closed" ? "active" : ""}
166 >
167 {counts?.closed ?? 0} Closed
168 </a>
169 </div>
170 {user && (
171 <a
172 href={`/${ownerName}/${repoName}/pulls/new`}
173 class="btn btn-primary"
174 >
175 New pull request
176 </a>
177 )}
178 </div>
179 {prList.length === 0 ? (
180 <div class="empty-state">
181 <p>No {state} pull requests.</p>
182 </div>
183 ) : (
184 <div class="issue-list">
6fc53bdClaude185 {prList.map(({ pr, author }) => {
186 const isDraft = pr.state === "open" && pr.isDraft;
187 const stateClass = isDraft
188 ? "state-draft"
189 : pr.state === "open"
190 ? "state-open"
191 : pr.state === "merged"
192 ? "state-merged"
193 : "state-closed";
194 const stateIcon = isDraft
195 ? "\u270E"
196 : pr.state === "open"
197 ? "\u25CB"
198 : pr.state === "merged"
199 ? "\u2B8C"
200 : "\u2713";
201 return (
202 <div class="issue-item">
203 <div class={`issue-state-icon ${stateClass}`}>{stateIcon}</div>
204 <div>
205 <div class="issue-title">
206 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
207 {pr.title}
208 </a>
209 {isDraft && (
210 <span class="issue-badge draft-badge" style="margin-left: 8px; font-size: 11px; padding: 2px 8px">
211 Draft
212 </span>
213 )}
214 </div>
215 <div class="issue-meta">
216 #{pr.number}{" "}
217 {pr.headBranch} → {pr.baseBranch}{" "}
218 by {author.username}{" "}
219 {formatRelative(pr.createdAt)}
220 </div>
0074234Claude221 </div>
222 </div>
6fc53bdClaude223 );
224 })}
0074234Claude225 </div>
226 )}
227 </Layout>
228 );
229});
230
231// New PR form
232pulls.get(
233 "/:owner/:repo/pulls/new",
234 softAuth,
235 requireAuth,
236 async (c) => {
237 const { owner: ownerName, repo: repoName } = c.req.param();
238 const user = c.get("user")!;
239 const branches = await listBranches(ownerName, repoName);
240 const error = c.req.query("error");
241 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude242 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude243
244 return c.html(
245 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
246 <RepoHeader owner={ownerName} repo={repoName} />
247 <PrNav owner={ownerName} repo={repoName} active="pulls" />
248 <div style="max-width: 800px">
249 <h2 style="margin-bottom: 16px">Open a pull request</h2>
24cf2caClaude250 {template && (
251 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
252 Using <code>PULL_REQUEST_TEMPLATE.md</code> from the default branch.
253 </div>
254 )}
0074234Claude255 {error && (
256 <div class="auth-error">{decodeURIComponent(error)}</div>
257 )}
258 <form method="POST" action={`/${ownerName}/${repoName}/pulls/new`}>
259 <div style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px">
260 <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">
261 {branches.map((b) => (
262 <option value={b} selected={b === defaultBase}>
263 {b}
264 </option>
265 ))}
266 </select>
267 <span style="color: var(--text-muted)">&larr;</span>
268 <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">
269 {branches
270 .filter((b) => b !== defaultBase)
271 .concat(defaultBase === branches[0] ? [] : [branches[0]])
272 .map((b) => (
273 <option value={b}>{b}</option>
274 ))}
275 </select>
276 </div>
277 <div class="form-group">
278 <input
279 type="text"
280 name="title"
281 required
282 placeholder="Title"
283 style="font-size: 16px; padding: 10px 14px"
284 />
285 </div>
286 <div class="form-group">
287 <textarea
288 name="body"
289 rows={8}
290 placeholder="Description (Markdown supported)"
291 style="font-family: var(--font-mono); font-size: 13px"
24cf2caClaude292 >
293 {template || ""}
294 </textarea>
0074234Claude295 </div>
6fc53bdClaude296 <div style="display: flex; gap: 8px">
297 <button type="submit" class="btn btn-primary">
298 Create pull request
299 </button>
300 <button
301 type="submit"
302 name="draft"
303 value="1"
304 class="btn"
305 title="Create a draft PR — skips AI review and cannot be merged until marked ready"
306 >
307 Create draft
308 </button>
309 </div>
0074234Claude310 </form>
311 </div>
312 </Layout>
313 );
314 }
315);
316
317// Create PR
318pulls.post(
319 "/:owner/:repo/pulls/new",
320 softAuth,
321 requireAuth,
322 async (c) => {
323 const { owner: ownerName, repo: repoName } = c.req.param();
324 const user = c.get("user")!;
325 const body = await c.req.parseBody();
326 const title = String(body.title || "").trim();
327 const prBody = String(body.body || "").trim();
328 const baseBranch = String(body.base || "main");
329 const headBranch = String(body.head || "");
330
331 if (!title || !headBranch) {
332 return c.redirect(
333 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
334 );
335 }
336
337 if (baseBranch === headBranch) {
338 return c.redirect(
339 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
340 );
341 }
342
343 const resolved = await resolveRepo(ownerName, repoName);
344 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
345
6fc53bdClaude346 const isDraft = String(body.draft || "") === "1";
347
0074234Claude348 const [pr] = await db
349 .insert(pullRequests)
350 .values({
351 repositoryId: resolved.repo.id,
352 authorId: user.id,
353 title,
354 body: prBody || null,
355 baseBranch,
356 headBranch,
6fc53bdClaude357 isDraft,
0074234Claude358 })
359 .returning();
360
6fc53bdClaude361 // Skip AI review on drafts — it runs again when the PR is marked ready.
362 if (!isDraft && isAiReviewEnabled()) {
e883329Claude363 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
364 (err) => console.error("[ai-review] Failed:", err)
365 );
366 }
367
3cbe3d6Claude368 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
369 triggerPrTriage({
370 ownerName,
371 repoName,
372 repositoryId: resolved.repo.id,
373 prId: pr.id,
374 prAuthorId: user.id,
375 title,
376 body: prBody,
377 baseBranch,
378 headBranch,
379 }).catch((err) => console.error("[pr-triage] Failed:", err));
380
0074234Claude381 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
382 }
383);
384
385// View single PR
386pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
387 const { owner: ownerName, repo: repoName } = c.req.param();
388 const prNum = parseInt(c.req.param("number"), 10);
389 const user = c.get("user");
390 const tab = c.req.query("tab") || "conversation";
391
392 const resolved = await resolveRepo(ownerName, repoName);
393 if (!resolved) return c.notFound();
394
395 const [pr] = await db
396 .select()
397 .from(pullRequests)
398 .where(
399 and(
400 eq(pullRequests.repositoryId, resolved.repo.id),
401 eq(pullRequests.number, prNum)
402 )
403 )
404 .limit(1);
405
406 if (!pr) return c.notFound();
407
408 const [author] = await db
409 .select()
410 .from(users)
411 .where(eq(users.id, pr.authorId))
412 .limit(1);
413
414 const comments = await db
415 .select({
416 comment: prComments,
417 author: { username: users.username },
418 })
419 .from(prComments)
420 .innerJoin(users, eq(prComments.authorId, users.id))
421 .where(eq(prComments.pullRequestId, pr.id))
422 .orderBy(asc(prComments.createdAt));
423
6fc53bdClaude424 // Reactions for the PR body + each comment, in parallel.
425 const [prReactions, ...prCommentReactions] = await Promise.all([
426 summariseReactions("pr", pr.id, user?.id),
427 ...comments.map((row) =>
428 summariseReactions("pr_comment", row.comment.id, user?.id)
429 ),
430 ]);
431
0074234Claude432 const canManage =
433 user &&
434 (user.id === resolved.owner.id || user.id === pr.authorId);
435
e883329Claude436 const error = c.req.query("error");
437
438 // Get gate check status for open PRs
439 let gateChecks: GateCheckResult[] = [];
440 if (pr.state === "open") {
441 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
442 if (headSha) {
443 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
444 const aiApproved = aiComments.length === 0 || aiComments.some(
445 ({ comment }) => comment.body.includes("**Approved**")
446 );
447 const gateResult = await runAllGateChecks(
448 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
449 );
450 gateChecks = gateResult.checks;
451 }
452 }
453
0074234Claude454 // Get diff for "Files changed" tab
455 let diffRaw = "";
456 let diffFiles: GitDiffFile[] = [];
457 if (tab === "files") {
458 const repoDir = getRepoPath(ownerName, repoName);
459 const proc = Bun.spawn(
460 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
461 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
462 );
463 diffRaw = await new Response(proc.stdout).text();
464 await proc.exited;
465
466 const statProc = Bun.spawn(
467 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
468 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
469 );
470 const stat = await new Response(statProc.stdout).text();
471 await statProc.exited;
472
473 diffFiles = stat
474 .trim()
475 .split("\n")
476 .filter(Boolean)
477 .map((line) => {
478 const [add, del, filePath] = line.split("\t");
479 return {
480 path: filePath,
481 status: "modified",
482 additions: add === "-" ? 0 : parseInt(add, 10),
483 deletions: del === "-" ? 0 : parseInt(del, 10),
484 patch: "",
485 };
486 });
487 }
488
489 return c.html(
490 <Layout
491 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
492 user={user}
493 >
494 <RepoHeader owner={ownerName} repo={repoName} />
495 <PrNav owner={ownerName} repo={repoName} active="pulls" />
496 <div class="issue-detail">
497 <h2>
498 {pr.title}{" "}
499 <span style="color: var(--text-muted); font-weight: 400">
500 #{pr.number}
501 </span>
502 </h2>
503 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
6fc53bdClaude504 {pr.state === "open" && pr.isDraft ? (
505 <span class="issue-badge draft-badge">
506 {"\u270E Draft"}
507 </span>
508 ) : (
509 <span
510 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
511 >
512 {pr.state === "open"
513 ? "\u25CB Open"
514 : pr.state === "merged"
515 ? "\u2B8C Merged"
516 : "\u2713 Closed"}
517 </span>
518 )}
0074234Claude519 <span style="color: var(--text-muted); font-size: 14px">
520 <strong style="color: var(--text)">
521 {author?.username}
522 </strong>{" "}
523 wants to merge <code>{pr.headBranch}</code> into{" "}
524 <code>{pr.baseBranch}</code>
525 </span>
526 </div>
527
528 <div class="issue-tabs" style="margin-bottom: 20px">
529 <a
530 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
531 class={tab === "conversation" ? "active" : ""}
532 >
533 Conversation
534 </a>
535 <a
536 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
537 class={tab === "files" ? "active" : ""}
538 >
539 Files changed
540 </a>
541 </div>
542
543 {tab === "files" ? (
544 <DiffView raw={diffRaw} files={diffFiles} />
545 ) : (
546 <>
547 {pr.body && (
548 <div class="issue-comment-box">
549 <div class="comment-header">
550 <strong>{author?.username}</strong> commented{" "}
551 {formatRelative(pr.createdAt)}
552 </div>
553 <div class="markdown-body">
554 {html([renderMarkdown(pr.body)] as unknown as TemplateStringsArray)}
555 </div>
6fc53bdClaude556 <div style="padding: 0 16px 12px">
557 <ReactionsBar
558 targetType="pr"
559 targetId={pr.id}
560 summaries={prReactions}
561 canReact={!!user}
562 />
563 </div>
0074234Claude564 </div>
565 )}
566
6fc53bdClaude567 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude568 <div
569 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
570 >
571 <div class="comment-header">
572 <strong>{commentAuthor.username}</strong>
573 {comment.isAiReview && (
574 <span class="badge" style="margin-left: 8px; background: rgba(31, 111, 235, 0.15); color: var(--text-link); border-color: var(--accent)">
575 AI Review
576 </span>
577 )}
578 {" "}
579 commented {formatRelative(comment.createdAt)}
580 {comment.filePath && (
581 <span style="margin-left: 8px; font-family: var(--font-mono); font-size: 11px">
582 {comment.filePath}
583 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
584 </span>
585 )}
586 </div>
587 <div class="markdown-body">
588 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
589 </div>
6fc53bdClaude590 <div style="padding: 0 16px 12px">
591 <ReactionsBar
592 targetType="pr_comment"
593 targetId={comment.id}
594 summaries={prCommentReactions[i] || []}
595 canReact={!!user}
596 />
597 </div>
0074234Claude598 </div>
599 ))}
600
e883329Claude601 {error && (
602 <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)">
603 {decodeURIComponent(error)}
604 </div>
605 )}
606
607 {pr.state === "open" && gateChecks.length > 0 && (
608 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
609 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
610 {gateChecks.map((check) => (
611 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
612 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
613 {check.passed ? "\u2713" : "\u2717"}
614 </span>
615 <strong style="font-size: 13px">{check.name}</strong>
616 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
617 </div>
618 ))}
619 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
620 {gateChecks.every((c) => c.passed)
621 ? "All checks passed — ready to merge"
622 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
623 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
624 : "Some checks failed — resolve issues before merging"}
625 </div>
626 </div>
627 )}
628
0074234Claude629 {user && pr.state === "open" && (
630 <div style="margin-top: 20px">
631 <form
632 method="POST"
633 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
634 >
635 <div class="form-group">
636 <textarea
637 name="body"
638 rows={6}
639 required
640 placeholder="Leave a comment... (Markdown supported)"
641 style="font-family: var(--font-mono); font-size: 13px"
642 />
643 </div>
644 <div style="display: flex; gap: 8px">
645 <button type="submit" class="btn btn-primary">
646 Comment
647 </button>
648 {canManage && (
649 <>
6fc53bdClaude650 {pr.isDraft ? (
651 <button
652 type="submit"
653 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
654 class="btn"
655 style="background: rgba(63, 185, 80, 0.15); border-color: var(--green); color: var(--green)"
656 title="Mark this draft PR as ready for review — triggers AI review"
657 >
658 Ready for review
659 </button>
660 ) : (
661 <>
662 <button
663 type="submit"
664 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
665 class="btn"
666 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)"}`}
667 >
668 {gateChecks.every((c) => c.passed)
669 ? "Merge pull request"
670 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
671 ? "Merge with auto-resolve"
672 : "Merge pull request"}
673 </button>
a79a9edClaude674 <button
675 type="submit"
676 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/enqueue`}
677 class="btn"
678 title="Queue this PR — gates will re-run against latest base before merge"
679 >
680 Add to merge queue
681 </button>
6fc53bdClaude682 <button
683 type="submit"
684 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
685 class="btn"
686 title="Convert back to draft"
687 >
688 Convert to draft
689 </button>
690 </>
691 )}
0074234Claude692 <button
693 type="submit"
694 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
695 class="btn btn-danger"
696 >
697 Close
698 </button>
699 </>
700 )}
701 </div>
702 </form>
703 </div>
704 )}
705 </>
706 )}
707 </div>
708 </Layout>
709 );
710});
711
712// Add comment to PR
713pulls.post(
714 "/:owner/:repo/pulls/:number/comment",
715 softAuth,
716 requireAuth,
717 async (c) => {
718 const { owner: ownerName, repo: repoName } = c.req.param();
719 const prNum = parseInt(c.req.param("number"), 10);
720 const user = c.get("user")!;
721 const body = await c.req.parseBody();
722 const commentBody = String(body.body || "").trim();
723
724 if (!commentBody) {
725 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
726 }
727
728 const resolved = await resolveRepo(ownerName, repoName);
729 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
730
731 const [pr] = await db
732 .select()
733 .from(pullRequests)
734 .where(
735 and(
736 eq(pullRequests.repositoryId, resolved.repo.id),
737 eq(pullRequests.number, prNum)
738 )
739 )
740 .limit(1);
741
742 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
743
744 await db.insert(prComments).values({
745 pullRequestId: pr.id,
746 authorId: user.id,
747 body: commentBody,
748 });
749
750 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
751 }
752);
753
e883329Claude754// Merge PR — with green gate enforcement and auto conflict resolution
0074234Claude755pulls.post(
756 "/:owner/:repo/pulls/:number/merge",
757 softAuth,
758 requireAuth,
759 async (c) => {
760 const { owner: ownerName, repo: repoName } = c.req.param();
761 const prNum = parseInt(c.req.param("number"), 10);
762 const user = c.get("user")!;
763
764 const resolved = await resolveRepo(ownerName, repoName);
765 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
766
767 const [pr] = await db
768 .select()
769 .from(pullRequests)
770 .where(
771 and(
772 eq(pullRequests.repositoryId, resolved.repo.id),
773 eq(pullRequests.number, prNum)
774 )
775 )
776 .limit(1);
777
778 if (!pr || pr.state !== "open") {
779 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
780 }
781
6fc53bdClaude782 // Draft PRs cannot be merged — must be marked ready first.
783 if (pr.isDraft) {
784 return c.redirect(
785 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
786 "This PR is a draft. Mark it as ready for review before merging."
787 )}`
788 );
789 }
790
e883329Claude791 // Resolve head SHA
792 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
793 if (!headSha) {
794 return c.redirect(
795 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
796 );
797 }
798
799 // Check if AI review approved this PR
800 const aiComments = await db
801 .select()
802 .from(prComments)
803 .where(
804 and(
805 eq(prComments.pullRequestId, pr.id),
806 eq(prComments.isAiReview, true)
807 )
808 );
809 const aiApproved = aiComments.length === 0 || aiComments.some(
810 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude811 );
e883329Claude812
813 // Run all green gate checks (GateTest + mergeability + AI review)
814 const gateResult = await runAllGateChecks(
815 ownerName,
816 repoName,
817 pr.baseBranch,
818 pr.headBranch,
819 headSha,
820 aiApproved
0074234Claude821 );
822
e883329Claude823 // If GateTest or AI review failed (hard blocks), reject the merge
824 const hardFailures = gateResult.checks.filter(
825 (check) => !check.passed && check.name !== "Merge check"
826 );
827 if (hardFailures.length > 0) {
828 const errorMsg = hardFailures
829 .map((f) => `${f.name}: ${f.details}`)
830 .join("; ");
0074234Claude831 return c.redirect(
e883329Claude832 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude833 );
834 }
835
1e162a8Claude836 // D5 — Branch-protection enforcement. Looks up the matching rule for the
837 // base branch and blocks the merge if requireAiApproval / requireGreenGates
838 // / requireHumanReview / requiredApprovals are not satisfied. Independent
839 // of repo-global settings, so owners can lock specific branches down
840 // further than the repo default.
841 const protectionRule = await matchProtection(
842 resolved.repo.id,
843 pr.baseBranch
844 );
845 if (protectionRule) {
846 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude847 const required = await listRequiredChecks(protectionRule.id);
848 const passingNames = required.length > 0
849 ? await passingCheckNames(resolved.repo.id, headSha)
850 : [];
851 const decision = evaluateProtection(
852 protectionRule,
853 {
854 aiApproved,
855 humanApprovalCount: humanApprovals,
856 gateResultGreen: hardFailures.length === 0,
857 hasFailedGates: hardFailures.length > 0,
858 passingCheckNames: passingNames,
859 },
860 required.map((r) => r.checkName)
861 );
1e162a8Claude862 if (!decision.allowed) {
863 return c.redirect(
864 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
865 decision.reasons.join(" ")
866 )}`
867 );
868 }
869 }
870
e883329Claude871 // Attempt the merge — with auto conflict resolution if needed
872 const repoDir = getRepoPath(ownerName, repoName);
873 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
874 const hasConflicts = mergeCheck && !mergeCheck.passed;
875
876 if (hasConflicts && isAiReviewEnabled()) {
877 // Use Claude to auto-resolve conflicts
878 const mergeResult = await mergeWithAutoResolve(
879 ownerName,
880 repoName,
881 pr.baseBranch,
882 pr.headBranch,
883 `Merge pull request #${pr.number}: ${pr.title}`
884 );
885
886 if (!mergeResult.success) {
887 return c.redirect(
888 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
889 );
890 }
891
892 // Post a comment about the auto-resolution
893 if (mergeResult.resolvedFiles.length > 0) {
894 await db.insert(prComments).values({
895 pullRequestId: pr.id,
896 authorId: user.id,
897 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
898 isAiReview: true,
899 });
900 }
901 } else {
902 // Standard merge — fast-forward or clean merge
903 const ffProc = Bun.spawn(
904 [
905 "git",
906 "update-ref",
907 `refs/heads/${pr.baseBranch}`,
908 `refs/heads/${pr.headBranch}`,
909 ],
910 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
911 );
912 const ffExit = await ffProc.exited;
913
914 if (ffExit !== 0) {
915 return c.redirect(
916 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
917 );
918 }
919 }
920
0074234Claude921 await db
922 .update(pullRequests)
923 .set({
924 state: "merged",
925 mergedAt: new Date(),
926 mergedBy: user.id,
927 updatedAt: new Date(),
928 })
929 .where(eq(pullRequests.id, pr.id));
930
d62fb36Claude931 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
932 // and auto-close each matching open issue with a back-link comment. Bounded
933 // to the same repo for v1 (cross-repo refs ignored). Failures never block
934 // the merge redirect.
935 try {
936 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
937 const refs = extractClosingRefsMulti([pr.title, pr.body]);
938 for (const n of refs) {
939 const [issue] = await db
940 .select()
941 .from(issues)
942 .where(
943 and(
944 eq(issues.repositoryId, resolved.repo.id),
945 eq(issues.number, n)
946 )
947 )
948 .limit(1);
949 if (!issue || issue.state !== "open") continue;
950 await db
951 .update(issues)
952 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
953 .where(eq(issues.id, issue.id));
954 await db.insert(issueComments).values({
955 issueId: issue.id,
956 authorId: user.id,
957 body: `Closed by pull request #${pr.number}.`,
958 });
959 }
960 } catch {
961 // Never block the merge on close-keyword failures.
962 }
963
0074234Claude964 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
965 }
966);
967
6fc53bdClaude968// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
969// hasn't run yet on this PR.
970pulls.post(
971 "/:owner/:repo/pulls/:number/ready",
972 softAuth,
973 requireAuth,
974 async (c) => {
975 const { owner: ownerName, repo: repoName } = c.req.param();
976 const prNum = parseInt(c.req.param("number"), 10);
977 const user = c.get("user")!;
978
979 const resolved = await resolveRepo(ownerName, repoName);
980 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
981
982 const [pr] = await db
983 .select()
984 .from(pullRequests)
985 .where(
986 and(
987 eq(pullRequests.repositoryId, resolved.repo.id),
988 eq(pullRequests.number, prNum)
989 )
990 )
991 .limit(1);
992 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
993
994 // Only the author or repo owner can toggle draft state.
995 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
996 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
997 }
998
999 if (pr.state === "open" && pr.isDraft) {
1000 await db
1001 .update(pullRequests)
1002 .set({ isDraft: false, updatedAt: new Date() })
1003 .where(eq(pullRequests.id, pr.id));
1004
1005 if (isAiReviewEnabled()) {
1006 triggerAiReview(
1007 ownerName,
1008 repoName,
1009 pr.id,
1010 pr.title,
1011 pr.body,
1012 pr.baseBranch,
1013 pr.headBranch
1014 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
1015 }
1016 }
1017
1018 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1019 }
1020);
1021
1022// Convert a PR back to draft.
1023pulls.post(
1024 "/:owner/:repo/pulls/:number/draft",
1025 softAuth,
1026 requireAuth,
1027 async (c) => {
1028 const { owner: ownerName, repo: repoName } = c.req.param();
1029 const prNum = parseInt(c.req.param("number"), 10);
1030 const user = c.get("user")!;
1031
1032 const resolved = await resolveRepo(ownerName, repoName);
1033 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1034
1035 const [pr] = await db
1036 .select()
1037 .from(pullRequests)
1038 .where(
1039 and(
1040 eq(pullRequests.repositoryId, resolved.repo.id),
1041 eq(pullRequests.number, prNum)
1042 )
1043 )
1044 .limit(1);
1045 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1046
1047 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1048 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1049 }
1050
1051 if (pr.state === "open" && !pr.isDraft) {
1052 await db
1053 .update(pullRequests)
1054 .set({ isDraft: true, updatedAt: new Date() })
1055 .where(eq(pullRequests.id, pr.id));
1056 }
1057
1058 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1059 }
1060);
1061
0074234Claude1062// Close PR
1063pulls.post(
1064 "/:owner/:repo/pulls/:number/close",
1065 softAuth,
1066 requireAuth,
1067 async (c) => {
1068 const { owner: ownerName, repo: repoName } = c.req.param();
1069 const prNum = parseInt(c.req.param("number"), 10);
1070
1071 const resolved = await resolveRepo(ownerName, repoName);
1072 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1073
1074 await db
1075 .update(pullRequests)
1076 .set({
1077 state: "closed",
1078 closedAt: new Date(),
1079 updatedAt: new Date(),
1080 })
1081 .where(
1082 and(
1083 eq(pullRequests.repositoryId, resolved.repo.id),
1084 eq(pullRequests.number, prNum)
1085 )
1086 );
1087
1088 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1089 }
1090);
1091
e883329Claude1092/**
1093 * Trigger AI code review asynchronously after PR creation.
1094 * Runs the diff through Claude and posts review comments.
1095 */
1096async function triggerAiReview(
1097 ownerName: string,
1098 repoName: string,
1099 prId: string,
1100 title: string,
1101 body: string | null,
1102 baseBranch: string,
1103 headBranch: string
1104): Promise<void> {
1105 const repoDir = getRepoPath(ownerName, repoName);
1106
1107 // Get the diff between branches
1108 const proc = Bun.spawn(
1109 ["git", "diff", `${baseBranch}...${headBranch}`],
1110 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1111 );
1112 const diffText = await new Response(proc.stdout).text();
1113 await proc.exited;
1114
1115 if (!diffText.trim()) return;
1116
1117 const result = await reviewDiff(
1118 `${ownerName}/${repoName}`,
1119 title,
1120 body,
1121 baseBranch,
1122 headBranch,
1123 diffText
1124 );
1125
1126 // We need a system user for AI reviews — use the PR author for now
1127 // Get the PR to find the author
1128 const [pr] = await db
1129 .select()
1130 .from(pullRequests)
1131 .where(eq(pullRequests.id, prId))
1132 .limit(1);
1133
1134 if (!pr) return;
1135
1136 // Post summary comment
1137 const statusEmoji = result.approved ? "**Approved**" : "**Changes Requested**";
1138 let commentBody = `## AI Code Review ${statusEmoji}\n\n${result.summary}`;
1139
1140 if (result.comments.length > 0) {
1141 commentBody += "\n\n### Issues Found\n";
1142 for (const comment of result.comments) {
1143 const location = comment.filePath
1144 ? `\`${comment.filePath}${comment.lineNumber ? `:${comment.lineNumber}` : ""}\``
1145 : "";
1146 commentBody += `\n---\n${location}\n\n${comment.body}\n`;
1147 }
1148 }
1149
1150 await db.insert(prComments).values({
1151 pullRequestId: prId,
1152 authorId: pr.authorId,
1153 body: commentBody,
1154 isAiReview: true,
1155 });
1156
1157 // Post individual file-level comments
1158 for (const comment of result.comments) {
1159 if (comment.filePath) {
1160 await db.insert(prComments).values({
1161 pullRequestId: prId,
1162 authorId: pr.authorId,
1163 body: comment.body,
1164 isAiReview: true,
1165 filePath: comment.filePath,
1166 lineNumber: comment.lineNumber,
1167 });
1168 }
1169 }
1170
1171 console.log(
1172 `[ai-review] Review posted for PR ${prId}: ${result.approved ? "approved" : "changes requested"}, ${result.comments.length} comments`
1173 );
1174}
1175
3cbe3d6Claude1176/**
1177 * D3 — AI PR triage. Runs Claude Haiku on the PR title/body + diff summary and
1178 * posts an AI-authored comment suggesting labels, reviewers, and priority.
1179 * Nothing is auto-applied — the PR author remains in control.
1180 */
1181async function triggerPrTriage(args: {
1182 ownerName: string;
1183 repoName: string;
1184 repositoryId: string;
1185 prId: string;
1186 prAuthorId: string;
1187 title: string;
1188 body: string;
1189 baseBranch: string;
1190 headBranch: string;
1191}): Promise<void> {
1192 try {
1193 // Gather candidate reviewers (top contributors from recent commits).
1194 const repoDir = getRepoPath(args.ownerName, args.repoName);
1195 const shortlogProc = Bun.spawn(
1196 ["git", "shortlog", "-sn", "--no-merges", "-50", args.baseBranch],
1197 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1198 );
1199 const shortlogOut = await new Response(shortlogProc.stdout).text();
1200 await shortlogProc.exited;
1201 const authorNames = shortlogOut
1202 .trim()
1203 .split("\n")
1204 .map((l) => l.trim().split(/\s+/).slice(1).join(" "))
1205 .filter(Boolean)
1206 .slice(0, 10);
1207
1208 // Look up usernames matching these author names (best-effort match).
1209 const candidateUsernames: string[] = [];
1210 if (authorNames.length > 0) {
1211 try {
1212 const matches = await db
1213 .select({ username: users.username, displayName: users.displayName })
1214 .from(users)
1215 .limit(100);
1216 for (const u of matches) {
1217 if (
1218 authorNames.some(
1219 (n) =>
1220 u.username === n ||
1221 (u.displayName && u.displayName === n)
1222 )
1223 ) {
1224 candidateUsernames.push(u.username);
1225 }
1226 }
1227 } catch {
1228 /* ignore */
1229 }
1230 }
1231 // Always include the repo owner as a candidate reviewer.
1232 try {
1233 const [ownerRow] = await db
1234 .select({ username: users.username })
1235 .from(users)
1236 .where(eq(users.username, args.ownerName))
1237 .limit(1);
1238 if (ownerRow && !candidateUsernames.includes(ownerRow.username)) {
1239 candidateUsernames.push(ownerRow.username);
1240 }
1241 } catch {
1242 /* ignore */
1243 }
1244
1245 // Load repo labels.
1246 const availableLabels = await db
1247 .select({ name: labelsTable.name })
1248 .from(labelsTable)
1249 .where(eq(labelsTable.repositoryId, args.repositoryId))
1250 .then((rows) => rows.map((r) => r.name))
1251 .catch(() => [] as string[]);
1252
1253 // Short diff summary (numstat only to keep prompt small).
1254 const statProc = Bun.spawn(
1255 ["git", "diff", "--numstat", `${args.baseBranch}...${args.headBranch}`],
1256 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1257 );
1258 const diffSummary = await new Response(statProc.stdout).text();
1259 await statProc.exited;
1260
1261 const result = await triagePullRequest(
1262 args.title,
1263 args.body,
1264 diffSummary,
1265 availableLabels,
1266 candidateUsernames
1267 );
1268
1269 // Skip posting if we have absolutely nothing useful to say.
1270 if (
1271 !result.summary &&
1272 result.suggestedLabels.length === 0 &&
1273 result.suggestedReviewerUsernames.length === 0
1274 ) {
1275 return;
1276 }
1277
1278 const priorityEmoji =
1279 result.priority === "critical"
1280 ? "**Critical**"
1281 : result.priority === "high"
1282 ? "**High**"
1283 : result.priority === "low"
1284 ? "**Low**"
1285 : "**Medium**";
1286 const parts: string[] = [`## AI Triage\n`];
1287 if (result.summary) parts.push(`${result.summary}\n`);
1288 parts.push(`- **Priority:** ${priorityEmoji}`);
1289 parts.push(`- **Risk area:** ${result.riskArea}`);
1290 if (result.suggestedLabels.length > 0) {
1291 parts.push(
1292 `- **Suggested labels:** ${result.suggestedLabels.map((l) => `\`${l}\``).join(", ")}`
1293 );
1294 }
1295 if (result.suggestedReviewerUsernames.length > 0) {
1296 parts.push(
1297 `- **Suggested reviewers:** ${result.suggestedReviewerUsernames.map((u) => `@${u}`).join(", ")}`
1298 );
1299 }
1300 parts.push(
1301 `\n_Suggestions only — nothing was auto-applied. The PR author remains in control._`
1302 );
1303
1304 await db.insert(prComments).values({
1305 pullRequestId: args.prId,
1306 authorId: args.prAuthorId,
1307 body: parts.join("\n"),
1308 isAiReview: true,
1309 });
1310 } catch (err) {
1311 console.error("[pr-triage]", err);
1312 }
1313}
1314
0074234Claude1315function formatRelative(date: Date | string): string {
1316 const d = typeof date === "string" ? new Date(date) : date;
1317 const now = new Date();
1318 const diffMs = now.getTime() - d.getTime();
1319 const diffMins = Math.floor(diffMs / 60000);
1320 if (diffMins < 1) return "just now";
1321 if (diffMins < 60) return `${diffMins}m ago`;
1322 const diffHours = Math.floor(diffMins / 60);
1323 if (diffHours < 24) return `${diffHours}h ago`;
1324 const diffDays = Math.floor(diffHours / 24);
1325 if (diffDays < 30) return `${diffDays}d ago`;
1326 return d.toLocaleDateString("en-US", {
1327 month: "short",
1328 day: "numeric",
1329 year: "numeric",
1330 });
1331}
1332
1333export default pulls;