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