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.tsxBlame1168 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";
b584e52Claude22import { liveCommentBannerScript } from "../lib/sse-client";
0074234Claude23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
04f6b7fClaude25import { requireRepoAccess } from "../middleware/repo-access";
0316dbbClaude26import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
27import { triggerPrTriage } from "../lib/pr-triage";
81c73c1Claude28import { generatePrSummary } from "../lib/ai-generators";
29import { isAiAvailable } from "../lib/ai-client";
30import { getRepoPath } from "../git/repository";
0316dbbClaude31import { runAllGateChecks } from "../lib/gate";
32import type { GateCheckResult } from "../lib/gate";
33import {
34 matchProtection,
35 countHumanApprovals,
36 listRequiredChecks,
37 passingCheckNames,
38 evaluateProtection,
39} from "../lib/branch-protection";
40import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude41import {
42 listBranches,
43 getRepoPath,
e883329Claude44 resolveRef,
0074234Claude45} from "../git/repository";
46import type { GitDiffFile } from "../git/repository";
47import { html } from "hono/html";
1e162a8Claude48import {
bb0f894Claude49 Flex,
50 Container,
51 Badge,
52 Button,
53 LinkButton,
54 Form,
55 FormGroup,
56 Input,
57 TextArea,
58 Select,
59 EmptyState,
60 FilterTabs,
61 TabNav,
62 List,
63 ListItem,
64 Text,
65 Alert,
66 MarkdownContent,
67 CommentBox,
68 formatRelative,
69} from "../views/ui";
0074234Claude70
71const pulls = new Hono<AuthEnv>();
72
81c73c1Claude73/**
74 * Tiny inline JS that drives the "Suggest description with AI" button.
75 * On click, gathers form values, POSTs JSON to the given endpoint, and
76 * pipes the response into the #pr-body textarea. All DOM lookups are
77 * defensive — element absence is a silent no-op.
78 *
79 * Built as a string template so it lives next to its server-side caller
80 * and there is no bundler dependency. The endpoint URL is JSON-escaped
81 * to avoid </script> breakouts.
82 */
83function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
84 const url = JSON.stringify(endpointUrl)
85 .split("<").join("\\u003C")
86 .split(">").join("\\u003E")
87 .split("&").join("\\u0026");
88 return (
89 "(function(){try{" +
90 "var btn=document.getElementById('ai-suggest-desc');" +
91 "var status=document.getElementById('ai-suggest-status');" +
92 "var body=document.getElementById('pr-body');" +
93 "var form=btn&&btn.closest&&btn.closest('form');" +
94 "if(!btn||!body||!form)return;" +
95 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
96 "var fd=new FormData(form);" +
97 "var title=String(fd.get('title')||'').trim();" +
98 "var base=String(fd.get('base')||'').trim();" +
99 "var head=String(fd.get('head')||'').trim();" +
100 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
101 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
102 "fetch(" + url + ",{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:'title='+encodeURIComponent(title)+'&base='+encodeURIComponent(base)+'&head='+encodeURIComponent(head),credentials:'same-origin'})" +
103 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
104 ".then(function(j){btn.disabled=false;" +
105 "if(j&&j.ok&&typeof j.body==='string'){if(body.value&&body.value.trim().length>0){if(!confirm('Replace existing description?')){if(status)status.textContent='Cancelled.';return;}}" +
106 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
107 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
108 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
109 "});" +
110 "}catch(e){}})();"
111 );
112}
113
0074234Claude114async function resolveRepo(ownerName: string, repoName: string) {
115 const [owner] = await db
116 .select()
117 .from(users)
118 .where(eq(users.username, ownerName))
119 .limit(1);
120 if (!owner) return null;
121 const [repo] = await db
122 .select()
123 .from(repositories)
124 .where(
125 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
126 )
127 .limit(1);
128 if (!repo) return null;
129 return { owner, repo };
130}
131
132// PR Nav helper
133const PrNav = ({
134 owner,
135 repo,
136 active,
137}: {
138 owner: string;
139 repo: string;
140 active: "code" | "issues" | "pulls" | "commits";
141}) => (
bb0f894Claude142 <TabNav
143 tabs={[
144 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
145 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
146 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
147 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
148 ]}
149 />
0074234Claude150);
151
152// List PRs
04f6b7fClaude153pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude154 const { owner: ownerName, repo: repoName } = c.req.param();
155 const user = c.get("user");
156 const state = c.req.query("state") || "open";
157
158 const resolved = await resolveRepo(ownerName, repoName);
159 if (!resolved) return c.notFound();
160
6fc53bdClaude161 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
162 const stateFilter =
163 state === "draft"
164 ? and(
165 eq(pullRequests.state, "open"),
166 eq(pullRequests.isDraft, true)
167 )
168 : eq(pullRequests.state, state);
169
0074234Claude170 const prList = await db
171 .select({
172 pr: pullRequests,
173 author: { username: users.username },
174 })
175 .from(pullRequests)
176 .innerJoin(users, eq(pullRequests.authorId, users.id))
177 .where(
6fc53bdClaude178 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude179 )
180 .orderBy(desc(pullRequests.createdAt));
181
182 const [counts] = await db
183 .select({
184 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude185 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude186 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
187 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
188 })
189 .from(pullRequests)
190 .where(eq(pullRequests.repositoryId, resolved.repo.id));
191
192 return c.html(
193 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
194 <RepoHeader owner={ownerName} repo={repoName} />
195 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude196 <Flex justify="space-between" align="center" style="margin-bottom:16px">
197 <FilterTabs
198 tabs={[
199 { label: `${counts?.open ?? 0} Open`, href: `/${ownerName}/${repoName}/pulls?state=open`, active: state === "open" },
200 { label: `${counts?.merged ?? 0} Merged`, href: `/${ownerName}/${repoName}/pulls?state=merged`, active: state === "merged" },
201 { label: `${counts?.closed ?? 0} Closed`, href: `/${ownerName}/${repoName}/pulls?state=closed`, active: state === "closed" },
202 ]}
203 />
0074234Claude204 {user && (
bb0f894Claude205 <LinkButton href={`/${ownerName}/${repoName}/pulls/new`} variant="primary">
0074234Claude206 New pull request
bb0f894Claude207 </LinkButton>
0074234Claude208 )}
bb0f894Claude209 </Flex>
0074234Claude210 {prList.length === 0 ? (
bb0f894Claude211 <EmptyState>
0074234Claude212 <p>No {state} pull requests.</p>
bb0f894Claude213 </EmptyState>
0074234Claude214 ) : (
bb0f894Claude215 <List>
0074234Claude216 {prList.map(({ pr, author }) => (
bb0f894Claude217 <ListItem>
0074234Claude218 <div
219 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
220 >
221 {pr.state === "open"
222 ? "\u25CB"
223 : pr.state === "merged"
224 ? "\u2B8C"
225 : "\u2713"}
226 </div>
227 <div>
228 <div class="issue-title">
229 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
230 {pr.title}
231 </a>
232 </div>
233 <div class="issue-meta">
234 #{pr.number}{" "}
235 {pr.headBranch} → {pr.baseBranch}{" "}
236 by {author.username}{" "}
237 {formatRelative(pr.createdAt)}
238 </div>
239 </div>
bb0f894Claude240 </ListItem>
0074234Claude241 ))}
bb0f894Claude242 </List>
0074234Claude243 )}
244 </Layout>
245 );
246});
247
248// New PR form
249pulls.get(
250 "/:owner/:repo/pulls/new",
251 softAuth,
252 requireAuth,
04f6b7fClaude253 requireRepoAccess("write"),
0074234Claude254 async (c) => {
255 const { owner: ownerName, repo: repoName } = c.req.param();
256 const user = c.get("user")!;
257 const branches = await listBranches(ownerName, repoName);
258 const error = c.req.query("error");
259 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude260 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude261
262 return c.html(
263 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
264 <RepoHeader owner={ownerName} repo={repoName} />
265 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude266 <Container maxWidth={800}>
267 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude268 {error && (
bb0f894Claude269 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude270 )}
0316dbbClaude271 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
272 <Flex gap={12} align="center" style="margin-bottom: 16px">
273 <Select name="base">
0074234Claude274 {branches.map((b) => (
275 <option value={b} selected={b === defaultBase}>
276 {b}
277 </option>
278 ))}
bb0f894Claude279 </Select>
280 <Text muted>&larr;</Text>
281 <Select name="head">
0074234Claude282 {branches
283 .filter((b) => b !== defaultBase)
284 .concat(defaultBase === branches[0] ? [] : [branches[0]])
285 .map((b) => (
286 <option value={b}>{b}</option>
287 ))}
bb0f894Claude288 </Select>
289 </Flex>
290 <FormGroup>
291 <Input
0074234Claude292 name="title"
293 required
294 placeholder="Title"
bb0f894Claude295 style="font-size:16px;padding:10px 14px"
0074234Claude296 />
bb0f894Claude297 </FormGroup>
298 <FormGroup>
299 <TextArea
0074234Claude300 name="body"
81c73c1Claude301 id="pr-body"
0074234Claude302 rows={8}
303 placeholder="Description (Markdown supported)"
bb0f894Claude304 mono
0074234Claude305 />
bb0f894Claude306 </FormGroup>
81c73c1Claude307 <Flex gap={8} align="center">
308 <Button type="submit" variant="primary">
309 Create pull request
310 </Button>
311 <button
312 type="button"
313 id="ai-suggest-desc"
314 class="btn"
315 style="font-weight:500"
316 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
317 >
318 Suggest description with AI
319 </button>
320 <span
321 id="ai-suggest-status"
322 style="color:var(--text-muted);font-size:13px"
323 />
324 </Flex>
bb0f894Claude325 </Form>
81c73c1Claude326 <script
327 dangerouslySetInnerHTML={{
328 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
329 }}
330 />
bb0f894Claude331 </Container>
0074234Claude332 </Layout>
333 );
334 }
335);
336
81c73c1Claude337// AI-suggested PR description — JSON endpoint driven by the form button.
338// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
339// 200; the inline script reads `ok` to decide what to do.
340pulls.post(
341 "/:owner/:repo/ai/pr-description",
342 softAuth,
343 requireAuth,
344 requireRepoAccess("write"),
345 async (c) => {
346 const { owner: ownerName, repo: repoName } = c.req.param();
347 if (!isAiAvailable()) {
348 return c.json({
349 ok: false,
350 error: "AI is not available — set ANTHROPIC_API_KEY.",
351 });
352 }
353 const body = await c.req.parseBody();
354 const title = String(body.title || "").trim();
355 const baseBranch = String(body.base || "").trim();
356 const headBranch = String(body.head || "").trim();
357 if (!baseBranch || !headBranch) {
358 return c.json({ ok: false, error: "Pick base + head branches first." });
359 }
360 if (baseBranch === headBranch) {
361 return c.json({ ok: false, error: "Base and head must differ." });
362 }
363
364 let diff = "";
365 try {
366 const cwd = getRepoPath(ownerName, repoName);
367 const proc = Bun.spawn(
368 [
369 "git",
370 "diff",
371 `${baseBranch}...${headBranch}`,
372 "--",
373 ],
374 { cwd, stdout: "pipe", stderr: "pipe" }
375 );
376 diff = await new Response(proc.stdout).text();
377 await proc.exited;
378 } catch {
379 diff = "";
380 }
381 if (!diff.trim()) {
382 return c.json({
383 ok: false,
384 error: "No diff between branches — nothing to summarise.",
385 });
386 }
387
388 let summary = "";
389 try {
390 summary = await generatePrSummary(title || "(untitled)", diff);
391 } catch (err) {
392 const msg = err instanceof Error ? err.message : "AI request failed.";
393 return c.json({ ok: false, error: msg });
394 }
395 if (!summary.trim()) {
396 return c.json({ ok: false, error: "AI returned an empty draft." });
397 }
398 return c.json({ ok: true, body: summary });
399 }
400);
401
0074234Claude402// Create PR
403pulls.post(
404 "/:owner/:repo/pulls/new",
405 softAuth,
406 requireAuth,
04f6b7fClaude407 requireRepoAccess("write"),
0074234Claude408 async (c) => {
409 const { owner: ownerName, repo: repoName } = c.req.param();
410 const user = c.get("user")!;
411 const body = await c.req.parseBody();
412 const title = String(body.title || "").trim();
413 const prBody = String(body.body || "").trim();
414 const baseBranch = String(body.base || "main");
415 const headBranch = String(body.head || "");
416
417 if (!title || !headBranch) {
418 return c.redirect(
419 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
420 );
421 }
422
423 if (baseBranch === headBranch) {
424 return c.redirect(
425 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
426 );
427 }
428
429 const resolved = await resolveRepo(ownerName, repoName);
430 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
431
6fc53bdClaude432 const isDraft = String(body.draft || "") === "1";
433
0074234Claude434 const [pr] = await db
435 .insert(pullRequests)
436 .values({
437 repositoryId: resolved.repo.id,
438 authorId: user.id,
439 title,
440 body: prBody || null,
441 baseBranch,
442 headBranch,
6fc53bdClaude443 isDraft,
0074234Claude444 })
445 .returning();
446
6fc53bdClaude447 // Skip AI review on drafts — it runs again when the PR is marked ready.
448 if (!isDraft && isAiReviewEnabled()) {
e883329Claude449 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
450 (err) => console.error("[ai-review] Failed:", err)
451 );
452 }
453
3cbe3d6Claude454 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
455 triggerPrTriage({
456 ownerName,
457 repoName,
458 repositoryId: resolved.repo.id,
459 prId: pr.id,
460 prAuthorId: user.id,
461 title,
462 body: prBody,
463 baseBranch,
464 headBranch,
465 }).catch((err) => console.error("[pr-triage] Failed:", err));
466
0074234Claude467 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
468 }
469);
470
471// View single PR
04f6b7fClaude472pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude473 const { owner: ownerName, repo: repoName } = c.req.param();
474 const prNum = parseInt(c.req.param("number"), 10);
475 const user = c.get("user");
476 const tab = c.req.query("tab") || "conversation";
477
478 const resolved = await resolveRepo(ownerName, repoName);
479 if (!resolved) return c.notFound();
480
481 const [pr] = await db
482 .select()
483 .from(pullRequests)
484 .where(
485 and(
486 eq(pullRequests.repositoryId, resolved.repo.id),
487 eq(pullRequests.number, prNum)
488 )
489 )
490 .limit(1);
491
492 if (!pr) return c.notFound();
493
494 const [author] = await db
495 .select()
496 .from(users)
497 .where(eq(users.id, pr.authorId))
498 .limit(1);
499
500 const comments = await db
501 .select({
502 comment: prComments,
503 author: { username: users.username },
504 })
505 .from(prComments)
506 .innerJoin(users, eq(prComments.authorId, users.id))
507 .where(eq(prComments.pullRequestId, pr.id))
508 .orderBy(asc(prComments.createdAt));
509
6fc53bdClaude510 // Reactions for the PR body + each comment, in parallel.
511 const [prReactions, ...prCommentReactions] = await Promise.all([
512 summariseReactions("pr", pr.id, user?.id),
513 ...comments.map((row) =>
514 summariseReactions("pr_comment", row.comment.id, user?.id)
515 ),
516 ]);
517
0074234Claude518 const canManage =
519 user &&
520 (user.id === resolved.owner.id || user.id === pr.authorId);
521
e883329Claude522 const error = c.req.query("error");
523
524 // Get gate check status for open PRs
525 let gateChecks: GateCheckResult[] = [];
526 if (pr.state === "open") {
527 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
528 if (headSha) {
529 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
530 const aiApproved = aiComments.length === 0 || aiComments.some(
531 ({ comment }) => comment.body.includes("**Approved**")
532 );
533 const gateResult = await runAllGateChecks(
534 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
535 );
536 gateChecks = gateResult.checks;
537 }
538 }
539
0074234Claude540 // Get diff for "Files changed" tab
541 let diffRaw = "";
542 let diffFiles: GitDiffFile[] = [];
543 if (tab === "files") {
544 const repoDir = getRepoPath(ownerName, repoName);
545 const proc = Bun.spawn(
546 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
547 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
548 );
549 diffRaw = await new Response(proc.stdout).text();
550 await proc.exited;
551
552 const statProc = Bun.spawn(
553 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
554 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
555 );
556 const stat = await new Response(statProc.stdout).text();
557 await statProc.exited;
558
559 diffFiles = stat
560 .trim()
561 .split("\n")
562 .filter(Boolean)
563 .map((line) => {
564 const [add, del, filePath] = line.split("\t");
565 return {
566 path: filePath,
567 status: "modified",
568 additions: add === "-" ? 0 : parseInt(add, 10),
569 deletions: del === "-" ? 0 : parseInt(del, 10),
570 patch: "",
571 };
572 });
573 }
574
575 return c.html(
576 <Layout
577 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
578 user={user}
579 >
580 <RepoHeader owner={ownerName} repo={repoName} />
581 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b584e52Claude582 <div
583 id="live-comment-banner"
584 class="alert"
585 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
586 >
587 <strong class="js-live-count">0</strong> new comment(s) —{" "}
588 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
589 reload to view
590 </a>
591 </div>
592 <script
593 dangerouslySetInnerHTML={{
594 __html: liveCommentBannerScript({
595 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
596 bannerElementId: "live-comment-banner",
597 }),
598 }}
599 />
0074234Claude600 <div class="issue-detail">
601 <h2>
602 {pr.title}{" "}
bb0f894Claude603 <Text color="var(--text-muted)" weight={400}>
0074234Claude604 #{pr.number}
bb0f894Claude605 </Text>
0074234Claude606 </h2>
bb0f894Claude607 <Flex align="center" gap={8} style="margin:8px 0 20px">
608 <Badge
609 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
0074234Claude610 >
611 {pr.state === "open"
612 ? "\u25CB Open"
613 : pr.state === "merged"
614 ? "\u2B8C Merged"
615 : "\u2713 Closed"}
bb0f894Claude616 </Badge>
617 <Text size={14} muted>
618 <strong style="color:var(--text)">
0074234Claude619 {author?.username}
620 </strong>{" "}
621 wants to merge <code>{pr.headBranch}</code> into{" "}
622 <code>{pr.baseBranch}</code>
bb0f894Claude623 </Text>
624 </Flex>
625
626 <FilterTabs
627 tabs={[
628 {
629 label: "Conversation",
630 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
631 active: tab === "conversation",
632 },
633 {
634 label: "Files changed",
635 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
636 active: tab === "files",
637 },
638 ]}
639 />
0074234Claude640
641 {tab === "files" ? (
642 <DiffView raw={diffRaw} files={diffFiles} />
643 ) : (
644 <>
645 {pr.body && (
bb0f894Claude646 <CommentBox
647 author={author?.username ?? "unknown"}
648 date={pr.createdAt}
649 body={renderMarkdown(pr.body)}
650 />
0074234Claude651 )}
652
6fc53bdClaude653 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude654 <div
655 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
656 >
657 <div class="comment-header">
bb0f894Claude658 <Flex gap={8} align="center">
659 <strong>{commentAuthor.username}</strong>
660 {comment.isAiReview && (
661 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
662 AI Review
663 </Badge>
664 )}
665 <Text size={13} muted>
666 commented {formatRelative(comment.createdAt)}
667 </Text>
668 {comment.filePath && (
669 <Text size={11} mono style="margin-left:8px">
670 {comment.filePath}
671 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
672 </Text>
673 )}
674 </Flex>
6fc53bdClaude675 </div>
bb0f894Claude676 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude677 </div>
678 ))}
679
e883329Claude680 {error && (
681 <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)">
682 {decodeURIComponent(error)}
683 </div>
684 )}
685
686 {pr.state === "open" && gateChecks.length > 0 && (
687 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
688 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
689 {gateChecks.map((check) => (
690 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
691 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
692 {check.passed ? "\u2713" : "\u2717"}
693 </span>
694 <strong style="font-size: 13px">{check.name}</strong>
695 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
696 </div>
697 ))}
698 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
699 {gateChecks.every((c) => c.passed)
700 ? "All checks passed — ready to merge"
701 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
702 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
703 : "Some checks failed — resolve issues before merging"}
704 </div>
705 </div>
706 )}
707
0074234Claude708 {user && pr.state === "open" && (
709 <div style="margin-top: 20px">
0316dbbClaude710 <Form
e7e240eClaude711 method="post"
0074234Claude712 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
713 >
bb0f894Claude714 <FormGroup>
715 <TextArea
0074234Claude716 name="body"
717 rows={6}
718 required
719 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude720 mono
0074234Claude721 />
bb0f894Claude722 </FormGroup>
723 <Flex gap={8}>
724 <Button type="submit" variant="primary">
0074234Claude725 Comment
bb0f894Claude726 </Button>
0074234Claude727 {canManage && (
728 <>
729 <button
730 type="submit"
731 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
732 class="btn"
bb0f894Claude733 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
0074234Claude734 >
735 Merge pull request
736 </button>
bb0f894Claude737 <Button
0074234Claude738 type="submit"
bb0f894Claude739 variant="danger"
0074234Claude740 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
741 >
742 Close
bb0f894Claude743 </Button>
0074234Claude744 </>
745 )}
bb0f894Claude746 </Flex>
747 </Form>
0074234Claude748 </div>
749 )}
750 </>
751 )}
752 </div>
753 </Layout>
754 );
755});
756
757// Add comment to PR
758pulls.post(
759 "/:owner/:repo/pulls/:number/comment",
760 softAuth,
761 requireAuth,
04f6b7fClaude762 requireRepoAccess("write"),
0074234Claude763 async (c) => {
764 const { owner: ownerName, repo: repoName } = c.req.param();
765 const prNum = parseInt(c.req.param("number"), 10);
766 const user = c.get("user")!;
767 const body = await c.req.parseBody();
768 const commentBody = String(body.body || "").trim();
769
770 if (!commentBody) {
771 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
772 }
773
774 const resolved = await resolveRepo(ownerName, repoName);
775 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
776
777 const [pr] = await db
778 .select()
779 .from(pullRequests)
780 .where(
781 and(
782 eq(pullRequests.repositoryId, resolved.repo.id),
783 eq(pullRequests.number, prNum)
784 )
785 )
786 .limit(1);
787
788 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
789
d4ac5c3Claude790 const [inserted] = await db
791 .insert(prComments)
792 .values({
793 pullRequestId: pr.id,
794 authorId: user.id,
795 body: commentBody,
796 })
797 .returning();
798
799 // Live update: nudge any browser tabs subscribed to this PR.
800 if (inserted) {
801 try {
802 const { publish } = await import("../lib/sse");
803 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
804 event: "pr-comment",
805 data: {
806 pullRequestId: pr.id,
807 commentId: inserted.id,
808 authorId: user.id,
809 authorUsername: user.username,
810 },
811 });
812 } catch {
813 /* SSE is best-effort */
814 }
815 }
0074234Claude816
817 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
818 }
819);
820
e883329Claude821// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude822// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
823// but we keep it at "write" for v1 so trusted collaborators can ship.
824// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
825// surface. Branch-protection rules (evaluated below) are the current mechanism
826// for locking down merges further on specific branches.
0074234Claude827pulls.post(
828 "/:owner/:repo/pulls/:number/merge",
829 softAuth,
830 requireAuth,
04f6b7fClaude831 requireRepoAccess("write"),
0074234Claude832 async (c) => {
833 const { owner: ownerName, repo: repoName } = c.req.param();
834 const prNum = parseInt(c.req.param("number"), 10);
835 const user = c.get("user")!;
836
837 const resolved = await resolveRepo(ownerName, repoName);
838 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
839
840 const [pr] = await db
841 .select()
842 .from(pullRequests)
843 .where(
844 and(
845 eq(pullRequests.repositoryId, resolved.repo.id),
846 eq(pullRequests.number, prNum)
847 )
848 )
849 .limit(1);
850
851 if (!pr || pr.state !== "open") {
852 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
853 }
854
6fc53bdClaude855 // Draft PRs cannot be merged — must be marked ready first.
856 if (pr.isDraft) {
857 return c.redirect(
858 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
859 "This PR is a draft. Mark it as ready for review before merging."
860 )}`
861 );
862 }
863
e883329Claude864 // Resolve head SHA
865 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
866 if (!headSha) {
867 return c.redirect(
868 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
869 );
870 }
871
872 // Check if AI review approved this PR
873 const aiComments = await db
874 .select()
875 .from(prComments)
876 .where(
877 and(
878 eq(prComments.pullRequestId, pr.id),
879 eq(prComments.isAiReview, true)
880 )
881 );
882 const aiApproved = aiComments.length === 0 || aiComments.some(
883 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude884 );
e883329Claude885
886 // Run all green gate checks (GateTest + mergeability + AI review)
887 const gateResult = await runAllGateChecks(
888 ownerName,
889 repoName,
890 pr.baseBranch,
891 pr.headBranch,
892 headSha,
893 aiApproved
0074234Claude894 );
895
e883329Claude896 // If GateTest or AI review failed (hard blocks), reject the merge
897 const hardFailures = gateResult.checks.filter(
898 (check) => !check.passed && check.name !== "Merge check"
899 );
900 if (hardFailures.length > 0) {
901 const errorMsg = hardFailures
902 .map((f) => `${f.name}: ${f.details}`)
903 .join("; ");
0074234Claude904 return c.redirect(
e883329Claude905 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude906 );
907 }
908
1e162a8Claude909 // D5 — Branch-protection enforcement. Looks up the matching rule for the
910 // base branch and blocks the merge if requireAiApproval / requireGreenGates
911 // / requireHumanReview / requiredApprovals are not satisfied. Independent
912 // of repo-global settings, so owners can lock specific branches down
913 // further than the repo default.
914 const protectionRule = await matchProtection(
915 resolved.repo.id,
916 pr.baseBranch
917 );
918 if (protectionRule) {
919 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude920 const required = await listRequiredChecks(protectionRule.id);
921 const passingNames = required.length > 0
922 ? await passingCheckNames(resolved.repo.id, headSha)
923 : [];
924 const decision = evaluateProtection(
925 protectionRule,
926 {
927 aiApproved,
928 humanApprovalCount: humanApprovals,
929 gateResultGreen: hardFailures.length === 0,
930 hasFailedGates: hardFailures.length > 0,
931 passingCheckNames: passingNames,
932 },
933 required.map((r) => r.checkName)
934 );
1e162a8Claude935 if (!decision.allowed) {
936 return c.redirect(
937 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
938 decision.reasons.join(" ")
939 )}`
940 );
941 }
942 }
943
e883329Claude944 // Attempt the merge — with auto conflict resolution if needed
945 const repoDir = getRepoPath(ownerName, repoName);
946 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
947 const hasConflicts = mergeCheck && !mergeCheck.passed;
948
949 if (hasConflicts && isAiReviewEnabled()) {
950 // Use Claude to auto-resolve conflicts
951 const mergeResult = await mergeWithAutoResolve(
952 ownerName,
953 repoName,
954 pr.baseBranch,
955 pr.headBranch,
956 `Merge pull request #${pr.number}: ${pr.title}`
957 );
958
959 if (!mergeResult.success) {
960 return c.redirect(
961 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
962 );
963 }
964
965 // Post a comment about the auto-resolution
966 if (mergeResult.resolvedFiles.length > 0) {
967 await db.insert(prComments).values({
968 pullRequestId: pr.id,
969 authorId: user.id,
970 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
971 isAiReview: true,
972 });
973 }
974 } else {
975 // Standard merge — fast-forward or clean merge
976 const ffProc = Bun.spawn(
977 [
978 "git",
979 "update-ref",
980 `refs/heads/${pr.baseBranch}`,
981 `refs/heads/${pr.headBranch}`,
982 ],
983 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
984 );
985 const ffExit = await ffProc.exited;
986
987 if (ffExit !== 0) {
988 return c.redirect(
989 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
990 );
991 }
992 }
993
0074234Claude994 await db
995 .update(pullRequests)
996 .set({
997 state: "merged",
998 mergedAt: new Date(),
999 mergedBy: user.id,
1000 updatedAt: new Date(),
1001 })
1002 .where(eq(pullRequests.id, pr.id));
1003
d62fb36Claude1004 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
1005 // and auto-close each matching open issue with a back-link comment. Bounded
1006 // to the same repo for v1 (cross-repo refs ignored). Failures never block
1007 // the merge redirect.
1008 try {
1009 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
1010 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1011 for (const n of refs) {
1012 const [issue] = await db
1013 .select()
1014 .from(issues)
1015 .where(
1016 and(
1017 eq(issues.repositoryId, resolved.repo.id),
1018 eq(issues.number, n)
1019 )
1020 )
1021 .limit(1);
1022 if (!issue || issue.state !== "open") continue;
1023 await db
1024 .update(issues)
1025 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1026 .where(eq(issues.id, issue.id));
1027 await db.insert(issueComments).values({
1028 issueId: issue.id,
1029 authorId: user.id,
1030 body: `Closed by pull request #${pr.number}.`,
1031 });
1032 }
1033 } catch {
1034 // Never block the merge on close-keyword failures.
1035 }
1036
0074234Claude1037 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1038 }
1039);
1040
6fc53bdClaude1041// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
1042// hasn't run yet on this PR.
1043pulls.post(
1044 "/:owner/:repo/pulls/:number/ready",
1045 softAuth,
1046 requireAuth,
04f6b7fClaude1047 requireRepoAccess("write"),
6fc53bdClaude1048 async (c) => {
1049 const { owner: ownerName, repo: repoName } = c.req.param();
1050 const prNum = parseInt(c.req.param("number"), 10);
1051 const user = c.get("user")!;
1052
1053 const resolved = await resolveRepo(ownerName, repoName);
1054 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1055
1056 const [pr] = await db
1057 .select()
1058 .from(pullRequests)
1059 .where(
1060 and(
1061 eq(pullRequests.repositoryId, resolved.repo.id),
1062 eq(pullRequests.number, prNum)
1063 )
1064 )
1065 .limit(1);
1066 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1067
1068 // Only the author or repo owner can toggle draft state.
1069 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1070 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1071 }
1072
1073 if (pr.state === "open" && pr.isDraft) {
1074 await db
1075 .update(pullRequests)
1076 .set({ isDraft: false, updatedAt: new Date() })
1077 .where(eq(pullRequests.id, pr.id));
1078
1079 if (isAiReviewEnabled()) {
1080 triggerAiReview(
1081 ownerName,
1082 repoName,
1083 pr.id,
1084 pr.title,
0316dbbClaude1085 pr.body || "",
6fc53bdClaude1086 pr.baseBranch,
1087 pr.headBranch
1088 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
1089 }
1090 }
1091
1092 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1093 }
1094);
1095
1096// Convert a PR back to draft.
1097pulls.post(
1098 "/:owner/:repo/pulls/:number/draft",
1099 softAuth,
1100 requireAuth,
04f6b7fClaude1101 requireRepoAccess("write"),
6fc53bdClaude1102 async (c) => {
1103 const { owner: ownerName, repo: repoName } = c.req.param();
1104 const prNum = parseInt(c.req.param("number"), 10);
1105 const user = c.get("user")!;
1106
1107 const resolved = await resolveRepo(ownerName, repoName);
1108 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1109
1110 const [pr] = await db
1111 .select()
1112 .from(pullRequests)
1113 .where(
1114 and(
1115 eq(pullRequests.repositoryId, resolved.repo.id),
1116 eq(pullRequests.number, prNum)
1117 )
1118 )
1119 .limit(1);
1120 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1121
1122 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1123 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1124 }
1125
1126 if (pr.state === "open" && !pr.isDraft) {
1127 await db
1128 .update(pullRequests)
1129 .set({ isDraft: true, updatedAt: new Date() })
1130 .where(eq(pullRequests.id, pr.id));
1131 }
1132
1133 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1134 }
1135);
1136
0074234Claude1137// Close PR
1138pulls.post(
1139 "/:owner/:repo/pulls/:number/close",
1140 softAuth,
1141 requireAuth,
04f6b7fClaude1142 requireRepoAccess("write"),
0074234Claude1143 async (c) => {
1144 const { owner: ownerName, repo: repoName } = c.req.param();
1145 const prNum = parseInt(c.req.param("number"), 10);
1146
1147 const resolved = await resolveRepo(ownerName, repoName);
1148 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1149
1150 await db
1151 .update(pullRequests)
1152 .set({
1153 state: "closed",
1154 closedAt: new Date(),
1155 updatedAt: new Date(),
1156 })
1157 .where(
1158 and(
1159 eq(pullRequests.repositoryId, resolved.repo.id),
1160 eq(pullRequests.number, prNum)
1161 )
1162 );
1163
1164 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1165 }
1166);
1167
1168export default pulls;