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.tsxBlame1242 lines · 2 contributors
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";
0316dbbClaude30import { runAllGateChecks } from "../lib/gate";
31import type { GateCheckResult } from "../lib/gate";
32import {
33 matchProtection,
34 countHumanApprovals,
35 listRequiredChecks,
36 passingCheckNames,
37 evaluateProtection,
38} from "../lib/branch-protection";
39import { mergeWithAutoResolve } from "../lib/merge-resolver";
0074234Claude40import {
41 listBranches,
42 getRepoPath,
e883329Claude43 resolveRef,
0074234Claude44} from "../git/repository";
45import type { GitDiffFile } from "../git/repository";
46import { html } from "hono/html";
1e162a8Claude47import {
bb0f894Claude48 Flex,
49 Container,
50 Badge,
51 Button,
52 LinkButton,
53 Form,
54 FormGroup,
55 Input,
56 TextArea,
57 Select,
58 EmptyState,
59 FilterTabs,
60 TabNav,
61 List,
62 ListItem,
63 Text,
64 Alert,
65 MarkdownContent,
66 CommentBox,
67 formatRelative,
68} from "../views/ui";
0074234Claude69
70const pulls = new Hono<AuthEnv>();
71
81c73c1Claude72/**
73 * Tiny inline JS that drives the "Suggest description with AI" button.
74 * On click, gathers form values, POSTs JSON to the given endpoint, and
75 * pipes the response into the #pr-body textarea. All DOM lookups are
76 * defensive — element absence is a silent no-op.
77 *
78 * Built as a string template so it lives next to its server-side caller
79 * and there is no bundler dependency. The endpoint URL is JSON-escaped
80 * to avoid </script> breakouts.
81 */
82function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
83 const url = JSON.stringify(endpointUrl)
84 .split("<").join("\\u003C")
85 .split(">").join("\\u003E")
86 .split("&").join("\\u0026");
87 return (
88 "(function(){try{" +
89 "var btn=document.getElementById('ai-suggest-desc');" +
90 "var status=document.getElementById('ai-suggest-status');" +
91 "var body=document.getElementById('pr-body');" +
92 "var form=btn&&btn.closest&&btn.closest('form');" +
93 "if(!btn||!body||!form)return;" +
94 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
95 "var fd=new FormData(form);" +
96 "var title=String(fd.get('title')||'').trim();" +
97 "var base=String(fd.get('base')||'').trim();" +
98 "var head=String(fd.get('head')||'').trim();" +
99 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
100 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
101 "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'})" +
102 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
103 ".then(function(j){btn.disabled=false;" +
104 "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;}}" +
105 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
106 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
107 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
108 "});" +
109 "}catch(e){}})();"
110 );
111}
112
0074234Claude113async function resolveRepo(ownerName: string, repoName: string) {
114 const [owner] = await db
115 .select()
116 .from(users)
117 .where(eq(users.username, ownerName))
118 .limit(1);
119 if (!owner) return null;
120 const [repo] = await db
121 .select()
122 .from(repositories)
123 .where(
124 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
125 )
126 .limit(1);
127 if (!repo) return null;
128 return { owner, repo };
129}
130
131// PR Nav helper
132const PrNav = ({
133 owner,
134 repo,
135 active,
136}: {
137 owner: string;
138 repo: string;
139 active: "code" | "issues" | "pulls" | "commits";
140}) => (
bb0f894Claude141 <TabNav
142 tabs={[
143 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
144 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
145 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
146 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
147 ]}
148 />
0074234Claude149);
150
151// List PRs
04f6b7fClaude152pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude153 const { owner: ownerName, repo: repoName } = c.req.param();
154 const user = c.get("user");
155 const state = c.req.query("state") || "open";
156
157 const resolved = await resolveRepo(ownerName, repoName);
158 if (!resolved) return c.notFound();
159
6fc53bdClaude160 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
161 const stateFilter =
162 state === "draft"
163 ? and(
164 eq(pullRequests.state, "open"),
165 eq(pullRequests.isDraft, true)
166 )
167 : eq(pullRequests.state, state);
168
0074234Claude169 const prList = await db
170 .select({
171 pr: pullRequests,
172 author: { username: users.username },
173 })
174 .from(pullRequests)
175 .innerJoin(users, eq(pullRequests.authorId, users.id))
176 .where(
6fc53bdClaude177 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
0074234Claude178 )
179 .orderBy(desc(pullRequests.createdAt));
180
181 const [counts] = await db
182 .select({
183 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
6fc53bdClaude184 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
0074234Claude185 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
186 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
187 })
188 .from(pullRequests)
189 .where(eq(pullRequests.repositoryId, resolved.repo.id));
190
191 return c.html(
192 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
193 <RepoHeader owner={ownerName} repo={repoName} />
194 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude195 <Flex justify="space-between" align="center" style="margin-bottom:16px">
196 <FilterTabs
197 tabs={[
198 { label: `${counts?.open ?? 0} Open`, href: `/${ownerName}/${repoName}/pulls?state=open`, active: state === "open" },
199 { label: `${counts?.merged ?? 0} Merged`, href: `/${ownerName}/${repoName}/pulls?state=merged`, active: state === "merged" },
200 { label: `${counts?.closed ?? 0} Closed`, href: `/${ownerName}/${repoName}/pulls?state=closed`, active: state === "closed" },
201 ]}
202 />
0074234Claude203 {user && (
bb0f894Claude204 <LinkButton href={`/${ownerName}/${repoName}/pulls/new`} variant="primary">
0074234Claude205 New pull request
bb0f894Claude206 </LinkButton>
0074234Claude207 )}
bb0f894Claude208 </Flex>
0074234Claude209 {prList.length === 0 ? (
bb0f894Claude210 <EmptyState>
0074234Claude211 <p>No {state} pull requests.</p>
bb0f894Claude212 </EmptyState>
0074234Claude213 ) : (
bb0f894Claude214 <List>
0074234Claude215 {prList.map(({ pr, author }) => (
bb0f894Claude216 <ListItem>
0074234Claude217 <div
218 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
219 >
220 {pr.state === "open"
221 ? "\u25CB"
222 : pr.state === "merged"
223 ? "\u2B8C"
224 : "\u2713"}
225 </div>
226 <div>
227 <div class="issue-title">
228 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
229 {pr.title}
230 </a>
231 </div>
232 <div class="issue-meta">
233 #{pr.number}{" "}
234 {pr.headBranch} → {pr.baseBranch}{" "}
235 by {author.username}{" "}
236 {formatRelative(pr.createdAt)}
237 </div>
238 </div>
bb0f894Claude239 </ListItem>
0074234Claude240 ))}
bb0f894Claude241 </List>
0074234Claude242 )}
243 </Layout>
244 );
245});
246
247// New PR form
248pulls.get(
249 "/:owner/:repo/pulls/new",
250 softAuth,
251 requireAuth,
04f6b7fClaude252 requireRepoAccess("write"),
0074234Claude253 async (c) => {
254 const { owner: ownerName, repo: repoName } = c.req.param();
255 const user = c.get("user")!;
256 const branches = await listBranches(ownerName, repoName);
257 const error = c.req.query("error");
258 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
24cf2caClaude259 const template = await loadPrTemplate(ownerName, repoName);
0074234Claude260
261 return c.html(
262 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
263 <RepoHeader owner={ownerName} repo={repoName} />
264 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude265 <Container maxWidth={800}>
266 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude267 {error && (
bb0f894Claude268 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude269 )}
0316dbbClaude270 <Form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
271 <Flex gap={12} align="center" style="margin-bottom: 16px">
272 <Select name="base">
0074234Claude273 {branches.map((b) => (
274 <option value={b} selected={b === defaultBase}>
275 {b}
276 </option>
277 ))}
bb0f894Claude278 </Select>
279 <Text muted>&larr;</Text>
280 <Select name="head">
0074234Claude281 {branches
282 .filter((b) => b !== defaultBase)
283 .concat(defaultBase === branches[0] ? [] : [branches[0]])
284 .map((b) => (
285 <option value={b}>{b}</option>
286 ))}
bb0f894Claude287 </Select>
288 </Flex>
289 <FormGroup>
290 <Input
0074234Claude291 name="title"
292 required
293 placeholder="Title"
bb0f894Claude294 style="font-size:16px;padding:10px 14px"
63c60ebcopilot-swe-agent[bot]295 aria-label="Pull request title"
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");
c3e0c07Claude523 const info = c.req.query("info");
e883329Claude524
525 // Get gate check status for open PRs
526 let gateChecks: GateCheckResult[] = [];
527 if (pr.state === "open") {
528 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
529 if (headSha) {
530 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
531 const aiApproved = aiComments.length === 0 || aiComments.some(
532 ({ comment }) => comment.body.includes("**Approved**")
533 );
534 const gateResult = await runAllGateChecks(
535 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
536 );
537 gateChecks = gateResult.checks;
538 }
539 }
540
0074234Claude541 // Get diff for "Files changed" tab
542 let diffRaw = "";
543 let diffFiles: GitDiffFile[] = [];
544 if (tab === "files") {
545 const repoDir = getRepoPath(ownerName, repoName);
546 const proc = Bun.spawn(
547 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
548 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
549 );
550 diffRaw = await new Response(proc.stdout).text();
551 await proc.exited;
552
553 const statProc = Bun.spawn(
554 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
555 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
556 );
557 const stat = await new Response(statProc.stdout).text();
558 await statProc.exited;
559
560 diffFiles = stat
561 .trim()
562 .split("\n")
563 .filter(Boolean)
564 .map((line) => {
565 const [add, del, filePath] = line.split("\t");
566 return {
567 path: filePath,
568 status: "modified",
569 additions: add === "-" ? 0 : parseInt(add, 10),
570 deletions: del === "-" ? 0 : parseInt(del, 10),
571 patch: "",
572 };
573 });
574 }
575
576 return c.html(
577 <Layout
578 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
579 user={user}
580 >
581 <RepoHeader owner={ownerName} repo={repoName} />
582 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b584e52Claude583 <div
584 id="live-comment-banner"
585 class="alert"
586 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
587 >
588 <strong class="js-live-count">0</strong> new comment(s) —{" "}
589 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
590 reload to view
591 </a>
592 </div>
593 <script
594 dangerouslySetInnerHTML={{
595 __html: liveCommentBannerScript({
596 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
597 bannerElementId: "live-comment-banner",
598 }),
599 }}
600 />
0074234Claude601 <div class="issue-detail">
602 <h2>
603 {pr.title}{" "}
bb0f894Claude604 <Text color="var(--text-muted)" weight={400}>
0074234Claude605 #{pr.number}
bb0f894Claude606 </Text>
0074234Claude607 </h2>
bb0f894Claude608 <Flex align="center" gap={8} style="margin:8px 0 20px">
609 <Badge
610 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
0074234Claude611 >
612 {pr.state === "open"
613 ? "\u25CB Open"
614 : pr.state === "merged"
615 ? "\u2B8C Merged"
616 : "\u2713 Closed"}
bb0f894Claude617 </Badge>
618 <Text size={14} muted>
619 <strong style="color:var(--text)">
0074234Claude620 {author?.username}
621 </strong>{" "}
622 wants to merge <code>{pr.headBranch}</code> into{" "}
623 <code>{pr.baseBranch}</code>
bb0f894Claude624 </Text>
625 </Flex>
626
627 <FilterTabs
628 tabs={[
629 {
630 label: "Conversation",
631 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
632 active: tab === "conversation",
633 },
634 {
635 label: "Files changed",
636 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
637 active: tab === "files",
638 },
639 ]}
640 />
0074234Claude641
642 {tab === "files" ? (
643 <DiffView raw={diffRaw} files={diffFiles} />
644 ) : (
645 <>
646 {pr.body && (
bb0f894Claude647 <CommentBox
648 author={author?.username ?? "unknown"}
649 date={pr.createdAt}
650 body={renderMarkdown(pr.body)}
651 />
0074234Claude652 )}
653
6fc53bdClaude654 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude655 <div
656 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
657 >
658 <div class="comment-header">
bb0f894Claude659 <Flex gap={8} align="center">
660 <strong>{commentAuthor.username}</strong>
661 {comment.isAiReview && (
662 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
663 AI Review
664 </Badge>
665 )}
666 <Text size={13} muted>
667 commented {formatRelative(comment.createdAt)}
668 </Text>
669 {comment.filePath && (
670 <Text size={11} mono style="margin-left:8px">
671 {comment.filePath}
672 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
673 </Text>
674 )}
675 </Flex>
6fc53bdClaude676 </div>
bb0f894Claude677 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude678 </div>
679 ))}
680
e883329Claude681 {error && (
682 <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)">
683 {decodeURIComponent(error)}
684 </div>
685 )}
686
c3e0c07Claude687 {info && (
688 <div style="margin-top: 16px; padding: 12px; background: rgba(56, 139, 253, 0.1); border: 1px solid var(--accent); border-radius: var(--radius); color: var(--text)">
689 {decodeURIComponent(info)}
690 </div>
691 )}
692
e883329Claude693 {pr.state === "open" && gateChecks.length > 0 && (
694 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
695 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
696 {gateChecks.map((check) => (
697 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
698 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
699 {check.passed ? "\u2713" : "\u2717"}
700 </span>
701 <strong style="font-size: 13px">{check.name}</strong>
702 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
703 </div>
704 ))}
705 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
706 {gateChecks.every((c) => c.passed)
707 ? "All checks passed — ready to merge"
708 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
709 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
710 : "Some checks failed — resolve issues before merging"}
711 </div>
712 </div>
713 )}
714
0074234Claude715 {user && pr.state === "open" && (
716 <div style="margin-top: 20px">
0316dbbClaude717 <Form
e7e240eClaude718 method="post"
0074234Claude719 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
720 >
bb0f894Claude721 <FormGroup>
722 <TextArea
0074234Claude723 name="body"
724 rows={6}
725 required
726 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude727 mono
0074234Claude728 />
bb0f894Claude729 </FormGroup>
730 <Flex gap={8}>
731 <Button type="submit" variant="primary">
0074234Claude732 Comment
bb0f894Claude733 </Button>
0074234Claude734 {canManage && (
735 <>
736 <button
737 type="submit"
738 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
739 class="btn"
bb0f894Claude740 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
0074234Claude741 >
742 Merge pull request
743 </button>
c3e0c07Claude744 {isAiReviewEnabled() && (
745 <button
746 type="submit"
747 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
748 formnovalidate
749 class="btn"
750 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
751 >
752 Re-run AI review
753 </button>
754 )}
bb0f894Claude755 <Button
0074234Claude756 type="submit"
bb0f894Claude757 variant="danger"
0074234Claude758 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
759 >
760 Close
bb0f894Claude761 </Button>
0074234Claude762 </>
763 )}
bb0f894Claude764 </Flex>
765 </Form>
0074234Claude766 </div>
767 )}
768 </>
769 )}
770 </div>
771 </Layout>
772 );
773});
774
775// Add comment to PR
776pulls.post(
777 "/:owner/:repo/pulls/:number/comment",
778 softAuth,
779 requireAuth,
04f6b7fClaude780 requireRepoAccess("write"),
0074234Claude781 async (c) => {
782 const { owner: ownerName, repo: repoName } = c.req.param();
783 const prNum = parseInt(c.req.param("number"), 10);
784 const user = c.get("user")!;
785 const body = await c.req.parseBody();
786 const commentBody = String(body.body || "").trim();
787
788 if (!commentBody) {
789 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
790 }
791
792 const resolved = await resolveRepo(ownerName, repoName);
793 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
794
795 const [pr] = await db
796 .select()
797 .from(pullRequests)
798 .where(
799 and(
800 eq(pullRequests.repositoryId, resolved.repo.id),
801 eq(pullRequests.number, prNum)
802 )
803 )
804 .limit(1);
805
806 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
807
d4ac5c3Claude808 const [inserted] = await db
809 .insert(prComments)
810 .values({
811 pullRequestId: pr.id,
812 authorId: user.id,
813 body: commentBody,
814 })
815 .returning();
816
817 // Live update: nudge any browser tabs subscribed to this PR.
818 if (inserted) {
819 try {
820 const { publish } = await import("../lib/sse");
821 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
822 event: "pr-comment",
823 data: {
824 pullRequestId: pr.id,
825 commentId: inserted.id,
826 authorId: user.id,
827 authorUsername: user.username,
828 },
829 });
830 } catch {
831 /* SSE is best-effort */
832 }
833 }
0074234Claude834
835 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
836 }
837);
838
e883329Claude839// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude840// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
841// but we keep it at "write" for v1 so trusted collaborators can ship.
842// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
843// surface. Branch-protection rules (evaluated below) are the current mechanism
844// for locking down merges further on specific branches.
0074234Claude845pulls.post(
846 "/:owner/:repo/pulls/:number/merge",
847 softAuth,
848 requireAuth,
04f6b7fClaude849 requireRepoAccess("write"),
0074234Claude850 async (c) => {
851 const { owner: ownerName, repo: repoName } = c.req.param();
852 const prNum = parseInt(c.req.param("number"), 10);
853 const user = c.get("user")!;
854
855 const resolved = await resolveRepo(ownerName, repoName);
856 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
857
858 const [pr] = await db
859 .select()
860 .from(pullRequests)
861 .where(
862 and(
863 eq(pullRequests.repositoryId, resolved.repo.id),
864 eq(pullRequests.number, prNum)
865 )
866 )
867 .limit(1);
868
869 if (!pr || pr.state !== "open") {
870 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
871 }
872
6fc53bdClaude873 // Draft PRs cannot be merged — must be marked ready first.
874 if (pr.isDraft) {
875 return c.redirect(
876 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
877 "This PR is a draft. Mark it as ready for review before merging."
878 )}`
879 );
880 }
881
e883329Claude882 // Resolve head SHA
883 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
884 if (!headSha) {
885 return c.redirect(
886 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
887 );
888 }
889
890 // Check if AI review approved this PR
891 const aiComments = await db
892 .select()
893 .from(prComments)
894 .where(
895 and(
896 eq(prComments.pullRequestId, pr.id),
897 eq(prComments.isAiReview, true)
898 )
899 );
900 const aiApproved = aiComments.length === 0 || aiComments.some(
901 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude902 );
e883329Claude903
904 // Run all green gate checks (GateTest + mergeability + AI review)
905 const gateResult = await runAllGateChecks(
906 ownerName,
907 repoName,
908 pr.baseBranch,
909 pr.headBranch,
910 headSha,
911 aiApproved
0074234Claude912 );
913
e883329Claude914 // If GateTest or AI review failed (hard blocks), reject the merge
915 const hardFailures = gateResult.checks.filter(
916 (check) => !check.passed && check.name !== "Merge check"
917 );
918 if (hardFailures.length > 0) {
919 const errorMsg = hardFailures
920 .map((f) => `${f.name}: ${f.details}`)
921 .join("; ");
0074234Claude922 return c.redirect(
e883329Claude923 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude924 );
925 }
926
1e162a8Claude927 // D5 — Branch-protection enforcement. Looks up the matching rule for the
928 // base branch and blocks the merge if requireAiApproval / requireGreenGates
929 // / requireHumanReview / requiredApprovals are not satisfied. Independent
930 // of repo-global settings, so owners can lock specific branches down
931 // further than the repo default.
932 const protectionRule = await matchProtection(
933 resolved.repo.id,
934 pr.baseBranch
935 );
936 if (protectionRule) {
937 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude938 const required = await listRequiredChecks(protectionRule.id);
939 const passingNames = required.length > 0
940 ? await passingCheckNames(resolved.repo.id, headSha)
941 : [];
942 const decision = evaluateProtection(
943 protectionRule,
944 {
945 aiApproved,
946 humanApprovalCount: humanApprovals,
947 gateResultGreen: hardFailures.length === 0,
948 hasFailedGates: hardFailures.length > 0,
949 passingCheckNames: passingNames,
950 },
951 required.map((r) => r.checkName)
952 );
1e162a8Claude953 if (!decision.allowed) {
954 return c.redirect(
955 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
956 decision.reasons.join(" ")
957 )}`
958 );
959 }
960 }
961
e883329Claude962 // Attempt the merge — with auto conflict resolution if needed
963 const repoDir = getRepoPath(ownerName, repoName);
964 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
965 const hasConflicts = mergeCheck && !mergeCheck.passed;
966
967 if (hasConflicts && isAiReviewEnabled()) {
968 // Use Claude to auto-resolve conflicts
969 const mergeResult = await mergeWithAutoResolve(
970 ownerName,
971 repoName,
972 pr.baseBranch,
973 pr.headBranch,
974 `Merge pull request #${pr.number}: ${pr.title}`
975 );
976
977 if (!mergeResult.success) {
978 return c.redirect(
979 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
980 );
981 }
982
983 // Post a comment about the auto-resolution
984 if (mergeResult.resolvedFiles.length > 0) {
985 await db.insert(prComments).values({
986 pullRequestId: pr.id,
987 authorId: user.id,
988 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
989 isAiReview: true,
990 });
991 }
992 } else {
993 // Standard merge — fast-forward or clean merge
994 const ffProc = Bun.spawn(
995 [
996 "git",
997 "update-ref",
998 `refs/heads/${pr.baseBranch}`,
999 `refs/heads/${pr.headBranch}`,
1000 ],
1001 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1002 );
1003 const ffExit = await ffProc.exited;
1004
1005 if (ffExit !== 0) {
1006 return c.redirect(
1007 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
1008 );
1009 }
1010 }
1011
0074234Claude1012 await db
1013 .update(pullRequests)
1014 .set({
1015 state: "merged",
1016 mergedAt: new Date(),
1017 mergedBy: user.id,
1018 updatedAt: new Date(),
1019 })
1020 .where(eq(pullRequests.id, pr.id));
1021
d62fb36Claude1022 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
1023 // and auto-close each matching open issue with a back-link comment. Bounded
1024 // to the same repo for v1 (cross-repo refs ignored). Failures never block
1025 // the merge redirect.
1026 try {
1027 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
1028 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1029 for (const n of refs) {
1030 const [issue] = await db
1031 .select()
1032 .from(issues)
1033 .where(
1034 and(
1035 eq(issues.repositoryId, resolved.repo.id),
1036 eq(issues.number, n)
1037 )
1038 )
1039 .limit(1);
1040 if (!issue || issue.state !== "open") continue;
1041 await db
1042 .update(issues)
1043 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1044 .where(eq(issues.id, issue.id));
1045 await db.insert(issueComments).values({
1046 issueId: issue.id,
1047 authorId: user.id,
1048 body: `Closed by pull request #${pr.number}.`,
1049 });
1050 }
1051 } catch {
1052 // Never block the merge on close-keyword failures.
1053 }
1054
0074234Claude1055 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1056 }
1057);
1058
6fc53bdClaude1059// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
1060// hasn't run yet on this PR.
1061pulls.post(
1062 "/:owner/:repo/pulls/:number/ready",
1063 softAuth,
1064 requireAuth,
04f6b7fClaude1065 requireRepoAccess("write"),
6fc53bdClaude1066 async (c) => {
1067 const { owner: ownerName, repo: repoName } = c.req.param();
1068 const prNum = parseInt(c.req.param("number"), 10);
1069 const user = c.get("user")!;
1070
1071 const resolved = await resolveRepo(ownerName, repoName);
1072 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1073
1074 const [pr] = await db
1075 .select()
1076 .from(pullRequests)
1077 .where(
1078 and(
1079 eq(pullRequests.repositoryId, resolved.repo.id),
1080 eq(pullRequests.number, prNum)
1081 )
1082 )
1083 .limit(1);
1084 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1085
1086 // Only the author or repo owner can toggle draft state.
1087 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1088 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1089 }
1090
1091 if (pr.state === "open" && pr.isDraft) {
1092 await db
1093 .update(pullRequests)
1094 .set({ isDraft: false, updatedAt: new Date() })
1095 .where(eq(pullRequests.id, pr.id));
1096
1097 if (isAiReviewEnabled()) {
1098 triggerAiReview(
1099 ownerName,
1100 repoName,
1101 pr.id,
1102 pr.title,
0316dbbClaude1103 pr.body || "",
6fc53bdClaude1104 pr.baseBranch,
1105 pr.headBranch
1106 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
1107 }
1108 }
1109
1110 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1111 }
1112);
1113
1114// Convert a PR back to draft.
1115pulls.post(
1116 "/:owner/:repo/pulls/:number/draft",
1117 softAuth,
1118 requireAuth,
04f6b7fClaude1119 requireRepoAccess("write"),
6fc53bdClaude1120 async (c) => {
1121 const { owner: ownerName, repo: repoName } = c.req.param();
1122 const prNum = parseInt(c.req.param("number"), 10);
1123 const user = c.get("user")!;
1124
1125 const resolved = await resolveRepo(ownerName, repoName);
1126 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1127
1128 const [pr] = await db
1129 .select()
1130 .from(pullRequests)
1131 .where(
1132 and(
1133 eq(pullRequests.repositoryId, resolved.repo.id),
1134 eq(pullRequests.number, prNum)
1135 )
1136 )
1137 .limit(1);
1138 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1139
1140 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1141 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1142 }
1143
1144 if (pr.state === "open" && !pr.isDraft) {
1145 await db
1146 .update(pullRequests)
1147 .set({ isDraft: true, updatedAt: new Date() })
1148 .where(eq(pullRequests.id, pr.id));
1149 }
1150
1151 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1152 }
1153);
1154
0074234Claude1155// Close PR
1156pulls.post(
1157 "/:owner/:repo/pulls/:number/close",
1158 softAuth,
1159 requireAuth,
04f6b7fClaude1160 requireRepoAccess("write"),
0074234Claude1161 async (c) => {
1162 const { owner: ownerName, repo: repoName } = c.req.param();
1163 const prNum = parseInt(c.req.param("number"), 10);
1164
1165 const resolved = await resolveRepo(ownerName, repoName);
1166 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1167
1168 await db
1169 .update(pullRequests)
1170 .set({
1171 state: "closed",
1172 closedAt: new Date(),
1173 updatedAt: new Date(),
1174 })
1175 .where(
1176 and(
1177 eq(pullRequests.repositoryId, resolved.repo.id),
1178 eq(pullRequests.number, prNum)
1179 )
1180 );
1181
1182 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1183 }
1184);
1185
c3e0c07Claude1186// Re-run AI review on demand (e.g. after a force-push). Bypasses the
1187// idempotency marker via { force: true }. Write-access only.
1188pulls.post(
1189 "/:owner/:repo/pulls/:number/ai-rereview",
1190 softAuth,
1191 requireAuth,
1192 requireRepoAccess("write"),
1193 async (c) => {
1194 const { owner: ownerName, repo: repoName } = c.req.param();
1195 const prNum = parseInt(c.req.param("number"), 10);
1196 const resolved = await resolveRepo(ownerName, repoName);
1197 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1198
1199 const [pr] = await db
1200 .select()
1201 .from(pullRequests)
1202 .where(
1203 and(
1204 eq(pullRequests.repositoryId, resolved.repo.id),
1205 eq(pullRequests.number, prNum)
1206 )
1207 )
1208 .limit(1);
1209 if (!pr) {
1210 return c.redirect(`/${ownerName}/${repoName}/pulls`);
1211 }
1212
1213 if (!isAiReviewEnabled()) {
1214 return c.redirect(
1215 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1216 "AI review is not configured (ANTHROPIC_API_KEY)."
1217 )}`
1218 );
1219 }
1220
1221 // Fire-and-forget but with { force: true } to bypass the
1222 // already-reviewed marker. The function still never throws.
1223 triggerAiReview(
1224 ownerName,
1225 repoName,
1226 pr.id,
1227 pr.title || "",
1228 pr.body || "",
1229 pr.baseBranch,
1230 pr.headBranch,
1231 { force: true }
1232 ).catch(() => {});
1233
1234 return c.redirect(
1235 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
1236 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
1237 )}`
1238 );
1239 }
1240);
1241
0074234Claude1242export default pulls;