Commit0074234unknown_key
feat: Week 4 — pull requests, web file editor, activity schema
feat: Week 4 — pull requests, web file editor, activity schema - Pull requests: create, list (open/merged/closed), view with conversation + files-changed tabs, comment, merge (fast-forward), close. Full markdown rendering in PR bodies and comments. - Web file editor: create new files and edit existing files directly in the browser via git plumbing (hash-object, mktree, commit-tree, update-ref). No working directory needed. - Schema: pull_requests, pr_comments (with AI review flag + file/line annotations), activity_feed tables - RepoNav: Pull Requests tab across all repo views - Blob view: Edit button for authenticated users - PR merge badge + state colors (open/merged/closed) - 60 passing tests https://claude.ai/code/session_013wpQ5iX7qU6zy6PrtML3fP
7 files changed+1106−100742345b8c4354f1c7bc0f997146d8dbdeb5482
7 changed files+1106−1
Modifiedsrc/app.tsx+8−0View fileUnifiedSplit
@@ -9,6 +9,8 @@ import settingsRoutes from "./routes/settings";
99import issueRoutes from "./routes/issues";
1010import repoSettings from "./routes/repo-settings";
1111import compareRoutes from "./routes/compare";
12import pullRoutes from "./routes/pulls";
13import editorRoutes from "./routes/editor";
1214import webRoutes from "./routes/web";
1315
1416const app = new Hono();
@@ -38,6 +40,12 @@ app.route("/", compareRoutes);
3840// Issue tracker
3941app.route("/", issueRoutes);
4042
43// Pull requests
44app.route("/", pullRoutes);
45
46// Web file editor
47app.route("/", editorRoutes);
48
4149// Web UI (catch-all, must be last)
4250app.route("/", webRoutes);
4351
Modifiedsrc/db/schema.ts+71−0View fileUnifiedSplit
@@ -144,6 +144,74 @@ export const issueLabels = pgTable(
144144 ]
145145);
146146
147export const pullRequests = pgTable(
148 "pull_requests",
149 {
150 id: uuid("id").primaryKey().defaultRandom(),
151 number: serial("number"),
152 repositoryId: uuid("repository_id")
153 .notNull()
154 .references(() => repositories.id, { onDelete: "cascade" }),
155 authorId: uuid("author_id")
156 .notNull()
157 .references(() => users.id),
158 title: text("title").notNull(),
159 body: text("body"),
160 state: text("state").notNull().default("open"), // open, closed, merged
161 baseBranch: text("base_branch").notNull(),
162 headBranch: text("head_branch").notNull(),
163 mergedAt: timestamp("merged_at"),
164 mergedBy: uuid("merged_by").references(() => users.id),
165 createdAt: timestamp("created_at").defaultNow().notNull(),
166 updatedAt: timestamp("updated_at").defaultNow().notNull(),
167 closedAt: timestamp("closed_at"),
168 },
169 (table) => [
170 index("prs_repo_state").on(table.repositoryId, table.state),
171 index("prs_repo_number").on(table.repositoryId, table.number),
172 ]
173);
174
175export const prComments = pgTable(
176 "pr_comments",
177 {
178 id: uuid("id").primaryKey().defaultRandom(),
179 pullRequestId: uuid("pull_request_id")
180 .notNull()
181 .references(() => pullRequests.id, { onDelete: "cascade" }),
182 authorId: uuid("author_id")
183 .notNull()
184 .references(() => users.id),
185 body: text("body").notNull(),
186 isAiReview: boolean("is_ai_review").default(false).notNull(),
187 filePath: text("file_path"),
188 lineNumber: integer("line_number"),
189 createdAt: timestamp("created_at").defaultNow().notNull(),
190 updatedAt: timestamp("updated_at").defaultNow().notNull(),
191 },
192 (table) => [index("pr_comments_pr").on(table.pullRequestId)]
193);
194
195export const activityFeed = pgTable(
196 "activity_feed",
197 {
198 id: uuid("id").primaryKey().defaultRandom(),
199 repositoryId: uuid("repository_id")
200 .notNull()
201 .references(() => repositories.id, { onDelete: "cascade" }),
202 userId: uuid("user_id").references(() => users.id),
203 action: text("action").notNull(), // push, issue_open, issue_close, pr_open, pr_merge, star, comment
204 targetType: text("target_type"), // issue, pr, commit
205 targetId: text("target_id"),
206 metadata: text("metadata"), // JSON string for extra data
207 createdAt: timestamp("created_at").defaultNow().notNull(),
208 },
209 (table) => [
210 index("activity_repo").on(table.repositoryId),
211 index("activity_user").on(table.userId),
212 ]
213);
214
147215export const sshKeys = pgTable("ssh_keys", {
148216 id: uuid("id").primaryKey().defaultRandom(),
149217 userId: uuid("user_id")
@@ -166,3 +234,6 @@ export type SshKey = typeof sshKeys.$inferSelect;
166234export type Issue = typeof issues.$inferSelect;
167235export type IssueComment = typeof issueComments.$inferSelect;
168236export type Label = typeof labels.$inferSelect;
237export type PullRequest = typeof pullRequests.$inferSelect;
238export type PrComment = typeof prComments.$inferSelect;
239export type ActivityEntry = typeof activityFeed.$inferSelect;
Addedsrc/routes/editor.tsx+331−0View fileUnifiedSplit
@@ -0,0 +1,331 @@
1/**
2 * Web file editor — create and edit files directly in the browser.
3 */
4
5import { Hono } from "hono";
6import { Layout } from "../views/layout";
7import { RepoHeader, RepoNav, Breadcrumb } from "../views/components";
8import {
9 getBlob,
10 getDefaultBranch,
11 getRepoPath,
12 repoExists,
13} from "../git/repository";
14import { softAuth, requireAuth } from "../middleware/auth";
15import type { AuthEnv } from "../middleware/auth";
16import { join } from "path";
17
18const editor = new Hono<AuthEnv>();
19
20editor.use("*", softAuth);
21
22// New file form
23editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, async (c) => {
24 const { owner, repo } = c.req.param();
25 const user = c.get("user")!;
26 const refAndPath = c.req.param("ref");
27
28 // Parse ref — use first segment
29 const slashIdx = refAndPath.indexOf("/");
30 const ref = slashIdx === -1 ? refAndPath : refAndPath.slice(0, slashIdx);
31 const dirPath = slashIdx === -1 ? "" : refAndPath.slice(slashIdx + 1);
32
33 return c.html(
34 <Layout title={`New file — ${owner}/${repo}`} user={user}>
35 <RepoHeader owner={owner} repo={repo} />
36 <RepoNav owner={owner} repo={repo} active="code" />
37 <div style="max-width: 900px">
38 <h2 style="margin-bottom: 16px">Create new file</h2>
39 <form method="POST" action={`/${owner}/${repo}/new/${ref}`}>
40 <input type="hidden" name="dir_path" value={dirPath} />
41 <div class="form-group">
42 <label>File path</label>
43 <div style="display: flex; align-items: center; gap: 4px">
44 {dirPath && (
45 <span style="color: var(--text-muted); font-size: 14px">
46 {dirPath}/
47 </span>
48 )}
49 <input
50 type="text"
51 name="filename"
52 required
53 placeholder="filename.ts"
54 style="flex: 1"
55 autocomplete="off"
56 />
57 </div>
58 </div>
59 <div class="form-group">
60 <label>Content</label>
61 <textarea
62 name="content"
63 rows={20}
64 style="font-family: var(--font-mono); font-size: 13px; line-height: 1.5; tab-size: 2"
65 placeholder="Enter file content..."
66 />
67 </div>
68 <div class="form-group">
69 <label>Commit message</label>
70 <input
71 type="text"
72 name="message"
73 placeholder="Create new file"
74 required
75 />
76 </div>
77 <button type="submit" class="btn btn-primary">
78 Commit new file
79 </button>
80 </form>
81 </div>
82 </Layout>
83 );
84});
85
86// Create file via commit
87editor.post("/:owner/:repo/new/:ref", requireAuth, async (c) => {
88 const { owner, repo } = c.req.param();
89 const user = c.get("user")!;
90 const ref = c.req.param("ref");
91 const body = await c.req.parseBody();
92 const dirPath = String(body.dir_path || "").trim();
93 const filename = String(body.filename || "").trim();
94 const content = String(body.content || "");
95 const message = String(body.message || `Create ${filename}`).trim();
96
97 if (!filename) return c.redirect(`/${owner}/${repo}`);
98
99 const fullPath = dirPath ? `${dirPath}/${filename}` : filename;
100
101 // Use git hash-object + update-index + write-tree + commit-tree
102 const repoDir = getRepoPath(owner, repo);
103
104 const run = async (cmd: string[], cwd: string, stdin?: string) => {
105 const proc = Bun.spawn(cmd, {
106 cwd,
107 stdout: "pipe",
108 stderr: "pipe",
109 stdin: stdin !== undefined ? "pipe" : undefined,
110 });
111 if (stdin !== undefined && proc.stdin) {
112 proc.stdin.write(new TextEncoder().encode(stdin));
113 proc.stdin.end();
114 }
115 const stdout = await new Response(proc.stdout).text();
116 await proc.exited;
117 return stdout.trim();
118 };
119
120 // Hash the new file content
121 const blobSha = await run(
122 ["git", "hash-object", "-w", "--stdin"],
123 repoDir,
124 content
125 );
126
127 // Read current tree
128 const currentTreeSha = await run(
129 ["git", "rev-parse", `${ref}^{tree}`],
130 repoDir
131 );
132
133 // Read current tree and add new entry
134 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
135 const entries = treeContent
136 .split("\n")
137 .filter(Boolean)
138 .map((line) => line + "\n")
139 .join("");
140 const newEntry = `100644 blob ${blobSha}\t${fullPath}\n`;
141
142 const newTreeSha = await run(
143 ["git", "mktree"],
144 repoDir,
145 entries + newEntry
146 );
147
148 // Get parent commit
149 const parentSha = await run(
150 ["git", "rev-parse", ref],
151 repoDir
152 );
153
154 // Create commit
155 const env = {
156 GIT_AUTHOR_NAME: user.displayName || user.username,
157 GIT_AUTHOR_EMAIL: user.email,
158 GIT_COMMITTER_NAME: user.displayName || user.username,
159 GIT_COMMITTER_EMAIL: user.email,
160 };
161
162 const commitProc = Bun.spawn(
163 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
164 {
165 cwd: repoDir,
166 stdout: "pipe",
167 stderr: "pipe",
168 env: { ...process.env, ...env },
169 }
170 );
171 const commitSha = (await new Response(commitProc.stdout).text()).trim();
172 await commitProc.exited;
173
174 // Update branch ref
175 await run(
176 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
177 repoDir
178 );
179
180 return c.redirect(`/${owner}/${repo}/blob/${ref}/${fullPath}`);
181});
182
183// Edit file form
184editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, async (c) => {
185 const { owner, repo } = c.req.param();
186 const user = c.get("user")!;
187 const refAndPath = c.req.param("ref");
188
189 // Parse ref/path
190 const slashIdx = refAndPath.indexOf("/");
191 if (slashIdx === -1) return c.text("Not found", 404);
192 const ref = refAndPath.slice(0, slashIdx);
193 const filePath = refAndPath.slice(slashIdx + 1);
194
195 const blob = await getBlob(owner, repo, ref, filePath);
196 if (!blob || blob.isBinary) {
197 return c.html(
198 <Layout title="Cannot edit" user={user}>
199 <div class="empty-state">
200 <h2>{blob?.isBinary ? "Cannot edit binary file" : "File not found"}</h2>
201 </div>
202 </Layout>,
203 404
204 );
205 }
206
207 return c.html(
208 <Layout title={`Editing ${filePath} — ${owner}/${repo}`} user={user}>
209 <RepoHeader owner={owner} repo={repo} />
210 <RepoNav owner={owner} repo={repo} active="code" />
211 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
212 <div style="max-width: 900px">
213 <form method="POST" action={`/${owner}/${repo}/edit/${ref}/${filePath}`}>
214 <div class="form-group">
215 <textarea
216 name="content"
217 rows={25}
218 style="font-family: var(--font-mono); font-size: 13px; line-height: 1.5; tab-size: 2; width: 100%"
219 >
220 {blob.content}
221 </textarea>
222 </div>
223 <div class="form-group">
224 <label>Commit message</label>
225 <input
226 type="text"
227 name="message"
228 placeholder={`Update ${filePath.split("/").pop()}`}
229 required
230 />
231 </div>
232 <div style="display: flex; gap: 8px">
233 <button type="submit" class="btn btn-primary">
234 Commit changes
235 </button>
236 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} class="btn">
237 Cancel
238 </a>
239 </div>
240 </form>
241 </div>
242 </Layout>
243 );
244});
245
246// Save edited file
247editor.post("/:owner/:repo/edit/:ref{.+$}", requireAuth, async (c) => {
248 const { owner, repo } = c.req.param();
249 const user = c.get("user")!;
250 const refAndPath = c.req.param("ref");
251
252 const slashIdx = refAndPath.indexOf("/");
253 if (slashIdx === -1) return c.redirect(`/${owner}/${repo}`);
254 const ref = refAndPath.slice(0, slashIdx);
255 const filePath = refAndPath.slice(slashIdx + 1);
256
257 const body = await c.req.parseBody();
258 const content = String(body.content || "");
259 const message = String(
260 body.message || `Update ${filePath.split("/").pop()}`
261 ).trim();
262
263 const repoDir = getRepoPath(owner, repo);
264
265 const run = async (cmd: string[], cwd: string, stdin?: string) => {
266 const proc = Bun.spawn(cmd, {
267 cwd,
268 stdout: "pipe",
269 stderr: "pipe",
270 stdin: stdin !== undefined ? "pipe" : undefined,
271 });
272 if (stdin !== undefined && proc.stdin) {
273 proc.stdin.write(new TextEncoder().encode(stdin));
274 proc.stdin.end();
275 }
276 const stdout = await new Response(proc.stdout).text();
277 await proc.exited;
278 return stdout.trim();
279 };
280
281 // Hash new content
282 const blobSha = await run(
283 ["git", "hash-object", "-w", "--stdin"],
284 repoDir,
285 content
286 );
287
288 // Read current tree, replace the file
289 const treeContent = await run(["git", "ls-tree", "-r", ref], repoDir);
290 const lines = treeContent.split("\n").filter(Boolean);
291 const updated = lines
292 .map((line) => {
293 const parts = line.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/);
294 if (parts && parts[4] === filePath) {
295 return `${parts[1]} blob ${blobSha}\t${parts[4]}`;
296 }
297 return line;
298 })
299 .join("\n") + "\n";
300
301 const newTreeSha = await run(["git", "mktree"], repoDir, updated);
302 const parentSha = await run(["git", "rev-parse", ref], repoDir);
303
304 const env = {
305 GIT_AUTHOR_NAME: user.displayName || user.username,
306 GIT_AUTHOR_EMAIL: user.email,
307 GIT_COMMITTER_NAME: user.displayName || user.username,
308 GIT_COMMITTER_EMAIL: user.email,
309 };
310
311 const commitProc = Bun.spawn(
312 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
313 {
314 cwd: repoDir,
315 stdout: "pipe",
316 stderr: "pipe",
317 env: { ...process.env, ...env },
318 }
319 );
320 const commitSha = (await new Response(commitProc.stdout).text()).trim();
321 await commitProc.exited;
322
323 await run(
324 ["git", "update-ref", `refs/heads/${ref}`, commitSha],
325 repoDir
326 );
327
328 return c.redirect(`/${owner}/${repo}/blob/${ref}/${filePath}`);
329});
330
331export default editor;
Addedsrc/routes/pulls.tsx+681−0View fileUnifiedSplit
@@ -0,0 +1,681 @@
1/**
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)">←</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;
Modifiedsrc/routes/web.tsx+5−0View fileUnifiedSplit
@@ -566,6 +566,11 @@ web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
566566 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
567567 Blame
568568 </a>
569 {user && (
570 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
571 Edit
572 </a>
573 )}
569574 </span>
570575 </div>
571576 {blob.isBinary ? (
Modifiedsrc/views/components.tsx+7−1View fileUnifiedSplit
@@ -42,7 +42,7 @@ export const RepoHeader: FC<{
4242export const RepoNav: FC<{
4343 owner: string;
4444 repo: string;
45 active: "code" | "commits" | "issues";
45 active: "code" | "commits" | "issues" | "pulls";
4646}> = ({ owner, repo, active }) => (
4747 <div class="repo-nav">
4848 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
@@ -54,6 +54,12 @@ export const RepoNav: FC<{
5454 >
5555 Issues
5656 </a>
57 <a
58 href={`/${owner}/${repo}/pulls`}
59 class={active === "pulls" ? "active" : ""}
60 >
61 Pull Requests
62 </a>
5763 <a
5864 href={`/${owner}/${repo}/commits`}
5965 class={active === "commits" ? "active" : ""}
Modifiedsrc/views/layout.tsx+3−0View fileUnifiedSplit
@@ -511,6 +511,9 @@ const css = `
511511 }
512512 .badge-open { background: rgba(63, 185, 80, 0.15); color: var(--green); border: 1px solid var(--green); }
513513 .badge-closed { background: rgba(152, 110, 226, 0.15); color: #986ee2; border: 1px solid #986ee2; }
514 .badge-merged { background: rgba(152, 110, 226, 0.15); color: #986ee2; border: 1px solid #986ee2; }
515 .state-merged { color: #986ee2; }
516 .ai-review { border-color: var(--accent); }
514517
515518 .issue-detail { max-width: 900px; }
516519 .issue-comment-box {
517520