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.tsxBlame1390 lines · 1 contributor
0074234Claude1/**
2 * Pull request routes — create, list, view, merge, close, comment.
3 */
4
5import { Hono } from "hono";
6import { eq, and, desc, asc, sql } from "drizzle-orm";
7import { db } from "../db";
8import {
9 pullRequests,
10 prComments,
11 repositories,
12 users,
d62fb36Claude13 issues,
14 issueComments,
0074234Claude15} from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader, DiffView } from "../views/components";
6fc53bdClaude18import { ReactionsBar } from "../views/reactions";
19import { summariseReactions } from "../lib/reactions";
24cf2caClaude20import { loadPrTemplate } from "../lib/templates";
0074234Claude21import { renderMarkdown } from "../lib/markdown";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import {
25 listBranches,
26 getRepoPath,
e883329Claude27 resolveRef,
0074234Claude28} from "../git/repository";
29import type { GitDiffFile } from "../git/repository";
30import { html } from "hono/html";
e883329Claude31import { reviewDiff, isAiReviewEnabled } from "../lib/ai-review";
3cbe3d6Claude32import { triagePullRequest } from "../lib/ai-generators";
e883329Claude33import { mergeWithAutoResolve } from "../lib/merge-resolver";
34import { runAllGateChecks, type GateCheckResult } from "../lib/gate";
3cbe3d6Claude35import { labels as labelsTable } from "../db/schema";
1e162a8Claude36import {
37 matchProtection,
38 evaluateProtection,
39 countHumanApprovals,
a79a9edClaude40 listRequiredChecks,
41 passingCheckNames,
1e162a8Claude42} from "../lib/branch-protection";
b2ff5c7Claude43import { recordReviewOutcome, extractPatterns } from "../lib/flywheel";
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 )}
9837657Claude259 <form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
0074234Claude260 <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()) {
b2ff5c7Claude364 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch, resolved.repo.id).catch(
e883329Claude365 (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
9837657Claude633 method="post"
0074234Claude634 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
745 await db.insert(prComments).values({
746 pullRequestId: pr.id,
747 authorId: user.id,
748 body: commentBody,
749 });
750
751 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
752 }
753);
754
e883329Claude755// Merge PR — with green gate enforcement and auto conflict resolution
0074234Claude756pulls.post(
757 "/:owner/:repo/pulls/:number/merge",
758 softAuth,
759 requireAuth,
760 async (c) => {
761 const { owner: ownerName, repo: repoName } = c.req.param();
762 const prNum = parseInt(c.req.param("number"), 10);
763 const user = c.get("user")!;
764
765 const resolved = await resolveRepo(ownerName, repoName);
766 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
767
768 const [pr] = await db
769 .select()
770 .from(pullRequests)
771 .where(
772 and(
773 eq(pullRequests.repositoryId, resolved.repo.id),
774 eq(pullRequests.number, prNum)
775 )
776 )
777 .limit(1);
778
779 if (!pr || pr.state !== "open") {
780 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
781 }
782
6fc53bdClaude783 // Draft PRs cannot be merged — must be marked ready first.
784 if (pr.isDraft) {
785 return c.redirect(
786 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
787 "This PR is a draft. Mark it as ready for review before merging."
788 )}`
789 );
790 }
791
e883329Claude792 // Resolve head SHA
793 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
794 if (!headSha) {
795 return c.redirect(
796 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
797 );
798 }
799
800 // Check if AI review approved this PR
801 const aiComments = await db
802 .select()
803 .from(prComments)
804 .where(
805 and(
806 eq(prComments.pullRequestId, pr.id),
807 eq(prComments.isAiReview, true)
808 )
809 );
810 const aiApproved = aiComments.length === 0 || aiComments.some(
811 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude812 );
e883329Claude813
814 // Run all green gate checks (GateTest + mergeability + AI review)
815 const gateResult = await runAllGateChecks(
816 ownerName,
817 repoName,
818 pr.baseBranch,
819 pr.headBranch,
820 headSha,
821 aiApproved
0074234Claude822 );
823
e883329Claude824 // If GateTest or AI review failed (hard blocks), reject the merge
825 const hardFailures = gateResult.checks.filter(
826 (check) => !check.passed && check.name !== "Merge check"
827 );
828 if (hardFailures.length > 0) {
829 const errorMsg = hardFailures
830 .map((f) => `${f.name}: ${f.details}`)
831 .join("; ");
0074234Claude832 return c.redirect(
e883329Claude833 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude834 );
835 }
836
1e162a8Claude837 // D5 — Branch-protection enforcement. Looks up the matching rule for the
838 // base branch and blocks the merge if requireAiApproval / requireGreenGates
839 // / requireHumanReview / requiredApprovals are not satisfied. Independent
840 // of repo-global settings, so owners can lock specific branches down
841 // further than the repo default.
842 const protectionRule = await matchProtection(
843 resolved.repo.id,
844 pr.baseBranch
845 );
846 if (protectionRule) {
847 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude848 const required = await listRequiredChecks(protectionRule.id);
849 const passingNames = required.length > 0
850 ? await passingCheckNames(resolved.repo.id, headSha)
851 : [];
852 const decision = evaluateProtection(
853 protectionRule,
854 {
855 aiApproved,
856 humanApprovalCount: humanApprovals,
857 gateResultGreen: hardFailures.length === 0,
858 hasFailedGates: hardFailures.length > 0,
859 passingCheckNames: passingNames,
860 },
861 required.map((r) => r.checkName)
862 );
1e162a8Claude863 if (!decision.allowed) {
864 return c.redirect(
865 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
866 decision.reasons.join(" ")
867 )}`
868 );
869 }
870 }
871
e883329Claude872 // Attempt the merge — with auto conflict resolution if needed
873 const repoDir = getRepoPath(ownerName, repoName);
874 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
875 const hasConflicts = mergeCheck && !mergeCheck.passed;
876
877 if (hasConflicts && isAiReviewEnabled()) {
878 // Use Claude to auto-resolve conflicts
879 const mergeResult = await mergeWithAutoResolve(
880 ownerName,
881 repoName,
882 pr.baseBranch,
883 pr.headBranch,
884 `Merge pull request #${pr.number}: ${pr.title}`
885 );
886
887 if (!mergeResult.success) {
888 return c.redirect(
889 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
890 );
891 }
892
893 // Post a comment about the auto-resolution
894 if (mergeResult.resolvedFiles.length > 0) {
895 await db.insert(prComments).values({
896 pullRequestId: pr.id,
897 authorId: user.id,
898 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
899 isAiReview: true,
900 });
901 }
902 } else {
903 // Standard merge — fast-forward or clean merge
904 const ffProc = Bun.spawn(
905 [
906 "git",
907 "update-ref",
908 `refs/heads/${pr.baseBranch}`,
909 `refs/heads/${pr.headBranch}`,
910 ],
911 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
912 );
913 const ffExit = await ffProc.exited;
914
915 if (ffExit !== 0) {
916 return c.redirect(
917 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
918 );
919 }
920 }
921
0074234Claude922 await db
923 .update(pullRequests)
924 .set({
925 state: "merged",
926 mergedAt: new Date(),
927 mergedBy: user.id,
928 updatedAt: new Date(),
929 })
930 .where(eq(pullRequests.id, pr.id));
931
b2ff5c7Claude932 // Flywheel: record AI review outcomes on merge (accepted = dev merged with AI comments present)
933 recordAiOutcomesOnMerge(resolved.repo.id, pr.id).catch((err) =>
934 console.error("[flywheel] outcome recording failed:", err)
935 );
936
d62fb36Claude937 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
938 // and auto-close each matching open issue with a back-link comment. Bounded
939 // to the same repo for v1 (cross-repo refs ignored). Failures never block
940 // the merge redirect.
941 try {
942 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
943 const refs = extractClosingRefsMulti([pr.title, pr.body]);
944 for (const n of refs) {
945 const [issue] = await db
946 .select()
947 .from(issues)
948 .where(
949 and(
950 eq(issues.repositoryId, resolved.repo.id),
951 eq(issues.number, n)
952 )
953 )
954 .limit(1);
955 if (!issue || issue.state !== "open") continue;
956 await db
957 .update(issues)
958 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
959 .where(eq(issues.id, issue.id));
960 await db.insert(issueComments).values({
961 issueId: issue.id,
962 authorId: user.id,
963 body: `Closed by pull request #${pr.number}.`,
964 });
965 }
966 } catch {
967 // Never block the merge on close-keyword failures.
968 }
969
0074234Claude970 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
971 }
972);
973
6fc53bdClaude974// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
975// hasn't run yet on this PR.
976pulls.post(
977 "/:owner/:repo/pulls/:number/ready",
978 softAuth,
979 requireAuth,
980 async (c) => {
981 const { owner: ownerName, repo: repoName } = c.req.param();
982 const prNum = parseInt(c.req.param("number"), 10);
983 const user = c.get("user")!;
984
985 const resolved = await resolveRepo(ownerName, repoName);
986 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
987
988 const [pr] = await db
989 .select()
990 .from(pullRequests)
991 .where(
992 and(
993 eq(pullRequests.repositoryId, resolved.repo.id),
994 eq(pullRequests.number, prNum)
995 )
996 )
997 .limit(1);
998 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
999
1000 // Only the author or repo owner can toggle draft state.
1001 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1002 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1003 }
1004
1005 if (pr.state === "open" && pr.isDraft) {
1006 await db
1007 .update(pullRequests)
1008 .set({ isDraft: false, updatedAt: new Date() })
1009 .where(eq(pullRequests.id, pr.id));
1010
1011 if (isAiReviewEnabled()) {
1012 triggerAiReview(
1013 ownerName,
1014 repoName,
1015 pr.id,
1016 pr.title,
1017 pr.body,
1018 pr.baseBranch,
b2ff5c7Claude1019 pr.headBranch,
1020 resolved.repo.id
6fc53bdClaude1021 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
1022 }
1023 }
1024
1025 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1026 }
1027);
1028
1029// Convert a PR back to draft.
1030pulls.post(
1031 "/:owner/:repo/pulls/:number/draft",
1032 softAuth,
1033 requireAuth,
1034 async (c) => {
1035 const { owner: ownerName, repo: repoName } = c.req.param();
1036 const prNum = parseInt(c.req.param("number"), 10);
1037 const user = c.get("user")!;
1038
1039 const resolved = await resolveRepo(ownerName, repoName);
1040 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1041
1042 const [pr] = await db
1043 .select()
1044 .from(pullRequests)
1045 .where(
1046 and(
1047 eq(pullRequests.repositoryId, resolved.repo.id),
1048 eq(pullRequests.number, prNum)
1049 )
1050 )
1051 .limit(1);
1052 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1053
1054 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1055 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1056 }
1057
1058 if (pr.state === "open" && !pr.isDraft) {
1059 await db
1060 .update(pullRequests)
1061 .set({ isDraft: true, updatedAt: new Date() })
1062 .where(eq(pullRequests.id, pr.id));
1063 }
1064
1065 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1066 }
1067);
1068
0074234Claude1069// Close PR
1070pulls.post(
1071 "/:owner/:repo/pulls/:number/close",
1072 softAuth,
1073 requireAuth,
1074 async (c) => {
1075 const { owner: ownerName, repo: repoName } = c.req.param();
1076 const prNum = parseInt(c.req.param("number"), 10);
1077
1078 const resolved = await resolveRepo(ownerName, repoName);
1079 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1080
1081 await db
1082 .update(pullRequests)
1083 .set({
1084 state: "closed",
1085 closedAt: new Date(),
1086 updatedAt: new Date(),
1087 })
1088 .where(
1089 and(
1090 eq(pullRequests.repositoryId, resolved.repo.id),
1091 eq(pullRequests.number, prNum)
1092 )
1093 );
1094
1095 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1096 }
1097);
1098
e883329Claude1099/**
1100 * Trigger AI code review asynchronously after PR creation.
1101 * Runs the diff through Claude and posts review comments.
1102 */
1103async function triggerAiReview(
1104 ownerName: string,
1105 repoName: string,
1106 prId: string,
1107 title: string,
1108 body: string | null,
1109 baseBranch: string,
b2ff5c7Claude1110 headBranch: string,
1111 repositoryId?: string
e883329Claude1112): Promise<void> {
1113 const repoDir = getRepoPath(ownerName, repoName);
1114
1115 // Get the diff between branches
1116 const proc = Bun.spawn(
1117 ["git", "diff", `${baseBranch}...${headBranch}`],
1118 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1119 );
1120 const diffText = await new Response(proc.stdout).text();
1121 await proc.exited;
1122
1123 if (!diffText.trim()) return;
1124
1125 const result = await reviewDiff(
1126 `${ownerName}/${repoName}`,
1127 title,
1128 body,
1129 baseBranch,
1130 headBranch,
b2ff5c7Claude1131 diffText,
1132 { repositoryId }
e883329Claude1133 );
1134
1135 // We need a system user for AI reviews — use the PR author for now
1136 // Get the PR to find the author
1137 const [pr] = await db
1138 .select()
1139 .from(pullRequests)
1140 .where(eq(pullRequests.id, prId))
1141 .limit(1);
1142
1143 if (!pr) return;
1144
1145 // Post summary comment
1146 const statusEmoji = result.approved ? "**Approved**" : "**Changes Requested**";
1147 let commentBody = `## AI Code Review ${statusEmoji}\n\n${result.summary}`;
1148
1149 if (result.comments.length > 0) {
1150 commentBody += "\n\n### Issues Found\n";
1151 for (const comment of result.comments) {
1152 const location = comment.filePath
1153 ? `\`${comment.filePath}${comment.lineNumber ? `:${comment.lineNumber}` : ""}\``
1154 : "";
1155 commentBody += `\n---\n${location}\n\n${comment.body}\n`;
1156 }
1157 }
1158
1159 await db.insert(prComments).values({
1160 pullRequestId: prId,
1161 authorId: pr.authorId,
1162 body: commentBody,
1163 isAiReview: true,
1164 });
1165
1166 // Post individual file-level comments
1167 for (const comment of result.comments) {
1168 if (comment.filePath) {
1169 await db.insert(prComments).values({
1170 pullRequestId: prId,
1171 authorId: pr.authorId,
1172 body: comment.body,
1173 isAiReview: true,
1174 filePath: comment.filePath,
1175 lineNumber: comment.lineNumber,
1176 });
1177 }
1178 }
1179
1180 console.log(
1181 `[ai-review] Review posted for PR ${prId}: ${result.approved ? "approved" : "changes requested"}, ${result.comments.length} comments`
1182 );
1183}
1184
b2ff5c7Claude1185/**
1186 * Flywheel: when a PR is merged, treat all AI review comments as "accepted"
1187 * (the developer saw them and merged anyway). When a PR is closed without
1188 * merging, treat them as "ignored". This is the primary signal for learning.
1189 */
1190async function recordAiOutcomesOnMerge(
1191 repositoryId: string,
1192 pullRequestId: string
1193): Promise<void> {
1194 const aiComments = await db
1195 .select()
1196 .from(prComments)
1197 .where(
1198 and(
1199 eq(prComments.pullRequestId, pullRequestId),
1200 eq(prComments.isAiReview, true)
1201 )
1202 );
1203
1204 for (const comment of aiComments) {
1205 if (!comment.filePath) continue; // skip summary comments
1206 const category = inferCategory(comment.body);
1207 await recordReviewOutcome({
1208 repositoryId,
1209 pullRequestId,
1210 commentId: comment.id,
1211 outcome: "accepted",
1212 category,
1213 filePath: comment.filePath,
1214 language: undefined,
1215 });
1216 }
1217
1218 // Periodically trigger pattern extraction
1219 if (aiComments.length > 0 && Math.random() < 0.2) {
1220 extractPatterns(repositoryId).catch(() => {});
1221 }
1222}
1223
1224function inferCategory(commentBody: string): string {
1225 const lower = commentBody.toLowerCase();
1226 if (lower.includes("security") || lower.includes("injection") || lower.includes("xss") || lower.includes("auth")) return "security";
1227 if (lower.includes("performance") || lower.includes("n+1") || lower.includes("blocking")) return "perf";
1228 if (lower.includes("breaking") || lower.includes("api contract")) return "breaking";
1229 if (lower.includes("logic") || lower.includes("off-by-one") || lower.includes("null")) return "logic";
1230 return "bug";
1231}
1232
3cbe3d6Claude1233/**
1234 * D3 — AI PR triage. Runs Claude Haiku on the PR title/body + diff summary and
1235 * posts an AI-authored comment suggesting labels, reviewers, and priority.
1236 * Nothing is auto-applied — the PR author remains in control.
1237 */
1238async function triggerPrTriage(args: {
1239 ownerName: string;
1240 repoName: string;
1241 repositoryId: string;
1242 prId: string;
1243 prAuthorId: string;
1244 title: string;
1245 body: string;
1246 baseBranch: string;
1247 headBranch: string;
1248}): Promise<void> {
1249 try {
1250 // Gather candidate reviewers (top contributors from recent commits).
1251 const repoDir = getRepoPath(args.ownerName, args.repoName);
1252 const shortlogProc = Bun.spawn(
1253 ["git", "shortlog", "-sn", "--no-merges", "-50", args.baseBranch],
1254 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1255 );
1256 const shortlogOut = await new Response(shortlogProc.stdout).text();
1257 await shortlogProc.exited;
1258 const authorNames = shortlogOut
1259 .trim()
1260 .split("\n")
1261 .map((l) => l.trim().split(/\s+/).slice(1).join(" "))
1262 .filter(Boolean)
1263 .slice(0, 10);
1264
1265 // Look up usernames matching these author names (best-effort match).
1266 const candidateUsernames: string[] = [];
1267 if (authorNames.length > 0) {
1268 try {
1269 const matches = await db
1270 .select({ username: users.username, displayName: users.displayName })
1271 .from(users)
1272 .limit(100);
1273 for (const u of matches) {
1274 if (
1275 authorNames.some(
1276 (n) =>
1277 u.username === n ||
1278 (u.displayName && u.displayName === n)
1279 )
1280 ) {
1281 candidateUsernames.push(u.username);
1282 }
1283 }
1284 } catch {
1285 /* ignore */
1286 }
1287 }
1288 // Always include the repo owner as a candidate reviewer.
1289 try {
1290 const [ownerRow] = await db
1291 .select({ username: users.username })
1292 .from(users)
1293 .where(eq(users.username, args.ownerName))
1294 .limit(1);
1295 if (ownerRow && !candidateUsernames.includes(ownerRow.username)) {
1296 candidateUsernames.push(ownerRow.username);
1297 }
1298 } catch {
1299 /* ignore */
1300 }
1301
1302 // Load repo labels.
1303 const availableLabels = await db
1304 .select({ name: labelsTable.name })
1305 .from(labelsTable)
1306 .where(eq(labelsTable.repositoryId, args.repositoryId))
1307 .then((rows) => rows.map((r) => r.name))
1308 .catch(() => [] as string[]);
1309
1310 // Short diff summary (numstat only to keep prompt small).
1311 const statProc = Bun.spawn(
1312 ["git", "diff", "--numstat", `${args.baseBranch}...${args.headBranch}`],
1313 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1314 );
1315 const diffSummary = await new Response(statProc.stdout).text();
1316 await statProc.exited;
1317
1318 const result = await triagePullRequest(
1319 args.title,
1320 args.body,
1321 diffSummary,
1322 availableLabels,
1323 candidateUsernames
1324 );
1325
1326 // Skip posting if we have absolutely nothing useful to say.
1327 if (
1328 !result.summary &&
1329 result.suggestedLabels.length === 0 &&
1330 result.suggestedReviewerUsernames.length === 0
1331 ) {
1332 return;
1333 }
1334
1335 const priorityEmoji =
1336 result.priority === "critical"
1337 ? "**Critical**"
1338 : result.priority === "high"
1339 ? "**High**"
1340 : result.priority === "low"
1341 ? "**Low**"
1342 : "**Medium**";
1343 const parts: string[] = [`## AI Triage\n`];
1344 if (result.summary) parts.push(`${result.summary}\n`);
1345 parts.push(`- **Priority:** ${priorityEmoji}`);
1346 parts.push(`- **Risk area:** ${result.riskArea}`);
1347 if (result.suggestedLabels.length > 0) {
1348 parts.push(
1349 `- **Suggested labels:** ${result.suggestedLabels.map((l) => `\`${l}\``).join(", ")}`
1350 );
1351 }
1352 if (result.suggestedReviewerUsernames.length > 0) {
1353 parts.push(
1354 `- **Suggested reviewers:** ${result.suggestedReviewerUsernames.map((u) => `@${u}`).join(", ")}`
1355 );
1356 }
1357 parts.push(
1358 `\n_Suggestions only — nothing was auto-applied. The PR author remains in control._`
1359 );
1360
1361 await db.insert(prComments).values({
1362 pullRequestId: args.prId,
1363 authorId: args.prAuthorId,
1364 body: parts.join("\n"),
1365 isAiReview: true,
1366 });
1367 } catch (err) {
1368 console.error("[pr-triage]", err);
1369 }
1370}
1371
0074234Claude1372function formatRelative(date: Date | string): string {
1373 const d = typeof date === "string" ? new Date(date) : date;
1374 const now = new Date();
1375 const diffMs = now.getTime() - d.getTime();
1376 const diffMins = Math.floor(diffMs / 60000);
1377 if (diffMins < 1) return "just now";
1378 if (diffMins < 60) return `${diffMins}m ago`;
1379 const diffHours = Math.floor(diffMins / 60);
1380 if (diffHours < 24) return `${diffHours}h ago`;
1381 const diffDays = Math.floor(diffHours / 24);
1382 if (diffDays < 30) return `${diffDays}d ago`;
1383 return d.toLocaleDateString("en-US", {
1384 month: "short",
1385 day: "numeric",
1386 year: "numeric",
1387 });
1388}
1389
1390export default pulls;