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.tsxBlame650 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,
13} from "../db/schema";
14import { Layout } from "../views/layout";
15import { RepoHeader, DiffView } from "../views/components";
16import { renderMarkdown } from "../lib/markdown";
17import { softAuth, requireAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import {
20 listBranches,
21 getRepoPath,
22} from "../git/repository";
23import type { GitDiffFile } from "../git/repository";
24import { html } from "hono/html";
bb0f894Claude25import {
26 Flex,
27 Container,
28 Badge,
29 Button,
30 LinkButton,
31 Form,
32 FormGroup,
33 Input,
34 TextArea,
35 Select,
36 EmptyState,
37 FilterTabs,
38 TabNav,
39 List,
40 ListItem,
41 Text,
42 Alert,
43 MarkdownContent,
44 CommentBox,
45 formatRelative,
46} from "../views/ui";
0074234Claude47
48const pulls = new Hono<AuthEnv>();
49
50async function resolveRepo(ownerName: string, repoName: string) {
51 const [owner] = await db
52 .select()
53 .from(users)
54 .where(eq(users.username, ownerName))
55 .limit(1);
56 if (!owner) return null;
57 const [repo] = await db
58 .select()
59 .from(repositories)
60 .where(
61 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
62 )
63 .limit(1);
64 if (!repo) return null;
65 return { owner, repo };
66}
67
68// PR Nav helper
69const PrNav = ({
70 owner,
71 repo,
72 active,
73}: {
74 owner: string;
75 repo: string;
76 active: "code" | "issues" | "pulls" | "commits";
77}) => (
bb0f894Claude78 <TabNav
79 tabs={[
80 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
81 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
82 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
83 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
84 ]}
85 />
0074234Claude86);
87
88// List PRs
89pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
90 const { owner: ownerName, repo: repoName } = c.req.param();
91 const user = c.get("user");
92 const state = c.req.query("state") || "open";
93
94 const resolved = await resolveRepo(ownerName, repoName);
95 if (!resolved) return c.notFound();
96
97 const prList = await db
98 .select({
99 pr: pullRequests,
100 author: { username: users.username },
101 })
102 .from(pullRequests)
103 .innerJoin(users, eq(pullRequests.authorId, users.id))
104 .where(
105 and(
106 eq(pullRequests.repositoryId, resolved.repo.id),
107 eq(pullRequests.state, state)
108 )
109 )
110 .orderBy(desc(pullRequests.createdAt));
111
112 const [counts] = await db
113 .select({
114 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
115 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
116 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
117 })
118 .from(pullRequests)
119 .where(eq(pullRequests.repositoryId, resolved.repo.id));
120
121 return c.html(
122 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
123 <RepoHeader owner={ownerName} repo={repoName} />
124 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude125 <Flex justify="space-between" align="center" style="margin-bottom:16px">
126 <FilterTabs
127 tabs={[
128 { label: `${counts?.open ?? 0} Open`, href: `/${ownerName}/${repoName}/pulls?state=open`, active: state === "open" },
129 { label: `${counts?.merged ?? 0} Merged`, href: `/${ownerName}/${repoName}/pulls?state=merged`, active: state === "merged" },
130 { label: `${counts?.closed ?? 0} Closed`, href: `/${ownerName}/${repoName}/pulls?state=closed`, active: state === "closed" },
131 ]}
132 />
0074234Claude133 {user && (
bb0f894Claude134 <LinkButton href={`/${ownerName}/${repoName}/pulls/new`} variant="primary">
0074234Claude135 New pull request
bb0f894Claude136 </LinkButton>
0074234Claude137 )}
bb0f894Claude138 </Flex>
0074234Claude139 {prList.length === 0 ? (
bb0f894Claude140 <EmptyState>
0074234Claude141 <p>No {state} pull requests.</p>
bb0f894Claude142 </EmptyState>
0074234Claude143 ) : (
bb0f894Claude144 <List>
0074234Claude145 {prList.map(({ pr, author }) => (
bb0f894Claude146 <ListItem>
0074234Claude147 <div
148 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
149 >
150 {pr.state === "open"
151 ? "\u25CB"
152 : pr.state === "merged"
153 ? "\u2B8C"
154 : "\u2713"}
155 </div>
156 <div>
157 <div class="issue-title">
158 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
159 {pr.title}
160 </a>
161 </div>
162 <div class="issue-meta">
163 #{pr.number}{" "}
164 {pr.headBranch} → {pr.baseBranch}{" "}
165 by {author.username}{" "}
166 {formatRelative(pr.createdAt)}
167 </div>
168 </div>
bb0f894Claude169 </ListItem>
0074234Claude170 ))}
bb0f894Claude171 </List>
0074234Claude172 )}
173 </Layout>
174 );
175});
176
177// New PR form
178pulls.get(
179 "/:owner/:repo/pulls/new",
180 softAuth,
181 requireAuth,
182 async (c) => {
183 const { owner: ownerName, repo: repoName } = c.req.param();
184 const user = c.get("user")!;
185 const branches = await listBranches(ownerName, repoName);
186 const error = c.req.query("error");
187 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
188
189 return c.html(
190 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
191 <RepoHeader owner={ownerName} repo={repoName} />
192 <PrNav owner={ownerName} repo={repoName} active="pulls" />
bb0f894Claude193 <Container maxWidth={800}>
194 <h2 style="margin-bottom:16px">Open a pull request</h2>
0074234Claude195 {error && (
bb0f894Claude196 <Alert variant="error">{decodeURIComponent(error)}</Alert>
0074234Claude197 )}
bb0f894Claude198 <Form action={`/${ownerName}/${repoName}/pulls/new`} method="POST">
199 <Flex gap={12} align="center" style="margin-bottom:16px">
200 <Select name="base" value={defaultBase}>
0074234Claude201 {branches.map((b) => (
202 <option value={b} selected={b === defaultBase}>
203 {b}
204 </option>
205 ))}
bb0f894Claude206 </Select>
207 <Text muted>&larr;</Text>
208 <Select name="head">
0074234Claude209 {branches
210 .filter((b) => b !== defaultBase)
211 .concat(defaultBase === branches[0] ? [] : [branches[0]])
212 .map((b) => (
213 <option value={b}>{b}</option>
214 ))}
bb0f894Claude215 </Select>
216 </Flex>
217 <FormGroup>
218 <Input
0074234Claude219 name="title"
220 required
221 placeholder="Title"
bb0f894Claude222 style="font-size:16px;padding:10px 14px"
0074234Claude223 />
bb0f894Claude224 </FormGroup>
225 <FormGroup>
226 <TextArea
0074234Claude227 name="body"
228 rows={8}
229 placeholder="Description (Markdown supported)"
bb0f894Claude230 mono
0074234Claude231 />
bb0f894Claude232 </FormGroup>
233 <Button type="submit" variant="primary">
0074234Claude234 Create pull request
bb0f894Claude235 </Button>
236 </Form>
237 </Container>
0074234Claude238 </Layout>
239 );
240 }
241);
242
243// Create PR
244pulls.post(
245 "/:owner/:repo/pulls/new",
246 softAuth,
247 requireAuth,
248 async (c) => {
249 const { owner: ownerName, repo: repoName } = c.req.param();
250 const user = c.get("user")!;
251 const body = await c.req.parseBody();
252 const title = String(body.title || "").trim();
253 const prBody = String(body.body || "").trim();
254 const baseBranch = String(body.base || "main");
255 const headBranch = String(body.head || "");
256
257 if (!title || !headBranch) {
258 return c.redirect(
259 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
260 );
261 }
262
263 if (baseBranch === headBranch) {
264 return c.redirect(
265 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
266 );
267 }
268
269 const resolved = await resolveRepo(ownerName, repoName);
270 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
271
272 const [pr] = await db
273 .insert(pullRequests)
274 .values({
275 repositoryId: resolved.repo.id,
276 authorId: user.id,
277 title,
278 body: prBody || null,
279 baseBranch,
280 headBranch,
281 })
282 .returning();
283
284 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
285 }
286);
287
288// View single PR
289pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
290 const { owner: ownerName, repo: repoName } = c.req.param();
291 const prNum = parseInt(c.req.param("number"), 10);
292 const user = c.get("user");
293 const tab = c.req.query("tab") || "conversation";
294
295 const resolved = await resolveRepo(ownerName, repoName);
296 if (!resolved) return c.notFound();
297
298 const [pr] = await db
299 .select()
300 .from(pullRequests)
301 .where(
302 and(
303 eq(pullRequests.repositoryId, resolved.repo.id),
304 eq(pullRequests.number, prNum)
305 )
306 )
307 .limit(1);
308
309 if (!pr) return c.notFound();
310
311 const [author] = await db
312 .select()
313 .from(users)
314 .where(eq(users.id, pr.authorId))
315 .limit(1);
316
317 const comments = await db
318 .select({
319 comment: prComments,
320 author: { username: users.username },
321 })
322 .from(prComments)
323 .innerJoin(users, eq(prComments.authorId, users.id))
324 .where(eq(prComments.pullRequestId, pr.id))
325 .orderBy(asc(prComments.createdAt));
326
327 const canManage =
328 user &&
329 (user.id === resolved.owner.id || user.id === pr.authorId);
330
331 // Get diff for "Files changed" tab
332 let diffRaw = "";
333 let diffFiles: GitDiffFile[] = [];
334 if (tab === "files") {
335 const repoDir = getRepoPath(ownerName, repoName);
336 const proc = Bun.spawn(
337 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
338 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
339 );
340 diffRaw = await new Response(proc.stdout).text();
341 await proc.exited;
342
343 const statProc = Bun.spawn(
344 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
345 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
346 );
347 const stat = await new Response(statProc.stdout).text();
348 await statProc.exited;
349
350 diffFiles = stat
351 .trim()
352 .split("\n")
353 .filter(Boolean)
354 .map((line) => {
355 const [add, del, filePath] = line.split("\t");
356 return {
357 path: filePath,
358 status: "modified",
359 additions: add === "-" ? 0 : parseInt(add, 10),
360 deletions: del === "-" ? 0 : parseInt(del, 10),
361 patch: "",
362 };
363 });
364 }
365
366 return c.html(
367 <Layout
368 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
369 user={user}
370 >
371 <RepoHeader owner={ownerName} repo={repoName} />
372 <PrNav owner={ownerName} repo={repoName} active="pulls" />
373 <div class="issue-detail">
374 <h2>
375 {pr.title}{" "}
bb0f894Claude376 <Text color="var(--text-muted)" weight={400}>
0074234Claude377 #{pr.number}
bb0f894Claude378 </Text>
0074234Claude379 </h2>
bb0f894Claude380 <Flex align="center" gap={8} style="margin:8px 0 20px">
381 <Badge
382 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
0074234Claude383 >
384 {pr.state === "open"
385 ? "\u25CB Open"
386 : pr.state === "merged"
387 ? "\u2B8C Merged"
388 : "\u2713 Closed"}
bb0f894Claude389 </Badge>
390 <Text size={14} muted>
391 <strong style="color:var(--text)">
0074234Claude392 {author?.username}
393 </strong>{" "}
394 wants to merge <code>{pr.headBranch}</code> into{" "}
395 <code>{pr.baseBranch}</code>
bb0f894Claude396 </Text>
397 </Flex>
398
399 <FilterTabs
400 tabs={[
401 {
402 label: "Conversation",
403 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
404 active: tab === "conversation",
405 },
406 {
407 label: "Files changed",
408 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
409 active: tab === "files",
410 },
411 ]}
412 />
0074234Claude413
414 {tab === "files" ? (
415 <DiffView raw={diffRaw} files={diffFiles} />
416 ) : (
417 <>
418 {pr.body && (
bb0f894Claude419 <CommentBox
420 author={author?.username ?? "unknown"}
421 date={pr.createdAt}
422 body={renderMarkdown(pr.body)}
423 />
0074234Claude424 )}
425
426 {comments.map(({ comment, author: commentAuthor }) => (
427 <div
428 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
429 >
430 <div class="comment-header">
bb0f894Claude431 <Flex gap={8} align="center">
432 <strong>{commentAuthor.username}</strong>
433 {comment.isAiReview && (
434 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
435 AI Review
436 </Badge>
437 )}
438 <Text size={13} muted>
439 commented {formatRelative(comment.createdAt)}
440 </Text>
441 {comment.filePath && (
442 <Text size={11} mono style="margin-left:8px">
443 {comment.filePath}
444 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
445 </Text>
446 )}
447 </Flex>
0074234Claude448 </div>
bb0f894Claude449 <MarkdownContent html={renderMarkdown(comment.body)} />
0074234Claude450 </div>
451 ))}
452
453 {user && pr.state === "open" && (
bb0f894Claude454 <div style="margin-top:20px">
455 <Form
0074234Claude456 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
bb0f894Claude457 method="POST"
0074234Claude458 >
bb0f894Claude459 <FormGroup>
460 <TextArea
0074234Claude461 name="body"
462 rows={6}
463 required
464 placeholder="Leave a comment... (Markdown supported)"
bb0f894Claude465 mono
0074234Claude466 />
bb0f894Claude467 </FormGroup>
468 <Flex gap={8}>
469 <Button type="submit" variant="primary">
0074234Claude470 Comment
bb0f894Claude471 </Button>
0074234Claude472 {canManage && (
473 <>
474 <button
475 type="submit"
476 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
477 class="btn"
bb0f894Claude478 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
0074234Claude479 >
480 Merge pull request
481 </button>
bb0f894Claude482 <Button
0074234Claude483 type="submit"
bb0f894Claude484 variant="danger"
0074234Claude485 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
486 >
487 Close
bb0f894Claude488 </Button>
0074234Claude489 </>
490 )}
bb0f894Claude491 </Flex>
492 </Form>
0074234Claude493 </div>
494 )}
495 </>
496 )}
497 </div>
498 </Layout>
499 );
500});
501
502// Add comment to PR
503pulls.post(
504 "/:owner/:repo/pulls/:number/comment",
505 softAuth,
506 requireAuth,
507 async (c) => {
508 const { owner: ownerName, repo: repoName } = c.req.param();
509 const prNum = parseInt(c.req.param("number"), 10);
510 const user = c.get("user")!;
511 const body = await c.req.parseBody();
512 const commentBody = String(body.body || "").trim();
513
514 if (!commentBody) {
515 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
516 }
517
518 const resolved = await resolveRepo(ownerName, repoName);
519 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
520
521 const [pr] = await db
522 .select()
523 .from(pullRequests)
524 .where(
525 and(
526 eq(pullRequests.repositoryId, resolved.repo.id),
527 eq(pullRequests.number, prNum)
528 )
529 )
530 .limit(1);
531
532 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
533
534 await db.insert(prComments).values({
535 pullRequestId: pr.id,
536 authorId: user.id,
537 body: commentBody,
538 });
539
540 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
541 }
542);
543
544// Merge PR
545pulls.post(
546 "/:owner/:repo/pulls/:number/merge",
547 softAuth,
548 requireAuth,
549 async (c) => {
550 const { owner: ownerName, repo: repoName } = c.req.param();
551 const prNum = parseInt(c.req.param("number"), 10);
552 const user = c.get("user")!;
553
554 const resolved = await resolveRepo(ownerName, repoName);
555 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
556
557 const [pr] = await db
558 .select()
559 .from(pullRequests)
560 .where(
561 and(
562 eq(pullRequests.repositoryId, resolved.repo.id),
563 eq(pullRequests.number, prNum)
564 )
565 )
566 .limit(1);
567
568 if (!pr || pr.state !== "open") {
569 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
570 }
571
572 // Perform git merge
573 const repoDir = getRepoPath(ownerName, repoName);
574 const mergeProc = Bun.spawn(
575 [
576 "git",
577 "merge-base",
578 "--is-ancestor",
579 pr.baseBranch,
580 pr.headBranch,
581 ],
582 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
583 );
584 await mergeProc.exited;
585
586 // Use git update-ref for fast-forward or create merge commit
587 const ffProc = Bun.spawn(
588 [
589 "git",
590 "update-ref",
591 `refs/heads/${pr.baseBranch}`,
592 `refs/heads/${pr.headBranch}`,
593 ],
594 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
595 );
596 const ffExit = await ffProc.exited;
597
598 if (ffExit !== 0) {
599 // Fallback: try creating a merge commit via a temporary checkout
600 // For now, just report the error
601 return c.redirect(
602 `/${ownerName}/${repoName}/pulls/${prNum}?error=merge_conflict`
603 );
604 }
605
606 await db
607 .update(pullRequests)
608 .set({
609 state: "merged",
610 mergedAt: new Date(),
611 mergedBy: user.id,
612 updatedAt: new Date(),
613 })
614 .where(eq(pullRequests.id, pr.id));
615
616 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
617 }
618);
619
620// Close PR
621pulls.post(
622 "/:owner/:repo/pulls/:number/close",
623 softAuth,
624 requireAuth,
625 async (c) => {
626 const { owner: ownerName, repo: repoName } = c.req.param();
627 const prNum = parseInt(c.req.param("number"), 10);
628
629 const resolved = await resolveRepo(ownerName, repoName);
630 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
631
632 await db
633 .update(pullRequests)
634 .set({
635 state: "closed",
636 closedAt: new Date(),
637 updatedAt: new Date(),
638 })
639 .where(
640 and(
641 eq(pullRequests.repositoryId, resolved.repo.id),
642 eq(pullRequests.number, prNum)
643 )
644 );
645
646 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
647 }
648);
649
650export default pulls;