Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

pulls.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

pulls.tsxBlame1243 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";
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"
63c60ebcopilot-swe-agent[bot]296 aria-label="Pull request title"
0074234Claude297 />
bb0f894Claude298 </FormGroup>
299 <FormGroup>
300 <TextArea
0074234Claude301 name="body"
81c73c1Claude302 id="pr-body"
0074234Claude303 rows={8}
304 placeholder="Description (Markdown supported)"
bb0f894Claude305 mono
0074234Claude306 />
bb0f894Claude307 </FormGroup>
81c73c1Claude308 <Flex gap={8} align="center">
309 <Button type="submit" variant="primary">
310 Create pull request
311 </Button>
312 <button
313 type="button"
314 id="ai-suggest-desc"
315 class="btn"
316 style="font-weight:500"
317 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
318 >
319 Suggest description with AI
320 </button>
321 <span
322 id="ai-suggest-status"
323 style="color:var(--text-muted);font-size:13px"
324 />
325 </Flex>
bb0f894Claude326 </Form>
81c73c1Claude327 <script
328 dangerouslySetInnerHTML={{
329 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
330 }}
331 />
bb0f894Claude332 </Container>
0074234Claude333 </Layout>
334 );
335 }
336);
337
81c73c1Claude338// AI-suggested PR description — JSON endpoint driven by the form button.
339// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
340// 200; the inline script reads `ok` to decide what to do.
341pulls.post(
342 "/:owner/:repo/ai/pr-description",
343 softAuth,
344 requireAuth,
345 requireRepoAccess("write"),
346 async (c) => {
347 const { owner: ownerName, repo: repoName } = c.req.param();
348 if (!isAiAvailable()) {
349 return c.json({
350 ok: false,
351 error: "AI is not available — set ANTHROPIC_API_KEY.",
352 });
353 }
354 const body = await c.req.parseBody();
355 const title = String(body.title || "").trim();
356 const baseBranch = String(body.base || "").trim();
357 const headBranch = String(body.head || "").trim();
358 if (!baseBranch || !headBranch) {
359 return c.json({ ok: false, error: "Pick base + head branches first." });
360 }
361 if (baseBranch === headBranch) {
362 return c.json({ ok: false, error: "Base and head must differ." });
363 }
364
365 let diff = "";
366 try {
367 const cwd = getRepoPath(ownerName, repoName);
368 const proc = Bun.spawn(
369 [
370 "git",
371 "diff",
372 `${baseBranch}...${headBranch}`,
373 "--",
374 ],
375 { cwd, stdout: "pipe", stderr: "pipe" }
376 );
377 diff = await new Response(proc.stdout).text();
378 await proc.exited;
379 } catch {
380 diff = "";
381 }
382 if (!diff.trim()) {
383 return c.json({
384 ok: false,
385 error: "No diff between branches — nothing to summarise.",
386 });
387 }
388
389 let summary = "";
390 try {
391 summary = await generatePrSummary(title || "(untitled)", diff);
392 } catch (err) {
393 const msg = err instanceof Error ? err.message : "AI request failed.";
394 return c.json({ ok: false, error: msg });
395 }
396 if (!summary.trim()) {
397 return c.json({ ok: false, error: "AI returned an empty draft." });
398 }
399 return c.json({ ok: true, body: summary });
400 }
401);
402
0074234Claude403// Create PR
404pulls.post(
405 "/:owner/:repo/pulls/new",
406 softAuth,
407 requireAuth,
04f6b7fClaude408 requireRepoAccess("write"),
0074234Claude409 async (c) => {
410 const { owner: ownerName, repo: repoName } = c.req.param();
411 const user = c.get("user")!;
412 const body = await c.req.parseBody();
413 const title = String(body.title || "").trim();
414 const prBody = String(body.body || "").trim();
415 const baseBranch = String(body.base || "main");
416 const headBranch = String(body.head || "");
417
418 if (!title || !headBranch) {
419 return c.redirect(
420 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
421 );
422 }
423
424 if (baseBranch === headBranch) {
425 return c.redirect(
426 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
427 );
428 }
429
430 const resolved = await resolveRepo(ownerName, repoName);
431 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
432
6fc53bdClaude433 const isDraft = String(body.draft || "") === "1";
434
0074234Claude435 const [pr] = await db
436 .insert(pullRequests)
437 .values({
438 repositoryId: resolved.repo.id,
439 authorId: user.id,
440 title,
441 body: prBody || null,
442 baseBranch,
443 headBranch,
6fc53bdClaude444 isDraft,
0074234Claude445 })
446 .returning();
447
6fc53bdClaude448 // Skip AI review on drafts — it runs again when the PR is marked ready.
449 if (!isDraft && isAiReviewEnabled()) {
e883329Claude450 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
451 (err) => console.error("[ai-review] Failed:", err)
452 );
453 }
454
3cbe3d6Claude455 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
456 triggerPrTriage({
457 ownerName,
458 repoName,
459 repositoryId: resolved.repo.id,
460 prId: pr.id,
461 prAuthorId: user.id,
462 title,
463 body: prBody,
464 baseBranch,
465 headBranch,
466 }).catch((err) => console.error("[pr-triage] Failed:", err));
467
0074234Claude468 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
469 }
470);
471
472// View single PR
04f6b7fClaude473pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), async (c) => {
0074234Claude474 const { owner: ownerName, repo: repoName } = c.req.param();
475 const prNum = parseInt(c.req.param("number"), 10);
476 const user = c.get("user");
477 const tab = c.req.query("tab") || "conversation";
478
479 const resolved = await resolveRepo(ownerName, repoName);
480 if (!resolved) return c.notFound();
481
482 const [pr] = await db
483 .select()
484 .from(pullRequests)
485 .where(
486 and(
487 eq(pullRequests.repositoryId, resolved.repo.id),
488 eq(pullRequests.number, prNum)
489 )
490 )
491 .limit(1);
492
493 if (!pr) return c.notFound();
494
495 const [author] = await db
496 .select()
497 .from(users)
498 .where(eq(users.id, pr.authorId))
499 .limit(1);
500
501 const comments = await db
502 .select({
503 comment: prComments,
504 author: { username: users.username },
505 })
506 .from(prComments)
507 .innerJoin(users, eq(prComments.authorId, users.id))
508 .where(eq(prComments.pullRequestId, pr.id))
509 .orderBy(asc(prComments.createdAt));
510
6fc53bdClaude511 // Reactions for the PR body + each comment, in parallel.
512 const [prReactions, ...prCommentReactions] = await Promise.all([
513 summariseReactions("pr", pr.id, user?.id),
514 ...comments.map((row) =>
515 summariseReactions("pr_comment", row.comment.id, user?.id)
516 ),
517 ]);
518
0074234Claude519 const canManage =
520 user &&
521 (user.id === resolved.owner.id || user.id === pr.authorId);
522
e883329Claude523 const error = c.req.query("error");
c3e0c07Claude524 const info = c.req.query("info");
e883329Claude525
526 // Get gate check status for open PRs
527 let gateChecks: GateCheckResult[] = [];
528 if (pr.state === "open") {
529 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
530 if (headSha) {
531 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
532 const aiApproved = aiComments.length === 0 || aiComments.some(
533 ({ comment }) => comment.body.includes("**Approved**")
534 );
535 const gateResult = await runAllGateChecks(
536 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
537 );
538 gateChecks = gateResult.checks;
539 }
540 }
541
0074234Claude542 // Get diff for "Files changed" tab
543 let diffRaw = "";
544 let diffFiles: GitDiffFile[] = [];
545 if (tab === "files") {
546 const repoDir = getRepoPath(ownerName, repoName);
547 const proc = Bun.spawn(
548 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
549 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
550 );
551 diffRaw = await new Response(proc.stdout).text();
552 await proc.exited;
553
554 const statProc = Bun.spawn(
555 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
556 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
557 );
558 const stat = await new Response(statProc.stdout).text();
559 await statProc.exited;
560
561 diffFiles = stat
562 .trim()
563 .split("\n")
564 .filter(Boolean)
565 .map((line) => {
566 const [add, del, filePath] = line.split("\t");
567 return {
568 path: filePath,
569 status: "modified",
570 additions: add === "-" ? 0 : parseInt(add, 10),
571 deletions: del === "-" ? 0 : parseInt(del, 10),
572 patch: "",
573 };
574 });
575 }
576
577 return c.html(
578 <Layout
579 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
580 user={user}
581 >
582 <RepoHeader owner={ownerName} repo={repoName} />
583 <PrNav owner={ownerName} repo={repoName} active="pulls" />
b584e52Claude584 <div
585 id="live-comment-banner"
586 class="alert"
587 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
588 >
589 <strong class="js-live-count">0</strong> new comment(s) —{" "}
590 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
591 reload to view
592 </a>
593 </div>
594 <script
595 dangerouslySetInnerHTML={{
596 __html: liveCommentBannerScript({
597 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
598 bannerElementId: "live-comment-banner",
599 }),
600 }}
601 />
0074234Claude602 <div class="issue-detail">
603 <h2>
604 {pr.title}{" "}
bb0f894Claude605 <Text color="var(--text-muted)" weight={400}>
0074234Claude606 #{pr.number}
bb0f894Claude607 </Text>
0074234Claude608 </h2>
bb0f894Claude609 <Flex align="center" gap={8} style="margin:8px 0 20px">
610 <Badge
611 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
0074234Claude612 >
613 {pr.state === "open"
614 ? "\u25CB Open"
615 : pr.state === "merged"
616 ? "\u2B8C Merged"
617 : "\u2713 Closed"}
bb0f894Claude618 </Badge>
619 <Text size={14} muted>
620 <strong style="color:var(--text)">
0074234Claude621 {author?.username}
622 </strong>{" "}
623 wants to merge <code>{pr.headBranch}</code> into{" "}
624 <code>{pr.baseBranch}</code>
bb0f894Claude625 </Text>
626 </Flex>
627
628 <FilterTabs
629 tabs={[
630 {
631 label: "Conversation",
632 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
633 active: tab === "conversation",
634 },
635 {
636 label: "Files changed",
637 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
638 active: tab === "files",
639 },
640 ]}
641 />
0074234Claude642
643 {tab === "files" ? (
644 <DiffView raw={diffRaw} files={diffFiles} />
645 ) : (
646 <>
647 {pr.body && (
bb0f894Claude648 <CommentBox
649 author={author?.username ?? "unknown"}
650 date={pr.createdAt}
651 body={renderMarkdown(pr.body)}
652 />
0074234Claude653 )}
654
6fc53bdClaude655 {comments.map(({ comment, author: commentAuthor }, i) => (
0074234Claude656 <div
657 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
658 >
659 <div class="comment-header">
bb0f894Claude660 <Flex gap={8} align="center">
661 <strong>{commentAuthor.username}</strong>
662 {comment.isAiReview && (
663 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
664 AI Review
665 </Badge>
666 )}
667 <Text size={13} muted>
668 commented {formatRelative(comment.createdAt)}
669 </Text>
670 {comment.filePath && (
671 <Text size={11} mono style="margin-left:8px">
672 {comment.filePath}
673 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
674 </Text>
675 )}
676 </Flex>
6fc53bdClaude677 </div>
bb0f894Claude678 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude679 </div>
680 ))}
681
e883329Claude682 {error && (
683 <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)">
684 {decodeURIComponent(error)}
685 </div>
686 )}
687
c3e0c07Claude688 {info && (
689 <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)">
690 {decodeURIComponent(info)}
691 </div>
692 )}
693
e883329Claude694 {pr.state === "open" && gateChecks.length > 0 && (
695 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
696 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
697 {gateChecks.map((check) => (
698 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
699 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
700 {check.passed ? "\u2713" : "\u2717"}
701 </span>
702 <strong style="font-size: 13px">{check.name}</strong>
703 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
704 </div>
705 ))}
706 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
707 {gateChecks.every((c) => c.passed)
708 ? "All checks passed — ready to merge"
709 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
710 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
711 : "Some checks failed — resolve issues before merging"}
712 </div>
713 </div>
714 )}
715
0074234Claude716 {user && pr.state === "open" && (
717 <div style="margin-top: 20px">
0316dbbClaude718 <Form
e7e240eClaude719 method="post"
0074234Claude720 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
721 >
bb0f894Claude722 <FormGroup>
723 <TextArea
0074234Claude724 name="body"
725 rows={6}
726 required
727 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude728 mono
0074234Claude729 />
bb0f894Claude730 </FormGroup>
731 <Flex gap={8}>
732 <Button type="submit" variant="primary">
0074234Claude733 Comment
bb0f894Claude734 </Button>
0074234Claude735 {canManage && (
736 <>
737 <button
738 type="submit"
739 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
740 class="btn"
bb0f894Claude741 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
0074234Claude742 >
743 Merge pull request
744 </button>
c3e0c07Claude745 {isAiReviewEnabled() && (
746 <button
747 type="submit"
748 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
749 formnovalidate
750 class="btn"
751 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
752 >
753 Re-run AI review
754 </button>
755 )}
bb0f894Claude756 <Button
0074234Claude757 type="submit"
bb0f894Claude758 variant="danger"
0074234Claude759 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
760 >
761 Close
bb0f894Claude762 </Button>
0074234Claude763 </>
764 )}
bb0f894Claude765 </Flex>
766 </Form>
0074234Claude767 </div>
768 )}
769 </>
770 )}
771 </div>
772 </Layout>
773 );
774});
775
776// Add comment to PR
777pulls.post(
778 "/:owner/:repo/pulls/:number/comment",
779 softAuth,
780 requireAuth,
04f6b7fClaude781 requireRepoAccess("write"),
0074234Claude782 async (c) => {
783 const { owner: ownerName, repo: repoName } = c.req.param();
784 const prNum = parseInt(c.req.param("number"), 10);
785 const user = c.get("user")!;
786 const body = await c.req.parseBody();
787 const commentBody = String(body.body || "").trim();
788
789 if (!commentBody) {
790 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
791 }
792
793 const resolved = await resolveRepo(ownerName, repoName);
794 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
795
796 const [pr] = await db
797 .select()
798 .from(pullRequests)
799 .where(
800 and(
801 eq(pullRequests.repositoryId, resolved.repo.id),
802 eq(pullRequests.number, prNum)
803 )
804 )
805 .limit(1);
806
807 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
808
d4ac5c3Claude809 const [inserted] = await db
810 .insert(prComments)
811 .values({
812 pullRequestId: pr.id,
813 authorId: user.id,
814 body: commentBody,
815 })
816 .returning();
817
818 // Live update: nudge any browser tabs subscribed to this PR.
819 if (inserted) {
820 try {
821 const { publish } = await import("../lib/sse");
822 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
823 event: "pr-comment",
824 data: {
825 pullRequestId: pr.id,
826 commentId: inserted.id,
827 authorId: user.id,
828 authorUsername: user.username,
829 },
830 });
831 } catch {
832 /* SSE is best-effort */
833 }
834 }
0074234Claude835
836 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
837 }
838);
839
e883329Claude840// Merge PR — with green gate enforcement and auto conflict resolution
04f6b7fClaude841// NOTE: Merging is a high-impact action that arguably warrants "admin" access,
842// but we keep it at "write" for v1 so trusted collaborators can ship.
843// Revisit when we introduce a distinct "maintain" / "admin" collaborator role
844// surface. Branch-protection rules (evaluated below) are the current mechanism
845// for locking down merges further on specific branches.
0074234Claude846pulls.post(
847 "/:owner/:repo/pulls/:number/merge",
848 softAuth,
849 requireAuth,
04f6b7fClaude850 requireRepoAccess("write"),
0074234Claude851 async (c) => {
852 const { owner: ownerName, repo: repoName } = c.req.param();
853 const prNum = parseInt(c.req.param("number"), 10);
854 const user = c.get("user")!;
855
856 const resolved = await resolveRepo(ownerName, repoName);
857 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
858
859 const [pr] = await db
860 .select()
861 .from(pullRequests)
862 .where(
863 and(
864 eq(pullRequests.repositoryId, resolved.repo.id),
865 eq(pullRequests.number, prNum)
866 )
867 )
868 .limit(1);
869
870 if (!pr || pr.state !== "open") {
871 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
872 }
873
6fc53bdClaude874 // Draft PRs cannot be merged — must be marked ready first.
875 if (pr.isDraft) {
876 return c.redirect(
877 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
878 "This PR is a draft. Mark it as ready for review before merging."
879 )}`
880 );
881 }
882
e883329Claude883 // Resolve head SHA
884 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
885 if (!headSha) {
886 return c.redirect(
887 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
888 );
889 }
890
891 // Check if AI review approved this PR
892 const aiComments = await db
893 .select()
894 .from(prComments)
895 .where(
896 and(
897 eq(prComments.pullRequestId, pr.id),
898 eq(prComments.isAiReview, true)
899 )
900 );
901 const aiApproved = aiComments.length === 0 || aiComments.some(
902 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
0074234Claude903 );
e883329Claude904
905 // Run all green gate checks (GateTest + mergeability + AI review)
906 const gateResult = await runAllGateChecks(
907 ownerName,
908 repoName,
909 pr.baseBranch,
910 pr.headBranch,
911 headSha,
912 aiApproved
0074234Claude913 );
914
e883329Claude915 // If GateTest or AI review failed (hard blocks), reject the merge
916 const hardFailures = gateResult.checks.filter(
917 (check) => !check.passed && check.name !== "Merge check"
918 );
919 if (hardFailures.length > 0) {
920 const errorMsg = hardFailures
921 .map((f) => `${f.name}: ${f.details}`)
922 .join("; ");
0074234Claude923 return c.redirect(
e883329Claude924 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
0074234Claude925 );
926 }
927
1e162a8Claude928 // D5 — Branch-protection enforcement. Looks up the matching rule for the
929 // base branch and blocks the merge if requireAiApproval / requireGreenGates
930 // / requireHumanReview / requiredApprovals are not satisfied. Independent
931 // of repo-global settings, so owners can lock specific branches down
932 // further than the repo default.
933 const protectionRule = await matchProtection(
934 resolved.repo.id,
935 pr.baseBranch
936 );
937 if (protectionRule) {
938 const humanApprovals = await countHumanApprovals(pr.id);
a79a9edClaude939 const required = await listRequiredChecks(protectionRule.id);
940 const passingNames = required.length > 0
941 ? await passingCheckNames(resolved.repo.id, headSha)
942 : [];
943 const decision = evaluateProtection(
944 protectionRule,
945 {
946 aiApproved,
947 humanApprovalCount: humanApprovals,
948 gateResultGreen: hardFailures.length === 0,
949 hasFailedGates: hardFailures.length > 0,
950 passingCheckNames: passingNames,
951 },
952 required.map((r) => r.checkName)
953 );
1e162a8Claude954 if (!decision.allowed) {
955 return c.redirect(
956 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
957 decision.reasons.join(" ")
958 )}`
959 );
960 }
961 }
962
e883329Claude963 // Attempt the merge — with auto conflict resolution if needed
964 const repoDir = getRepoPath(ownerName, repoName);
965 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
966 const hasConflicts = mergeCheck && !mergeCheck.passed;
967
968 if (hasConflicts && isAiReviewEnabled()) {
969 // Use Claude to auto-resolve conflicts
970 const mergeResult = await mergeWithAutoResolve(
971 ownerName,
972 repoName,
973 pr.baseBranch,
974 pr.headBranch,
975 `Merge pull request #${pr.number}: ${pr.title}`
976 );
977
978 if (!mergeResult.success) {
979 return c.redirect(
980 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
981 );
982 }
983
984 // Post a comment about the auto-resolution
985 if (mergeResult.resolvedFiles.length > 0) {
986 await db.insert(prComments).values({
987 pullRequestId: pr.id,
988 authorId: user.id,
989 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
990 isAiReview: true,
991 });
992 }
993 } else {
994 // Standard merge — fast-forward or clean merge
995 const ffProc = Bun.spawn(
996 [
997 "git",
998 "update-ref",
999 `refs/heads/${pr.baseBranch}`,
1000 `refs/heads/${pr.headBranch}`,
1001 ],
1002 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1003 );
1004 const ffExit = await ffProc.exited;
1005
1006 if (ffExit !== 0) {
1007 return c.redirect(
1008 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
1009 );
1010 }
1011 }
1012
0074234Claude1013 await db
1014 .update(pullRequests)
1015 .set({
1016 state: "merged",
1017 mergedAt: new Date(),
1018 mergedBy: user.id,
1019 updatedAt: new Date(),
1020 })
1021 .where(eq(pullRequests.id, pr.id));
1022
d62fb36Claude1023 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
1024 // and auto-close each matching open issue with a back-link comment. Bounded
1025 // to the same repo for v1 (cross-repo refs ignored). Failures never block
1026 // the merge redirect.
1027 try {
1028 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
1029 const refs = extractClosingRefsMulti([pr.title, pr.body]);
1030 for (const n of refs) {
1031 const [issue] = await db
1032 .select()
1033 .from(issues)
1034 .where(
1035 and(
1036 eq(issues.repositoryId, resolved.repo.id),
1037 eq(issues.number, n)
1038 )
1039 )
1040 .limit(1);
1041 if (!issue || issue.state !== "open") continue;
1042 await db
1043 .update(issues)
1044 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
1045 .where(eq(issues.id, issue.id));
1046 await db.insert(issueComments).values({
1047 issueId: issue.id,
1048 authorId: user.id,
1049 body: `Closed by pull request #${pr.number}.`,
1050 });
1051 }
1052 } catch {
1053 // Never block the merge on close-keyword failures.
1054 }
1055
0074234Claude1056 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1057 }
1058);
1059
6fc53bdClaude1060// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
1061// hasn't run yet on this PR.
1062pulls.post(
1063 "/:owner/:repo/pulls/:number/ready",
1064 softAuth,
1065 requireAuth,
04f6b7fClaude1066 requireRepoAccess("write"),
6fc53bdClaude1067 async (c) => {
1068 const { owner: ownerName, repo: repoName } = c.req.param();
1069 const prNum = parseInt(c.req.param("number"), 10);
1070 const user = c.get("user")!;
1071
1072 const resolved = await resolveRepo(ownerName, repoName);
1073 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1074
1075 const [pr] = await db
1076 .select()
1077 .from(pullRequests)
1078 .where(
1079 and(
1080 eq(pullRequests.repositoryId, resolved.repo.id),
1081 eq(pullRequests.number, prNum)
1082 )
1083 )
1084 .limit(1);
1085 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1086
1087 // Only the author or repo owner can toggle draft state.
1088 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1089 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1090 }
1091
1092 if (pr.state === "open" && pr.isDraft) {
1093 await db
1094 .update(pullRequests)
1095 .set({ isDraft: false, updatedAt: new Date() })
1096 .where(eq(pullRequests.id, pr.id));
1097
1098 if (isAiReviewEnabled()) {
1099 triggerAiReview(
1100 ownerName,
1101 repoName,
1102 pr.id,
1103 pr.title,
0316dbbClaude1104 pr.body || "",
6fc53bdClaude1105 pr.baseBranch,
1106 pr.headBranch
1107 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
1108 }
1109 }
1110
1111 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1112 }
1113);
1114
1115// Convert a PR back to draft.
1116pulls.post(
1117 "/:owner/:repo/pulls/:number/draft",
1118 softAuth,
1119 requireAuth,
04f6b7fClaude1120 requireRepoAccess("write"),
6fc53bdClaude1121 async (c) => {
1122 const { owner: ownerName, repo: repoName } = c.req.param();
1123 const prNum = parseInt(c.req.param("number"), 10);
1124 const user = c.get("user")!;
1125
1126 const resolved = await resolveRepo(ownerName, repoName);
1127 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1128
1129 const [pr] = await db
1130 .select()
1131 .from(pullRequests)
1132 .where(
1133 and(
1134 eq(pullRequests.repositoryId, resolved.repo.id),
1135 eq(pullRequests.number, prNum)
1136 )
1137 )
1138 .limit(1);
1139 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
1140
1141 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
1142 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1143 }
1144
1145 if (pr.state === "open" && !pr.isDraft) {
1146 await db
1147 .update(pullRequests)
1148 .set({ isDraft: true, updatedAt: new Date() })
1149 .where(eq(pullRequests.id, pr.id));
1150 }
1151
1152 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1153 }
1154);
1155
0074234Claude1156// Close PR
1157pulls.post(
1158 "/:owner/:repo/pulls/:number/close",
1159 softAuth,
1160 requireAuth,
04f6b7fClaude1161 requireRepoAccess("write"),
0074234Claude1162 async (c) => {
1163 const { owner: ownerName, repo: repoName } = c.req.param();
1164 const prNum = parseInt(c.req.param("number"), 10);
1165
1166 const resolved = await resolveRepo(ownerName, repoName);
1167 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1168
1169 await db
1170 .update(pullRequests)
1171 .set({
1172 state: "closed",
1173 closedAt: new Date(),
1174 updatedAt: new Date(),
1175 })
1176 .where(
1177 and(
1178 eq(pullRequests.repositoryId, resolved.repo.id),
1179 eq(pullRequests.number, prNum)
1180 )
1181 );
1182
1183 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1184 }
1185);
1186
c3e0c07Claude1187// Re-run AI review on demand (e.g. after a force-push). Bypasses the
1188// idempotency marker via { force: true }. Write-access only.
1189pulls.post(
1190 "/:owner/:repo/pulls/:number/ai-rereview",
1191 softAuth,
1192 requireAuth,
1193 requireRepoAccess("write"),
1194 async (c) => {
1195 const { owner: ownerName, repo: repoName } = c.req.param();
1196 const prNum = parseInt(c.req.param("number"), 10);
1197 const resolved = await resolveRepo(ownerName, repoName);
1198 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1199
1200 const [pr] = await db
1201 .select()
1202 .from(pullRequests)
1203 .where(
1204 and(
1205 eq(pullRequests.repositoryId, resolved.repo.id),
1206 eq(pullRequests.number, prNum)
1207 )
1208 )
1209 .limit(1);
1210 if (!pr) {
1211 return c.redirect(`/${ownerName}/${repoName}/pulls`);
1212 }
1213
1214 if (!isAiReviewEnabled()) {
1215 return c.redirect(
1216 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1217 "AI review is not configured (ANTHROPIC_API_KEY)."
1218 )}`
1219 );
1220 }
1221
1222 // Fire-and-forget but with { force: true } to bypass the
1223 // already-reviewed marker. The function still never throws.
1224 triggerAiReview(
1225 ownerName,
1226 repoName,
1227 pr.id,
1228 pr.title || "",
1229 pr.body || "",
1230 pr.baseBranch,
1231 pr.headBranch,
1232 { force: true }
1233 ).catch(() => {});
1234
1235 return c.redirect(
1236 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
1237 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
1238 )}`
1239 );
1240 }
1241);
1242
0074234Claude1243export default pulls;