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