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.tsxBlame681 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";
25
26const pulls = new Hono<AuthEnv>();
27
28async function resolveRepo(ownerName: string, repoName: string) {
29 const [owner] = await db
30 .select()
31 .from(users)
32 .where(eq(users.username, ownerName))
33 .limit(1);
34 if (!owner) return null;
35 const [repo] = await db
36 .select()
37 .from(repositories)
38 .where(
39 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
40 )
41 .limit(1);
42 if (!repo) return null;
43 return { owner, repo };
44}
45
46// PR Nav helper
47const PrNav = ({
48 owner,
49 repo,
50 active,
51}: {
52 owner: string;
53 repo: string;
54 active: "code" | "issues" | "pulls" | "commits";
55}) => (
56 <div class="repo-nav">
57 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
58 Code
59 </a>
60 <a
61 href={`/${owner}/${repo}/issues`}
62 class={active === "issues" ? "active" : ""}
63 >
64 Issues
65 </a>
66 <a
67 href={`/${owner}/${repo}/pulls`}
68 class={active === "pulls" ? "active" : ""}
69 >
70 Pull Requests
71 </a>
72 <a
73 href={`/${owner}/${repo}/commits`}
74 class={active === "commits" ? "active" : ""}
75 >
76 Commits
77 </a>
78 </div>
79);
80
81// List PRs
82pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
83 const { owner: ownerName, repo: repoName } = c.req.param();
84 const user = c.get("user");
85 const state = c.req.query("state") || "open";
86
87 const resolved = await resolveRepo(ownerName, repoName);
88 if (!resolved) return c.notFound();
89
90 const prList = await db
91 .select({
92 pr: pullRequests,
93 author: { username: users.username },
94 })
95 .from(pullRequests)
96 .innerJoin(users, eq(pullRequests.authorId, users.id))
97 .where(
98 and(
99 eq(pullRequests.repositoryId, resolved.repo.id),
100 eq(pullRequests.state, state)
101 )
102 )
103 .orderBy(desc(pullRequests.createdAt));
104
105 const [counts] = await db
106 .select({
107 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
108 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
109 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
110 })
111 .from(pullRequests)
112 .where(eq(pullRequests.repositoryId, resolved.repo.id));
113
114 return c.html(
115 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
116 <RepoHeader owner={ownerName} repo={repoName} />
117 <PrNav owner={ownerName} repo={repoName} active="pulls" />
118 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
119 <div class="issue-tabs">
120 <a
121 href={`/${ownerName}/${repoName}/pulls?state=open`}
122 class={state === "open" ? "active" : ""}
123 >
124 {counts?.open ?? 0} Open
125 </a>
126 <a
127 href={`/${ownerName}/${repoName}/pulls?state=merged`}
128 class={state === "merged" ? "active" : ""}
129 >
130 {counts?.merged ?? 0} Merged
131 </a>
132 <a
133 href={`/${ownerName}/${repoName}/pulls?state=closed`}
134 class={state === "closed" ? "active" : ""}
135 >
136 {counts?.closed ?? 0} Closed
137 </a>
138 </div>
139 {user && (
140 <a
141 href={`/${ownerName}/${repoName}/pulls/new`}
142 class="btn btn-primary"
143 >
144 New pull request
145 </a>
146 )}
147 </div>
148 {prList.length === 0 ? (
149 <div class="empty-state">
150 <p>No {state} pull requests.</p>
151 </div>
152 ) : (
153 <div class="issue-list">
154 {prList.map(({ pr, author }) => (
155 <div class="issue-item">
156 <div
157 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
158 >
159 {pr.state === "open"
160 ? "\u25CB"
161 : pr.state === "merged"
162 ? "\u2B8C"
163 : "\u2713"}
164 </div>
165 <div>
166 <div class="issue-title">
167 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
168 {pr.title}
169 </a>
170 </div>
171 <div class="issue-meta">
172 #{pr.number}{" "}
173 {pr.headBranch} → {pr.baseBranch}{" "}
174 by {author.username}{" "}
175 {formatRelative(pr.createdAt)}
176 </div>
177 </div>
178 </div>
179 ))}
180 </div>
181 )}
182 </Layout>
183 );
184});
185
186// New PR form
187pulls.get(
188 "/:owner/:repo/pulls/new",
189 softAuth,
190 requireAuth,
191 async (c) => {
192 const { owner: ownerName, repo: repoName } = c.req.param();
193 const user = c.get("user")!;
194 const branches = await listBranches(ownerName, repoName);
195 const error = c.req.query("error");
196 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
197
198 return c.html(
199 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
200 <RepoHeader owner={ownerName} repo={repoName} />
201 <PrNav owner={ownerName} repo={repoName} active="pulls" />
202 <div style="max-width: 800px">
203 <h2 style="margin-bottom: 16px">Open a pull request</h2>
204 {error && (
205 <div class="auth-error">{decodeURIComponent(error)}</div>
206 )}
207 <form method="POST" action={`/${ownerName}/${repoName}/pulls/new`}>
208 <div style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px">
209 <select name="base" style="padding: 6px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px">
210 {branches.map((b) => (
211 <option value={b} selected={b === defaultBase}>
212 {b}
213 </option>
214 ))}
215 </select>
216 <span style="color: var(--text-muted)">&larr;</span>
217 <select name="head" style="padding: 6px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px">
218 {branches
219 .filter((b) => b !== defaultBase)
220 .concat(defaultBase === branches[0] ? [] : [branches[0]])
221 .map((b) => (
222 <option value={b}>{b}</option>
223 ))}
224 </select>
225 </div>
226 <div class="form-group">
227 <input
228 type="text"
229 name="title"
230 required
231 placeholder="Title"
232 style="font-size: 16px; padding: 10px 14px"
233 />
234 </div>
235 <div class="form-group">
236 <textarea
237 name="body"
238 rows={8}
239 placeholder="Description (Markdown supported)"
240 style="font-family: var(--font-mono); font-size: 13px"
241 />
242 </div>
243 <button type="submit" class="btn btn-primary">
244 Create pull request
245 </button>
246 </form>
247 </div>
248 </Layout>
249 );
250 }
251);
252
253// Create PR
254pulls.post(
255 "/:owner/:repo/pulls/new",
256 softAuth,
257 requireAuth,
258 async (c) => {
259 const { owner: ownerName, repo: repoName } = c.req.param();
260 const user = c.get("user")!;
261 const body = await c.req.parseBody();
262 const title = String(body.title || "").trim();
263 const prBody = String(body.body || "").trim();
264 const baseBranch = String(body.base || "main");
265 const headBranch = String(body.head || "");
266
267 if (!title || !headBranch) {
268 return c.redirect(
269 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
270 );
271 }
272
273 if (baseBranch === headBranch) {
274 return c.redirect(
275 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
276 );
277 }
278
279 const resolved = await resolveRepo(ownerName, repoName);
280 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
281
282 const [pr] = await db
283 .insert(pullRequests)
284 .values({
285 repositoryId: resolved.repo.id,
286 authorId: user.id,
287 title,
288 body: prBody || null,
289 baseBranch,
290 headBranch,
291 })
292 .returning();
293
294 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
295 }
296);
297
298// View single PR
299pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
300 const { owner: ownerName, repo: repoName } = c.req.param();
301 const prNum = parseInt(c.req.param("number"), 10);
302 const user = c.get("user");
303 const tab = c.req.query("tab") || "conversation";
304
305 const resolved = await resolveRepo(ownerName, repoName);
306 if (!resolved) return c.notFound();
307
308 const [pr] = await db
309 .select()
310 .from(pullRequests)
311 .where(
312 and(
313 eq(pullRequests.repositoryId, resolved.repo.id),
314 eq(pullRequests.number, prNum)
315 )
316 )
317 .limit(1);
318
319 if (!pr) return c.notFound();
320
321 const [author] = await db
322 .select()
323 .from(users)
324 .where(eq(users.id, pr.authorId))
325 .limit(1);
326
327 const comments = await db
328 .select({
329 comment: prComments,
330 author: { username: users.username },
331 })
332 .from(prComments)
333 .innerJoin(users, eq(prComments.authorId, users.id))
334 .where(eq(prComments.pullRequestId, pr.id))
335 .orderBy(asc(prComments.createdAt));
336
337 const canManage =
338 user &&
339 (user.id === resolved.owner.id || user.id === pr.authorId);
340
341 // Get diff for "Files changed" tab
342 let diffRaw = "";
343 let diffFiles: GitDiffFile[] = [];
344 if (tab === "files") {
345 const repoDir = getRepoPath(ownerName, repoName);
346 const proc = Bun.spawn(
347 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
348 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
349 );
350 diffRaw = await new Response(proc.stdout).text();
351 await proc.exited;
352
353 const statProc = Bun.spawn(
354 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
355 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
356 );
357 const stat = await new Response(statProc.stdout).text();
358 await statProc.exited;
359
360 diffFiles = stat
361 .trim()
362 .split("\n")
363 .filter(Boolean)
364 .map((line) => {
365 const [add, del, filePath] = line.split("\t");
366 return {
367 path: filePath,
368 status: "modified",
369 additions: add === "-" ? 0 : parseInt(add, 10),
370 deletions: del === "-" ? 0 : parseInt(del, 10),
371 patch: "",
372 };
373 });
374 }
375
376 return c.html(
377 <Layout
378 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
379 user={user}
380 >
381 <RepoHeader owner={ownerName} repo={repoName} />
382 <PrNav owner={ownerName} repo={repoName} active="pulls" />
383 <div class="issue-detail">
384 <h2>
385 {pr.title}{" "}
386 <span style="color: var(--text-muted); font-weight: 400">
387 #{pr.number}
388 </span>
389 </h2>
390 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
391 <span
392 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
393 >
394 {pr.state === "open"
395 ? "\u25CB Open"
396 : pr.state === "merged"
397 ? "\u2B8C Merged"
398 : "\u2713 Closed"}
399 </span>
400 <span style="color: var(--text-muted); font-size: 14px">
401 <strong style="color: var(--text)">
402 {author?.username}
403 </strong>{" "}
404 wants to merge <code>{pr.headBranch}</code> into{" "}
405 <code>{pr.baseBranch}</code>
406 </span>
407 </div>
408
409 <div class="issue-tabs" style="margin-bottom: 20px">
410 <a
411 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
412 class={tab === "conversation" ? "active" : ""}
413 >
414 Conversation
415 </a>
416 <a
417 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
418 class={tab === "files" ? "active" : ""}
419 >
420 Files changed
421 </a>
422 </div>
423
424 {tab === "files" ? (
425 <DiffView raw={diffRaw} files={diffFiles} />
426 ) : (
427 <>
428 {pr.body && (
429 <div class="issue-comment-box">
430 <div class="comment-header">
431 <strong>{author?.username}</strong> commented{" "}
432 {formatRelative(pr.createdAt)}
433 </div>
434 <div class="markdown-body">
435 {html([renderMarkdown(pr.body)] as unknown as TemplateStringsArray)}
436 </div>
437 </div>
438 )}
439
440 {comments.map(({ comment, author: commentAuthor }) => (
441 <div
442 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
443 >
444 <div class="comment-header">
445 <strong>{commentAuthor.username}</strong>
446 {comment.isAiReview && (
447 <span class="badge" style="margin-left: 8px; background: rgba(31, 111, 235, 0.15); color: var(--text-link); border-color: var(--accent)">
448 AI Review
449 </span>
450 )}
451 {" "}
452 commented {formatRelative(comment.createdAt)}
453 {comment.filePath && (
454 <span style="margin-left: 8px; font-family: var(--font-mono); font-size: 11px">
455 {comment.filePath}
456 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
457 </span>
458 )}
459 </div>
460 <div class="markdown-body">
461 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
462 </div>
463 </div>
464 ))}
465
466 {user && pr.state === "open" && (
467 <div style="margin-top: 20px">
468 <form
469 method="POST"
470 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
471 >
472 <div class="form-group">
473 <textarea
474 name="body"
475 rows={6}
476 required
477 placeholder="Leave a comment... (Markdown supported)"
478 style="font-family: var(--font-mono); font-size: 13px"
479 />
480 </div>
481 <div style="display: flex; gap: 8px">
482 <button type="submit" class="btn btn-primary">
483 Comment
484 </button>
485 {canManage && (
486 <>
487 <button
488 type="submit"
489 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
490 class="btn"
491 style="background: rgba(63, 185, 80, 0.15); border-color: var(--green); color: var(--green)"
492 >
493 Merge pull request
494 </button>
495 <button
496 type="submit"
497 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
498 class="btn btn-danger"
499 >
500 Close
501 </button>
502 </>
503 )}
504 </div>
505 </form>
506 </div>
507 )}
508 </>
509 )}
510 </div>
511 </Layout>
512 );
513});
514
515// Add comment to PR
516pulls.post(
517 "/:owner/:repo/pulls/:number/comment",
518 softAuth,
519 requireAuth,
520 async (c) => {
521 const { owner: ownerName, repo: repoName } = c.req.param();
522 const prNum = parseInt(c.req.param("number"), 10);
523 const user = c.get("user")!;
524 const body = await c.req.parseBody();
525 const commentBody = String(body.body || "").trim();
526
527 if (!commentBody) {
528 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
529 }
530
531 const resolved = await resolveRepo(ownerName, repoName);
532 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
533
534 const [pr] = await db
535 .select()
536 .from(pullRequests)
537 .where(
538 and(
539 eq(pullRequests.repositoryId, resolved.repo.id),
540 eq(pullRequests.number, prNum)
541 )
542 )
543 .limit(1);
544
545 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
546
547 await db.insert(prComments).values({
548 pullRequestId: pr.id,
549 authorId: user.id,
550 body: commentBody,
551 });
552
553 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
554 }
555);
556
557// Merge PR
558pulls.post(
559 "/:owner/:repo/pulls/:number/merge",
560 softAuth,
561 requireAuth,
562 async (c) => {
563 const { owner: ownerName, repo: repoName } = c.req.param();
564 const prNum = parseInt(c.req.param("number"), 10);
565 const user = c.get("user")!;
566
567 const resolved = await resolveRepo(ownerName, repoName);
568 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
569
570 const [pr] = await db
571 .select()
572 .from(pullRequests)
573 .where(
574 and(
575 eq(pullRequests.repositoryId, resolved.repo.id),
576 eq(pullRequests.number, prNum)
577 )
578 )
579 .limit(1);
580
581 if (!pr || pr.state !== "open") {
582 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
583 }
584
585 // Perform git merge
586 const repoDir = getRepoPath(ownerName, repoName);
587 const mergeProc = Bun.spawn(
588 [
589 "git",
590 "merge-base",
591 "--is-ancestor",
592 pr.baseBranch,
593 pr.headBranch,
594 ],
595 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
596 );
597 await mergeProc.exited;
598
599 // Use git update-ref for fast-forward or create merge commit
600 const ffProc = Bun.spawn(
601 [
602 "git",
603 "update-ref",
604 `refs/heads/${pr.baseBranch}`,
605 `refs/heads/${pr.headBranch}`,
606 ],
607 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
608 );
609 const ffExit = await ffProc.exited;
610
611 if (ffExit !== 0) {
612 // Fallback: try creating a merge commit via a temporary checkout
613 // For now, just report the error
614 return c.redirect(
615 `/${ownerName}/${repoName}/pulls/${prNum}?error=merge_conflict`
616 );
617 }
618
619 await db
620 .update(pullRequests)
621 .set({
622 state: "merged",
623 mergedAt: new Date(),
624 mergedBy: user.id,
625 updatedAt: new Date(),
626 })
627 .where(eq(pullRequests.id, pr.id));
628
629 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
630 }
631);
632
633// Close PR
634pulls.post(
635 "/:owner/:repo/pulls/:number/close",
636 softAuth,
637 requireAuth,
638 async (c) => {
639 const { owner: ownerName, repo: repoName } = c.req.param();
640 const prNum = parseInt(c.req.param("number"), 10);
641
642 const resolved = await resolveRepo(ownerName, repoName);
643 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
644
645 await db
646 .update(pullRequests)
647 .set({
648 state: "closed",
649 closedAt: new Date(),
650 updatedAt: new Date(),
651 })
652 .where(
653 and(
654 eq(pullRequests.repositoryId, resolved.repo.id),
655 eq(pullRequests.number, prNum)
656 )
657 );
658
659 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
660 }
661);
662
663function formatRelative(date: Date | string): string {
664 const d = typeof date === "string" ? new Date(date) : date;
665 const now = new Date();
666 const diffMs = now.getTime() - d.getTime();
667 const diffMins = Math.floor(diffMs / 60000);
668 if (diffMins < 1) return "just now";
669 if (diffMins < 60) return `${diffMins}m ago`;
670 const diffHours = Math.floor(diffMins / 60);
671 if (diffHours < 24) return `${diffHours}h ago`;
672 const diffDays = Math.floor(diffHours / 24);
673 if (diffDays < 30) return `${diffDays}d ago`;
674 return d.toLocaleDateString("en-US", {
675 month: "short",
676 day: "numeric",
677 year: "numeric",
678 });
679}
680
681export default pulls;