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