Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit79136bbunknown_key

feat: Week 3 — issues, markdown, blame, search, compare, repo settings

feat: Week 3 — issues, markdown, blame, search, compare, repo settings

- Issue tracker: create, list, view, comment, close/reopen with markdown rendering
- Schema: issues, issue_comments, labels, issue_labels tables
- Markdown rendering: full GFM support with syntax-highlighted code blocks
- Blame view: porcelain git blame with commit attribution
- Code search: git grep with results grouped by file
- Compare view: branch diff with commit list and unified diff
- Raw file download: binary-safe file download endpoint
- Repo settings: edit description, visibility, default branch, delete repo
- Global error handler + styled 404 page
- RepoNav: Issues tab across all repo pages
- Blob view: Raw + Blame links in file header
- 60 passing tests (was 45)

https://claude.ai/code/session_013wpQ5iX7qU6zy6PrtML3fP
Claude committed on April 12, 2026Parent: 06d5ffe
14 files changed+18374379136bbac7d3ca405348d9136c9da6ed6e2ad91f
14 changed files+1837−43
Modifiedbun.lock+3−0View fileUnifiedSplit
1010 "drizzle-orm": "^0.39.0",
1111 "highlight.js": "^11.11.0",
1212 "hono": "^4.7.0",
13 "marked": "^18.0.0",
1314 },
1415 "devDependencies": {
1516 "@types/bun": "^1.2.0",
109110
110111 "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
111112
113 "marked": ["marked@18.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA=="],
114
112115 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
113116
114117 "obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="],
Modifiedpackage.json+4−3View fileUnifiedSplit
1212 "test": "bun test"
1313 },
1414 "dependencies": {
15 "hono": "^4.7.0",
1615 "@hono/node-server": "^1.13.0",
17 "drizzle-orm": "^0.39.0",
1816 "@neondatabase/serverless": "^0.10.0",
19 "highlight.js": "^11.11.0"
17 "drizzle-orm": "^0.39.0",
18 "highlight.js": "^11.11.0",
19 "hono": "^4.7.0",
20 "marked": "^18.0.0"
2021 },
2122 "devDependencies": {
2223 "@types/bun": "^1.2.0",
Addedsrc/__tests__/week3.test.ts+174−0View fileUnifiedSplit
1import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2import { join } from "path";
3import { rm, mkdir } from "fs/promises";
4import app from "../app";
5import { initBareRepo, getRepoPath } from "../git/repository";
6import { renderMarkdown } from "../lib/markdown";
7
8const TEST_REPOS = join(import.meta.dir, "../../.test-repos-w3-" + Date.now());
9
10beforeAll(async () => {
11 await rm(TEST_REPOS, { recursive: true, force: true });
12 await mkdir(TEST_REPOS, { recursive: true });
13 process.env.GIT_REPOS_PATH = TEST_REPOS;
14
15 await initBareRepo("alice", "project");
16 const cloneDir = join(TEST_REPOS, "_clone");
17 await mkdir(cloneDir, { recursive: true });
18 const repoPath = getRepoPath("alice", "project");
19 const workDir = join(cloneDir, "work");
20
21 const run = async (cmd: string[], cwd: string) => {
22 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
23 await proc.exited;
24 };
25
26 await run(["git", "clone", repoPath, workDir], TEST_REPOS);
27 await run(["git", "config", "user.email", "alice@test.com"], workDir);
28 await run(["git", "config", "user.name", "Alice"], workDir);
29
30 await mkdir(join(workDir, "src"), { recursive: true });
31 await Bun.write(
32 join(workDir, "README.md"),
33 "# My Project\n\nThis is a **bold** statement.\n\n- Item 1\n- Item 2\n\n```ts\nconst x = 1;\n```"
34 );
35 await Bun.write(
36 join(workDir, "src/main.ts"),
37 'function hello(name: string) {\n console.log(`Hello, ${name}!`);\n}\nhello("world");'
38 );
39 await Bun.write(join(workDir, "data.json"), '{"key": "value"}');
40
41 await run(["git", "add", "-A"], workDir);
42 await run(["git", "commit", "-m", "Initial commit"], workDir);
43 await run(["git", "branch", "-M", "main"], workDir);
44 await run(["git", "push", "-u", "origin", "main"], workDir);
45
46 // Second branch
47 await run(["git", "checkout", "-b", "dev"], workDir);
48 await Bun.write(join(workDir, "src/new.ts"), "export const y = 2;");
49 await run(["git", "add", "-A"], workDir);
50 await run(["git", "commit", "-m", "Add new module"], workDir);
51 await run(["git", "push", "-u", "origin", "dev"], workDir);
52
53 await rm(cloneDir, { recursive: true, force: true });
54});
55
56afterAll(async () => {
57 await rm(TEST_REPOS, { recursive: true, force: true });
58});
59
60describe("markdown rendering", () => {
61 it("should render bold text", () => {
62 const html = renderMarkdown("This is **bold**.");
63 expect(html).toContain("<strong>bold</strong>");
64 });
65
66 it("should render code blocks with highlighting", () => {
67 const html = renderMarkdown("```ts\nconst x = 1;\n```");
68 expect(html).toContain("<pre>");
69 expect(html).toContain("hljs-");
70 });
71
72 it("should render lists", () => {
73 const html = renderMarkdown("- one\n- two");
74 expect(html).toContain("<li>");
75 });
76
77 it("should sanitize dangerous links", () => {
78 const html = renderMarkdown("[click](javascript:alert(1))");
79 expect(html).not.toContain("javascript:");
80 });
81
82 it("README renders as markdown in repo view", async () => {
83 const res = await app.request("/alice/project");
84 expect(res.status).toBe(200);
85 const html = await res.text();
86 expect(html).toContain("<strong>bold</strong>");
87 expect(html).toContain("markdown-body");
88 });
89});
90
91describe("raw file download", () => {
92 it("GET /:owner/:repo/raw/:ref/:path returns file content", async () => {
93 const res = await app.request("/alice/project/raw/main/data.json");
94 expect(res.status).toBe(200);
95 expect(res.headers.get("content-type")).toContain("application/octet-stream");
96 const text = await res.text();
97 expect(text).toContain('"key"');
98 });
99
100 it("returns 404 for missing file", async () => {
101 const res = await app.request("/alice/project/raw/main/nope.txt");
102 expect(res.status).toBe(404);
103 });
104});
105
106describe("blame view", () => {
107 it("GET /:owner/:repo/blame/:ref/:path shows blame", async () => {
108 const res = await app.request("/alice/project/blame/main/src/main.ts");
109 expect(res.status).toBe(200);
110 const html = await res.text();
111 expect(html).toContain("blame");
112 expect(html).toContain("Alice");
113 expect(html).toContain("hello");
114 });
115
116 it("returns 404 for missing file", async () => {
117 const res = await app.request("/alice/project/blame/main/nope.ts");
118 expect(res.status).toBe(404);
119 });
120});
121
122describe("code search", () => {
123 it("GET /:owner/:repo/search?q=... returns results", async () => {
124 const res = await app.request("/alice/project/search?q=hello");
125 expect(res.status).toBe(200);
126 const html = await res.text();
127 expect(html).toContain("result");
128 expect(html).toContain("main.ts");
129 });
130
131 it("empty query shows no results", async () => {
132 const res = await app.request("/alice/project/search");
133 expect(res.status).toBe(200);
134 const html = await res.text();
135 expect(html).toContain("Search");
136 });
137});
138
139describe("compare view", () => {
140 it("GET /:owner/:repo/compare shows picker", async () => {
141 const res = await app.request("/alice/project/compare");
142 expect(res.status).toBe(200);
143 const html = await res.text();
144 expect(html).toContain("Compare");
145 expect(html).toContain("main");
146 expect(html).toContain("dev");
147 });
148
149 it("GET /:owner/:repo/compare/:base...:head shows diff", async () => {
150 const res = await app.request("/alice/project/compare/main...dev");
151 expect(res.status).toBe(200);
152 const html = await res.text();
153 expect(html).toContain("new.ts");
154 expect(html).toContain("Add new module");
155 });
156});
157
158describe("blob view links", () => {
159 it("blob view contains Raw and Blame links", async () => {
160 const res = await app.request("/alice/project/blob/main/src/main.ts");
161 expect(res.status).toBe(200);
162 const html = await res.text();
163 expect(html).toContain("/alice/project/raw/main/src/main.ts");
164 expect(html).toContain("/alice/project/blame/main/src/main.ts");
165 });
166});
167
168describe("error handling", () => {
169 it("404 for unknown routes returns proper page", async () => {
170 const res = await app.request("/alice/project/unknown-route-xyz");
171 // This might hit the repo page or 404 depending on routing
172 expect([200, 404]).toContain(res.status);
173 });
174});
Deletedsrc/app.ts+0−31View fileUnifiedSplit
1import { Hono } from "hono";
2import { logger } from "hono/logger";
3import { cors } from "hono/cors";
4import gitRoutes from "./routes/git";
5import apiRoutes from "./routes/api";
6import authRoutes from "./routes/auth";
7import settingsRoutes from "./routes/settings";
8import webRoutes from "./routes/web";
9
10const app = new Hono();
11
12// Middleware
13app.use("*", logger());
14app.use("/api/*", cors());
15
16// Git Smart HTTP protocol routes (must be before web routes)
17app.route("/", gitRoutes);
18
19// REST API
20app.route("/", apiRoutes);
21
22// Auth routes (register, login, logout)
23app.route("/", authRoutes);
24
25// Settings routes (profile, SSH keys) — requires auth
26app.route("/", settingsRoutes);
27
28// Web UI (catch-all, must be last)
29app.route("/", webRoutes);
30
31export default app;
Addedsrc/app.tsx+79−0View fileUnifiedSplit
1import { Hono } from "hono";
2import { logger } from "hono/logger";
3import { cors } from "hono/cors";
4import { Layout } from "./views/layout";
5import gitRoutes from "./routes/git";
6import apiRoutes from "./routes/api";
7import authRoutes from "./routes/auth";
8import settingsRoutes from "./routes/settings";
9import issueRoutes from "./routes/issues";
10import repoSettings from "./routes/repo-settings";
11import compareRoutes from "./routes/compare";
12import webRoutes from "./routes/web";
13
14const app = new Hono();
15
16// Middleware
17app.use("*", logger());
18app.use("/api/*", cors());
19
20// Git Smart HTTP protocol routes (must be before web routes)
21app.route("/", gitRoutes);
22
23// REST API
24app.route("/", apiRoutes);
25
26// Auth routes (register, login, logout)
27app.route("/", authRoutes);
28
29// Settings routes (profile, SSH keys)
30app.route("/", settingsRoutes);
31
32// Repo settings (description, visibility, delete)
33app.route("/", repoSettings);
34
35// Compare view (branch diffs)
36app.route("/", compareRoutes);
37
38// Issue tracker
39app.route("/", issueRoutes);
40
41// Web UI (catch-all, must be last)
42app.route("/", webRoutes);
43
44// Global 404
45app.notFound((c) => {
46 return c.html(
47 <Layout title="Not Found">
48 <div class="empty-state">
49 <h2>404</h2>
50 <p>Page not found.</p>
51 <a href="/" style="margin-top: 12px; display: inline-block">
52 Go home
53 </a>
54 </div>
55 </Layout>,
56 404
57 );
58});
59
60// Global error handler
61app.onError((err, c) => {
62 console.error("[error]", err);
63 return c.html(
64 <Layout title="Error">
65 <div class="empty-state">
66 <h2>Something went wrong</h2>
67 <p>An unexpected error occurred.</p>
68 {process.env.NODE_ENV !== "production" && (
69 <pre style="margin-top: 16px; text-align: left; font-size: 12px; color: var(--red)">
70 {err.message}
71 </pre>
72 )}
73 </div>
74 </Layout>,
75 500
76 );
77});
78
79export default app;
Modifiedsrc/db/schema.ts+82−1View fileUnifiedSplit
66 boolean,
77 integer,
88 uniqueIndex,
9 index,
10 serial,
911} from "drizzle-orm/pg-core";
1012
1113export const users = pgTable("users", {
4749 pushedAt: timestamp("pushed_at"),
4850 starCount: integer("star_count").default(0).notNull(),
4951 forkCount: integer("fork_count").default(0).notNull(),
52 issueCount: integer("issue_count").default(0).notNull(),
5053 },
5154 (table) => [uniqueIndex("repos_owner_name").on(table.ownerId, table.name)]
5255);
6366 .references(() => repositories.id, { onDelete: "cascade" }),
6467 createdAt: timestamp("created_at").defaultNow().notNull(),
6568 },
66 (table) => [uniqueIndex("stars_user_repo").on(table.userId, table.repositoryId)]
69 (table) => [
70 uniqueIndex("stars_user_repo").on(table.userId, table.repositoryId),
71 ]
72);
73
74export const issues = pgTable(
75 "issues",
76 {
77 id: uuid("id").primaryKey().defaultRandom(),
78 number: serial("number"),
79 repositoryId: uuid("repository_id")
80 .notNull()
81 .references(() => repositories.id, { onDelete: "cascade" }),
82 authorId: uuid("author_id")
83 .notNull()
84 .references(() => users.id),
85 title: text("title").notNull(),
86 body: text("body"),
87 state: text("state").notNull().default("open"), // open, closed
88 createdAt: timestamp("created_at").defaultNow().notNull(),
89 updatedAt: timestamp("updated_at").defaultNow().notNull(),
90 closedAt: timestamp("closed_at"),
91 },
92 (table) => [
93 index("issues_repo_state").on(table.repositoryId, table.state),
94 index("issues_repo_number").on(table.repositoryId, table.number),
95 ]
96);
97
98export const issueComments = pgTable(
99 "issue_comments",
100 {
101 id: uuid("id").primaryKey().defaultRandom(),
102 issueId: uuid("issue_id")
103 .notNull()
104 .references(() => issues.id, { onDelete: "cascade" }),
105 authorId: uuid("author_id")
106 .notNull()
107 .references(() => users.id),
108 body: text("body").notNull(),
109 createdAt: timestamp("created_at").defaultNow().notNull(),
110 updatedAt: timestamp("updated_at").defaultNow().notNull(),
111 },
112 (table) => [index("comments_issue").on(table.issueId)]
113);
114
115export const labels = pgTable(
116 "labels",
117 {
118 id: uuid("id").primaryKey().defaultRandom(),
119 repositoryId: uuid("repository_id")
120 .notNull()
121 .references(() => repositories.id, { onDelete: "cascade" }),
122 name: text("name").notNull(),
123 color: text("color").notNull().default("#8b949e"),
124 description: text("description"),
125 },
126 (table) => [
127 uniqueIndex("labels_repo_name").on(table.repositoryId, table.name),
128 ]
129);
130
131export const issueLabels = pgTable(
132 "issue_labels",
133 {
134 id: uuid("id").primaryKey().defaultRandom(),
135 issueId: uuid("issue_id")
136 .notNull()
137 .references(() => issues.id, { onDelete: "cascade" }),
138 labelId: uuid("label_id")
139 .notNull()
140 .references(() => labels.id, { onDelete: "cascade" }),
141 },
142 (table) => [
143 uniqueIndex("issue_labels_unique").on(table.issueId, table.labelId),
144 ]
67145);
68146
69147export const sshKeys = pgTable("ssh_keys", {
85163export type Session = typeof sessions.$inferSelect;
86164export type Star = typeof stars.$inferSelect;
87165export type SshKey = typeof sshKeys.$inferSelect;
166export type Issue = typeof issues.$inferSelect;
167export type IssueComment = typeof issueComments.$inferSelect;
168export type Label = typeof labels.$inferSelect;
Modifiedsrc/git/repository.ts+114−0View fileUnifiedSplit
312312 return { files, raw };
313313}
314314
315export interface BlameLine {
316 sha: string;
317 author: string;
318 date: string;
319 lineNum: number;
320 content: string;
321}
322
323export async function getBlame(
324 owner: string,
325 name: string,
326 ref: string,
327 filePath: string
328): Promise<BlameLine[]> {
329 const path = repoPath(owner, name);
330 const { stdout, exitCode } = await exec(
331 ["git", "blame", "--porcelain", ref, "--", filePath],
332 { cwd: path }
333 );
334 if (exitCode !== 0) return [];
335
336 const lines: BlameLine[] = [];
337 const commits: Record<string, { author: string; date: string }> = {};
338 let currentSha = "";
339 let currentLineNum = 0;
340
341 for (const line of stdout.split("\n")) {
342 // Header line: <sha> <orig-line> <final-line> [<group-lines>]
343 const headerMatch = line.match(
344 /^([0-9a-f]{40}) \d+ (\d+)/
345 );
346 if (headerMatch) {
347 currentSha = headerMatch[1];
348 currentLineNum = parseInt(headerMatch[2], 10);
349 continue;
350 }
351
352 if (line.startsWith("author ")) {
353 if (!commits[currentSha]) commits[currentSha] = { author: "", date: "" };
354 commits[currentSha].author = line.slice(7);
355 } else if (line.startsWith("author-time ")) {
356 if (!commits[currentSha]) commits[currentSha] = { author: "", date: "" };
357 commits[currentSha].date = new Date(
358 parseInt(line.slice(12), 10) * 1000
359 ).toISOString();
360 } else if (line.startsWith("\t")) {
361 const info = commits[currentSha] || { author: "unknown", date: "" };
362 lines.push({
363 sha: currentSha,
364 author: info.author,
365 date: info.date,
366 lineNum: currentLineNum,
367 content: line.slice(1),
368 });
369 }
370 }
371
372 return lines;
373}
374
375export async function getRawBlob(
376 owner: string,
377 name: string,
378 ref: string,
379 filePath: string
380): Promise<Uint8Array | null> {
381 const path = repoPath(owner, name);
382 const proc = Bun.spawn(["git", "show", `${ref}:${filePath}`], {
383 cwd: path,
384 stdout: "pipe",
385 stderr: "pipe",
386 });
387 const data = await new Response(proc.stdout).arrayBuffer();
388 const exitCode = await proc.exited;
389 if (exitCode !== 0) return null;
390 return new Uint8Array(data);
391}
392
393export async function searchCode(
394 owner: string,
395 name: string,
396 ref: string,
397 query: string,
398 maxResults = 50
399): Promise<Array<{ file: string; lineNum: number; line: string }>> {
400 const path = repoPath(owner, name);
401 const { stdout, exitCode } = await exec(
402 ["git", "grep", "-n", "-I", `--max-count=${maxResults}`, query, ref],
403 { cwd: path }
404 );
405 if (exitCode !== 0) return [];
406
407 return stdout
408 .trim()
409 .split("\n")
410 .filter(Boolean)
411 .slice(0, maxResults)
412 .map((line) => {
413 // Format: <ref>:<file>:<lineNum>:<content>
414 const refPrefix = ref + ":";
415 const rest = line.startsWith(refPrefix) ? line.slice(refPrefix.length) : line;
416 const colonIdx = rest.indexOf(":");
417 if (colonIdx === -1) return null;
418 const file = rest.slice(0, colonIdx);
419 const afterFile = rest.slice(colonIdx + 1);
420 const numColonIdx = afterFile.indexOf(":");
421 if (numColonIdx === -1) return null;
422 const lineNum = parseInt(afterFile.slice(0, numColonIdx), 10);
423 const content = afterFile.slice(numColonIdx + 1);
424 return { file, lineNum, line: content };
425 })
426 .filter((r): r is { file: string; lineNum: number; line: string } => r !== null);
427}
428
315429export async function getReadme(
316430 owner: string,
317431 name: string,
Addedsrc/lib/markdown.ts+133−0View fileUnifiedSplit
1/**
2 * Markdown rendering — server-side, sanitized.
3 */
4
5import { Marked } from "marked";
6import { highlightCode } from "./highlight";
7
8const marked = new Marked({
9 gfm: true,
10 breaks: false,
11 renderer: {
12 code({ text, lang }) {
13 if (lang) {
14 const { html } = highlightCode(text, `code.${lang}`);
15 return `<pre><code class="language-${escapeAttr(lang)}">${html}</code></pre>`;
16 }
17 return `<pre><code>${escapeHtml(text)}</code></pre>`;
18 },
19 link({ href, title, text }) {
20 // Sanitize: only allow http(s) and relative links
21 const safeHref =
22 href.startsWith("http://") ||
23 href.startsWith("https://") ||
24 href.startsWith("/") ||
25 href.startsWith("#") ||
26 href.startsWith(".")
27 ? href
28 : "#";
29 const titleAttr = title ? ` title="${escapeAttr(title)}"` : "";
30 return `<a href="${escapeAttr(safeHref)}"${titleAttr}>${text}</a>`;
31 },
32 image({ href, title, text }) {
33 const titleAttr = title ? ` title="${escapeAttr(title)}"` : "";
34 return `<img src="${escapeAttr(href)}" alt="${escapeAttr(text)}"${titleAttr} style="max-width: 100%;" loading="lazy" />`;
35 },
36 },
37});
38
39export function renderMarkdown(source: string): string {
40 const html = marked.parse(source);
41 if (typeof html !== "string") return "";
42 return html;
43}
44
45function escapeHtml(str: string): string {
46 return str
47 .replace(/&/g, "&amp;")
48 .replace(/</g, "&lt;")
49 .replace(/>/g, "&gt;")
50 .replace(/"/g, "&quot;");
51}
52
53function escapeAttr(str: string): string {
54 return str
55 .replace(/&/g, "&amp;")
56 .replace(/"/g, "&quot;")
57 .replace(/'/g, "&#39;")
58 .replace(/</g, "&lt;")
59 .replace(/>/g, "&gt;");
60}
61
62export const markdownCss = `
63 .markdown-body {
64 font-size: 15px;
65 line-height: 1.7;
66 word-wrap: break-word;
67 padding: 20px;
68 }
69 .markdown-body h1, .markdown-body h2, .markdown-body h3,
70 .markdown-body h4, .markdown-body h5, .markdown-body h6 {
71 margin-top: 24px;
72 margin-bottom: 16px;
73 font-weight: 600;
74 line-height: 1.25;
75 padding-bottom: 8px;
76 border-bottom: 1px solid var(--border);
77 }
78 .markdown-body h1 { font-size: 2em; }
79 .markdown-body h2 { font-size: 1.5em; }
80 .markdown-body h3 { font-size: 1.25em; border-bottom: none; }
81 .markdown-body h4 { font-size: 1em; border-bottom: none; }
82 .markdown-body p { margin-bottom: 16px; }
83 .markdown-body ul, .markdown-body ol { padding-left: 2em; margin-bottom: 16px; }
84 .markdown-body li { margin-bottom: 4px; }
85 .markdown-body code {
86 font-family: var(--font-mono);
87 font-size: 85%;
88 padding: 2px 6px;
89 background: var(--bg-tertiary);
90 border-radius: 3px;
91 }
92 .markdown-body pre {
93 padding: 16px;
94 overflow-x: auto;
95 background: var(--bg-secondary);
96 border: 1px solid var(--border);
97 border-radius: var(--radius);
98 margin-bottom: 16px;
99 line-height: 1.5;
100 }
101 .markdown-body pre code {
102 padding: 0;
103 background: transparent;
104 font-size: 13px;
105 }
106 .markdown-body blockquote {
107 padding: 0 1em;
108 color: var(--text-muted);
109 border-left: 3px solid var(--border);
110 margin-bottom: 16px;
111 }
112 .markdown-body table {
113 width: 100%;
114 border-collapse: collapse;
115 margin-bottom: 16px;
116 }
117 .markdown-body table th, .markdown-body table td {
118 padding: 8px 16px;
119 border: 1px solid var(--border);
120 text-align: left;
121 }
122 .markdown-body table th { background: var(--bg-secondary); font-weight: 600; }
123 .markdown-body hr {
124 height: 2px;
125 background: var(--border);
126 border: none;
127 margin: 24px 0;
128 }
129 .markdown-body a { color: var(--text-link); }
130 .markdown-body img { max-width: 100%; border-radius: var(--radius); }
131 .markdown-body .task-list-item { list-style: none; }
132 .markdown-body .task-list-item input { margin-right: 8px; }
133`;
Addedsrc/routes/compare.tsx+178−0View fileUnifiedSplit
1/**
2 * Compare view — diff between two branches or commits.
3 * URL: /:owner/:repo/compare/:base...:head
4 */
5
6import { Hono } from "hono";
7import { join } from "path";
8import { Layout } from "../views/layout";
9import { RepoHeader, DiffView } from "../views/components";
10import { IssueNav } from "./issues";
11import {
12 listBranches,
13 listCommits,
14 repoExists,
15 getRepoPath,
16} from "../git/repository";
17import { softAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import type { GitDiffFile } from "../git/repository";
20
21const compare = new Hono<AuthEnv>();
22
23compare.use("*", softAuth);
24
25compare.get("/:owner/:repo/compare/:spec?", async (c) => {
26 const { owner, repo } = c.req.param();
27 const user = c.get("user");
28 const spec = c.req.param("spec");
29
30 if (!(await repoExists(owner, repo))) {
31 return c.html(
32 <Layout title="Not Found" user={user}>
33 <div class="empty-state">
34 <h2>Repository not found</h2>
35 </div>
36 </Layout>,
37 404
38 );
39 }
40
41 const branches = await listBranches(owner, repo);
42
43 if (!spec || !spec.includes("...")) {
44 // Show compare picker
45 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
46 return c.html(
47 <Layout title={`Compare — ${owner}/${repo}`} user={user}>
48 <RepoHeader owner={owner} repo={repo} />
49 <IssueNav owner={owner} repo={repo} active="code" />
50 <h2 style="margin-bottom: 16px">Compare changes</h2>
51 <form
52 method="GET"
53 action={`/${owner}/${repo}/compare`}
54 style="display: flex; gap: 12px; align-items: center; margin-bottom: 20px"
55 >
56 <select name="base" class="branch-selector" style="cursor: pointer">
57 {branches.map((b) => (
58 <option value={b} selected={b === defaultBase}>
59 {b}
60 </option>
61 ))}
62 </select>
63 <span style="color: var(--text-muted)">...</span>
64 <select name="head" class="branch-selector" style="cursor: pointer">
65 {branches.map((b) => (
66 <option value={b} selected={b !== defaultBase}>
67 {b}
68 </option>
69 ))}
70 </select>
71 <button
72 type="submit"
73 class="btn btn-primary"
74 onclick={`this.form.action='/${owner}/${repo}/compare/'+this.form.base.value+'...'+this.form.head.value; return true;`}
75 >
76 Compare
77 </button>
78 </form>
79 </Layout>
80 );
81 }
82
83 const [base, head] = spec.split("...");
84
85 // Get diff
86 const repoDir = getRepoPath(owner, repo);
87 const proc = Bun.spawn(
88 ["git", "diff", `${base}...${head}`],
89 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
90 );
91 const raw = await new Response(proc.stdout).text();
92 await proc.exited;
93
94 // Get numstat
95 const statProc = Bun.spawn(
96 ["git", "diff", "--numstat", `${base}...${head}`],
97 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
98 );
99 const stat = await new Response(statProc.stdout).text();
100 await statProc.exited;
101
102 const files: GitDiffFile[] = stat
103 .trim()
104 .split("\n")
105 .filter(Boolean)
106 .map((line) => {
107 const [add, del, filePath] = line.split("\t");
108 return {
109 path: filePath,
110 status: "modified",
111 additions: add === "-" ? 0 : parseInt(add, 10),
112 deletions: del === "-" ? 0 : parseInt(del, 10),
113 patch: "",
114 };
115 });
116
117 // Get commits between
118 const logProc = Bun.spawn(
119 [
120 "git",
121 "log",
122 "--format=%H%x00%s%x00%an%x00%aI",
123 `${base}...${head}`,
124 ],
125 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
126 );
127 const logOutput = await new Response(logProc.stdout).text();
128 await logProc.exited;
129
130 const commitsBetween = logOutput
131 .trim()
132 .split("\n")
133 .filter(Boolean)
134 .map((line) => {
135 const [sha, msg, author, date] = line.split("\0");
136 return { sha, message: msg, author, date };
137 });
138
139 return c.html(
140 <Layout title={`${base}...${head} — ${owner}/${repo}`} user={user}>
141 <RepoHeader owner={owner} repo={repo} />
142 <IssueNav owner={owner} repo={repo} active="code" />
143 <h2 style="margin-bottom: 8px">
144 Comparing {base}...{head}
145 </h2>
146 <div style="margin-bottom: 20px; font-size: 14px; color: var(--text-muted)">
147 {commitsBetween.length} commit{commitsBetween.length !== 1 ? "s" : ""}
148 </div>
149
150 {commitsBetween.length > 0 && (
151 <div class="commit-list" style="margin-bottom: 24px">
152 {commitsBetween.map((cm) => (
153 <div class="commit-item">
154 <div>
155 <div class="commit-message">
156 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
157 {cm.message}
158 </a>
159 </div>
160 <div class="commit-meta">{cm.author}</div>
161 </div>
162 <a
163 href={`/${owner}/${repo}/commit/${cm.sha}`}
164 class="commit-sha"
165 >
166 {cm.sha.slice(0, 7)}
167 </a>
168 </div>
169 ))}
170 </div>
171 )}
172
173 <DiffView raw={raw} files={files} />
174 </Layout>
175 );
176});
177
178export default compare;
Addedsrc/routes/issues.tsx+549−0View fileUnifiedSplit
1/**
2 * Issue tracker routes — list, create, view, comment, close/reopen.
3 */
4
5import { Hono } from "hono";
6import { eq, and, desc, asc, sql } from "drizzle-orm";
7import { db } from "../db";
8import {
9 issues,
10 issueComments,
11 repositories,
12 users,
13 labels,
14 issueLabels,
15} from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader, RepoNav } from "../views/components";
18import { renderMarkdown } from "../lib/markdown";
19import { softAuth, requireAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21import { html } from "hono/html";
22
23const issueRoutes = new Hono<AuthEnv>();
24
25// Helper to resolve repo from :owner/:repo params
26async function resolveRepo(ownerName: string, repoName: string) {
27 const [owner] = await db
28 .select()
29 .from(users)
30 .where(eq(users.username, ownerName))
31 .limit(1);
32 if (!owner) return null;
33
34 const [repo] = await db
35 .select()
36 .from(repositories)
37 .where(
38 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
39 )
40 .limit(1);
41 if (!repo) return null;
42
43 return { owner, repo };
44}
45
46// Issue list
47issueRoutes.get("/:owner/:repo/issues", softAuth, async (c) => {
48 const { owner: ownerName, repo: repoName } = c.req.param();
49 const user = c.get("user");
50 const state = c.req.query("state") || "open";
51
52 const resolved = await resolveRepo(ownerName, repoName);
53 if (!resolved) {
54 return c.html(
55 <Layout title="Not Found" user={user}>
56 <div class="empty-state">
57 <h2>Repository not found</h2>
58 </div>
59 </Layout>,
60 404
61 );
62 }
63
64 const { repo } = resolved;
65
66 const issueList = await db
67 .select({
68 issue: issues,
69 author: { username: users.username },
70 })
71 .from(issues)
72 .innerJoin(users, eq(issues.authorId, users.id))
73 .where(
74 and(eq(issues.repositoryId, repo.id), eq(issues.state, state))
75 )
76 .orderBy(desc(issues.createdAt));
77
78 // Count open/closed
79 const [counts] = await db
80 .select({
81 open: sql<number>`count(*) filter (where ${issues.state} = 'open')`,
82 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')`,
83 })
84 .from(issues)
85 .where(eq(issues.repositoryId, repo.id));
86
87 return c.html(
88 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
89 <RepoHeader owner={ownerName} repo={repoName} />
90 <IssueNav owner={ownerName} repo={repoName} active="issues" />
91 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
92 <div class="issue-tabs">
93 <a
94 href={`/${ownerName}/${repoName}/issues?state=open`}
95 class={state === "open" ? "active" : ""}
96 >
97 {counts?.open ?? 0} Open
98 </a>
99 <a
100 href={`/${ownerName}/${repoName}/issues?state=closed`}
101 class={state === "closed" ? "active" : ""}
102 >
103 {counts?.closed ?? 0} Closed
104 </a>
105 </div>
106 {user && (
107 <a
108 href={`/${ownerName}/${repoName}/issues/new`}
109 class="btn btn-primary"
110 >
111 New issue
112 </a>
113 )}
114 </div>
115 {issueList.length === 0 ? (
116 <div class="empty-state">
117 <p>
118 No {state} issues.
119 {state === "closed" && (
120 <span>
121 {" "}
122 <a href={`/${ownerName}/${repoName}/issues?state=open`}>
123 View open issues
124 </a>
125 </span>
126 )}
127 </p>
128 </div>
129 ) : (
130 <div class="issue-list">
131 {issueList.map(({ issue, author }) => (
132 <div class="issue-item">
133 <div
134 class={`issue-state-icon ${issue.state === "open" ? "state-open" : "state-closed"}`}
135 >
136 {issue.state === "open" ? "\u25CB" : "\u2713"}
137 </div>
138 <div>
139 <div class="issue-title">
140 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
141 {issue.title}
142 </a>
143 </div>
144 <div class="issue-meta">
145 #{issue.number} opened by {author.username}{" "}
146 {formatRelative(issue.createdAt)}
147 </div>
148 </div>
149 </div>
150 ))}
151 </div>
152 )}
153 </Layout>
154 );
155});
156
157// New issue form
158issueRoutes.get(
159 "/:owner/:repo/issues/new",
160 softAuth,
161 requireAuth,
162 async (c) => {
163 const { owner: ownerName, repo: repoName } = c.req.param();
164 const user = c.get("user")!;
165 const error = c.req.query("error");
166
167 return c.html(
168 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
169 <RepoHeader owner={ownerName} repo={repoName} />
170 <IssueNav owner={ownerName} repo={repoName} active="issues" />
171 <div style="max-width: 800px">
172 <h2 style="margin-bottom: 16px">New issue</h2>
173 {error && (
174 <div class="auth-error">{decodeURIComponent(error)}</div>
175 )}
176 <form method="POST" action={`/${ownerName}/${repoName}/issues/new`}>
177 <div class="form-group">
178 <input
179 type="text"
180 name="title"
181 required
182 placeholder="Title"
183 style="font-size: 16px; padding: 10px 14px"
184 />
185 </div>
186 <div class="form-group">
187 <textarea
188 name="body"
189 rows={12}
190 placeholder="Leave a comment... (Markdown supported)"
191 style="font-family: var(--font-mono); font-size: 13px"
192 />
193 </div>
194 <button type="submit" class="btn btn-primary">
195 Submit new issue
196 </button>
197 </form>
198 </div>
199 </Layout>
200 );
201 }
202);
203
204// Create issue
205issueRoutes.post(
206 "/:owner/:repo/issues/new",
207 softAuth,
208 requireAuth,
209 async (c) => {
210 const { owner: ownerName, repo: repoName } = c.req.param();
211 const user = c.get("user")!;
212 const body = await c.req.parseBody();
213 const title = String(body.title || "").trim();
214 const issueBody = String(body.body || "").trim();
215
216 if (!title) {
217 return c.redirect(
218 `/${ownerName}/${repoName}/issues/new?error=Title+is+required`
219 );
220 }
221
222 const resolved = await resolveRepo(ownerName, repoName);
223 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
224
225 const [issue] = await db
226 .insert(issues)
227 .values({
228 repositoryId: resolved.repo.id,
229 authorId: user.id,
230 title,
231 body: issueBody || null,
232 })
233 .returning();
234
235 // Update issue count
236 await db
237 .update(repositories)
238 .set({ issueCount: resolved.repo.issueCount + 1 })
239 .where(eq(repositories.id, resolved.repo.id));
240
241 return c.redirect(
242 `/${ownerName}/${repoName}/issues/${issue.number}`
243 );
244 }
245);
246
247// View single issue
248issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
249 const { owner: ownerName, repo: repoName } = c.req.param();
250 const issueNum = parseInt(c.req.param("number"), 10);
251 const user = c.get("user");
252
253 const resolved = await resolveRepo(ownerName, repoName);
254 if (!resolved) {
255 return c.html(
256 <Layout title="Not Found" user={user}>
257 <div class="empty-state">
258 <h2>Not found</h2>
259 </div>
260 </Layout>,
261 404
262 );
263 }
264
265 const [issue] = await db
266 .select()
267 .from(issues)
268 .where(
269 and(
270 eq(issues.repositoryId, resolved.repo.id),
271 eq(issues.number, issueNum)
272 )
273 )
274 .limit(1);
275
276 if (!issue) {
277 return c.html(
278 <Layout title="Not Found" user={user}>
279 <div class="empty-state">
280 <h2>Issue not found</h2>
281 </div>
282 </Layout>,
283 404
284 );
285 }
286
287 const [author] = await db
288 .select()
289 .from(users)
290 .where(eq(users.id, issue.authorId))
291 .limit(1);
292
293 // Get comments
294 const comments = await db
295 .select({
296 comment: issueComments,
297 author: { username: users.username },
298 })
299 .from(issueComments)
300 .innerJoin(users, eq(issueComments.authorId, users.id))
301 .where(eq(issueComments.issueId, issue.id))
302 .orderBy(asc(issueComments.createdAt));
303
304 const canManage =
305 user &&
306 (user.id === resolved.owner.id || user.id === issue.authorId);
307
308 return c.html(
309 <Layout
310 title={`${issue.title} #${issue.number} — ${ownerName}/${repoName}`}
311 user={user}
312 >
313 <RepoHeader owner={ownerName} repo={repoName} />
314 <IssueNav owner={ownerName} repo={repoName} active="issues" />
315 <div class="issue-detail">
316 <h2>
317 {issue.title}{" "}
318 <span style="color: var(--text-muted); font-weight: 400">
319 #{issue.number}
320 </span>
321 </h2>
322 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
323 <span
324 class={`issue-badge ${issue.state === "open" ? "badge-open" : "badge-closed"}`}
325 >
326 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
327 </span>
328 <span style="color: var(--text-muted); font-size: 14px">
329 <strong style="color: var(--text)">
330 {author?.username || "unknown"}
331 </strong>{" "}
332 opened this issue {formatRelative(issue.createdAt)}
333 </span>
334 </div>
335
336 {issue.body && (
337 <div class="issue-comment-box">
338 <div class="comment-header">
339 <strong>{author?.username}</strong> commented{" "}
340 {formatRelative(issue.createdAt)}
341 </div>
342 <div class="markdown-body">
343 {html([renderMarkdown(issue.body)] as unknown as TemplateStringsArray)}
344 </div>
345 </div>
346 )}
347
348 {comments.map(({ comment, author: commentAuthor }) => (
349 <div class="issue-comment-box">
350 <div class="comment-header">
351 <strong>{commentAuthor.username}</strong> commented{" "}
352 {formatRelative(comment.createdAt)}
353 </div>
354 <div class="markdown-body">
355 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
356 </div>
357 </div>
358 ))}
359
360 {user && (
361 <div style="margin-top: 20px">
362 <form
363 method="POST"
364 action={`/${ownerName}/${repoName}/issues/${issue.number}/comment`}
365 >
366 <div class="form-group">
367 <textarea
368 name="body"
369 rows={6}
370 required
371 placeholder="Leave a comment... (Markdown supported)"
372 style="font-family: var(--font-mono); font-size: 13px"
373 />
374 </div>
375 <div style="display: flex; gap: 8px">
376 <button type="submit" class="btn btn-primary">
377 Comment
378 </button>
379 {canManage && (
380 <button
381 type="submit"
382 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/${issue.state === "open" ? "close" : "reopen"}`}
383 class={`btn ${issue.state === "open" ? "btn-danger" : ""}`}
384 >
385 {issue.state === "open"
386 ? "Close issue"
387 : "Reopen issue"}
388 </button>
389 )}
390 </div>
391 </form>
392 </div>
393 )}
394 </div>
395 </Layout>
396 );
397});
398
399// Add comment
400issueRoutes.post(
401 "/:owner/:repo/issues/:number/comment",
402 softAuth,
403 requireAuth,
404 async (c) => {
405 const { owner: ownerName, repo: repoName } = c.req.param();
406 const issueNum = parseInt(c.req.param("number"), 10);
407 const user = c.get("user")!;
408 const body = await c.req.parseBody();
409 const commentBody = String(body.body || "").trim();
410
411 if (!commentBody) {
412 return c.redirect(
413 `/${ownerName}/${repoName}/issues/${issueNum}`
414 );
415 }
416
417 const resolved = await resolveRepo(ownerName, repoName);
418 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
419
420 const [issue] = await db
421 .select()
422 .from(issues)
423 .where(
424 and(
425 eq(issues.repositoryId, resolved.repo.id),
426 eq(issues.number, issueNum)
427 )
428 )
429 .limit(1);
430
431 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
432
433 await db.insert(issueComments).values({
434 issueId: issue.id,
435 authorId: user.id,
436 body: commentBody,
437 });
438
439 return c.redirect(
440 `/${ownerName}/${repoName}/issues/${issueNum}`
441 );
442 }
443);
444
445// Close issue
446issueRoutes.post(
447 "/:owner/:repo/issues/:number/close",
448 softAuth,
449 requireAuth,
450 async (c) => {
451 const { owner: ownerName, repo: repoName } = c.req.param();
452 const issueNum = parseInt(c.req.param("number"), 10);
453
454 const resolved = await resolveRepo(ownerName, repoName);
455 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
456
457 await db
458 .update(issues)
459 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
460 .where(
461 and(
462 eq(issues.repositoryId, resolved.repo.id),
463 eq(issues.number, issueNum)
464 )
465 );
466
467 return c.redirect(
468 `/${ownerName}/${repoName}/issues/${issueNum}`
469 );
470 }
471);
472
473// Reopen issue
474issueRoutes.post(
475 "/:owner/:repo/issues/:number/reopen",
476 softAuth,
477 requireAuth,
478 async (c) => {
479 const { owner: ownerName, repo: repoName } = c.req.param();
480 const issueNum = parseInt(c.req.param("number"), 10);
481
482 const resolved = await resolveRepo(ownerName, repoName);
483 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
484
485 await db
486 .update(issues)
487 .set({ state: "open", closedAt: null, updatedAt: new Date() })
488 .where(
489 and(
490 eq(issues.repositoryId, resolved.repo.id),
491 eq(issues.number, issueNum)
492 )
493 );
494
495 return c.redirect(
496 `/${ownerName}/${repoName}/issues/${issueNum}`
497 );
498 }
499);
500
501// Shared nav component with issues tab
502const IssueNav = ({
503 owner,
504 repo,
505 active,
506}: {
507 owner: string;
508 repo: string;
509 active: "code" | "commits" | "issues";
510}) => (
511 <div class="repo-nav">
512 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
513 Code
514 </a>
515 <a
516 href={`/${owner}/${repo}/issues`}
517 class={active === "issues" ? "active" : ""}
518 >
519 Issues
520 </a>
521 <a
522 href={`/${owner}/${repo}/commits`}
523 class={active === "commits" ? "active" : ""}
524 >
525 Commits
526 </a>
527 </div>
528);
529
530function formatRelative(date: Date | string): string {
531 const d = typeof date === "string" ? new Date(date) : date;
532 const now = new Date();
533 const diffMs = now.getTime() - d.getTime();
534 const diffMins = Math.floor(diffMs / 60000);
535 if (diffMins < 1) return "just now";
536 if (diffMins < 60) return `${diffMins}m ago`;
537 const diffHours = Math.floor(diffMins / 60);
538 if (diffHours < 24) return `${diffHours}h ago`;
539 const diffDays = Math.floor(diffHours / 24);
540 if (diffDays < 30) return `${diffDays}d ago`;
541 return d.toLocaleDateString("en-US", {
542 month: "short",
543 day: "numeric",
544 year: "numeric",
545 });
546}
547
548export default issueRoutes;
549export { IssueNav };
Addedsrc/routes/repo-settings.tsx+227−0View fileUnifiedSplit
1/**
2 * Repository settings — description, visibility, default branch, danger zone.
3 */
4
5import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
7import { db } from "../db";
8import { repositories, users } from "../db/schema";
9import { Layout } from "../views/layout";
10import { RepoHeader } from "../views/components";
11import { softAuth, requireAuth } from "../middleware/auth";
12import type { AuthEnv } from "../middleware/auth";
13import { listBranches } from "../git/repository";
14import { rm } from "fs/promises";
15
16const repoSettings = new Hono<AuthEnv>();
17
18repoSettings.use("*", softAuth);
19
20// Settings page
21repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
22 const { owner: ownerName, repo: repoName } = c.req.param();
23 const user = c.get("user")!;
24 const success = c.req.query("success");
25 const error = c.req.query("error");
26
27 const [owner] = await db
28 .select()
29 .from(users)
30 .where(eq(users.username, ownerName))
31 .limit(1);
32
33 if (!owner || owner.id !== user.id) {
34 return c.html(
35 <Layout title="Unauthorized" user={user}>
36 <div class="empty-state">
37 <h2>Unauthorized</h2>
38 <p>Only the repository owner can access settings.</p>
39 </div>
40 </Layout>,
41 403
42 );
43 }
44
45 const [repo] = await db
46 .select()
47 .from(repositories)
48 .where(
49 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
50 )
51 .limit(1);
52
53 if (!repo) return c.notFound();
54
55 const branches = await listBranches(ownerName, repoName);
56
57 return c.html(
58 <Layout title={`Settings — ${ownerName}/${repoName}`} user={user}>
59 <RepoHeader owner={ownerName} repo={repoName} />
60 <div style="max-width: 600px">
61 <h2 style="margin-bottom: 20px">Repository settings</h2>
62 {success && (
63 <div class="auth-success">{decodeURIComponent(success)}</div>
64 )}
65 {error && (
66 <div class="auth-error">{decodeURIComponent(error)}</div>
67 )}
68
69 <form
70 method="POST"
71 action={`/${ownerName}/${repoName}/settings`}
72 >
73 <div class="form-group">
74 <label for="description">Description</label>
75 <input
76 type="text"
77 id="description"
78 name="description"
79 value={repo.description || ""}
80 placeholder="A short description"
81 />
82 </div>
83 <div class="form-group">
84 <label for="default_branch">Default branch</label>
85 <select id="default_branch" name="default_branch">
86 {branches.length === 0 ? (
87 <option value={repo.defaultBranch}>
88 {repo.defaultBranch}
89 </option>
90 ) : (
91 branches.map((b) => (
92 <option value={b} selected={b === repo.defaultBranch}>
93 {b}
94 </option>
95 ))
96 )}
97 </select>
98 </div>
99 <div class="form-group">
100 <label>Visibility</label>
101 <div class="visibility-options">
102 <label class="visibility-option">
103 <input
104 type="radio"
105 name="visibility"
106 value="public"
107 checked={!repo.isPrivate}
108 />
109 <div class="vis-label">Public</div>
110 </label>
111 <label class="visibility-option">
112 <input
113 type="radio"
114 name="visibility"
115 value="private"
116 checked={repo.isPrivate}
117 />
118 <div class="vis-label">Private</div>
119 </label>
120 </div>
121 </div>
122 <button type="submit" class="btn btn-primary">
123 Save changes
124 </button>
125 </form>
126
127 <div
128 style="margin-top: 40px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
129 >
130 <h3 style="color: var(--red); margin-bottom: 12px">Danger zone</h3>
131 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
132 Permanently delete this repository and all its data.
133 </p>
134 <form
135 method="POST"
136 action={`/${ownerName}/${repoName}/settings/delete`}
137 onsubmit="return confirm('Are you sure? This cannot be undone.')"
138 >
139 <button type="submit" class="btn btn-danger">
140 Delete this repository
141 </button>
142 </form>
143 </div>
144 </div>
145 </Layout>
146 );
147});
148
149// Save settings
150repoSettings.post("/:owner/:repo/settings", requireAuth, async (c) => {
151 const { owner: ownerName, repo: repoName } = c.req.param();
152 const user = c.get("user")!;
153 const body = await c.req.parseBody();
154
155 const [owner] = await db
156 .select()
157 .from(users)
158 .where(eq(users.username, ownerName))
159 .limit(1);
160
161 if (!owner || owner.id !== user.id) {
162 return c.redirect(`/${ownerName}/${repoName}`);
163 }
164
165 await db
166 .update(repositories)
167 .set({
168 description: String(body.description || "").trim() || null,
169 defaultBranch: String(body.default_branch || "main"),
170 isPrivate: body.visibility === "private",
171 updatedAt: new Date(),
172 })
173 .where(
174 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
175 );
176
177 return c.redirect(
178 `/${ownerName}/${repoName}/settings?success=Settings+saved`
179 );
180});
181
182// Delete repository
183repoSettings.post(
184 "/:owner/:repo/settings/delete",
185 requireAuth,
186 async (c) => {
187 const { owner: ownerName, repo: repoName } = c.req.param();
188 const user = c.get("user")!;
189
190 const [owner] = await db
191 .select()
192 .from(users)
193 .where(eq(users.username, ownerName))
194 .limit(1);
195
196 if (!owner || owner.id !== user.id) {
197 return c.redirect(`/${ownerName}/${repoName}`);
198 }
199
200 const [repo] = await db
201 .select()
202 .from(repositories)
203 .where(
204 and(
205 eq(repositories.ownerId, owner.id),
206 eq(repositories.name, repoName)
207 )
208 )
209 .limit(1);
210
211 if (!repo) return c.redirect(`/${ownerName}`);
212
213 // Delete from disk
214 try {
215 await rm(repo.diskPath, { recursive: true, force: true });
216 } catch {
217 // Disk cleanup best-effort
218 }
219
220 // Delete from DB (cascades to stars, issues, etc.)
221 await db.delete(repositories).where(eq(repositories.id, repo.id));
222
223 return c.redirect(`/${ownerName}`);
224 }
225);
226
227export default repoSettings;
Modifiedsrc/routes/web.tsx+233−7View fileUnifiedSplit
44 */
55
66import { Hono } from "hono";
7import { html } from "hono/html";
78import { eq, and, desc } from "drizzle-orm";
89import { db } from "../db";
910import { users, repositories, stars } from "../db/schema";
3233 listBranches,
3334 repoExists,
3435 initBareRepo,
36 getBlame,
37 getRawBlob,
38 searchCode,
3539} from "../git/repository";
40import { renderMarkdown, markdownCss } from "../lib/markdown";
3641import { highlightCode } from "../lib/highlight";
3742import { softAuth, requireAuth } from "../middleware/auth";
3843import type { AuthEnv } from "../middleware/auth";
429434 ref={defaultBranch}
430435 path=""
431436 />
432 {readme && (
433 <div class="blob-view" style="margin-top: 20px">
434 <div class="blob-header">README.md</div>
435 <div style="padding: 16px; white-space: pre-wrap; font-size: 14px;">
436 {readme}
437 {readme && (() => {
438 const readmeHtml = renderMarkdown(readme);
439 return (
440 <div class="blob-view" style="margin-top: 20px">
441 <div class="blob-header">README.md</div>
442 <style>{markdownCss}</style>
443 <div class="markdown-body">
444 {html([readmeHtml] as unknown as TemplateStringsArray)}
445 </div>
437446 </div>
438 </div>
439 )}
447 );
448 })()}
440449 </Layout>
441450 );
442451});
550559 <div class="blob-view">
551560 <div class="blob-header">
552561 <span>{fileName} — {blob.size} bytes</span>
562 <span style="display: flex; gap: 12px">
563 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
564 Raw
565 </a>
566 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
567 Blame
568 </a>
569 </span>
553570 </div>
554571 {blob.isBinary ? (
555572 <div style="padding: 16px; color: var(--text-muted)">
680697 );
681698});
682699
700// Raw file download
701web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
702 const { owner, repo } = c.req.param();
703 const refAndPath = c.req.param("ref");
704
705 const branches = await listBranches(owner, repo);
706 let ref = "";
707 let filePath = "";
708
709 for (const branch of branches) {
710 if (refAndPath.startsWith(branch + "/")) {
711 ref = branch;
712 filePath = refAndPath.slice(branch.length + 1);
713 break;
714 }
715 }
716
717 if (!ref) {
718 const slashIdx = refAndPath.indexOf("/");
719 if (slashIdx === -1) return c.text("Not found", 404);
720 ref = refAndPath.slice(0, slashIdx);
721 filePath = refAndPath.slice(slashIdx + 1);
722 }
723
724 const data = await getRawBlob(owner, repo, ref, filePath);
725 if (!data) return c.text("Not found", 404);
726
727 const fileName = filePath.split("/").pop() || "file";
728 return new Response(data, {
729 headers: {
730 "Content-Type": "application/octet-stream",
731 "Content-Disposition": `attachment; filename="${fileName}"`,
732 "Cache-Control": "no-cache",
733 },
734 });
735});
736
737// Blame view
738web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
739 const { owner, repo } = c.req.param();
740 const user = c.get("user");
741 const refAndPath = c.req.param("ref");
742
743 const branches = await listBranches(owner, repo);
744 let ref = "";
745 let filePath = "";
746
747 for (const branch of branches) {
748 if (refAndPath.startsWith(branch + "/")) {
749 ref = branch;
750 filePath = refAndPath.slice(branch.length + 1);
751 break;
752 }
753 }
754
755 if (!ref) {
756 const slashIdx = refAndPath.indexOf("/");
757 if (slashIdx === -1) return c.text("Not found", 404);
758 ref = refAndPath.slice(0, slashIdx);
759 filePath = refAndPath.slice(slashIdx + 1);
760 }
761
762 const blameLines = await getBlame(owner, repo, ref, filePath);
763 if (blameLines.length === 0) {
764 return c.html(
765 <Layout title="Not Found" user={user}>
766 <div class="empty-state">
767 <h2>File not found</h2>
768 </div>
769 </Layout>,
770 404
771 );
772 }
773
774 const fileName = filePath.split("/").pop() || filePath;
775
776 return c.html(
777 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
778 <RepoHeader owner={owner} repo={repo} />
779 <RepoNav owner={owner} repo={repo} active="code" />
780 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
781 <div class="blob-view">
782 <div class="blob-header">
783 <span>{fileName} — blame</span>
784 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
785 Normal view
786 </a>
787 </div>
788 <div class="blob-code" style="overflow-x: auto">
789 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
790 <tbody>
791 {blameLines.map((line, i) => {
792 const showInfo =
793 i === 0 || blameLines[i - 1].sha !== line.sha;
794 return (
795 <tr style="border-bottom: 1px solid var(--border)">
796 <td
797 style={`width: 200px; padding: 0 8px; font-size: 11px; color: var(--text-muted); white-space: nowrap; vertical-align: top; ${showInfo ? "border-top: 1px solid var(--border)" : ""}`}
798 >
799 {showInfo && (
800 <>
801 <a
802 href={`/${owner}/${repo}/commit/${line.sha}`}
803 style="color: var(--text-link); font-family: var(--font-mono)"
804 >
805 {line.sha.slice(0, 7)}
806 </a>{" "}
807 <span>{line.author}</span>
808 </>
809 )}
810 </td>
811 <td class="line-num">{line.lineNum}</td>
812 <td class="line-content">{line.content}</td>
813 </tr>
814 );
815 })}
816 </tbody>
817 </table>
818 </div>
819 </div>
820 </Layout>
821 );
822});
823
824// Search
825web.get("/:owner/:repo/search", async (c) => {
826 const { owner, repo } = c.req.param();
827 const user = c.get("user");
828 const q = c.req.query("q") || "";
829
830 if (!(await repoExists(owner, repo))) return c.notFound();
831
832 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
833 let results: Array<{ file: string; lineNum: number; line: string }> = [];
834
835 if (q.trim()) {
836 results = await searchCode(owner, repo, defaultBranch, q.trim());
837 }
838
839 return c.html(
840 <Layout title={`Search — ${owner}/${repo}`} user={user}>
841 <RepoHeader owner={owner} repo={repo} />
842 <RepoNav owner={owner} repo={repo} active="code" />
843 <form
844 method="GET"
845 action={`/${owner}/${repo}/search`}
846 style="margin-bottom: 20px"
847 >
848 <div style="display: flex; gap: 8px">
849 <input
850 type="text"
851 name="q"
852 value={q}
853 placeholder="Search code..."
854 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
855 />
856 <button type="submit" class="btn btn-primary">
857 Search
858 </button>
859 </div>
860 </form>
861 {q && (
862 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
863 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
864 <strong style="color: var(--text)">"{q}"</strong>
865 </p>
866 )}
867 {results.length > 0 && (
868 <div class="search-results">
869 {(() => {
870 // Group by file
871 const grouped: Record<
872 string,
873 Array<{ lineNum: number; line: string }>
874 > = {};
875 for (const r of results) {
876 if (!grouped[r.file]) grouped[r.file] = [];
877 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
878 }
879 return Object.entries(grouped).map(([file, matches]) => (
880 <div class="diff-file" style="margin-bottom: 12px">
881 <div class="diff-file-header">
882 <a
883 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
884 >
885 {file}
886 </a>
887 </div>
888 <div class="blob-code">
889 <table>
890 <tbody>
891 {matches.map((m) => (
892 <tr>
893 <td class="line-num">{m.lineNum}</td>
894 <td class="line-content">{m.line}</td>
895 </tr>
896 ))}
897 </tbody>
898 </table>
899 </div>
900 </div>
901 ));
902 })()}
903 </div>
904 )}
905 </Layout>
906 );
907});
908
683909export default web;
Modifiedsrc/views/components.tsx+7−1View fileUnifiedSplit
4242export const RepoNav: FC<{
4343 owner: string;
4444 repo: string;
45 active: "code" | "commits";
45 active: "code" | "commits" | "issues";
4646}> = ({ owner, repo, active }) => (
4747 <div class="repo-nav">
4848 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
4949 Code
5050 </a>
51 <a
52 href={`/${owner}/${repo}/issues`}
53 class={active === "issues" ? "active" : ""}
54 >
55 Issues
56 </a>
5157 <a
5258 href={`/${owner}/${repo}/commits`}
5359 class={active === "commits" ? "active" : ""}
Modifiedsrc/views/layout.tsx+54−0View fileUnifiedSplit
475475 .visibility-option input { display: none; }
476476 .visibility-option .vis-label { font-size: 14px; font-weight: 500; }
477477 .visibility-option .vis-desc { font-size: 12px; color: var(--text-muted); }
478
479 /* Issues */
480 .issue-tabs { display: flex; gap: 16px; }
481 .issue-tabs a { color: var(--text-muted); font-size: 14px; font-weight: 500; }
482 .issue-tabs a:hover { color: var(--text); text-decoration: none; }
483 .issue-tabs a.active { color: var(--text); }
484
485 .issue-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
486 .issue-item {
487 display: flex;
488 gap: 12px;
489 align-items: flex-start;
490 padding: 12px 16px;
491 border-bottom: 1px solid var(--border);
492 }
493 .issue-item:last-child { border-bottom: none; }
494 .issue-item:hover { background: var(--bg-secondary); }
495 .issue-state-icon { font-size: 16px; padding-top: 2px; }
496 .state-open { color: var(--green); }
497 .state-closed { color: #986ee2; }
498 .issue-title { font-size: 15px; font-weight: 600; }
499 .issue-title a { color: var(--text); }
500 .issue-title a:hover { color: var(--text-link); }
501 .issue-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
502
503 .issue-badge {
504 display: inline-flex;
505 align-items: center;
506 gap: 4px;
507 padding: 4px 12px;
508 border-radius: 20px;
509 font-size: 13px;
510 font-weight: 500;
511 }
512 .badge-open { background: rgba(63, 185, 80, 0.15); color: var(--green); border: 1px solid var(--green); }
513 .badge-closed { background: rgba(152, 110, 226, 0.15); color: #986ee2; border: 1px solid #986ee2; }
514
515 .issue-detail { max-width: 900px; }
516 .issue-comment-box {
517 border: 1px solid var(--border);
518 border-radius: var(--radius);
519 margin-bottom: 16px;
520 overflow: hidden;
521 }
522 .comment-header {
523 background: var(--bg-secondary);
524 padding: 8px 16px;
525 border-bottom: 1px solid var(--border);
526 font-size: 13px;
527 color: var(--text-muted);
528 }
529
530 /* Search */
531 .search-results .diff-file { margin-bottom: 12px; }
478532`;
479533