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

Merge pull request #2 from ccantynz-alt/claude/ship-fixes-and-tests-Jvz1c

Merge pull request #2 from ccantynz-alt/claude/ship-fixes-and-tests-Jvz1c

Add complete web UI, git hosting, and repository management
Dictation App committed on April 12, 2026Parents: 2b8a724 43de941
44 files changed+817428d998db2208e89add7cd23416eee1e94fbdacf6e
44 changed files+8174−2
Added.env.example+5−0View fileUnifiedSplit
1DATABASE_URL=postgresql://user:password@host/gluecron
2GIT_REPOS_PATH=./repos
3PORT=3000
4GATETEST_URL=https://gatetest.ai/api/scan/run
5CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
Added.gitignore+8−0View fileUnifiedSplit
1node_modules/
2dist/
3.env
4.env.local
5*.log
6repos/
7drizzle/meta/
8.DS_Store
AddedCLAUDE.md+92−0View fileUnifiedSplit
1# gluecron
2
3AI-native code intelligence platform — git hosting, automated CI, and green ecosystem enforcement.
4
5## Stack
6
7- **Runtime:** Bun
8- **Framework:** Hono (with JSX for server-rendered views)
9- **Database:** Drizzle ORM + Neon (PostgreSQL)
10- **Git:** Smart HTTP protocol via git CLI subprocesses
11
12## Development
13
14```bash
15bun install # install dependencies
16bun dev # start dev server (hot reload)
17bun test # run tests
18bun run db:migrate # run database migrations
19```
20
21## Architecture
22
23```
24src/
25 index.ts Entry point (Bun server)
26 app.tsx Hono app composition + error handlers
27 lib/
28 config.ts Environment config (getters, reads env at access time)
29 auth.ts Password hashing (bcrypt), session tokens
30 highlight.ts Syntax highlighting (highlight.js, 40+ languages)
31 markdown.ts Markdown rendering (GFM + syntax highlighting)
32 db/
33 schema.ts Drizzle schema (all tables)
34 index.ts Lazy DB connection (proxy pattern)
35 migrate.ts Migration runner
36 git/
37 repository.ts Git operations (tree, blob, commits, diff, branches, blame, search, raw)
38 protocol.ts Smart HTTP protocol (pkt-line, service RPC)
39 hooks/
40 post-receive.ts GateTest + Crontech webhooks on push
41 middleware/
42 auth.ts softAuth + requireAuth middleware
43 routes/
44 git.ts Git HTTP endpoints (clone/push)
45 api.ts REST API (repo CRUD, setup)
46 auth.tsx Register, login, logout (web + API)
47 web.tsx Web UI (file browser, commits, diffs, search, blame, raw)
48 issues.tsx Issue tracker (CRUD, comments, close/reopen)
49 pulls.tsx Pull requests (create, review, merge, close)
50 editor.tsx Web file editor (create/edit via git plumbing)
51 compare.tsx Branch comparison (diff + commit list)
52 settings.tsx User settings (profile, SSH keys)
53 repo-settings.tsx Repository settings (description, visibility, delete)
54 webhooks.tsx Webhook management + delivery engine
55 fork.ts Repository forking
56 explore.tsx Explore/discover public repos
57 tokens.tsx Personal access tokens
58 contributors.tsx Contributor list + commit activity graph
59 views/
60 layout.tsx HTML shell + CSS (dark theme) + auth-aware nav
61 components.tsx UI components (file table, commit list, diff viewer, etc.)
62```
63
64## Database Schema
65
66- `users` — accounts with bcrypt password hashing
67- `sessions` — cookie-based auth sessions (30 day expiry)
68- `repositories` — repos with fork tracking, star/fork/issue counts
69- `stars` — user-repo star relationships
70- `issues` — issue tracker with open/closed state
71- `issue_comments` — threaded comments on issues
72- `labels` + `issue_labels` — issue categorization
73- `pull_requests` — PRs with base/head branches, open/closed/merged state
74- `pr_comments` — PR comments with AI review flag + file/line annotations
75- `activity_feed` — event log for repos
76- `webhooks` — registered webhook URLs with HMAC secret + event filtering
77- `api_tokens` — personal access tokens with SHA-256 hashing
78- `repo_topics` — repository tags for discoverability
79- `ssh_keys` — user SSH public keys
80
81## Integrations
82
83- **GateTest:** POST `https://gatetest.ai/api/scan/run` on every `git push`
84- **Crontech:** POST `https://crontech.ai/api/trpc/tenant.deploy` on push to main
85- **Webhooks:** POST to registered URLs on push/issue/PR/star events with HMAC signatures
86
87## Environment Variables
88
89See `.env.example` for required variables. Key ones:
90- `DATABASE_URL` — Neon PostgreSQL connection string
91- `GIT_REPOS_PATH` — directory for bare git repos (default: `./repos`)
92- `PORT` — HTTP port (default: 3000)
ModifiedREADME.md+29−2View fileUnifiedSplit
1# Gluecron.com
2AI-native code intelligence platform — git hosting, automated CI, and green ecosystem enforcement
1# gluecron
2
3AI-native code intelligence platform — git hosting, automated CI, and green ecosystem enforcement.
4
5## Quick Start
6
7```bash
8bun install
9bun dev
10```
11
12Then visit `http://localhost:3000` to register and create your first repository.
13
14## Features
15
16- **Git hosting** — clone, push, fetch via Smart HTTP protocol
17- **Web code browser** — file tree, syntax-highlighted source, commit log, unified diffs
18- **Authentication** — registration, login, sessions with bcrypt password hashing
19- **User profiles** — avatar, bio, public repository listing
20- **Repository management** — create repos via web UI, public/private visibility
21- **Branch switching** — dropdown to navigate between branches
22- **Stars** — star/unstar repositories
23- **SSH keys** — add/remove SSH keys for your account
24- **GateTest integration** — automated code scanning on every push
25- **Crontech deploy** — trigger deploys on push to main
26
27## Stack
28
29Bun + Hono + Drizzle ORM + Neon (PostgreSQL)
Addedbun.lock+199−0View fileUnifiedSplit
1{
2 "lockfileVersion": 1,
3 "configVersion": 1,
4 "workspaces": {
5 "": {
6 "name": "gluecron",
7 "dependencies": {
8 "@hono/node-server": "^1.13.0",
9 "@neondatabase/serverless": "^0.10.0",
10 "drizzle-orm": "^0.39.0",
11 "highlight.js": "^11.11.0",
12 "hono": "^4.7.0",
13 "marked": "^18.0.0",
14 },
15 "devDependencies": {
16 "@types/bun": "^1.2.0",
17 "drizzle-kit": "^0.30.0",
18 "typescript": "^5.7.0",
19 },
20 },
21 },
22 "packages": {
23 "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
24
25 "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
26
27 "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
28
29 "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
30
31 "@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],
32
33 "@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="],
34
35 "@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="],
36
37 "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="],
38
39 "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="],
40
41 "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="],
42
43 "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="],
44
45 "@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="],
46
47 "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="],
48
49 "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="],
50
51 "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="],
52
53 "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="],
54
55 "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="],
56
57 "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="],
58
59 "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="],
60
61 "@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="],
62
63 "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="],
64
65 "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="],
66
67 "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="],
68
69 "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="],
70
71 "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="],
72
73 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
74
75 "@hono/node-server": ["@hono/node-server@1.19.13", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
76
77 "@neondatabase/serverless": ["@neondatabase/serverless@0.10.4", "", { "dependencies": { "@types/pg": "8.11.6" } }, "sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA=="],
78
79 "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
80
81 "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
82
83 "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
84
85 "@types/pg": ["@types/pg@8.11.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ=="],
86
87 "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
88
89 "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
90
91 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
92
93 "drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
94
95 "drizzle-orm": ["drizzle-orm@0.39.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-EZ8ZpYvDIvKU9C56JYLOmUskazhad+uXZCTCRN4OnRMsL+xAJ05dv1eCpAG5xzhsm1hqiuC5kAZUCS924u2DTw=="],
96
97 "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
98
99 "esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
100
101 "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
102
103 "gel": ["gel@2.2.0", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ=="],
104
105 "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
106
107 "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
108
109 "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="],
110
111 "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
112
113 "marked": ["marked@18.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA=="],
114
115 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
116
117 "obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="],
118
119 "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
120
121 "pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="],
122
123 "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="],
124
125 "pg-types": ["pg-types@4.1.0", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg=="],
126
127 "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
128
129 "postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="],
130
131 "postgres-date": ["postgres-date@2.1.0", "", {}, "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA=="],
132
133 "postgres-interval": ["postgres-interval@3.0.0", "", {}, "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw=="],
134
135 "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="],
136
137 "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
138
139 "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
140
141 "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
142
143 "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
144
145 "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
146
147 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
148
149 "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
150
151 "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
152
153 "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
154
155 "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
156
157 "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
158
159 "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
160
161 "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
162
163 "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
164
165 "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
166
167 "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
168
169 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
170
171 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
172
173 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
174
175 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
176
177 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
178
179 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
180
181 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
182
183 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
184
185 "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
186
187 "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
188
189 "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
190
191 "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
192
193 "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
194
195 "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
196
197 "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
198 }
199}
Addeddrizzle.config.ts+10−0View fileUnifiedSplit
1import { defineConfig } from "drizzle-kit";
2
3export default defineConfig({
4 schema: "./src/db/schema.ts",
5 out: "./drizzle",
6 dialect: "postgresql",
7 dbCredentials: {
8 url: process.env.DATABASE_URL!,
9 },
10});
Addedpackage.json+27−0View fileUnifiedSplit
1{
2 "name": "gluecron",
3 "version": "0.1.0",
4 "description": "AI-native code intelligence platform — git hosting, automated CI, and green ecosystem enforcement",
5 "type": "module",
6 "scripts": {
7 "dev": "bun run --hot src/index.ts",
8 "start": "bun run src/index.ts",
9 "db:generate": "drizzle-kit generate",
10 "db:migrate": "bun run src/db/migrate.ts",
11 "db:studio": "drizzle-kit studio",
12 "test": "bun test"
13 },
14 "dependencies": {
15 "@hono/node-server": "^1.13.0",
16 "@neondatabase/serverless": "^0.10.0",
17 "drizzle-orm": "^0.39.0",
18 "highlight.js": "^11.11.0",
19 "hono": "^4.7.0",
20 "marked": "^18.0.0"
21 },
22 "devDependencies": {
23 "@types/bun": "^1.2.0",
24 "drizzle-kit": "^0.30.0",
25 "typescript": "^5.7.0"
26 }
27}
Addedsrc/__tests__/api.test.ts+56−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";
5
6const TEST_REPOS = join(import.meta.dir, "../../.test-repos-api");
7
8beforeAll(async () => {
9 process.env.GIT_REPOS_PATH = TEST_REPOS;
10 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
11 await mkdir(TEST_REPOS, { recursive: true });
12});
13
14afterAll(async () => {
15 await rm(TEST_REPOS, { recursive: true, force: true });
16});
17
18describe("API routes", () => {
19 it("GET / returns home page", async () => {
20 const res = await app.request("/");
21 expect(res.status).toBe(200);
22 const text = await res.text();
23 expect(text).toContain("gluecron");
24 });
25
26 it("GET /api/repos/:owner/:name returns 404 for missing repo", async () => {
27 // This will fail without DB, but verifies route exists
28 const res = await app.request("/api/repos/nobody/nothing");
29 // Without DB connection, this returns 500 or 404
30 expect([404, 500]).toContain(res.status);
31 });
32
33 it("POST /api/repos returns 400 without required fields", async () => {
34 const res = await app.request("/api/repos", {
35 method: "POST",
36 headers: { "Content-Type": "application/json" },
37 body: JSON.stringify({}),
38 });
39 // Without DB, might be 400 or 500
40 expect([400, 500]).toContain(res.status);
41 });
42});
43
44describe("Git HTTP routes", () => {
45 it("returns 400 for invalid service", async () => {
46 const res = await app.request("/test/repo.git/info/refs?service=invalid");
47 expect(res.status).toBe(400);
48 });
49
50 it("returns 404 for non-existent repo", async () => {
51 const res = await app.request(
52 "/nobody/nothing.git/info/refs?service=git-upload-pack"
53 );
54 expect(res.status).toBe(404);
55 });
56});
Addedsrc/__tests__/auth.test.ts+40−0View fileUnifiedSplit
1import { describe, it, expect } from "bun:test";
2import {
3 hashPassword,
4 verifyPassword,
5 generateSessionToken,
6 sessionExpiry,
7} from "../lib/auth";
8
9describe("auth utilities", () => {
10 it("should hash and verify passwords", async () => {
11 const password = "testpassword123";
12 const hash = await hashPassword(password);
13
14 expect(hash).toBeTruthy();
15 expect(hash).not.toBe(password);
16 expect(await verifyPassword(password, hash)).toBe(true);
17 expect(await verifyPassword("wrongpassword", hash)).toBe(false);
18 });
19
20 it("should generate unique session tokens", () => {
21 const token1 = generateSessionToken();
22 const token2 = generateSessionToken();
23
24 expect(token1).toBeTruthy();
25 expect(token1.length).toBe(64); // 32 bytes hex
26 expect(token1).not.toBe(token2);
27 });
28
29 it("should create future session expiry", () => {
30 const expiry = sessionExpiry();
31 const now = new Date();
32
33 expect(expiry.getTime()).toBeGreaterThan(now.getTime());
34 // Should be ~30 days from now
35 const diffDays =
36 (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
37 expect(diffDays).toBeGreaterThan(29);
38 expect(diffDays).toBeLessThan(31);
39 });
40});
Addedsrc/__tests__/git-repository.test.ts+159−0View fileUnifiedSplit
1import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2import { join } from "path";
3import { rm, mkdir } from "fs/promises";
4import {
5 initBareRepo,
6 repoExists,
7 getRepoPath,
8 listBranches,
9 getDefaultBranch,
10 resolveRef,
11 getTree,
12 getBlob,
13 listCommits,
14 getCommit,
15 getDiff,
16 getReadme,
17} from "../git/repository";
18
19const TEST_REPOS = join(import.meta.dir, "../../.test-repos-" + Date.now());
20
21beforeAll(async () => {
22 // Clean slate
23 await rm(TEST_REPOS, { recursive: true, force: true });
24 await mkdir(TEST_REPOS, { recursive: true });
25 process.env.GIT_REPOS_PATH = TEST_REPOS;
26});
27
28afterAll(async () => {
29 await rm(TEST_REPOS, { recursive: true, force: true });
30});
31
32describe("git repository management", () => {
33 const owner = "testuser";
34 const repo = "testrepo";
35
36 it("should initialize a bare repository", async () => {
37 const path = await initBareRepo(owner, repo);
38 expect(path).toContain(`${owner}/${repo}.git`);
39 expect(await repoExists(owner, repo)).toBe(true);
40 });
41
42 it("should report non-existent repos", async () => {
43 expect(await repoExists("nobody", "nothing")).toBe(false);
44 });
45
46 it("should have main as default branch", async () => {
47 const branch = await getDefaultBranch(owner, repo);
48 expect(branch).toBe("main");
49 });
50
51 it("should return empty tree for fresh bare repo", async () => {
52 // Fresh bare repo has no commits, so listing "main" returns nothing
53 const tree = await getTree(owner, repo, "main");
54 expect(tree).toEqual([]);
55 });
56
57 it("should return empty commits for fresh bare repo", async () => {
58 const commits = await listCommits(owner, repo, "main");
59 expect(commits).toEqual([]);
60 });
61
62 describe("with commits", () => {
63 beforeAll(async () => {
64 const cloneDir = join(TEST_REPOS, "_clone_tmp");
65 await mkdir(cloneDir, { recursive: true });
66 const repoPath = getRepoPath(owner, repo);
67
68 const run = async (cmd: string[], cwd: string) => {
69 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
70 await proc.exited;
71 };
72
73 const workDir = join(cloneDir, "work");
74 await run(["git", "clone", repoPath, workDir], TEST_REPOS);
75 await run(["git", "config", "user.email", "test@gluecron.com"], workDir);
76 await run(["git", "config", "user.name", "Test User"], workDir);
77
78 // Create files
79 await mkdir(join(workDir, "src"), { recursive: true });
80 await Bun.write(
81 join(workDir, "README.md"),
82 "# Test Repo\nHello gluecron"
83 );
84 await Bun.write(join(workDir, "src/index.ts"), "console.log('hello');");
85
86 await run(["git", "add", "-A"], workDir);
87 await run(["git", "commit", "-m", "Initial commit"], workDir);
88 await run(["git", "branch", "-M", "main"], workDir);
89 await run(["git", "push", "-u", "origin", "main"], workDir);
90
91 // Second commit
92 await Bun.write(join(workDir, "src/util.ts"), "export const x = 1;");
93 await run(["git", "add", "-A"], workDir);
94 await run(["git", "commit", "-m", "Add util module"], workDir);
95 await run(["git", "push", "origin", "main"], workDir);
96
97 await rm(cloneDir, { recursive: true, force: true });
98 });
99
100 it("should list branches", async () => {
101 const branches = await listBranches(owner, repo);
102 expect(branches).toContain("main");
103 });
104
105 it("should resolve HEAD ref", async () => {
106 const sha = await resolveRef(owner, repo, "HEAD");
107 expect(sha).toBeTruthy();
108 expect(sha!.length).toBe(40);
109 });
110
111 it("should list root tree", async () => {
112 const tree = await getTree(owner, repo, "main");
113 expect(tree.length).toBeGreaterThan(0);
114 const names = tree.map((e) => e.name);
115 expect(names).toContain("README.md");
116 expect(names).toContain("src");
117 });
118
119 it("should list subtree", async () => {
120 const tree = await getTree(owner, repo, "main", "src");
121 const names = tree.map((e) => e.name);
122 expect(names).toContain("index.ts");
123 expect(names).toContain("util.ts");
124 });
125
126 it("should read blob content", async () => {
127 const blob = await getBlob(owner, repo, "main", "README.md");
128 expect(blob).not.toBeNull();
129 expect(blob!.content).toContain("Hello gluecron");
130 expect(blob!.isBinary).toBe(false);
131 });
132
133 it("should list commits", async () => {
134 const commits = await listCommits(owner, repo, "main");
135 expect(commits.length).toBe(2);
136 expect(commits[0].message).toBe("Add util module");
137 expect(commits[1].message).toBe("Initial commit");
138 });
139
140 it("should get single commit", async () => {
141 const commits = await listCommits(owner, repo, "main", 1);
142 const commit = await getCommit(owner, repo, commits[0].sha);
143 expect(commit).not.toBeNull();
144 expect(commit!.author).toBe("Test User");
145 });
146
147 it("should get diff for commit", async () => {
148 const commits = await listCommits(owner, repo, "main", 1);
149 const { files, raw } = await getDiff(owner, repo, commits[0].sha);
150 expect(files.length).toBeGreaterThan(0);
151 expect(raw).toContain("util.ts");
152 });
153
154 it("should find README", async () => {
155 const readme = await getReadme(owner, repo, "main");
156 expect(readme).toContain("Hello gluecron");
157 });
158 });
159});
Addedsrc/__tests__/highlight.test.ts+85−0View fileUnifiedSplit
1import { describe, it, expect } from "bun:test";
2import { highlightCode } from "../lib/highlight";
3
4describe("syntax highlighting", () => {
5 it("should highlight TypeScript code", () => {
6 const code = 'const x: number = 42;\nconsole.log(x);';
7 const result = highlightCode(code, "index.ts");
8
9 expect(result.language).toBe("typescript");
10 expect(result.html).toContain("hljs-");
11 expect(result.html).toContain("42");
12 });
13
14 it("should highlight Python code", () => {
15 const code = 'def hello():\n print("hello world")';
16 const result = highlightCode(code, "main.py");
17
18 expect(result.language).toBe("python");
19 expect(result.html).toContain("hljs-");
20 });
21
22 it("should highlight JSON", () => {
23 const code = '{"key": "value", "count": 42}';
24 const result = highlightCode(code, "config.json");
25
26 expect(result.language).toBe("json");
27 expect(result.html).toContain("hljs-");
28 });
29
30 it("should highlight Go code", () => {
31 const code = 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("hello")\n}';
32 const result = highlightCode(code, "main.go");
33
34 expect(result.language).toBe("go");
35 expect(result.html).toContain("hljs-");
36 });
37
38 it("should highlight Rust code", () => {
39 const code = 'fn main() {\n println!("hello");\n}';
40 const result = highlightCode(code, "main.rs");
41
42 expect(result.language).toBe("rust");
43 expect(result.html).toContain("hljs-");
44 });
45
46 it("should handle unknown extensions gracefully", () => {
47 const code = "just some plain text";
48 const result = highlightCode(code, "readme.xyz");
49
50 // Should return escaped HTML
51 expect(result.html).toContain("just some plain text");
52 });
53
54 it("should escape HTML in plain text", () => {
55 const code = '<script>alert("xss")</script>';
56 const result = highlightCode(code, "file.xyz");
57
58 expect(result.html).not.toContain("<script>");
59 expect(result.html).toContain("&lt;script&gt;");
60 });
61
62 it("should highlight CSS", () => {
63 const code = "body { color: red; font-size: 14px; }";
64 const result = highlightCode(code, "style.css");
65
66 expect(result.language).toBe("css");
67 expect(result.html).toContain("hljs-");
68 });
69
70 it("should highlight SQL", () => {
71 const code = "SELECT * FROM users WHERE id = 1;";
72 const result = highlightCode(code, "query.sql");
73
74 expect(result.language).toBe("sql");
75 expect(result.html).toContain("hljs-");
76 });
77
78 it("should highlight Dockerfile", () => {
79 const code = "FROM node:20\nRUN npm install\nCMD [\"node\", \"index.js\"]";
80 const result = highlightCode(code, "Dockerfile");
81
82 // Dockerfile extension is lowercase 'dockerfile'
83 expect(result.html).toContain("node");
84 });
85});
Addedsrc/__tests__/web-routes.test.ts+153−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";
6
7const TEST_REPOS = join(import.meta.dir, "../../.test-repos-web-" + Date.now());
8
9beforeAll(async () => {
10 await rm(TEST_REPOS, { recursive: true, force: true });
11 await mkdir(TEST_REPOS, { recursive: true });
12 process.env.GIT_REPOS_PATH = TEST_REPOS;
13
14 // Create a test repo with content
15 await initBareRepo("testuser", "myrepo");
16 const cloneDir = join(TEST_REPOS, "_clone");
17 await mkdir(cloneDir, { recursive: true });
18 const repoPath = getRepoPath("testuser", "myrepo");
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", "test@test.com"], workDir);
28 await run(["git", "config", "user.name", "Test"], workDir);
29
30 await mkdir(join(workDir, "src"), { recursive: true });
31 await Bun.write(join(workDir, "README.md"), "# My Repo\nHello");
32 await Bun.write(
33 join(workDir, "src/index.ts"),
34 'const x: number = 42;\nconsole.log(x);'
35 );
36
37 await run(["git", "add", "-A"], workDir);
38 await run(["git", "commit", "-m", "Initial commit"], workDir);
39 await run(["git", "branch", "-M", "main"], workDir);
40 await run(["git", "push", "-u", "origin", "main"], workDir);
41
42 // Create a second branch
43 await run(["git", "checkout", "-b", "feature"], workDir);
44 await Bun.write(join(workDir, "src/feature.ts"), "export const y = 1;");
45 await run(["git", "add", "-A"], workDir);
46 await run(["git", "commit", "-m", "Add feature"], workDir);
47 await run(["git", "push", "-u", "origin", "feature"], workDir);
48
49 await rm(cloneDir, { recursive: true, force: true });
50});
51
52afterAll(async () => {
53 await rm(TEST_REPOS, { recursive: true, force: true });
54});
55
56describe("web routes", () => {
57 it("GET / returns landing page", async () => {
58 const res = await app.request("/");
59 expect(res.status).toBe(200);
60 const html = await res.text();
61 expect(html).toContain("gluecron");
62 });
63
64 it("GET /login returns login form", async () => {
65 const res = await app.request("/login");
66 expect(res.status).toBe(200);
67 const html = await res.text();
68 expect(html).toContain("Sign in");
69 expect(html).toContain('name="username"');
70 expect(html).toContain('name="password"');
71 });
72
73 it("GET /register returns registration form", async () => {
74 const res = await app.request("/register");
75 expect(res.status).toBe(200);
76 const html = await res.text();
77 expect(html).toContain("Create account");
78 expect(html).toContain('name="username"');
79 expect(html).toContain('name="email"');
80 });
81
82 it("GET /new redirects to login without auth", async () => {
83 const res = await app.request("/new", { redirect: "manual" });
84 expect(res.status).toBe(302);
85 expect(res.headers.get("location")).toContain("/login");
86 });
87
88 it("GET /settings redirects to login without auth", async () => {
89 const res = await app.request("/settings", { redirect: "manual" });
90 expect(res.status).toBe(302);
91 expect(res.headers.get("location")).toContain("/login");
92 });
93
94 it("GET /:owner/:repo shows repo page", async () => {
95 const res = await app.request("/testuser/myrepo");
96 expect(res.status).toBe(200);
97 const html = await res.text();
98 expect(html).toContain("testuser");
99 expect(html).toContain("myrepo");
100 expect(html).toContain("README.md");
101 expect(html).toContain("src");
102 });
103
104 it("GET /:owner/:repo returns 404 for missing repo", async () => {
105 const res = await app.request("/nobody/nope");
106 expect(res.status).toBe(404);
107 });
108
109 it("GET /:owner/:repo/tree/:ref shows tree", async () => {
110 const res = await app.request("/testuser/myrepo/tree/main/src");
111 expect(res.status).toBe(200);
112 const html = await res.text();
113 expect(html).toContain("index.ts");
114 });
115
116 it("GET /:owner/:repo/blob/:ref/:path shows file with syntax highlighting", async () => {
117 const res = await app.request("/testuser/myrepo/blob/main/src/index.ts");
118 expect(res.status).toBe(200);
119 const html = await res.text();
120 // Should contain highlighted code
121 expect(html).toContain("hljs-");
122 expect(html).toContain("42");
123 });
124
125 it("GET /:owner/:repo/commits shows commits", async () => {
126 const res = await app.request("/testuser/myrepo/commits");
127 expect(res.status).toBe(200);
128 const html = await res.text();
129 expect(html).toContain("Initial commit");
130 });
131
132 it("supports branch switching — feature branch", async () => {
133 const res = await app.request("/testuser/myrepo/tree/feature");
134 expect(res.status).toBe(200);
135 const html = await res.text();
136 expect(html).toContain("feature");
137 });
138
139 it("feature branch has feature.ts", async () => {
140 const res = await app.request("/testuser/myrepo/tree/feature/src");
141 expect(res.status).toBe(200);
142 const html = await res.text();
143 expect(html).toContain("feature.ts");
144 });
145
146 it("shows branch dropdown when multiple branches", async () => {
147 const res = await app.request("/testuser/myrepo");
148 const html = await res.text();
149 expect(html).toContain("branch-dropdown");
150 expect(html).toContain("main");
151 expect(html).toContain("feature");
152 });
153});
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});
Addedsrc/app.tsx+107−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 pullRoutes from "./routes/pulls";
13import editorRoutes from "./routes/editor";
14import forkRoutes from "./routes/fork";
15import webhookRoutes from "./routes/webhooks";
16import exploreRoutes from "./routes/explore";
17import tokenRoutes from "./routes/tokens";
18import contributorRoutes from "./routes/contributors";
19import webRoutes from "./routes/web";
20
21const app = new Hono();
22
23// Middleware
24app.use("*", logger());
25app.use("/api/*", cors());
26
27// Git Smart HTTP protocol routes (must be before web routes)
28app.route("/", gitRoutes);
29
30// REST API
31app.route("/", apiRoutes);
32
33// Auth routes (register, login, logout)
34app.route("/", authRoutes);
35
36// Settings routes (profile, SSH keys)
37app.route("/", settingsRoutes);
38
39// API tokens
40app.route("/", tokenRoutes);
41
42// Repo settings (description, visibility, delete)
43app.route("/", repoSettings);
44
45// Webhooks management
46app.route("/", webhookRoutes);
47
48// Compare view (branch diffs)
49app.route("/", compareRoutes);
50
51// Issue tracker
52app.route("/", issueRoutes);
53
54// Pull requests
55app.route("/", pullRoutes);
56
57// Fork
58app.route("/", forkRoutes);
59
60// Web file editor
61app.route("/", editorRoutes);
62
63// Contributors
64app.route("/", contributorRoutes);
65
66// Explore page
67app.route("/", exploreRoutes);
68
69// Web UI (catch-all, must be last)
70app.route("/", webRoutes);
71
72// Global 404
73app.notFound((c) => {
74 return c.html(
75 <Layout title="Not Found">
76 <div class="empty-state">
77 <h2>404</h2>
78 <p>Page not found.</p>
79 <a href="/" style="margin-top: 12px; display: inline-block">
80 Go home
81 </a>
82 </div>
83 </Layout>,
84 404
85 );
86});
87
88// Global error handler
89app.onError((err, c) => {
90 console.error("[error]", err);
91 return c.html(
92 <Layout title="Error">
93 <div class="empty-state">
94 <h2>Something went wrong</h2>
95 <p>An unexpected error occurred.</p>
96 {process.env.NODE_ENV !== "production" && (
97 <pre style="margin-top: 16px; text-align: left; font-size: 12px; color: var(--red)">
98 {err.message}
99 </pre>
100 )}
101 </div>
102 </Layout>,
103 500
104 );
105});
106
107export default app;
Addedsrc/db/index.ts+26−0View fileUnifiedSplit
1import { neon } from "@neondatabase/serverless";
2import { drizzle, type NeonHttpDatabase } from "drizzle-orm/neon-http";
3import * as schema from "./schema";
4import { config } from "../lib/config";
5
6let _db: NeonHttpDatabase<typeof schema> | null = null;
7
8export function getDb(): NeonHttpDatabase<typeof schema> {
9 if (!_db) {
10 if (!config.databaseUrl) {
11 throw new Error(
12 "DATABASE_URL is not set. Set it in your environment or .env file."
13 );
14 }
15 const sql = neon(config.databaseUrl);
16 _db = drizzle(sql, { schema });
17 }
18 return _db;
19}
20
21// Re-export as `db` for convenience — will throw on access without DATABASE_URL
22export const db = new Proxy({} as NeonHttpDatabase<typeof schema>, {
23 get(_target, prop, receiver) {
24 return Reflect.get(getDb(), prop, receiver);
25 },
26});
Addedsrc/db/migrate.ts+17−0View fileUnifiedSplit
1import { neon } from "@neondatabase/serverless";
2import { drizzle } from "drizzle-orm/neon-http";
3import { migrate } from "drizzle-orm/neon-http/migrator";
4import { config } from "../lib/config";
5
6async function runMigrations() {
7 const sql = neon(config.databaseUrl);
8 const db = drizzle(sql);
9 console.log("Running migrations...");
10 await migrate(db, { migrationsFolder: "./drizzle" });
11 console.log("Migrations complete.");
12}
13
14runMigrations().catch((err) => {
15 console.error("Migration failed:", err);
16 process.exit(1);
17});
Addedsrc/db/schema.ts+292−0View fileUnifiedSplit
1import {
2 pgTable,
3 text,
4 timestamp,
5 uuid,
6 boolean,
7 integer,
8 uniqueIndex,
9 index,
10 serial,
11} from "drizzle-orm/pg-core";
12
13export const users = pgTable("users", {
14 id: uuid("id").primaryKey().defaultRandom(),
15 username: text("username").notNull().unique(),
16 email: text("email").notNull().unique(),
17 displayName: text("display_name"),
18 passwordHash: text("password_hash").notNull(),
19 avatarUrl: text("avatar_url"),
20 bio: text("bio"),
21 createdAt: timestamp("created_at").defaultNow().notNull(),
22 updatedAt: timestamp("updated_at").defaultNow().notNull(),
23});
24
25export const sessions = pgTable("sessions", {
26 id: uuid("id").primaryKey().defaultRandom(),
27 userId: uuid("user_id")
28 .notNull()
29 .references(() => users.id, { onDelete: "cascade" }),
30 token: text("token").notNull().unique(),
31 expiresAt: timestamp("expires_at").notNull(),
32 createdAt: timestamp("created_at").defaultNow().notNull(),
33});
34
35export const repositories = pgTable(
36 "repositories",
37 {
38 id: uuid("id").primaryKey().defaultRandom(),
39 name: text("name").notNull(),
40 ownerId: uuid("owner_id")
41 .notNull()
42 .references(() => users.id),
43 description: text("description"),
44 isPrivate: boolean("is_private").default(false).notNull(),
45 defaultBranch: text("default_branch").default("main").notNull(),
46 diskPath: text("disk_path").notNull(),
47 forkedFromId: uuid("forked_from_id").references(() => repositories.id, {
48 onDelete: "set null",
49 }),
50 createdAt: timestamp("created_at").defaultNow().notNull(),
51 updatedAt: timestamp("updated_at").defaultNow().notNull(),
52 pushedAt: timestamp("pushed_at"),
53 starCount: integer("star_count").default(0).notNull(),
54 forkCount: integer("fork_count").default(0).notNull(),
55 issueCount: integer("issue_count").default(0).notNull(),
56 },
57 (table) => [uniqueIndex("repos_owner_name").on(table.ownerId, table.name)]
58);
59
60export const stars = pgTable(
61 "stars",
62 {
63 id: uuid("id").primaryKey().defaultRandom(),
64 userId: uuid("user_id")
65 .notNull()
66 .references(() => users.id, { onDelete: "cascade" }),
67 repositoryId: uuid("repository_id")
68 .notNull()
69 .references(() => repositories.id, { onDelete: "cascade" }),
70 createdAt: timestamp("created_at").defaultNow().notNull(),
71 },
72 (table) => [
73 uniqueIndex("stars_user_repo").on(table.userId, table.repositoryId),
74 ]
75);
76
77export const issues = pgTable(
78 "issues",
79 {
80 id: uuid("id").primaryKey().defaultRandom(),
81 number: serial("number"),
82 repositoryId: uuid("repository_id")
83 .notNull()
84 .references(() => repositories.id, { onDelete: "cascade" }),
85 authorId: uuid("author_id")
86 .notNull()
87 .references(() => users.id),
88 title: text("title").notNull(),
89 body: text("body"),
90 state: text("state").notNull().default("open"), // open, closed
91 createdAt: timestamp("created_at").defaultNow().notNull(),
92 updatedAt: timestamp("updated_at").defaultNow().notNull(),
93 closedAt: timestamp("closed_at"),
94 },
95 (table) => [
96 index("issues_repo_state").on(table.repositoryId, table.state),
97 index("issues_repo_number").on(table.repositoryId, table.number),
98 ]
99);
100
101export const issueComments = pgTable(
102 "issue_comments",
103 {
104 id: uuid("id").primaryKey().defaultRandom(),
105 issueId: uuid("issue_id")
106 .notNull()
107 .references(() => issues.id, { onDelete: "cascade" }),
108 authorId: uuid("author_id")
109 .notNull()
110 .references(() => users.id),
111 body: text("body").notNull(),
112 createdAt: timestamp("created_at").defaultNow().notNull(),
113 updatedAt: timestamp("updated_at").defaultNow().notNull(),
114 },
115 (table) => [index("comments_issue").on(table.issueId)]
116);
117
118export const labels = pgTable(
119 "labels",
120 {
121 id: uuid("id").primaryKey().defaultRandom(),
122 repositoryId: uuid("repository_id")
123 .notNull()
124 .references(() => repositories.id, { onDelete: "cascade" }),
125 name: text("name").notNull(),
126 color: text("color").notNull().default("#8b949e"),
127 description: text("description"),
128 },
129 (table) => [
130 uniqueIndex("labels_repo_name").on(table.repositoryId, table.name),
131 ]
132);
133
134export const issueLabels = pgTable(
135 "issue_labels",
136 {
137 id: uuid("id").primaryKey().defaultRandom(),
138 issueId: uuid("issue_id")
139 .notNull()
140 .references(() => issues.id, { onDelete: "cascade" }),
141 labelId: uuid("label_id")
142 .notNull()
143 .references(() => labels.id, { onDelete: "cascade" }),
144 },
145 (table) => [
146 uniqueIndex("issue_labels_unique").on(table.issueId, table.labelId),
147 ]
148);
149
150export const pullRequests = pgTable(
151 "pull_requests",
152 {
153 id: uuid("id").primaryKey().defaultRandom(),
154 number: serial("number"),
155 repositoryId: uuid("repository_id")
156 .notNull()
157 .references(() => repositories.id, { onDelete: "cascade" }),
158 authorId: uuid("author_id")
159 .notNull()
160 .references(() => users.id),
161 title: text("title").notNull(),
162 body: text("body"),
163 state: text("state").notNull().default("open"), // open, closed, merged
164 baseBranch: text("base_branch").notNull(),
165 headBranch: text("head_branch").notNull(),
166 mergedAt: timestamp("merged_at"),
167 mergedBy: uuid("merged_by").references(() => users.id),
168 createdAt: timestamp("created_at").defaultNow().notNull(),
169 updatedAt: timestamp("updated_at").defaultNow().notNull(),
170 closedAt: timestamp("closed_at"),
171 },
172 (table) => [
173 index("prs_repo_state").on(table.repositoryId, table.state),
174 index("prs_repo_number").on(table.repositoryId, table.number),
175 ]
176);
177
178export const prComments = pgTable(
179 "pr_comments",
180 {
181 id: uuid("id").primaryKey().defaultRandom(),
182 pullRequestId: uuid("pull_request_id")
183 .notNull()
184 .references(() => pullRequests.id, { onDelete: "cascade" }),
185 authorId: uuid("author_id")
186 .notNull()
187 .references(() => users.id),
188 body: text("body").notNull(),
189 isAiReview: boolean("is_ai_review").default(false).notNull(),
190 filePath: text("file_path"),
191 lineNumber: integer("line_number"),
192 createdAt: timestamp("created_at").defaultNow().notNull(),
193 updatedAt: timestamp("updated_at").defaultNow().notNull(),
194 },
195 (table) => [index("pr_comments_pr").on(table.pullRequestId)]
196);
197
198export const activityFeed = pgTable(
199 "activity_feed",
200 {
201 id: uuid("id").primaryKey().defaultRandom(),
202 repositoryId: uuid("repository_id")
203 .notNull()
204 .references(() => repositories.id, { onDelete: "cascade" }),
205 userId: uuid("user_id").references(() => users.id),
206 action: text("action").notNull(), // push, issue_open, issue_close, pr_open, pr_merge, star, comment
207 targetType: text("target_type"), // issue, pr, commit
208 targetId: text("target_id"),
209 metadata: text("metadata"), // JSON string for extra data
210 createdAt: timestamp("created_at").defaultNow().notNull(),
211 },
212 (table) => [
213 index("activity_repo").on(table.repositoryId),
214 index("activity_user").on(table.userId),
215 ]
216);
217
218export const webhooks = pgTable(
219 "webhooks",
220 {
221 id: uuid("id").primaryKey().defaultRandom(),
222 repositoryId: uuid("repository_id")
223 .notNull()
224 .references(() => repositories.id, { onDelete: "cascade" }),
225 url: text("url").notNull(),
226 secret: text("secret"),
227 events: text("events").notNull().default("push"), // comma-separated: push,issue,pr
228 isActive: boolean("is_active").default(true).notNull(),
229 lastDeliveredAt: timestamp("last_delivered_at"),
230 lastStatus: integer("last_status"),
231 createdAt: timestamp("created_at").defaultNow().notNull(),
232 },
233 (table) => [index("webhooks_repo").on(table.repositoryId)]
234);
235
236export const apiTokens = pgTable("api_tokens", {
237 id: uuid("id").primaryKey().defaultRandom(),
238 userId: uuid("user_id")
239 .notNull()
240 .references(() => users.id, { onDelete: "cascade" }),
241 name: text("name").notNull(),
242 tokenHash: text("token_hash").notNull(),
243 tokenPrefix: text("token_prefix").notNull(), // first 8 chars for display
244 scopes: text("scopes").notNull().default("repo"), // comma-separated
245 lastUsedAt: timestamp("last_used_at"),
246 expiresAt: timestamp("expires_at"),
247 createdAt: timestamp("created_at").defaultNow().notNull(),
248});
249
250export const repoTopics = pgTable(
251 "repo_topics",
252 {
253 id: uuid("id").primaryKey().defaultRandom(),
254 repositoryId: uuid("repository_id")
255 .notNull()
256 .references(() => repositories.id, { onDelete: "cascade" }),
257 topic: text("topic").notNull(),
258 },
259 (table) => [
260 uniqueIndex("repo_topics_unique").on(table.repositoryId, table.topic),
261 index("topics_name").on(table.topic),
262 ]
263);
264
265export const sshKeys = pgTable("ssh_keys", {
266 id: uuid("id").primaryKey().defaultRandom(),
267 userId: uuid("user_id")
268 .notNull()
269 .references(() => users.id, { onDelete: "cascade" }),
270 title: text("title").notNull(),
271 fingerprint: text("fingerprint").notNull(),
272 publicKey: text("public_key").notNull(),
273 lastUsedAt: timestamp("last_used_at"),
274 createdAt: timestamp("created_at").defaultNow().notNull(),
275});
276
277export type User = typeof users.$inferSelect;
278export type NewUser = typeof users.$inferInsert;
279export type Repository = typeof repositories.$inferSelect;
280export type NewRepository = typeof repositories.$inferInsert;
281export type Session = typeof sessions.$inferSelect;
282export type Star = typeof stars.$inferSelect;
283export type SshKey = typeof sshKeys.$inferSelect;
284export type Issue = typeof issues.$inferSelect;
285export type IssueComment = typeof issueComments.$inferSelect;
286export type Label = typeof labels.$inferSelect;
287export type PullRequest = typeof pullRequests.$inferSelect;
288export type PrComment = typeof prComments.$inferSelect;
289export type ActivityEntry = typeof activityFeed.$inferSelect;
290export type Webhook = typeof webhooks.$inferSelect;
291export type ApiToken = typeof apiTokens.$inferSelect;
292export type RepoTopic = typeof repoTopics.$inferSelect;
Addedsrc/git/protocol.ts+84−0View fileUnifiedSplit
1/**
2 * Git Smart HTTP Protocol implementation.
3 *
4 * Handles the server side of:
5 * - GET /:owner/:repo.git/info/refs?service=git-upload-pack
6 * - GET /:owner/:repo.git/info/refs?service=git-receive-pack
7 * - POST /:owner/:repo.git/git-upload-pack
8 * - POST /:owner/:repo.git/git-receive-pack
9 *
10 * Reference: https://git-scm.com/docs/http-protocol
11 */
12
13import { getRepoPath } from "./repository";
14
15function pktLine(data: string): string {
16 const len = (data.length + 4).toString(16).padStart(4, "0");
17 return `${len}${data}`;
18}
19
20function pktFlush(): string {
21 return "0000";
22}
23
24export function infoRefsResponse(
25 service: string,
26 advertise: string
27): Response {
28 const body = pktLine(`# service=${service}\n`) + pktFlush() + advertise;
29
30 return new Response(body, {
31 headers: {
32 "Content-Type": `application/x-${service}-advertisement`,
33 "Cache-Control": "no-cache",
34 },
35 });
36}
37
38export async function getInfoRefs(
39 owner: string,
40 repo: string,
41 service: string
42): Promise<Response> {
43 const repoDir = getRepoPath(owner, repo);
44 const proc = Bun.spawn([service, "--stateless-rpc", "--advertise-refs", repoDir], {
45 stdout: "pipe",
46 stderr: "pipe",
47 });
48 const stdout = await new Response(proc.stdout).text();
49 await proc.exited;
50 return infoRefsResponse(service, stdout);
51}
52
53export async function serviceRpc(
54 owner: string,
55 repo: string,
56 service: string,
57 body: ReadableStream<Uint8Array> | ArrayBuffer | null
58): Promise<Response> {
59 const repoDir = getRepoPath(owner, repo);
60 const inputBytes = body
61 ? body instanceof ArrayBuffer
62 ? new Uint8Array(body)
63 : new Uint8Array(await new Response(body).arrayBuffer())
64 : new Uint8Array();
65
66 const proc = Bun.spawn([service, "--stateless-rpc", repoDir], {
67 stdin: "pipe",
68 stdout: "pipe",
69 stderr: "pipe",
70 });
71
72 proc.stdin.write(inputBytes);
73 proc.stdin.end();
74
75 const stdout = await new Response(proc.stdout).arrayBuffer();
76 await proc.exited;
77
78 return new Response(stdout, {
79 headers: {
80 "Content-Type": `application/x-${service}-result`,
81 "Cache-Control": "no-cache",
82 },
83 });
84}
Addedsrc/git/repository.ts+441−0View fileUnifiedSplit
1import { join } from "path";
2import { mkdir } from "fs/promises";
3import { config } from "../lib/config";
4
5export interface GitCommit {
6 sha: string;
7 message: string;
8 author: string;
9 authorEmail: string;
10 date: string;
11 parentShas: string[];
12}
13
14export interface GitTreeEntry {
15 mode: string;
16 type: "blob" | "tree" | "commit";
17 sha: string;
18 name: string;
19 size?: number;
20}
21
22export interface GitBlob {
23 content: string;
24 size: number;
25 isBinary: boolean;
26}
27
28export interface GitDiffFile {
29 path: string;
30 oldPath?: string;
31 status: string;
32 additions: number;
33 deletions: number;
34 patch: string;
35}
36
37function repoPath(owner: string, name: string): string {
38 return join(config.gitReposPath, owner, `${name}.git`);
39}
40
41async function exec(
42 cmd: string[],
43 opts?: { cwd?: string; env?: Record<string, string> }
44): Promise<{ stdout: string; stderr: string; exitCode: number }> {
45 const proc = Bun.spawn(cmd, {
46 cwd: opts?.cwd,
47 env: { ...process.env, ...opts?.env },
48 stdout: "pipe",
49 stderr: "pipe",
50 });
51 const [stdout, stderr] = await Promise.all([
52 new Response(proc.stdout).text(),
53 new Response(proc.stderr).text(),
54 ]);
55 const exitCode = await proc.exited;
56 return { stdout, stderr, exitCode };
57}
58
59export async function initBareRepo(
60 owner: string,
61 name: string
62): Promise<string> {
63 const path = repoPath(owner, name);
64 await mkdir(join(config.gitReposPath, owner), { recursive: true });
65 await exec(["git", "init", "--bare", path]);
66 // Set default branch to main
67 await exec(["git", "symbolic-ref", "HEAD", "refs/heads/main"], {
68 cwd: path,
69 });
70 return path;
71}
72
73export async function repoExists(
74 owner: string,
75 name: string
76): Promise<boolean> {
77 const path = repoPath(owner, name);
78 const file = Bun.file(join(path, "HEAD"));
79 return file.exists();
80}
81
82export function getRepoPath(owner: string, name: string): string {
83 return repoPath(owner, name);
84}
85
86export async function listBranches(
87 owner: string,
88 name: string
89): Promise<string[]> {
90 const path = repoPath(owner, name);
91 const { stdout, exitCode } = await exec(
92 ["git", "for-each-ref", "--format=%(refname:short)", "refs/heads/"],
93 { cwd: path }
94 );
95 if (exitCode !== 0) return [];
96 return stdout.trim().split("\n").filter(Boolean);
97}
98
99export async function getDefaultBranch(
100 owner: string,
101 name: string
102): Promise<string | null> {
103 const path = repoPath(owner, name);
104 const { stdout, exitCode } = await exec(
105 ["git", "symbolic-ref", "--short", "HEAD"],
106 { cwd: path }
107 );
108 if (exitCode !== 0) return null;
109 return stdout.trim() || null;
110}
111
112export async function resolveRef(
113 owner: string,
114 name: string,
115 ref: string
116): Promise<string | null> {
117 const path = repoPath(owner, name);
118 const { stdout, exitCode } = await exec(
119 ["git", "rev-parse", "--verify", ref],
120 { cwd: path }
121 );
122 if (exitCode !== 0) return null;
123 return stdout.trim();
124}
125
126export async function getCommit(
127 owner: string,
128 name: string,
129 sha: string
130): Promise<GitCommit | null> {
131 const path = repoPath(owner, name);
132 const format = "%H%n%s%n%an%n%ae%n%aI%n%P";
133 const { stdout, exitCode } = await exec(
134 ["git", "log", "-1", `--format=${format}`, sha],
135 { cwd: path }
136 );
137 if (exitCode !== 0) return null;
138 const lines = stdout.trim().split("\n");
139 if (lines.length < 5) return null;
140 return {
141 sha: lines[0],
142 message: lines[1],
143 author: lines[2],
144 authorEmail: lines[3],
145 date: lines[4],
146 parentShas: lines[5] ? lines[5].split(" ").filter(Boolean) : [],
147 };
148}
149
150export async function getCommitFullMessage(
151 owner: string,
152 name: string,
153 sha: string
154): Promise<string> {
155 const path = repoPath(owner, name);
156 const { stdout } = await exec(
157 ["git", "log", "-1", "--format=%B", sha],
158 { cwd: path }
159 );
160 return stdout.trim();
161}
162
163export async function listCommits(
164 owner: string,
165 name: string,
166 ref: string,
167 limit = 30,
168 offset = 0
169): Promise<GitCommit[]> {
170 const path = repoPath(owner, name);
171 const format = "%H%x00%s%x00%an%x00%ae%x00%aI%x00%P";
172 const { stdout, exitCode } = await exec(
173 [
174 "git",
175 "log",
176 `--format=${format}`,
177 `--skip=${offset}`,
178 `-${limit}`,
179 ref,
180 ],
181 { cwd: path }
182 );
183 if (exitCode !== 0) return [];
184 return stdout
185 .trim()
186 .split("\n")
187 .filter(Boolean)
188 .map((line) => {
189 const [sha, message, author, authorEmail, date, parents] =
190 line.split("\0");
191 return {
192 sha,
193 message,
194 author,
195 authorEmail,
196 date,
197 parentShas: parents ? parents.split(" ").filter(Boolean) : [],
198 };
199 });
200}
201
202export async function getTree(
203 owner: string,
204 name: string,
205 ref: string,
206 treePath = ""
207): Promise<GitTreeEntry[]> {
208 const path = repoPath(owner, name);
209 const treeish = treePath ? `${ref}:${treePath}` : `${ref}`;
210 const { stdout, exitCode } = await exec(
211 ["git", "ls-tree", "-l", treeish],
212 { cwd: path }
213 );
214 if (exitCode !== 0) return [];
215 return stdout
216 .trim()
217 .split("\n")
218 .filter(Boolean)
219 .map((line) => {
220 // format: <mode> <type> <sha>\t<size>\t<name>
221 // Actually: <mode> SP <type> SP <sha> SP <size> TAB <name>
222 const match = line.match(
223 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\s+(-|\d+)\t(.+)$/
224 );
225 if (!match) return null;
226 return {
227 mode: match[1],
228 type: match[2] as "blob" | "tree" | "commit",
229 sha: match[3],
230 size: match[4] === "-" ? undefined : parseInt(match[4], 10),
231 name: match[5],
232 };
233 })
234 .filter((e): e is GitTreeEntry => e !== null)
235 .sort((a, b) => {
236 // directories first, then files
237 if (a.type === "tree" && b.type !== "tree") return -1;
238 if (a.type !== "tree" && b.type === "tree") return 1;
239 return a.name.localeCompare(b.name);
240 });
241}
242
243export async function getBlob(
244 owner: string,
245 name: string,
246 ref: string,
247 filePath: string
248): Promise<GitBlob | null> {
249 const path = repoPath(owner, name);
250 const { stdout, exitCode } = await exec(
251 ["git", "cat-file", "-s", `${ref}:${filePath}`],
252 { cwd: path }
253 );
254 if (exitCode !== 0) return null;
255 const size = parseInt(stdout.trim(), 10);
256
257 // Check if binary
258 const { stdout: content, exitCode: catCode } = await exec(
259 ["git", "show", `${ref}:${filePath}`],
260 { cwd: path }
261 );
262 if (catCode !== 0) return null;
263
264 const isBinary = content.includes("\0");
265 return {
266 content: isBinary ? "" : content,
267 size,
268 isBinary,
269 };
270}
271
272export async function getDiff(
273 owner: string,
274 name: string,
275 sha: string
276): Promise<{ files: GitDiffFile[]; raw: string }> {
277 const path = repoPath(owner, name);
278 // For initial commits (no parent), diff against empty tree
279 const commit = await getCommit(owner, name, sha);
280 if (!commit) return { files: [], raw: "" };
281
282 let diffCmd: string[];
283 if (commit.parentShas.length === 0) {
284 const emptyTree = "4b825dc642cb6eb9a060e54bf899d15363da7b23";
285 diffCmd = ["git", "diff", emptyTree, sha];
286 } else {
287 diffCmd = ["git", "diff", `${commit.parentShas[0]}..${sha}`];
288 }
289
290 const { stdout: raw } = await exec(diffCmd, { cwd: path });
291
292 // Also get --stat for file-level summary
293 const { stdout: stat } = await exec([...diffCmd, "--numstat"], {
294 cwd: path,
295 });
296
297 const files = stat
298 .trim()
299 .split("\n")
300 .filter(Boolean)
301 .map((line) => {
302 const [add, del, filePath] = line.split("\t");
303 return {
304 path: filePath,
305 status: "modified",
306 additions: add === "-" ? 0 : parseInt(add, 10),
307 deletions: del === "-" ? 0 : parseInt(del, 10),
308 patch: "",
309 };
310 });
311
312 return { files, raw };
313}
314
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
429export async function getReadme(
430 owner: string,
431 name: string,
432 ref: string
433): Promise<string | null> {
434 const tree = await getTree(owner, name, ref);
435 const readme = tree.find((e) =>
436 /^readme(\.(md|txt|rst))?$/i.test(e.name)
437 );
438 if (!readme) return null;
439 const blob = await getBlob(owner, name, ref, readme.name);
440 return blob?.content || null;
441}
Addedsrc/hooks/post-receive.ts+85−0View fileUnifiedSplit
1/**
2 * Post-receive hook logic.
3 * Called after a successful git push to trigger GateTest scans
4 * and Crontech deploys.
5 */
6
7import { config } from "../lib/config";
8
9interface PushRef {
10 oldSha: string;
11 newSha: string;
12 refName: string;
13}
14
15export async function onPostReceive(
16 owner: string,
17 repo: string,
18 refs: PushRef[]
19): Promise<void> {
20 const promises: Promise<void>[] = [];
21
22 // GateTest scan on every push
23 promises.push(triggerGateTest(owner, repo, refs));
24
25 // Crontech deploy on push to main
26 const mainPush = refs.find(
27 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
28 );
29 if (mainPush) {
30 promises.push(triggerCrontechDeploy(owner, repo, mainPush.newSha));
31 }
32
33 await Promise.allSettled(promises);
34}
35
36async function triggerGateTest(
37 owner: string,
38 repo: string,
39 refs: PushRef[]
40): Promise<void> {
41 try {
42 const response = await fetch(config.gatetestUrl, {
43 method: "POST",
44 headers: { "Content-Type": "application/json" },
45 body: JSON.stringify({
46 repository: `${owner}/${repo}`,
47 refs: refs.map((r) => ({
48 ref: r.refName,
49 before: r.oldSha,
50 after: r.newSha,
51 })),
52 source: "gluecron",
53 }),
54 });
55 console.log(
56 `[gatetest] scan triggered for ${owner}/${repo}: ${response.status}`
57 );
58 } catch (err) {
59 console.error(`[gatetest] failed to trigger scan:`, err);
60 }
61}
62
63async function triggerCrontechDeploy(
64 owner: string,
65 repo: string,
66 sha: string
67): Promise<void> {
68 try {
69 const response = await fetch(config.crontechDeployUrl, {
70 method: "POST",
71 headers: { "Content-Type": "application/json" },
72 body: JSON.stringify({
73 repository: `${owner}/${repo}`,
74 sha,
75 branch: "main",
76 source: "gluecron",
77 }),
78 });
79 console.log(
80 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
81 );
82 } catch (err) {
83 console.error(`[crontech] failed to trigger deploy:`, err);
84 }
85}
Addedsrc/index.ts+18−0View fileUnifiedSplit
1import { mkdir } from "fs/promises";
2import app from "./app";
3import { config } from "./lib/config";
4
5// Ensure repos directory exists
6await mkdir(config.gitReposPath, { recursive: true });
7
8console.log(`
9 gluecron v0.1.0
10 ──────────────────────
11 http://localhost:${config.port}
12 repos: ${config.gitReposPath}
13`);
14
15export default {
16 port: config.port,
17 fetch: app.fetch,
18};
Addedsrc/lib/auth.ts+44−0View fileUnifiedSplit
1/**
2 * Authentication utilities — password hashing, session tokens.
3 * Uses Bun's native crypto for Argon2-like password hashing.
4 */
5
6const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
7
8export async function hashPassword(password: string): Promise<string> {
9 return await Bun.password.hash(password, { algorithm: "bcrypt", cost: 10 });
10}
11
12export async function verifyPassword(
13 password: string,
14 hash: string
15): Promise<boolean> {
16 return await Bun.password.verify(password, hash);
17}
18
19export function generateSessionToken(): string {
20 const bytes = crypto.getRandomValues(new Uint8Array(32));
21 return Array.from(bytes)
22 .map((b) => b.toString(16).padStart(2, "0"))
23 .join("");
24}
25
26export function sessionCookieOptions(): {
27 httpOnly: boolean;
28 secure: boolean;
29 sameSite: "Lax";
30 path: string;
31 maxAge: number;
32} {
33 return {
34 httpOnly: true,
35 secure: process.env.NODE_ENV === "production",
36 sameSite: "Lax",
37 path: "/",
38 maxAge: SESSION_DURATION_MS / 1000,
39 };
40}
41
42export function sessionExpiry(): Date {
43 return new Date(Date.now() + SESSION_DURATION_MS);
44}
Addedsrc/lib/config.ts+22−0View fileUnifiedSplit
1import { join } from "path";
2
3export const config = {
4 get port() {
5 return Number(process.env.PORT || 3000);
6 },
7 get databaseUrl() {
8 return process.env.DATABASE_URL || "";
9 },
10 get gitReposPath() {
11 return process.env.GIT_REPOS_PATH || join(process.cwd(), "repos");
12 },
13 get gatetestUrl() {
14 return process.env.GATETEST_URL || "https://gatetest.ai/api/scan/run";
15 },
16 get crontechDeployUrl() {
17 return (
18 process.env.CRONTECH_DEPLOY_URL ||
19 "https://crontech.ai/api/trpc/tenant.deploy"
20 );
21 },
22};
Addedsrc/lib/highlight.ts+132−0View fileUnifiedSplit
1/**
2 * Syntax highlighting via highlight.js — server-side.
3 * Returns pre-highlighted HTML strings for code display.
4 */
5
6import hljs from "highlight.js";
7
8const EXT_TO_LANG: Record<string, string> = {
9 ts: "typescript",
10 tsx: "typescript",
11 js: "javascript",
12 jsx: "javascript",
13 py: "python",
14 rb: "ruby",
15 rs: "rust",
16 go: "go",
17 java: "java",
18 c: "c",
19 cpp: "cpp",
20 h: "c",
21 hpp: "cpp",
22 cs: "csharp",
23 php: "php",
24 swift: "swift",
25 kt: "kotlin",
26 scala: "scala",
27 sh: "bash",
28 bash: "bash",
29 zsh: "bash",
30 fish: "bash",
31 ps1: "powershell",
32 sql: "sql",
33 html: "html",
34 htm: "html",
35 css: "css",
36 scss: "scss",
37 less: "less",
38 json: "json",
39 yaml: "yaml",
40 yml: "yaml",
41 toml: "ini",
42 xml: "xml",
43 md: "markdown",
44 markdown: "markdown",
45 dockerfile: "dockerfile",
46 makefile: "makefile",
47 cmake: "cmake",
48 lua: "lua",
49 r: "r",
50 dart: "dart",
51 ex: "elixir",
52 exs: "elixir",
53 erl: "erlang",
54 hs: "haskell",
55 ml: "ocaml",
56 clj: "clojure",
57 vim: "vim",
58 tf: "hcl",
59 proto: "protobuf",
60 graphql: "graphql",
61 gql: "graphql",
62};
63
64export function highlightCode(
65 code: string,
66 filename: string
67): { html: string; language: string | null } {
68 const ext = filename.split(".").pop()?.toLowerCase() || "";
69 const lang = EXT_TO_LANG[ext];
70
71 if (lang) {
72 try {
73 const result = hljs.highlight(code, { language: lang });
74 return { html: result.value, language: lang };
75 } catch {
76 // fallback to auto-detect
77 }
78 }
79
80 // Try auto-detection for unknown extensions
81 try {
82 const result = hljs.highlightAuto(code);
83 if (result.language && result.relevance > 5) {
84 return { html: result.value, language: result.language };
85 }
86 } catch {
87 // fallback to plain
88 }
89
90 return { html: escapeHtml(code), language: null };
91}
92
93function escapeHtml(str: string): string {
94 return str
95 .replace(/&/g, "&amp;")
96 .replace(/</g, "&lt;")
97 .replace(/>/g, "&gt;")
98 .replace(/"/g, "&quot;");
99}
100
101export const hljsThemeCss = `
102 .hljs-keyword { color: #ff7b72; }
103 .hljs-built_in { color: #ffa657; }
104 .hljs-type { color: #ffa657; }
105 .hljs-literal { color: #79c0ff; }
106 .hljs-number { color: #79c0ff; }
107 .hljs-string { color: #a5d6ff; }
108 .hljs-regexp { color: #a5d6ff; }
109 .hljs-symbol { color: #79c0ff; }
110 .hljs-title { color: #d2a8ff; }
111 .hljs-title.function_ { color: #d2a8ff; }
112 .hljs-title.class_ { color: #ffa657; }
113 .hljs-params { color: #e6edf3; }
114 .hljs-comment { color: #8b949e; font-style: italic; }
115 .hljs-doctag { color: #8b949e; }
116 .hljs-meta { color: #79c0ff; }
117 .hljs-attr { color: #79c0ff; }
118 .hljs-attribute { color: #79c0ff; }
119 .hljs-selector-tag { color: #ff7b72; }
120 .hljs-selector-class { color: #d2a8ff; }
121 .hljs-selector-id { color: #79c0ff; }
122 .hljs-variable { color: #ffa657; }
123 .hljs-template-variable { color: #ffa657; }
124 .hljs-tag { color: #7ee787; }
125 .hljs-name { color: #7ee787; }
126 .hljs-section { color: #d2a8ff; font-weight: bold; }
127 .hljs-addition { color: #aff5b4; background: rgba(63, 185, 80, 0.15); }
128 .hljs-deletion { color: #ffdcd7; background: rgba(248, 81, 73, 0.1); }
129 .hljs-property { color: #79c0ff; }
130 .hljs-subst { color: #e6edf3; }
131 .hljs-punctuation { color: #8b949e; }
132`;
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/middleware/auth.ts+91−0View fileUnifiedSplit
1/**
2 * Auth middleware — reads session cookie, injects user into context.
3 */
4
5import { createMiddleware } from "hono/factory";
6import { getCookie } from "hono/cookie";
7import { eq, gt } from "drizzle-orm";
8import { db } from "../db";
9import { sessions, users } from "../db/schema";
10import type { User } from "../db/schema";
11
12export type AuthEnv = {
13 Variables: {
14 user: User | null;
15 };
16};
17
18/**
19 * Soft auth — sets c.get("user") to the current user or null.
20 * Does NOT block unauthenticated requests.
21 */
22export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
23 const token = getCookie(c, "session");
24 if (!token) {
25 c.set("user", null);
26 return next();
27 }
28
29 try {
30 const [session] = await db
31 .select()
32 .from(sessions)
33 .where(eq(sessions.token, token))
34 .limit(1);
35
36 if (!session || new Date(session.expiresAt) < new Date()) {
37 c.set("user", null);
38 return next();
39 }
40
41 const [user] = await db
42 .select()
43 .from(users)
44 .where(eq(users.id, session.userId))
45 .limit(1);
46
47 c.set("user", user || null);
48 } catch {
49 c.set("user", null);
50 }
51
52 return next();
53});
54
55/**
56 * Hard auth — redirects to /login if not authenticated.
57 */
58export const requireAuth = createMiddleware<AuthEnv>(async (c, next) => {
59 const token = getCookie(c, "session");
60 if (!token) {
61 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
62 }
63
64 try {
65 const [session] = await db
66 .select()
67 .from(sessions)
68 .where(eq(sessions.token, token))
69 .limit(1);
70
71 if (!session || new Date(session.expiresAt) < new Date()) {
72 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
73 }
74
75 const [user] = await db
76 .select()
77 .from(users)
78 .where(eq(users.id, session.userId))
79 .limit(1);
80
81 if (!user) {
82 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
83 }
84
85 c.set("user", user);
86 } catch {
87 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
88 }
89
90 return next();
91});
Addedsrc/routes/api.ts+142−0View fileUnifiedSplit
1/**
2 * REST API routes for repository management.
3 */
4
5import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
7import { db } from "../db";
8import { users, repositories } from "../db/schema";
9import { initBareRepo, repoExists } from "../git/repository";
10import { hashPassword } from "../lib/auth";
11
12const api = new Hono().basePath("/api");
13
14// Create repository
15api.post("/repos", async (c) => {
16 const body = await c.req.json<{
17 name: string;
18 owner: string;
19 description?: string;
20 isPrivate?: boolean;
21 }>();
22
23 if (!body.name || !body.owner) {
24 return c.json({ error: "name and owner are required" }, 400);
25 }
26
27 // Validate repo name
28 if (!/^[a-zA-Z0-9._-]+$/.test(body.name)) {
29 return c.json({ error: "Invalid repository name" }, 400);
30 }
31
32 // Find owner
33 const [owner] = await db
34 .select()
35 .from(users)
36 .where(eq(users.username, body.owner));
37
38 if (!owner) {
39 return c.json({ error: "Owner not found" }, 404);
40 }
41
42 // Check duplicate
43 if (await repoExists(body.owner, body.name)) {
44 return c.json({ error: "Repository already exists" }, 409);
45 }
46
47 // Init bare repo on disk
48 const diskPath = await initBareRepo(body.owner, body.name);
49
50 // Insert into DB
51 const [repo] = await db
52 .insert(repositories)
53 .values({
54 name: body.name,
55 ownerId: owner.id,
56 description: body.description || null,
57 isPrivate: body.isPrivate || false,
58 diskPath,
59 })
60 .returning();
61
62 return c.json(repo, 201);
63});
64
65// List user's repositories
66api.get("/users/:username/repos", async (c) => {
67 const { username } = c.req.param();
68 const [owner] = await db
69 .select()
70 .from(users)
71 .where(eq(users.username, username));
72 if (!owner) return c.json({ error: "User not found" }, 404);
73
74 const repos = await db
75 .select()
76 .from(repositories)
77 .where(eq(repositories.ownerId, owner.id));
78
79 return c.json(repos);
80});
81
82// Get single repository
83api.get("/repos/:owner/:name", async (c) => {
84 const { owner: ownerName, name } = c.req.param();
85 const [owner] = await db
86 .select()
87 .from(users)
88 .where(eq(users.username, ownerName));
89 if (!owner) return c.json({ error: "Not found" }, 404);
90
91 const [repo] = await db
92 .select()
93 .from(repositories)
94 .where(
95 and(eq(repositories.ownerId, owner.id), eq(repositories.name, name))
96 );
97 if (!repo) return c.json({ error: "Not found" }, 404);
98
99 return c.json(repo);
100});
101
102// Quick-setup: create user + repo in one call (dev convenience)
103api.post("/setup", async (c) => {
104 const body = await c.req.json<{
105 username: string;
106 email: string;
107 repoName: string;
108 description?: string;
109 }>();
110
111 // Upsert user
112 let [user] = await db
113 .select()
114 .from(users)
115 .where(eq(users.username, body.username));
116
117 if (!user) {
118 [user] = await db
119 .insert(users)
120 .values({
121 username: body.username,
122 email: body.email,
123 passwordHash: await hashPassword("changeme"),
124 })
125 .returning();
126 }
127
128 // Create repo if not exists
129 if (!(await repoExists(body.username, body.repoName))) {
130 const diskPath = await initBareRepo(body.username, body.repoName);
131 await db.insert(repositories).values({
132 name: body.repoName,
133 ownerId: user.id,
134 description: body.description || null,
135 diskPath,
136 });
137 }
138
139 return c.json({ user: user.username, repo: body.repoName, status: "ready" });
140});
141
142export default api;
Addedsrc/routes/auth.tsx+326−0View fileUnifiedSplit
1/**
2 * Auth routes — register, login, logout (web + API).
3 */
4
5import { Hono } from "hono";
6import { setCookie, deleteCookie } from "hono/cookie";
7import { eq } from "drizzle-orm";
8import { db } from "../db";
9import { users, sessions } from "../db/schema";
10import {
11 hashPassword,
12 verifyPassword,
13 generateSessionToken,
14 sessionCookieOptions,
15 sessionExpiry,
16} from "../lib/auth";
17import { Layout } from "../views/layout";
18import type { AuthEnv } from "../middleware/auth";
19
20const auth = new Hono<AuthEnv>();
21
22// --- Web UI ---
23
24auth.get("/register", (c) => {
25 const error = c.req.query("error");
26 return c.html(
27 <Layout title="Register">
28 <div class="auth-container">
29 <h2>Create account</h2>
30 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
31 <form method="POST" action="/register">
32 <div class="form-group">
33 <label for="username">Username</label>
34 <input
35 type="text"
36 id="username"
37 name="username"
38 required
39 pattern="^[a-zA-Z0-9_-]+$"
40 minLength={2}
41 maxLength={39}
42 placeholder="your-username"
43 autocomplete="username"
44 />
45 </div>
46 <div class="form-group">
47 <label for="email">Email</label>
48 <input
49 type="email"
50 id="email"
51 name="email"
52 required
53 placeholder="you@example.com"
54 autocomplete="email"
55 />
56 </div>
57 <div class="form-group">
58 <label for="password">Password</label>
59 <input
60 type="password"
61 id="password"
62 name="password"
63 required
64 minLength={8}
65 placeholder="Min 8 characters"
66 autocomplete="new-password"
67 />
68 </div>
69 <button type="submit" class="btn btn-primary">
70 Create account
71 </button>
72 </form>
73 <p class="auth-switch">
74 Already have an account? <a href="/login">Sign in</a>
75 </p>
76 </div>
77 </Layout>
78 );
79});
80
81auth.post("/register", async (c) => {
82 const body = await c.req.parseBody();
83 const username = String(body.username || "").trim();
84 const email = String(body.email || "").trim();
85 const password = String(body.password || "");
86
87 if (!username || !email || !password) {
88 return c.redirect("/register?error=All+fields+are+required");
89 }
90
91 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
92 return c.redirect(
93 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
94 );
95 }
96
97 if (password.length < 8) {
98 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
99 }
100
101 // Check existing
102 const [existingUser] = await db
103 .select()
104 .from(users)
105 .where(eq(users.username, username))
106 .limit(1);
107 if (existingUser) {
108 return c.redirect("/register?error=Username+already+taken");
109 }
110
111 const [existingEmail] = await db
112 .select()
113 .from(users)
114 .where(eq(users.email, email))
115 .limit(1);
116 if (existingEmail) {
117 return c.redirect("/register?error=Email+already+registered");
118 }
119
120 const passwordHash = await hashPassword(password);
121
122 const [user] = await db
123 .insert(users)
124 .values({ username, email, passwordHash })
125 .returning();
126
127 // Create session
128 const token = generateSessionToken();
129 await db.insert(sessions).values({
130 userId: user.id,
131 token,
132 expiresAt: sessionExpiry(),
133 });
134
135 setCookie(c, "session", token, sessionCookieOptions());
136
137 const redirect = c.req.query("redirect") || "/";
138 return c.redirect(redirect);
139});
140
141auth.get("/login", (c) => {
142 const error = c.req.query("error");
143 const redirect = c.req.query("redirect") || "";
144 return c.html(
145 <Layout title="Sign in">
146 <div class="auth-container">
147 <h2>Sign in</h2>
148 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
149 <form
150 method="POST"
151 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
152 >
153 <div class="form-group">
154 <label for="username">Username or email</label>
155 <input
156 type="text"
157 id="username"
158 name="username"
159 required
160 placeholder="username or email"
161 autocomplete="username"
162 />
163 </div>
164 <div class="form-group">
165 <label for="password">Password</label>
166 <input
167 type="password"
168 id="password"
169 name="password"
170 required
171 placeholder="Password"
172 autocomplete="current-password"
173 />
174 </div>
175 <button type="submit" class="btn btn-primary">
176 Sign in
177 </button>
178 </form>
179 <p class="auth-switch">
180 New to gluecron? <a href="/register">Create an account</a>
181 </p>
182 </div>
183 </Layout>
184 );
185});
186
187auth.post("/login", async (c) => {
188 const body = await c.req.parseBody();
189 const identifier = String(body.username || "").trim();
190 const password = String(body.password || "");
191 const redirect = c.req.query("redirect") || "/";
192
193 if (!identifier || !password) {
194 return c.redirect("/login?error=All+fields+are+required");
195 }
196
197 // Find user by username or email
198 const isEmail = identifier.includes("@");
199 const [user] = await db
200 .select()
201 .from(users)
202 .where(
203 isEmail
204 ? eq(users.email, identifier)
205 : eq(users.username, identifier)
206 )
207 .limit(1);
208
209 if (!user) {
210 return c.redirect("/login?error=Invalid+credentials");
211 }
212
213 const valid = await verifyPassword(password, user.passwordHash);
214 if (!valid) {
215 return c.redirect("/login?error=Invalid+credentials");
216 }
217
218 const token = generateSessionToken();
219 await db.insert(sessions).values({
220 userId: user.id,
221 token,
222 expiresAt: sessionExpiry(),
223 });
224
225 setCookie(c, "session", token, sessionCookieOptions());
226 return c.redirect(redirect);
227});
228
229auth.get("/logout", async (c) => {
230 deleteCookie(c, "session", { path: "/" });
231 return c.redirect("/");
232});
233
234// --- API ---
235
236auth.post("/api/auth/register", async (c) => {
237 const body = await c.req.json<{
238 username: string;
239 email: string;
240 password: string;
241 }>();
242
243 if (!body.username || !body.email || !body.password) {
244 return c.json({ error: "username, email, and password are required" }, 400);
245 }
246
247 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
248 return c.json({ error: "Invalid username" }, 400);
249 }
250
251 if (body.password.length < 8) {
252 return c.json({ error: "Password must be at least 8 characters" }, 400);
253 }
254
255 const [existing] = await db
256 .select()
257 .from(users)
258 .where(eq(users.username, body.username))
259 .limit(1);
260 if (existing) {
261 return c.json({ error: "Username already taken" }, 409);
262 }
263
264 const passwordHash = await hashPassword(body.password);
265 const [user] = await db
266 .insert(users)
267 .values({
268 username: body.username,
269 email: body.email,
270 passwordHash,
271 })
272 .returning();
273
274 const token = generateSessionToken();
275 await db.insert(sessions).values({
276 userId: user.id,
277 token,
278 expiresAt: sessionExpiry(),
279 });
280
281 return c.json(
282 {
283 user: { id: user.id, username: user.username, email: user.email },
284 token,
285 },
286 201
287 );
288});
289
290auth.post("/api/auth/login", async (c) => {
291 const body = await c.req.json<{ username: string; password: string }>();
292
293 if (!body.username || !body.password) {
294 return c.json({ error: "username and password are required" }, 400);
295 }
296
297 const isEmail = body.username.includes("@");
298 const [user] = await db
299 .select()
300 .from(users)
301 .where(
302 isEmail
303 ? eq(users.email, body.username)
304 : eq(users.username, body.username)
305 )
306 .limit(1);
307
308 if (!user) return c.json({ error: "Invalid credentials" }, 401);
309
310 const valid = await verifyPassword(body.password, user.passwordHash);
311 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
312
313 const token = generateSessionToken();
314 await db.insert(sessions).values({
315 userId: user.id,
316 token,
317 expiresAt: sessionExpiry(),
318 });
319
320 return c.json({
321 user: { id: user.id, username: user.username, email: user.email },
322 token,
323 });
324});
325
326export default auth;
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/contributors.tsx+142−0View fileUnifiedSplit
1/**
2 * Contributors page — who contributed to this repo, commit counts.
3 */
4
5import { Hono } from "hono";
6import { Layout } from "../views/layout";
7import { RepoHeader, RepoNav } from "../views/components";
8import { getRepoPath, repoExists, getDefaultBranch } from "../git/repository";
9import { softAuth } from "../middleware/auth";
10import type { AuthEnv } from "../middleware/auth";
11
12const contributors = new Hono<AuthEnv>();
13
14contributors.use("*", softAuth);
15
16interface Contributor {
17 name: string;
18 email: string;
19 commits: number;
20 additions: number;
21 deletions: number;
22}
23
24contributors.get("/:owner/:repo/contributors", async (c) => {
25 const { owner, repo } = c.req.param();
26 const user = c.get("user");
27
28 if (!(await repoExists(owner, repo))) return c.notFound();
29
30 const ref = (await getDefaultBranch(owner, repo)) || "main";
31 const repoDir = getRepoPath(owner, repo);
32
33 // Get shortlog for commit counts
34 const shortlogProc = Bun.spawn(
35 ["git", "shortlog", "-sne", ref],
36 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
37 );
38 const shortlogOut = await new Response(shortlogProc.stdout).text();
39 await shortlogProc.exited;
40
41 const contribs: Contributor[] = shortlogOut
42 .trim()
43 .split("\n")
44 .filter(Boolean)
45 .map((line) => {
46 const match = line.trim().match(/^(\d+)\t(.+?)\s+<(.+?)>$/);
47 if (!match) return null;
48 return {
49 name: match[2],
50 email: match[3],
51 commits: parseInt(match[1], 10),
52 additions: 0,
53 deletions: 0,
54 };
55 })
56 .filter((c): c is Contributor => c !== null)
57 .sort((a, b) => b.commits - a.commits);
58
59 // Get recent commit activity (last 52 weeks)
60 const activityProc = Bun.spawn(
61 [
62 "git",
63 "log",
64 "--format=%aI",
65 "--since=1 year ago",
66 ref,
67 ],
68 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
69 );
70 const activityOut = await new Response(activityProc.stdout).text();
71 await activityProc.exited;
72
73 // Build weekly commit counts
74 const weekCounts: number[] = new Array(52).fill(0);
75 const now = Date.now();
76 for (const line of activityOut.trim().split("\n").filter(Boolean)) {
77 const date = new Date(line);
78 const weeksAgo = Math.floor(
79 (now - date.getTime()) / (7 * 24 * 60 * 60 * 1000)
80 );
81 if (weeksAgo >= 0 && weeksAgo < 52) {
82 weekCounts[51 - weeksAgo]++;
83 }
84 }
85
86 const maxWeek = Math.max(...weekCounts, 1);
87
88 return c.html(
89 <Layout title={`Contributors — ${owner}/${repo}`} user={user}>
90 <RepoHeader owner={owner} repo={repo} />
91 <RepoNav owner={owner} repo={repo} active="code" />
92 <h2 style="margin-bottom: 16px">Contributors</h2>
93
94 <div
95 style="margin-bottom: 24px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px"
96 >
97 <div style="font-size: 13px; color: var(--text-muted); margin-bottom: 8px">
98 Commit activity — last year
99 </div>
100 <div style="display: flex; gap: 2px; align-items: flex-end; height: 60px">
101 {weekCounts.map((count) => (
102 <div
103 style={`flex: 1; background: var(--green); opacity: ${count === 0 ? "0.1" : Math.max(0.3, count / maxWeek).toFixed(2)}; height: ${count === 0 ? "2px" : Math.max(4, (count / maxWeek) * 60).toFixed(0) + "px"}; border-radius: 1px;`}
104 title={`${count} commits`}
105 />
106 ))}
107 </div>
108 </div>
109
110 <div class="issue-list">
111 {contribs.map((contrib, i) => (
112 <div class="issue-item">
113 <div style="display: flex; align-items: center; gap: 12px; flex: 1">
114 <div class="user-avatar" style="width: 36px; height: 36px; font-size: 16px; flex-shrink: 0">
115 {contrib.name[0].toUpperCase()}
116 </div>
117 <div>
118 <div style="font-weight: 600; font-size: 14px">
119 {contrib.name}
120 </div>
121 <div style="font-size: 12px; color: var(--text-muted)">
122 {contrib.email}
123 </div>
124 </div>
125 </div>
126 <div style="text-align: right">
127 <span style="font-weight: 600; font-size: 14px">
128 {contrib.commits}
129 </span>
130 <span style="color: var(--text-muted); font-size: 13px">
131 {" "}
132 commit{contrib.commits !== 1 ? "s" : ""}
133 </span>
134 </div>
135 </div>
136 ))}
137 </div>
138 </Layout>
139 );
140});
141
142export default contributors;
Addedsrc/routes/editor.tsx+331−0View fileUnifiedSplit
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/explore.tsx+173−0View fileUnifiedSplit
1/**
2 * Explore page — discover public repositories, search, trending.
3 */
4
5import { Hono } from "hono";
6import { eq, desc, sql, like, and } from "drizzle-orm";
7import { db } from "../db";
8import { repositories, users, repoTopics } from "../db/schema";
9import { Layout } from "../views/layout";
10import { RepoCard } from "../views/components";
11import { softAuth } from "../middleware/auth";
12import type { AuthEnv } from "../middleware/auth";
13
14const explore = new Hono<AuthEnv>();
15
16explore.use("*", softAuth);
17
18explore.get("/explore", async (c) => {
19 const user = c.get("user");
20 const q = c.req.query("q") || "";
21 const sort = c.req.query("sort") || "recent";
22 const topic = c.req.query("topic") || "";
23
24 let repoList: Array<{
25 repo: typeof repositories.$inferSelect;
26 ownerName: string;
27 }> = [];
28
29 if (q.trim()) {
30 // Search repos
31 const results = await db
32 .select({
33 repo: repositories,
34 ownerName: users.username,
35 })
36 .from(repositories)
37 .innerJoin(users, eq(repositories.ownerId, users.id))
38 .where(
39 and(
40 eq(repositories.isPrivate, false),
41 sql`(${repositories.name} ILIKE ${'%' + q + '%'} OR ${repositories.description} ILIKE ${'%' + q + '%'})`
42 )
43 )
44 .orderBy(desc(repositories.starCount))
45 .limit(50);
46
47 repoList = results.map((r) => ({
48 repo: r.repo,
49 ownerName: r.ownerName,
50 }));
51 } else if (topic) {
52 // Filter by topic
53 const results = await db
54 .select({
55 repo: repositories,
56 ownerName: users.username,
57 })
58 .from(repositories)
59 .innerJoin(users, eq(repositories.ownerId, users.id))
60 .innerJoin(repoTopics, eq(repoTopics.repositoryId, repositories.id))
61 .where(
62 and(
63 eq(repositories.isPrivate, false),
64 eq(repoTopics.topic, topic.toLowerCase())
65 )
66 )
67 .orderBy(desc(repositories.starCount))
68 .limit(50);
69
70 repoList = results.map((r) => ({
71 repo: r.repo,
72 ownerName: r.ownerName,
73 }));
74 } else {
75 // Default: recent or popular
76 const orderBy =
77 sort === "stars"
78 ? desc(repositories.starCount)
79 : sort === "forks"
80 ? desc(repositories.forkCount)
81 : desc(repositories.createdAt);
82
83 const results = await db
84 .select({
85 repo: repositories,
86 ownerName: users.username,
87 })
88 .from(repositories)
89 .innerJoin(users, eq(repositories.ownerId, users.id))
90 .where(eq(repositories.isPrivate, false))
91 .orderBy(orderBy)
92 .limit(50);
93
94 repoList = results.map((r) => ({
95 repo: r.repo,
96 ownerName: r.ownerName,
97 }));
98 }
99
100 return c.html(
101 <Layout title="Explore" user={user}>
102 <h2 style="margin-bottom: 16px">Explore repositories</h2>
103 <div style="display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; align-items: center">
104 <form
105 method="GET"
106 action="/explore"
107 style="display: flex; gap: 8px; flex: 1; min-width: 250px"
108 >
109 <input
110 type="text"
111 name="q"
112 value={q}
113 placeholder="Search repositories..."
114 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
115 />
116 <button type="submit" class="btn btn-primary">
117 Search
118 </button>
119 </form>
120 <div style="display: flex; gap: 8px">
121 <a
122 href="/explore?sort=recent"
123 class={`btn btn-sm ${sort === "recent" && !q ? "btn-primary" : ""}`}
124 >
125 Recent
126 </a>
127 <a
128 href="/explore?sort=stars"
129 class={`btn btn-sm ${sort === "stars" ? "btn-primary" : ""}`}
130 >
131 Most stars
132 </a>
133 <a
134 href="/explore?sort=forks"
135 class={`btn btn-sm ${sort === "forks" ? "btn-primary" : ""}`}
136 >
137 Most forks
138 </a>
139 </div>
140 </div>
141 {topic && (
142 <div style="margin-bottom: 16px">
143 <span class="badge" style="font-size: 14px; padding: 4px 12px">
144 Topic: {topic}
145 </span>
146 <a
147 href="/explore"
148 style="margin-left: 8px; font-size: 13px; color: var(--text-muted)"
149 >
150 Clear
151 </a>
152 </div>
153 )}
154 {repoList.length === 0 ? (
155 <div class="empty-state">
156 <p>
157 {q
158 ? `No repositories matching "${q}"`
159 : "No public repositories yet."}
160 </p>
161 </div>
162 ) : (
163 <div class="card-grid">
164 {repoList.map(({ repo, ownerName }) => (
165 <RepoCard repo={repo} ownerName={ownerName} />
166 ))}
167 </div>
168 )}
169 </Layout>
170 );
171});
172
173export default explore;
Addedsrc/routes/fork.ts+103−0View fileUnifiedSplit
1/**
2 * Fork route — copy a repository into your account.
3 */
4
5import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
7import { db } from "../db";
8import { repositories, users, activityFeed } from "../db/schema";
9import { softAuth, requireAuth } from "../middleware/auth";
10import type { AuthEnv } from "../middleware/auth";
11import { getRepoPath, repoExists, initBareRepo } from "../git/repository";
12import { config } from "../lib/config";
13import { join } from "path";
14
15const fork = new Hono<AuthEnv>();
16
17fork.use("*", softAuth);
18
19// Fork a repository
20fork.post("/:owner/:repo/fork", requireAuth, async (c) => {
21 const { owner: ownerName, repo: repoName } = c.req.param();
22 const user = c.get("user")!;
23
24 // Can't fork your own repo
25 if (ownerName === user.username) {
26 return c.redirect(`/${ownerName}/${repoName}`);
27 }
28
29 // Check source exists
30 if (!(await repoExists(ownerName, repoName))) {
31 return c.redirect(`/${ownerName}/${repoName}`);
32 }
33
34 // Check if already forked
35 if (await repoExists(user.username, repoName)) {
36 return c.redirect(`/${user.username}/${repoName}`);
37 }
38
39 // Get source repo from DB
40 const [sourceOwner] = await db
41 .select()
42 .from(users)
43 .where(eq(users.username, ownerName))
44 .limit(1);
45 if (!sourceOwner) return c.redirect(`/${ownerName}/${repoName}`);
46
47 const [sourceRepo] = await db
48 .select()
49 .from(repositories)
50 .where(
51 and(
52 eq(repositories.ownerId, sourceOwner.id),
53 eq(repositories.name, repoName)
54 )
55 )
56 .limit(1);
57 if (!sourceRepo) return c.redirect(`/${ownerName}/${repoName}`);
58
59 // Clone the bare repo
60 const sourcePath = getRepoPath(ownerName, repoName);
61 const destPath = join(config.gitReposPath, user.username, `${repoName}.git`);
62
63 const proc = Bun.spawn(["git", "clone", "--bare", sourcePath, destPath], {
64 stdout: "pipe",
65 stderr: "pipe",
66 });
67 await proc.exited;
68
69 // Insert into DB
70 await db.insert(repositories).values({
71 name: repoName,
72 ownerId: user.id,
73 description: sourceRepo.description
74 ? `Fork of ${ownerName}/${repoName}${sourceRepo.description}`
75 : `Fork of ${ownerName}/${repoName}`,
76 isPrivate: false,
77 defaultBranch: sourceRepo.defaultBranch,
78 diskPath: destPath,
79 forkedFromId: sourceRepo.id,
80 });
81
82 // Update fork count
83 await db
84 .update(repositories)
85 .set({ forkCount: sourceRepo.forkCount + 1 })
86 .where(eq(repositories.id, sourceRepo.id));
87
88 // Log activity
89 try {
90 await db.insert(activityFeed).values({
91 repositoryId: sourceRepo.id,
92 userId: user.id,
93 action: "fork",
94 metadata: JSON.stringify({ forkOwner: user.username }),
95 });
96 } catch {
97 // best effort
98 }
99
100 return c.redirect(`/${user.username}/${repoName}`);
101});
102
103export default fork;
Addedsrc/routes/git.ts+117−0View fileUnifiedSplit
1/**
2 * Git Smart HTTP routes.
3 *
4 * Mounted at /:owner/:repo.git/ — handles clone, fetch, push.
5 */
6
7import { Hono } from "hono";
8import { getInfoRefs, serviceRpc } from "../git/protocol";
9import { repoExists } from "../git/repository";
10import { onPostReceive } from "../hooks/post-receive";
11
12const git = new Hono();
13
14// Discovery: GET /:owner/:repo.git/info/refs?service=...
15git.get("/:owner/:repo.git/info/refs", async (c) => {
16 const { owner, repo } = c.req.param();
17 const service = c.req.query("service");
18
19 if (!service || !["git-upload-pack", "git-receive-pack"].includes(service)) {
20 return c.text("Invalid service", 400);
21 }
22
23 if (!(await repoExists(owner, repo))) {
24 return c.text("Repository not found", 404);
25 }
26
27 return getInfoRefs(owner, repo, service);
28});
29
30// GET /:owner/:repo.git/HEAD
31git.get("/:owner/:repo.git/HEAD", async (c) => {
32 const { owner, repo } = c.req.param();
33 if (!(await repoExists(owner, repo))) {
34 return c.text("Repository not found", 404);
35 }
36 const path = `repos/${owner}/${repo}.git/HEAD`;
37 const file = Bun.file(path);
38 if (!(await file.exists())) return c.text("Not found", 404);
39 return c.text(await file.text());
40});
41
42// Upload pack (clone/fetch)
43git.post("/:owner/:repo.git/git-upload-pack", async (c) => {
44 const { owner, repo } = c.req.param();
45 if (!(await repoExists(owner, repo))) {
46 return c.text("Repository not found", 404);
47 }
48 return serviceRpc(owner, repo, "git-upload-pack", c.req.raw.body);
49});
50
51// Receive pack (push)
52git.post("/:owner/:repo.git/git-receive-pack", async (c) => {
53 const { owner, repo } = c.req.param();
54 if (!(await repoExists(owner, repo))) {
55 return c.text("Repository not found", 404);
56 }
57
58 // Parse the incoming refs from the request body before passing to git
59 const bodyBuffer = await c.req.arrayBuffer();
60 const response = await serviceRpc(
61 owner,
62 repo,
63 "git-receive-pack",
64 bodyBuffer
65 );
66
67 // Fire post-receive hooks asynchronously (don't block response)
68 // We parse updated refs from the pkt-line protocol in the request
69 const refs = parseReceivePackRefs(new Uint8Array(bodyBuffer));
70 if (refs.length > 0) {
71 onPostReceive(owner, repo, refs).catch((err) =>
72 console.error("[post-receive] hook error:", err)
73 );
74 }
75
76 return response;
77});
78
79/**
80 * Parse ref updates from git-receive-pack request body.
81 * Format: <old-sha> <new-sha> <ref-name>
82 */
83function parseReceivePackRefs(
84 data: Uint8Array
85): Array<{ oldSha: string; newSha: string; refName: string }> {
86 const text = new TextDecoder().decode(data);
87 const refs: Array<{ oldSha: string; newSha: string; refName: string }> = [];
88 // Pkt-line format: 4-hex-length followed by data
89 let offset = 0;
90 while (offset < text.length) {
91 const lenHex = text.slice(offset, offset + 4);
92 const len = parseInt(lenHex, 16);
93 if (len === 0) {
94 offset += 4;
95 break; // flush packet
96 }
97 if (len < 4) break;
98 const line = text.slice(offset + 4, offset + len);
99 offset += len;
100
101 // Match: <old-sha> <new-sha> <ref-name>\0<capabilities>
102 // or: <old-sha> <new-sha> <ref-name>
103 const match = line.match(
104 /^([0-9a-f]{40}) ([0-9a-f]{40}) ([^\0\n]+)/
105 );
106 if (match) {
107 refs.push({
108 oldSha: match[1],
109 newSha: match[2],
110 refName: match[3].trim(),
111 });
112 }
113 }
114 return refs;
115}
116
117export default git;
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/pulls.tsx+681−0View fileUnifiedSplit
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)">&larr;</span>
217 <select name="head" style="padding: 6px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px">
218 {branches
219 .filter((b) => b !== defaultBase)
220 .concat(defaultBase === branches[0] ? [] : [branches[0]])
221 .map((b) => (
222 <option value={b}>{b}</option>
223 ))}
224 </select>
225 </div>
226 <div class="form-group">
227 <input
228 type="text"
229 name="title"
230 required
231 placeholder="Title"
232 style="font-size: 16px; padding: 10px 14px"
233 />
234 </div>
235 <div class="form-group">
236 <textarea
237 name="body"
238 rows={8}
239 placeholder="Description (Markdown supported)"
240 style="font-family: var(--font-mono); font-size: 13px"
241 />
242 </div>
243 <button type="submit" class="btn btn-primary">
244 Create pull request
245 </button>
246 </form>
247 </div>
248 </Layout>
249 );
250 }
251);
252
253// Create PR
254pulls.post(
255 "/:owner/:repo/pulls/new",
256 softAuth,
257 requireAuth,
258 async (c) => {
259 const { owner: ownerName, repo: repoName } = c.req.param();
260 const user = c.get("user")!;
261 const body = await c.req.parseBody();
262 const title = String(body.title || "").trim();
263 const prBody = String(body.body || "").trim();
264 const baseBranch = String(body.base || "main");
265 const headBranch = String(body.head || "");
266
267 if (!title || !headBranch) {
268 return c.redirect(
269 `/${ownerName}/${repoName}/pulls/new?error=Title+and+branches+are+required`
270 );
271 }
272
273 if (baseBranch === headBranch) {
274 return c.redirect(
275 `/${ownerName}/${repoName}/pulls/new?error=Base+and+head+branches+must+be+different`
276 );
277 }
278
279 const resolved = await resolveRepo(ownerName, repoName);
280 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
281
282 const [pr] = await db
283 .insert(pullRequests)
284 .values({
285 repositoryId: resolved.repo.id,
286 authorId: user.id,
287 title,
288 body: prBody || null,
289 baseBranch,
290 headBranch,
291 })
292 .returning();
293
294 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
295 }
296);
297
298// View single PR
299pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
300 const { owner: ownerName, repo: repoName } = c.req.param();
301 const prNum = parseInt(c.req.param("number"), 10);
302 const user = c.get("user");
303 const tab = c.req.query("tab") || "conversation";
304
305 const resolved = await resolveRepo(ownerName, repoName);
306 if (!resolved) return c.notFound();
307
308 const [pr] = await db
309 .select()
310 .from(pullRequests)
311 .where(
312 and(
313 eq(pullRequests.repositoryId, resolved.repo.id),
314 eq(pullRequests.number, prNum)
315 )
316 )
317 .limit(1);
318
319 if (!pr) return c.notFound();
320
321 const [author] = await db
322 .select()
323 .from(users)
324 .where(eq(users.id, pr.authorId))
325 .limit(1);
326
327 const comments = await db
328 .select({
329 comment: prComments,
330 author: { username: users.username },
331 })
332 .from(prComments)
333 .innerJoin(users, eq(prComments.authorId, users.id))
334 .where(eq(prComments.pullRequestId, pr.id))
335 .orderBy(asc(prComments.createdAt));
336
337 const canManage =
338 user &&
339 (user.id === resolved.owner.id || user.id === pr.authorId);
340
341 // Get diff for "Files changed" tab
342 let diffRaw = "";
343 let diffFiles: GitDiffFile[] = [];
344 if (tab === "files") {
345 const repoDir = getRepoPath(ownerName, repoName);
346 const proc = Bun.spawn(
347 ["git", "diff", `${pr.baseBranch}...${pr.headBranch}`],
348 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
349 );
350 diffRaw = await new Response(proc.stdout).text();
351 await proc.exited;
352
353 const statProc = Bun.spawn(
354 ["git", "diff", "--numstat", `${pr.baseBranch}...${pr.headBranch}`],
355 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
356 );
357 const stat = await new Response(statProc.stdout).text();
358 await statProc.exited;
359
360 diffFiles = stat
361 .trim()
362 .split("\n")
363 .filter(Boolean)
364 .map((line) => {
365 const [add, del, filePath] = line.split("\t");
366 return {
367 path: filePath,
368 status: "modified",
369 additions: add === "-" ? 0 : parseInt(add, 10),
370 deletions: del === "-" ? 0 : parseInt(del, 10),
371 patch: "",
372 };
373 });
374 }
375
376 return c.html(
377 <Layout
378 title={`${pr.title} #${pr.number} — ${ownerName}/${repoName}`}
379 user={user}
380 >
381 <RepoHeader owner={ownerName} repo={repoName} />
382 <PrNav owner={ownerName} repo={repoName} active="pulls" />
383 <div class="issue-detail">
384 <h2>
385 {pr.title}{" "}
386 <span style="color: var(--text-muted); font-weight: 400">
387 #{pr.number}
388 </span>
389 </h2>
390 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
391 <span
392 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
393 >
394 {pr.state === "open"
395 ? "\u25CB Open"
396 : pr.state === "merged"
397 ? "\u2B8C Merged"
398 : "\u2713 Closed"}
399 </span>
400 <span style="color: var(--text-muted); font-size: 14px">
401 <strong style="color: var(--text)">
402 {author?.username}
403 </strong>{" "}
404 wants to merge <code>{pr.headBranch}</code> into{" "}
405 <code>{pr.baseBranch}</code>
406 </span>
407 </div>
408
409 <div class="issue-tabs" style="margin-bottom: 20px">
410 <a
411 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
412 class={tab === "conversation" ? "active" : ""}
413 >
414 Conversation
415 </a>
416 <a
417 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
418 class={tab === "files" ? "active" : ""}
419 >
420 Files changed
421 </a>
422 </div>
423
424 {tab === "files" ? (
425 <DiffView raw={diffRaw} files={diffFiles} />
426 ) : (
427 <>
428 {pr.body && (
429 <div class="issue-comment-box">
430 <div class="comment-header">
431 <strong>{author?.username}</strong> commented{" "}
432 {formatRelative(pr.createdAt)}
433 </div>
434 <div class="markdown-body">
435 {html([renderMarkdown(pr.body)] as unknown as TemplateStringsArray)}
436 </div>
437 </div>
438 )}
439
440 {comments.map(({ comment, author: commentAuthor }) => (
441 <div
442 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
443 >
444 <div class="comment-header">
445 <strong>{commentAuthor.username}</strong>
446 {comment.isAiReview && (
447 <span class="badge" style="margin-left: 8px; background: rgba(31, 111, 235, 0.15); color: var(--text-link); border-color: var(--accent)">
448 AI Review
449 </span>
450 )}
451 {" "}
452 commented {formatRelative(comment.createdAt)}
453 {comment.filePath && (
454 <span style="margin-left: 8px; font-family: var(--font-mono); font-size: 11px">
455 {comment.filePath}
456 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
457 </span>
458 )}
459 </div>
460 <div class="markdown-body">
461 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
462 </div>
463 </div>
464 ))}
465
466 {user && pr.state === "open" && (
467 <div style="margin-top: 20px">
468 <form
469 method="POST"
470 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
471 >
472 <div class="form-group">
473 <textarea
474 name="body"
475 rows={6}
476 required
477 placeholder="Leave a comment... (Markdown supported)"
478 style="font-family: var(--font-mono); font-size: 13px"
479 />
480 </div>
481 <div style="display: flex; gap: 8px">
482 <button type="submit" class="btn btn-primary">
483 Comment
484 </button>
485 {canManage && (
486 <>
487 <button
488 type="submit"
489 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
490 class="btn"
491 style="background: rgba(63, 185, 80, 0.15); border-color: var(--green); color: var(--green)"
492 >
493 Merge pull request
494 </button>
495 <button
496 type="submit"
497 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
498 class="btn btn-danger"
499 >
500 Close
501 </button>
502 </>
503 )}
504 </div>
505 </form>
506 </div>
507 )}
508 </>
509 )}
510 </div>
511 </Layout>
512 );
513});
514
515// Add comment to PR
516pulls.post(
517 "/:owner/:repo/pulls/:number/comment",
518 softAuth,
519 requireAuth,
520 async (c) => {
521 const { owner: ownerName, repo: repoName } = c.req.param();
522 const prNum = parseInt(c.req.param("number"), 10);
523 const user = c.get("user")!;
524 const body = await c.req.parseBody();
525 const commentBody = String(body.body || "").trim();
526
527 if (!commentBody) {
528 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
529 }
530
531 const resolved = await resolveRepo(ownerName, repoName);
532 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
533
534 const [pr] = await db
535 .select()
536 .from(pullRequests)
537 .where(
538 and(
539 eq(pullRequests.repositoryId, resolved.repo.id),
540 eq(pullRequests.number, prNum)
541 )
542 )
543 .limit(1);
544
545 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
546
547 await db.insert(prComments).values({
548 pullRequestId: pr.id,
549 authorId: user.id,
550 body: commentBody,
551 });
552
553 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
554 }
555);
556
557// Merge PR
558pulls.post(
559 "/:owner/:repo/pulls/:number/merge",
560 softAuth,
561 requireAuth,
562 async (c) => {
563 const { owner: ownerName, repo: repoName } = c.req.param();
564 const prNum = parseInt(c.req.param("number"), 10);
565 const user = c.get("user")!;
566
567 const resolved = await resolveRepo(ownerName, repoName);
568 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
569
570 const [pr] = await db
571 .select()
572 .from(pullRequests)
573 .where(
574 and(
575 eq(pullRequests.repositoryId, resolved.repo.id),
576 eq(pullRequests.number, prNum)
577 )
578 )
579 .limit(1);
580
581 if (!pr || pr.state !== "open") {
582 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
583 }
584
585 // Perform git merge
586 const repoDir = getRepoPath(ownerName, repoName);
587 const mergeProc = Bun.spawn(
588 [
589 "git",
590 "merge-base",
591 "--is-ancestor",
592 pr.baseBranch,
593 pr.headBranch,
594 ],
595 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
596 );
597 await mergeProc.exited;
598
599 // Use git update-ref for fast-forward or create merge commit
600 const ffProc = Bun.spawn(
601 [
602 "git",
603 "update-ref",
604 `refs/heads/${pr.baseBranch}`,
605 `refs/heads/${pr.headBranch}`,
606 ],
607 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
608 );
609 const ffExit = await ffProc.exited;
610
611 if (ffExit !== 0) {
612 // Fallback: try creating a merge commit via a temporary checkout
613 // For now, just report the error
614 return c.redirect(
615 `/${ownerName}/${repoName}/pulls/${prNum}?error=merge_conflict`
616 );
617 }
618
619 await db
620 .update(pullRequests)
621 .set({
622 state: "merged",
623 mergedAt: new Date(),
624 mergedBy: user.id,
625 updatedAt: new Date(),
626 })
627 .where(eq(pullRequests.id, pr.id));
628
629 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
630 }
631);
632
633// Close PR
634pulls.post(
635 "/:owner/:repo/pulls/:number/close",
636 softAuth,
637 requireAuth,
638 async (c) => {
639 const { owner: ownerName, repo: repoName } = c.req.param();
640 const prNum = parseInt(c.req.param("number"), 10);
641
642 const resolved = await resolveRepo(ownerName, repoName);
643 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
644
645 await db
646 .update(pullRequests)
647 .set({
648 state: "closed",
649 closedAt: new Date(),
650 updatedAt: new Date(),
651 })
652 .where(
653 and(
654 eq(pullRequests.repositoryId, resolved.repo.id),
655 eq(pullRequests.number, prNum)
656 )
657 );
658
659 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
660 }
661);
662
663function formatRelative(date: Date | string): string {
664 const d = typeof date === "string" ? new Date(date) : date;
665 const now = new Date();
666 const diffMs = now.getTime() - d.getTime();
667 const diffMins = Math.floor(diffMs / 60000);
668 if (diffMins < 1) return "just now";
669 if (diffMins < 60) return `${diffMins}m ago`;
670 const diffHours = Math.floor(diffMins / 60);
671 if (diffHours < 24) return `${diffHours}h ago`;
672 const diffDays = Math.floor(diffHours / 24);
673 if (diffDays < 30) return `${diffDays}d ago`;
674 return d.toLocaleDateString("en-US", {
675 month: "short",
676 day: "numeric",
677 year: "numeric",
678 });
679}
680
681export default pulls;
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;
Addedsrc/routes/settings.tsx+309−0View fileUnifiedSplit
1/**
2 * User settings routes — profile, SSH keys.
3 */
4
5import { Hono } from "hono";
6import { eq } from "drizzle-orm";
7import { db } from "../db";
8import { users, sshKeys } from "../db/schema";
9import type { AuthEnv } from "../middleware/auth";
10import { requireAuth } from "../middleware/auth";
11import { Layout } from "../views/layout";
12
13const settings = new Hono<AuthEnv>();
14
15// Auth guard scoped to /settings paths only
16settings.use("/settings/*", requireAuth);
17settings.use("/settings", requireAuth);
18settings.use("/api/user/*", requireAuth);
19
20// Profile settings
21settings.get("/settings", (c) => {
22 const user = c.get("user")!;
23 const success = c.req.query("success");
24 return c.html(
25 <Layout title="Settings">
26 <div class="settings-container">
27 <h2>Profile settings</h2>
28 {success && (
29 <div class="auth-success">
30 {decodeURIComponent(success)}
31 </div>
32 )}
33 <form method="POST" action="/settings/profile">
34 <div class="form-group">
35 <label for="username">Username</label>
36 <input
37 type="text"
38 id="username"
39 value={user.username}
40 disabled
41 class="input-disabled"
42 />
43 </div>
44 <div class="form-group">
45 <label for="display_name">Display name</label>
46 <input
47 type="text"
48 id="display_name"
49 name="display_name"
50 value={user.displayName || ""}
51 placeholder="Your display name"
52 />
53 </div>
54 <div class="form-group">
55 <label for="bio">Bio</label>
56 <textarea
57 id="bio"
58 name="bio"
59 rows={3}
60 placeholder="Tell us about yourself"
61 >
62 {user.bio || ""}
63 </textarea>
64 </div>
65 <div class="form-group">
66 <label for="email">Email</label>
67 <input
68 type="email"
69 id="email"
70 name="email"
71 value={user.email}
72 required
73 />
74 </div>
75 <button type="submit" class="btn btn-primary">
76 Update profile
77 </button>
78 </form>
79 </div>
80 </Layout>
81 );
82});
83
84settings.post("/settings/profile", async (c) => {
85 const user = c.get("user")!;
86 const body = await c.req.parseBody();
87
88 await db
89 .update(users)
90 .set({
91 displayName: String(body.display_name || "").trim() || null,
92 bio: String(body.bio || "").trim() || null,
93 email: String(body.email || "").trim() || user.email,
94 updatedAt: new Date(),
95 })
96 .where(eq(users.id, user.id));
97
98 return c.redirect("/settings?success=Profile+updated");
99});
100
101// SSH Keys page
102settings.get("/settings/keys", async (c) => {
103 const user = c.get("user")!;
104 const success = c.req.query("success");
105 const error = c.req.query("error");
106
107 const keys = await db
108 .select()
109 .from(sshKeys)
110 .where(eq(sshKeys.userId, user.id));
111
112 return c.html(
113 <Layout title="SSH Keys">
114 <div class="settings-container">
115 <h2>SSH Keys</h2>
116 {success && (
117 <div class="auth-success">{decodeURIComponent(success)}</div>
118 )}
119 {error && (
120 <div class="auth-error">{decodeURIComponent(error)}</div>
121 )}
122 <div class="ssh-keys-list">
123 {keys.length === 0 ? (
124 <p style="color: var(--text-muted)">
125 No SSH keys yet. Add one below.
126 </p>
127 ) : (
128 keys.map((key) => (
129 <div class="ssh-key-item">
130 <div>
131 <strong>{key.title}</strong>
132 <div class="ssh-key-meta">
133 <code>{key.fingerprint}</code>
134 <span>
135 Added{" "}
136 {new Date(key.createdAt).toLocaleDateString("en-US", {
137 month: "short",
138 day: "numeric",
139 year: "numeric",
140 })}
141 </span>
142 {key.lastUsedAt && (
143 <span>
144 — Last used{" "}
145 {new Date(key.lastUsedAt).toLocaleDateString()}
146 </span>
147 )}
148 </div>
149 </div>
150 <form method="POST" action={`/settings/keys/${key.id}/delete`}>
151 <button type="submit" class="btn btn-danger btn-sm">
152 Delete
153 </button>
154 </form>
155 </div>
156 ))
157 )}
158 </div>
159
160 <h3 style="margin-top: 24px">Add new SSH key</h3>
161 <form method="POST" action="/settings/keys">
162 <div class="form-group">
163 <label for="title">Title</label>
164 <input
165 type="text"
166 id="title"
167 name="title"
168 required
169 placeholder="e.g. My laptop"
170 />
171 </div>
172 <div class="form-group">
173 <label for="public_key">Public key</label>
174 <textarea
175 id="public_key"
176 name="public_key"
177 rows={4}
178 required
179 placeholder="ssh-ed25519 AAAA... or ssh-rsa AAAA..."
180 style="font-family: var(--font-mono); font-size: 12px"
181 />
182 </div>
183 <button type="submit" class="btn btn-primary">
184 Add SSH key
185 </button>
186 </form>
187 </div>
188 </Layout>
189 );
190});
191
192settings.post("/settings/keys", async (c) => {
193 const user = c.get("user")!;
194 const body = await c.req.parseBody();
195 const title = String(body.title || "").trim();
196 const publicKey = String(body.public_key || "").trim();
197
198 if (!title || !publicKey) {
199 return c.redirect("/settings/keys?error=Title+and+key+are+required");
200 }
201
202 // Basic validation
203 if (
204 !publicKey.startsWith("ssh-rsa ") &&
205 !publicKey.startsWith("ssh-ed25519 ") &&
206 !publicKey.startsWith("ecdsa-sha2-")
207 ) {
208 return c.redirect("/settings/keys?error=Invalid+SSH+public+key+format");
209 }
210
211 // Generate a simple fingerprint (hash of key data)
212 const keyData = publicKey.split(" ")[1] || "";
213 const hashBuffer = await crypto.subtle.digest(
214 "SHA-256",
215 new TextEncoder().encode(keyData)
216 );
217 const fingerprint =
218 "SHA256:" +
219 btoa(String.fromCharCode(...new Uint8Array(hashBuffer)))
220 .replace(/=+$/, "");
221
222 await db.insert(sshKeys).values({
223 userId: user.id,
224 title,
225 fingerprint,
226 publicKey,
227 });
228
229 return c.redirect("/settings/keys?success=SSH+key+added");
230});
231
232settings.post("/settings/keys/:id/delete", async (c) => {
233 const user = c.get("user")!;
234 const keyId = c.req.param("id");
235
236 // Verify ownership
237 const [key] = await db
238 .select()
239 .from(sshKeys)
240 .where(eq(sshKeys.id, keyId))
241 .limit(1);
242
243 if (!key || key.userId !== user.id) {
244 return c.redirect("/settings/keys?error=Key+not+found");
245 }
246
247 await db.delete(sshKeys).where(eq(sshKeys.id, keyId));
248 return c.redirect("/settings/keys?success=SSH+key+deleted");
249});
250
251// SSH Keys API
252settings.get("/api/user/keys", async (c) => {
253 const user = c.get("user")!;
254 const keys = await db
255 .select()
256 .from(sshKeys)
257 .where(eq(sshKeys.userId, user.id));
258 return c.json(keys);
259});
260
261settings.post("/api/user/keys", async (c) => {
262 const user = c.get("user")!;
263 const body = await c.req.json<{ title: string; public_key: string }>();
264
265 if (!body.title || !body.public_key) {
266 return c.json({ error: "title and public_key are required" }, 400);
267 }
268
269 const keyData = body.public_key.split(" ")[1] || "";
270 const hashBuffer = await crypto.subtle.digest(
271 "SHA-256",
272 new TextEncoder().encode(keyData)
273 );
274 const fingerprint =
275 "SHA256:" +
276 btoa(String.fromCharCode(...new Uint8Array(hashBuffer))).replace(/=+$/, "");
277
278 const [key] = await db
279 .insert(sshKeys)
280 .values({
281 userId: user.id,
282 title: body.title,
283 fingerprint,
284 publicKey: body.public_key,
285 })
286 .returning();
287
288 return c.json(key, 201);
289});
290
291settings.delete("/api/user/keys/:id", async (c) => {
292 const user = c.get("user")!;
293 const keyId = c.req.param("id");
294
295 const [key] = await db
296 .select()
297 .from(sshKeys)
298 .where(eq(sshKeys.id, keyId))
299 .limit(1);
300
301 if (!key || key.userId !== user.id) {
302 return c.json({ error: "Key not found" }, 404);
303 }
304
305 await db.delete(sshKeys).where(eq(sshKeys.id, keyId));
306 return c.json({ deleted: true });
307});
308
309export default settings;
Addedsrc/routes/tokens.tsx+209−0View fileUnifiedSplit
1/**
2 * API tokens — personal access tokens for automation.
3 */
4
5import { Hono } from "hono";
6import { eq } from "drizzle-orm";
7import { db } from "../db";
8import { apiTokens } from "../db/schema";
9import { Layout } from "../views/layout";
10import { softAuth, requireAuth } from "../middleware/auth";
11import type { AuthEnv } from "../middleware/auth";
12
13const tokens = new Hono<AuthEnv>();
14
15tokens.use("/settings/tokens*", softAuth, requireAuth);
16tokens.use("/api/user/tokens*", softAuth, requireAuth);
17
18function generateToken(): string {
19 const bytes = crypto.getRandomValues(new Uint8Array(32));
20 return (
21 "glc_" +
22 Array.from(bytes)
23 .map((b) => b.toString(16).padStart(2, "0"))
24 .join("")
25 );
26}
27
28async function hashToken(token: string): Promise<string> {
29 const data = new TextEncoder().encode(token);
30 const hash = await crypto.subtle.digest("SHA-256", data);
31 return Array.from(new Uint8Array(hash))
32 .map((b) => b.toString(16).padStart(2, "0"))
33 .join("");
34}
35
36// Token settings page
37tokens.get("/settings/tokens", async (c) => {
38 const user = c.get("user")!;
39 const success = c.req.query("success");
40 const newToken = c.req.query("new_token");
41
42 const userTokens = await db
43 .select()
44 .from(apiTokens)
45 .where(eq(apiTokens.userId, user.id));
46
47 return c.html(
48 <Layout title="API Tokens" user={user}>
49 <div class="settings-container">
50 <h2>Personal access tokens</h2>
51 {success && (
52 <div class="auth-success" style="margin-top: 12px">
53 {decodeURIComponent(success)}
54 </div>
55 )}
56 {newToken && (
57 <div
58 class="auth-success"
59 style="margin-top: 12px; font-family: var(--font-mono); word-break: break-all"
60 >
61 New token (copy now — it won't be shown again):{" "}
62 <strong>{decodeURIComponent(newToken)}</strong>
63 </div>
64 )}
65 <div style="margin-top: 16px">
66 {userTokens.length === 0 ? (
67 <p style="color: var(--text-muted)">No tokens yet.</p>
68 ) : (
69 userTokens.map((token) => (
70 <div class="ssh-key-item">
71 <div>
72 <strong>{token.name}</strong>
73 <div class="ssh-key-meta">
74 <code>{token.tokenPrefix}...</code>
75 <span style="margin-left: 8px">
76 Scopes: {token.scopes}
77 </span>
78 {token.lastUsedAt && (
79 <span>
80 {" "}
81 | Last used{" "}
82 {new Date(token.lastUsedAt).toLocaleDateString()}
83 </span>
84 )}
85 </div>
86 </div>
87 <form
88 method="POST"
89 action={`/settings/tokens/${token.id}/delete`}
90 >
91 <button type="submit" class="btn btn-danger btn-sm">
92 Revoke
93 </button>
94 </form>
95 </div>
96 ))
97 )}
98 </div>
99
100 <h3 style="margin-top: 24px; margin-bottom: 12px">
101 Generate new token
102 </h3>
103 <form method="POST" action="/settings/tokens">
104 <div class="form-group">
105 <label for="name">Token name</label>
106 <input
107 type="text"
108 id="name"
109 name="name"
110 required
111 placeholder="e.g. CI/CD pipeline"
112 />
113 </div>
114 <div class="form-group">
115 <label>Scopes</label>
116 <div style="display: flex; gap: 16px; flex-wrap: wrap">
117 {["repo", "user", "admin"].map((scope) => (
118 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
119 <input
120 type="checkbox"
121 name="scopes"
122 value={scope}
123 checked={scope === "repo"}
124 />{" "}
125 {scope}
126 </label>
127 ))}
128 </div>
129 </div>
130 <button type="submit" class="btn btn-primary">
131 Generate token
132 </button>
133 </form>
134 </div>
135 </Layout>
136 );
137});
138
139// Create token
140tokens.post("/settings/tokens", async (c) => {
141 const user = c.get("user")!;
142 const body = await c.req.parseBody();
143 const name = String(body.name || "").trim();
144
145 let scopes: string;
146 const rawScopes = body.scopes;
147 if (Array.isArray(rawScopes)) {
148 scopes = rawScopes.join(",");
149 } else {
150 scopes = String(rawScopes || "repo");
151 }
152
153 if (!name) {
154 return c.redirect("/settings/tokens?error=Name+is+required");
155 }
156
157 const token = generateToken();
158 const tokenH = await hashToken(token);
159
160 await db.insert(apiTokens).values({
161 userId: user.id,
162 name,
163 tokenHash: tokenH,
164 tokenPrefix: token.slice(0, 12),
165 scopes,
166 });
167
168 return c.redirect(
169 `/settings/tokens?new_token=${encodeURIComponent(token)}`
170 );
171});
172
173// Delete token
174tokens.post("/settings/tokens/:id/delete", async (c) => {
175 const user = c.get("user")!;
176 const tokenId = c.req.param("id");
177
178 const [token] = await db
179 .select()
180 .from(apiTokens)
181 .where(eq(apiTokens.id, tokenId))
182 .limit(1);
183
184 if (!token || token.userId !== user.id) {
185 return c.redirect("/settings/tokens");
186 }
187
188 await db.delete(apiTokens).where(eq(apiTokens.id, tokenId));
189 return c.redirect("/settings/tokens?success=Token+revoked");
190});
191
192// API endpoint
193tokens.get("/api/user/tokens", async (c) => {
194 const user = c.get("user")!;
195 const userTokens = await db
196 .select({
197 id: apiTokens.id,
198 name: apiTokens.name,
199 tokenPrefix: apiTokens.tokenPrefix,
200 scopes: apiTokens.scopes,
201 lastUsedAt: apiTokens.lastUsedAt,
202 createdAt: apiTokens.createdAt,
203 })
204 .from(apiTokens)
205 .where(eq(apiTokens.userId, user.id));
206 return c.json(userTokens);
207});
208
209export default tokens;
Addedsrc/routes/web.tsx+914−0View fileUnifiedSplit
1/**
2 * Web UI routes — browse repositories, code, commits, diffs.
3 * Now auth-aware with user profiles, repo creation, stars, and syntax highlighting.
4 */
5
6import { Hono } from "hono";
7import { html } from "hono/html";
8import { eq, and, desc } from "drizzle-orm";
9import { db } from "../db";
10import { users, repositories, stars } from "../db/schema";
11import { Layout } from "../views/layout";
12import {
13 RepoHeader,
14 RepoNav,
15 Breadcrumb,
16 FileTable,
17 CommitList,
18 DiffView,
19 RepoCard,
20 BranchSwitcher,
21 HighlightedCode,
22 PlainCode,
23} from "../views/components";
24import {
25 getTree,
26 getBlob,
27 listCommits,
28 getCommit,
29 getCommitFullMessage,
30 getDiff,
31 getReadme,
32 getDefaultBranch,
33 listBranches,
34 repoExists,
35 initBareRepo,
36 getBlame,
37 getRawBlob,
38 searchCode,
39} from "../git/repository";
40import { renderMarkdown, markdownCss } from "../lib/markdown";
41import { highlightCode } from "../lib/highlight";
42import { softAuth, requireAuth } from "../middleware/auth";
43import type { AuthEnv } from "../middleware/auth";
44
45const web = new Hono<AuthEnv>();
46
47// Soft auth on all web routes — c.get("user") available but may be null
48web.use("*", softAuth);
49
50// Home page
51web.get("/", async (c) => {
52 const user = c.get("user");
53
54 if (user) {
55 // Show user's repos
56 const repos = await db
57 .select()
58 .from(repositories)
59 .where(eq(repositories.ownerId, user.id))
60 .orderBy(desc(repositories.updatedAt));
61
62 return c.html(
63 <Layout title="Dashboard" user={user}>
64 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px">
65 <h2>Your repositories</h2>
66 <a href="/new" class="btn btn-primary">
67 + New repository
68 </a>
69 </div>
70 {repos.length === 0 ? (
71 <div class="empty-state">
72 <h2>No repositories yet</h2>
73 <p>Create your first repository to get started.</p>
74 </div>
75 ) : (
76 <div class="card-grid">
77 {repos.map((repo) => (
78 <RepoCard repo={repo} ownerName={user.username} />
79 ))}
80 </div>
81 )}
82 </Layout>
83 );
84 }
85
86 return c.html(
87 <Layout user={null}>
88 <div class="empty-state">
89 <h2>gluecron</h2>
90 <p>AI-native code intelligence platform</p>
91 <div style="margin-top: 24px; display: flex; gap: 12px; justify-content: center">
92 <a href="/register" class="btn btn-primary">
93 Get started
94 </a>
95 <a href="/login" class="btn">
96 Sign in
97 </a>
98 </div>
99 <pre style="margin-top: 32px">{`# Quick start
100curl -X POST http://localhost:3000/api/setup \\
101 -H 'Content-Type: application/json' \\
102 -d '{"username":"you","email":"you@dev.com","repoName":"hello"}'
103
104git remote add gluecron http://localhost:3000/you/hello.git
105git push gluecron main`}</pre>
106 </div>
107 </Layout>
108 );
109});
110
111// New repository form
112web.get("/new", requireAuth, (c) => {
113 const user = c.get("user")!;
114 const error = c.req.query("error");
115
116 return c.html(
117 <Layout title="New repository" user={user}>
118 <div class="new-repo-form">
119 <h2>Create a new repository</h2>
120 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
121 <form method="POST" action="/new">
122 <div class="form-group">
123 <label>Owner</label>
124 <input type="text" value={user.username} disabled class="input-disabled" />
125 </div>
126 <div class="form-group">
127 <label for="name">Repository name</label>
128 <input
129 type="text"
130 id="name"
131 name="name"
132 required
133 pattern="^[a-zA-Z0-9._-]+$"
134 placeholder="my-project"
135 autocomplete="off"
136 />
137 </div>
138 <div class="form-group">
139 <label for="description">Description (optional)</label>
140 <input
141 type="text"
142 id="description"
143 name="description"
144 placeholder="A short description of your repository"
145 />
146 </div>
147 <div class="visibility-options">
148 <label class="visibility-option">
149 <input type="radio" name="visibility" value="public" checked />
150 <div class="vis-label">Public</div>
151 <div class="vis-desc">Anyone can see this repository</div>
152 </label>
153 <label class="visibility-option">
154 <input type="radio" name="visibility" value="private" />
155 <div class="vis-label">Private</div>
156 <div class="vis-desc">Only you can see this repository</div>
157 </label>
158 </div>
159 <button type="submit" class="btn btn-primary">
160 Create repository
161 </button>
162 </form>
163 </div>
164 </Layout>
165 );
166});
167
168web.post("/new", requireAuth, async (c) => {
169 const user = c.get("user")!;
170 const body = await c.req.parseBody();
171 const name = String(body.name || "").trim();
172 const description = String(body.description || "").trim();
173 const isPrivate = body.visibility === "private";
174
175 if (!name) {
176 return c.redirect("/new?error=Repository+name+is+required");
177 }
178
179 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
180 return c.redirect("/new?error=Invalid+repository+name");
181 }
182
183 if (await repoExists(user.username, name)) {
184 return c.redirect("/new?error=Repository+already+exists");
185 }
186
187 const diskPath = await initBareRepo(user.username, name);
188
189 await db.insert(repositories).values({
190 name,
191 ownerId: user.id,
192 description: description || null,
193 isPrivate,
194 diskPath,
195 });
196
197 return c.redirect(`/${user.username}/${name}`);
198});
199
200// User profile
201web.get("/:owner", async (c) => {
202 const { owner: ownerName } = c.req.param();
203 const user = c.get("user");
204
205 // Avoid clashing with fixed routes
206 if (
207 ["login", "register", "logout", "new", "settings", "api"].includes(
208 ownerName
209 )
210 ) {
211 return c.notFound();
212 }
213
214 let ownerUser;
215 try {
216 const [found] = await db
217 .select()
218 .from(users)
219 .where(eq(users.username, ownerName))
220 .limit(1);
221 ownerUser = found;
222 } catch {
223 // DB not available — check if repos exist on disk
224 ownerUser = null;
225 }
226
227 // Even without DB, show repos if they exist on disk
228 let repos: any[] = [];
229 if (ownerUser) {
230 const allRepos = await db
231 .select()
232 .from(repositories)
233 .where(eq(repositories.ownerId, ownerUser.id))
234 .orderBy(desc(repositories.updatedAt));
235
236 // Show public repos to everyone, private only to owner
237 repos =
238 user?.id === ownerUser.id
239 ? allRepos
240 : allRepos.filter((r) => !r.isPrivate);
241 }
242
243 return c.html(
244 <Layout title={ownerName} user={user}>
245 <div class="user-profile">
246 <div class="user-avatar">
247 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
248 </div>
249 <div class="user-info">
250 <h2>{ownerUser?.displayName || ownerName}</h2>
251 <div class="username">@{ownerName}</div>
252 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
253 </div>
254 </div>
255 <h3 style="margin-bottom: 16px">Repositories</h3>
256 {repos.length === 0 ? (
257 <p style="color: var(--text-muted)">No repositories yet.</p>
258 ) : (
259 <div class="card-grid">
260 {repos.map((repo) => (
261 <RepoCard repo={repo} ownerName={ownerName} />
262 ))}
263 </div>
264 )}
265 </Layout>
266 );
267});
268
269// Star/unstar a repo
270web.post("/:owner/:repo/star", requireAuth, async (c) => {
271 const { owner: ownerName, repo: repoName } = c.req.param();
272 const user = c.get("user")!;
273
274 try {
275 const [ownerUser] = await db
276 .select()
277 .from(users)
278 .where(eq(users.username, ownerName))
279 .limit(1);
280 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
281
282 const [repo] = await db
283 .select()
284 .from(repositories)
285 .where(
286 and(
287 eq(repositories.ownerId, ownerUser.id),
288 eq(repositories.name, repoName)
289 )
290 )
291 .limit(1);
292 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
293
294 // Toggle star
295 const [existing] = await db
296 .select()
297 .from(stars)
298 .where(
299 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
300 )
301 .limit(1);
302
303 if (existing) {
304 await db.delete(stars).where(eq(stars.id, existing.id));
305 await db
306 .update(repositories)
307 .set({ starCount: Math.max(0, repo.starCount - 1) })
308 .where(eq(repositories.id, repo.id));
309 } else {
310 await db.insert(stars).values({
311 userId: user.id,
312 repositoryId: repo.id,
313 });
314 await db
315 .update(repositories)
316 .set({ starCount: repo.starCount + 1 })
317 .where(eq(repositories.id, repo.id));
318 }
319 } catch {
320 // DB error — ignore
321 }
322
323 return c.redirect(`/${ownerName}/${repoName}`);
324});
325
326// Repository overview — file tree at HEAD
327web.get("/:owner/:repo", async (c) => {
328 const { owner, repo } = c.req.param();
329 const user = c.get("user");
330
331 if (!(await repoExists(owner, repo))) {
332 return c.html(
333 <Layout title="Not Found" user={user}>
334 <div class="empty-state">
335 <h2>Repository not found</h2>
336 <p>
337 {owner}/{repo} does not exist.
338 </p>
339 </div>
340 </Layout>,
341 404
342 );
343 }
344
345 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
346 const branches = await listBranches(owner, repo);
347 const tree = await getTree(owner, repo, defaultBranch);
348
349 // Get star info if user logged in
350 let starCount = 0;
351 let starred = false;
352 try {
353 const [ownerUser] = await db
354 .select()
355 .from(users)
356 .where(eq(users.username, owner))
357 .limit(1);
358 if (ownerUser) {
359 const [repoRow] = await db
360 .select()
361 .from(repositories)
362 .where(
363 and(
364 eq(repositories.ownerId, ownerUser.id),
365 eq(repositories.name, repo)
366 )
367 )
368 .limit(1);
369 if (repoRow) {
370 starCount = repoRow.starCount;
371 if (user) {
372 const [star] = await db
373 .select()
374 .from(stars)
375 .where(
376 and(
377 eq(stars.userId, user.id),
378 eq(stars.repositoryId, repoRow.id)
379 )
380 )
381 .limit(1);
382 starred = !!star;
383 }
384 }
385 }
386 } catch {
387 // DB not available
388 }
389
390 if (tree.length === 0) {
391 return c.html(
392 <Layout title={`${owner}/${repo}`} user={user}>
393 <RepoHeader
394 owner={owner}
395 repo={repo}
396 starCount={starCount}
397 starred={starred}
398 currentUser={user?.username}
399 />
400 <RepoNav owner={owner} repo={repo} active="code" />
401 <div class="empty-state">
402 <h2>Empty repository</h2>
403 <p>Get started by pushing code:</p>
404 <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git
405git push -u gluecron main`}</pre>
406 </div>
407 </Layout>
408 );
409 }
410
411 const readme = await getReadme(owner, repo, defaultBranch);
412
413 return c.html(
414 <Layout title={`${owner}/${repo}`} user={user}>
415 <RepoHeader
416 owner={owner}
417 repo={repo}
418 starCount={starCount}
419 starred={starred}
420 currentUser={user?.username}
421 />
422 <RepoNav owner={owner} repo={repo} active="code" />
423 <BranchSwitcher
424 owner={owner}
425 repo={repo}
426 currentRef={defaultBranch}
427 branches={branches}
428 pathType="tree"
429 />
430 <FileTable
431 entries={tree}
432 owner={owner}
433 repo={repo}
434 ref={defaultBranch}
435 path=""
436 />
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>
446 </div>
447 );
448 })()}
449 </Layout>
450 );
451});
452
453// Browse tree at ref/path
454web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
455 const { owner, repo } = c.req.param();
456 const user = c.get("user");
457 const refAndPath = c.req.param("ref");
458
459 const branches = await listBranches(owner, repo);
460 let ref = "";
461 let treePath = "";
462
463 for (const branch of branches) {
464 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
465 ref = branch;
466 treePath = refAndPath.slice(branch.length + 1);
467 break;
468 }
469 }
470
471 if (!ref) {
472 const slashIdx = refAndPath.indexOf("/");
473 if (slashIdx === -1) {
474 ref = refAndPath;
475 } else {
476 ref = refAndPath.slice(0, slashIdx);
477 treePath = refAndPath.slice(slashIdx + 1);
478 }
479 }
480
481 const tree = await getTree(owner, repo, ref, treePath);
482
483 return c.html(
484 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
485 <RepoHeader owner={owner} repo={repo} />
486 <RepoNav owner={owner} repo={repo} active="code" />
487 <BranchSwitcher
488 owner={owner}
489 repo={repo}
490 currentRef={ref}
491 branches={branches}
492 pathType="tree"
493 subPath={treePath}
494 />
495 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
496 <FileTable
497 entries={tree}
498 owner={owner}
499 repo={repo}
500 ref={ref}
501 path={treePath}
502 />
503 </Layout>
504 );
505});
506
507// View file blob with syntax highlighting
508web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
509 const { owner, repo } = c.req.param();
510 const user = c.get("user");
511 const refAndPath = c.req.param("ref");
512
513 const branches = await listBranches(owner, repo);
514 let ref = "";
515 let filePath = "";
516
517 for (const branch of branches) {
518 if (refAndPath.startsWith(branch + "/")) {
519 ref = branch;
520 filePath = refAndPath.slice(branch.length + 1);
521 break;
522 }
523 }
524
525 if (!ref) {
526 const slashIdx = refAndPath.indexOf("/");
527 if (slashIdx === -1) return c.text("Not found", 404);
528 ref = refAndPath.slice(0, slashIdx);
529 filePath = refAndPath.slice(slashIdx + 1);
530 }
531
532 const blob = await getBlob(owner, repo, ref, filePath);
533 if (!blob) {
534 return c.html(
535 <Layout title="Not Found" user={user}>
536 <div class="empty-state">
537 <h2>File not found</h2>
538 </div>
539 </Layout>,
540 404
541 );
542 }
543
544 const fileName = filePath.split("/").pop() || filePath;
545
546 return c.html(
547 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
548 <RepoHeader owner={owner} repo={repo} />
549 <RepoNav owner={owner} repo={repo} active="code" />
550 <BranchSwitcher
551 owner={owner}
552 repo={repo}
553 currentRef={ref}
554 branches={branches}
555 pathType="blob"
556 subPath={filePath}
557 />
558 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
559 <div class="blob-view">
560 <div class="blob-header">
561 <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 {user && (
570 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
571 Edit
572 </a>
573 )}
574 </span>
575 </div>
576 {blob.isBinary ? (
577 <div style="padding: 16px; color: var(--text-muted)">
578 Binary file not shown.
579 </div>
580 ) : (() => {
581 const { html: highlighted, language } = highlightCode(
582 blob.content,
583 fileName
584 );
585 const lineCount = blob.content.split("\n").length;
586 // Trim trailing newline from count
587 const adjustedCount =
588 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
589
590 if (language) {
591 return (
592 <HighlightedCode
593 highlightedHtml={highlighted}
594 lineCount={adjustedCount}
595 />
596 );
597 }
598 const lines = blob.content.split("\n");
599 if (lines[lines.length - 1] === "") lines.pop();
600 return <PlainCode lines={lines} />;
601 })()}
602 </div>
603 </Layout>
604 );
605});
606
607// Commit log
608web.get("/:owner/:repo/commits/:ref?", async (c) => {
609 const { owner, repo } = c.req.param();
610 const user = c.get("user");
611 const ref =
612 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
613 const branches = await listBranches(owner, repo);
614
615 const commits = await listCommits(owner, repo, ref, 50);
616
617 return c.html(
618 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
619 <RepoHeader owner={owner} repo={repo} />
620 <RepoNav owner={owner} repo={repo} active="commits" />
621 <BranchSwitcher
622 owner={owner}
623 repo={repo}
624 currentRef={ref}
625 branches={branches}
626 pathType="commits"
627 />
628 {commits.length === 0 ? (
629 <div class="empty-state">
630 <p>No commits yet.</p>
631 </div>
632 ) : (
633 <CommitList commits={commits} owner={owner} repo={repo} />
634 )}
635 </Layout>
636 );
637});
638
639// Single commit with diff
640web.get("/:owner/:repo/commit/:sha", async (c) => {
641 const { owner, repo, sha } = c.req.param();
642 const user = c.get("user");
643
644 const commit = await getCommit(owner, repo, sha);
645 if (!commit) {
646 return c.html(
647 <Layout title="Not Found" user={user}>
648 <div class="empty-state">
649 <h2>Commit not found</h2>
650 </div>
651 </Layout>,
652 404
653 );
654 }
655
656 const fullMessage = await getCommitFullMessage(owner, repo, sha);
657 const { files, raw } = await getDiff(owner, repo, sha);
658
659 return c.html(
660 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
661 <RepoHeader owner={owner} repo={repo} />
662 <div
663 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
664 >
665 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
666 {commit.message}
667 </div>
668 {fullMessage !== commit.message && (
669 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
670 {fullMessage}
671 </div>
672 )}
673 <div style="font-size: 13px; color: var(--text-muted)">
674 <strong style="color: var(--text)">{commit.author}</strong>{" "}
675 committed on{" "}
676 {new Date(commit.date).toLocaleDateString("en-US", {
677 month: "long",
678 day: "numeric",
679 year: "numeric",
680 })}
681 </div>
682 <div style="margin-top: 8px">
683 <span class="commit-sha">{commit.sha}</span>
684 {commit.parentShas.length > 0 && (
685 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
686 Parent:{" "}
687 {commit.parentShas.map((p) => (
688 <a
689 href={`/${owner}/${repo}/commit/${p}`}
690 class="commit-sha"
691 style="margin-left: 4px"
692 >
693 {p.slice(0, 7)}
694 </a>
695 ))}
696 </span>
697 )}
698 </div>
699 </div>
700 <DiffView raw={raw} files={files} />
701 </Layout>
702 );
703});
704
705// Raw file download
706web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
707 const { owner, repo } = c.req.param();
708 const refAndPath = c.req.param("ref");
709
710 const branches = await listBranches(owner, repo);
711 let ref = "";
712 let filePath = "";
713
714 for (const branch of branches) {
715 if (refAndPath.startsWith(branch + "/")) {
716 ref = branch;
717 filePath = refAndPath.slice(branch.length + 1);
718 break;
719 }
720 }
721
722 if (!ref) {
723 const slashIdx = refAndPath.indexOf("/");
724 if (slashIdx === -1) return c.text("Not found", 404);
725 ref = refAndPath.slice(0, slashIdx);
726 filePath = refAndPath.slice(slashIdx + 1);
727 }
728
729 const data = await getRawBlob(owner, repo, ref, filePath);
730 if (!data) return c.text("Not found", 404);
731
732 const fileName = filePath.split("/").pop() || "file";
733 return new Response(data, {
734 headers: {
735 "Content-Type": "application/octet-stream",
736 "Content-Disposition": `attachment; filename="${fileName}"`,
737 "Cache-Control": "no-cache",
738 },
739 });
740});
741
742// Blame view
743web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
744 const { owner, repo } = c.req.param();
745 const user = c.get("user");
746 const refAndPath = c.req.param("ref");
747
748 const branches = await listBranches(owner, repo);
749 let ref = "";
750 let filePath = "";
751
752 for (const branch of branches) {
753 if (refAndPath.startsWith(branch + "/")) {
754 ref = branch;
755 filePath = refAndPath.slice(branch.length + 1);
756 break;
757 }
758 }
759
760 if (!ref) {
761 const slashIdx = refAndPath.indexOf("/");
762 if (slashIdx === -1) return c.text("Not found", 404);
763 ref = refAndPath.slice(0, slashIdx);
764 filePath = refAndPath.slice(slashIdx + 1);
765 }
766
767 const blameLines = await getBlame(owner, repo, ref, filePath);
768 if (blameLines.length === 0) {
769 return c.html(
770 <Layout title="Not Found" user={user}>
771 <div class="empty-state">
772 <h2>File not found</h2>
773 </div>
774 </Layout>,
775 404
776 );
777 }
778
779 const fileName = filePath.split("/").pop() || filePath;
780
781 return c.html(
782 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
783 <RepoHeader owner={owner} repo={repo} />
784 <RepoNav owner={owner} repo={repo} active="code" />
785 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
786 <div class="blob-view">
787 <div class="blob-header">
788 <span>{fileName} — blame</span>
789 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
790 Normal view
791 </a>
792 </div>
793 <div class="blob-code" style="overflow-x: auto">
794 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
795 <tbody>
796 {blameLines.map((line, i) => {
797 const showInfo =
798 i === 0 || blameLines[i - 1].sha !== line.sha;
799 return (
800 <tr style="border-bottom: 1px solid var(--border)">
801 <td
802 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)" : ""}`}
803 >
804 {showInfo && (
805 <>
806 <a
807 href={`/${owner}/${repo}/commit/${line.sha}`}
808 style="color: var(--text-link); font-family: var(--font-mono)"
809 >
810 {line.sha.slice(0, 7)}
811 </a>{" "}
812 <span>{line.author}</span>
813 </>
814 )}
815 </td>
816 <td class="line-num">{line.lineNum}</td>
817 <td class="line-content">{line.content}</td>
818 </tr>
819 );
820 })}
821 </tbody>
822 </table>
823 </div>
824 </div>
825 </Layout>
826 );
827});
828
829// Search
830web.get("/:owner/:repo/search", async (c) => {
831 const { owner, repo } = c.req.param();
832 const user = c.get("user");
833 const q = c.req.query("q") || "";
834
835 if (!(await repoExists(owner, repo))) return c.notFound();
836
837 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
838 let results: Array<{ file: string; lineNum: number; line: string }> = [];
839
840 if (q.trim()) {
841 results = await searchCode(owner, repo, defaultBranch, q.trim());
842 }
843
844 return c.html(
845 <Layout title={`Search — ${owner}/${repo}`} user={user}>
846 <RepoHeader owner={owner} repo={repo} />
847 <RepoNav owner={owner} repo={repo} active="code" />
848 <form
849 method="GET"
850 action={`/${owner}/${repo}/search`}
851 style="margin-bottom: 20px"
852 >
853 <div style="display: flex; gap: 8px">
854 <input
855 type="text"
856 name="q"
857 value={q}
858 placeholder="Search code..."
859 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
860 />
861 <button type="submit" class="btn btn-primary">
862 Search
863 </button>
864 </div>
865 </form>
866 {q && (
867 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
868 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
869 <strong style="color: var(--text)">"{q}"</strong>
870 </p>
871 )}
872 {results.length > 0 && (
873 <div class="search-results">
874 {(() => {
875 // Group by file
876 const grouped: Record<
877 string,
878 Array<{ lineNum: number; line: string }>
879 > = {};
880 for (const r of results) {
881 if (!grouped[r.file]) grouped[r.file] = [];
882 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
883 }
884 return Object.entries(grouped).map(([file, matches]) => (
885 <div class="diff-file" style="margin-bottom: 12px">
886 <div class="diff-file-header">
887 <a
888 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
889 >
890 {file}
891 </a>
892 </div>
893 <div class="blob-code">
894 <table>
895 <tbody>
896 {matches.map((m) => (
897 <tr>
898 <td class="line-num">{m.lineNum}</td>
899 <td class="line-content">{m.line}</td>
900 </tr>
901 ))}
902 </tbody>
903 </table>
904 </div>
905 </div>
906 ));
907 })()}
908 </div>
909 )}
910 </Layout>
911 );
912});
913
914export default web;
Addedsrc/routes/webhooks.tsx+298−0View fileUnifiedSplit
1/**
2 * Webhooks management — register, list, delete, test.
3 */
4
5import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
7import { db } from "../db";
8import { webhooks, 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";
13
14const webhookRoutes = new Hono<AuthEnv>();
15
16webhookRoutes.use("*", softAuth);
17
18// List webhooks
19webhookRoutes.get(
20 "/:owner/:repo/settings/webhooks",
21 requireAuth,
22 async (c) => {
23 const { owner: ownerName, repo: repoName } = c.req.param();
24 const user = c.get("user")!;
25 const success = c.req.query("success");
26 const error = c.req.query("error");
27
28 const [owner] = await db
29 .select()
30 .from(users)
31 .where(eq(users.username, ownerName))
32 .limit(1);
33 if (!owner || owner.id !== user.id) {
34 return c.text("Unauthorized", 403);
35 }
36
37 const [repo] = await db
38 .select()
39 .from(repositories)
40 .where(
41 and(
42 eq(repositories.ownerId, owner.id),
43 eq(repositories.name, repoName)
44 )
45 )
46 .limit(1);
47 if (!repo) return c.notFound();
48
49 const hooks = await db
50 .select()
51 .from(webhooks)
52 .where(eq(webhooks.repositoryId, repo.id));
53
54 return c.html(
55 <Layout title={`Webhooks — ${ownerName}/${repoName}`} user={user}>
56 <RepoHeader owner={ownerName} repo={repoName} />
57 <div style="max-width: 700px">
58 <h2 style="margin-bottom: 16px">Webhooks</h2>
59 {success && (
60 <div class="auth-success">{decodeURIComponent(success)}</div>
61 )}
62 {error && (
63 <div class="auth-error">{decodeURIComponent(error)}</div>
64 )}
65 {hooks.length > 0 && (
66 <div style="margin-bottom: 24px">
67 {hooks.map((hook) => (
68 <div class="ssh-key-item">
69 <div>
70 <strong>{hook.url}</strong>
71 <div class="ssh-key-meta">
72 Events: {hook.events} |{" "}
73 {hook.isActive ? (
74 <span style="color: var(--green)">Active</span>
75 ) : (
76 <span style="color: var(--red)">Inactive</span>
77 )}
78 {hook.lastDeliveredAt && (
79 <span>
80 {" "}
81 | Last: {hook.lastStatus}
82 </span>
83 )}
84 </div>
85 </div>
86 <form
87 method="POST"
88 action={`/${ownerName}/${repoName}/settings/webhooks/${hook.id}/delete`}
89 >
90 <button type="submit" class="btn btn-danger btn-sm">
91 Delete
92 </button>
93 </form>
94 </div>
95 ))}
96 </div>
97 )}
98
99 <h3 style="margin-bottom: 12px">Add webhook</h3>
100 <form
101 method="POST"
102 action={`/${ownerName}/${repoName}/settings/webhooks`}
103 >
104 <div class="form-group">
105 <label>Payload URL</label>
106 <input
107 type="url"
108 name="url"
109 required
110 placeholder="https://example.com/hooks/gluecron"
111 />
112 </div>
113 <div class="form-group">
114 <label>Secret (optional)</label>
115 <input
116 type="text"
117 name="secret"
118 placeholder="Shared secret for HMAC verification"
119 />
120 </div>
121 <div class="form-group">
122 <label>Events</label>
123 <div style="display: flex; gap: 16px; flex-wrap: wrap">
124 {["push", "issue", "pr", "star"].map((evt) => (
125 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
126 <input
127 type="checkbox"
128 name="events"
129 value={evt}
130 checked={evt === "push"}
131 />{" "}
132 {evt}
133 </label>
134 ))}
135 </div>
136 </div>
137 <button type="submit" class="btn btn-primary">
138 Add webhook
139 </button>
140 </form>
141 </div>
142 </Layout>
143 );
144 }
145);
146
147// Create webhook
148webhookRoutes.post(
149 "/:owner/:repo/settings/webhooks",
150 requireAuth,
151 async (c) => {
152 const { owner: ownerName, repo: repoName } = c.req.param();
153 const user = c.get("user")!;
154 const body = await c.req.parseBody();
155 const url = String(body.url || "").trim();
156 const secret = String(body.secret || "").trim() || null;
157
158 // Events can be a string or array
159 let events: string;
160 const rawEvents = body.events;
161 if (Array.isArray(rawEvents)) {
162 events = rawEvents.join(",");
163 } else {
164 events = String(rawEvents || "push");
165 }
166
167 if (!url) {
168 return c.redirect(
169 `/${ownerName}/${repoName}/settings/webhooks?error=URL+is+required`
170 );
171 }
172
173 const [owner] = await db
174 .select()
175 .from(users)
176 .where(eq(users.username, ownerName))
177 .limit(1);
178 if (!owner || owner.id !== user.id) {
179 return c.redirect(`/${ownerName}/${repoName}`);
180 }
181
182 const [repo] = await db
183 .select()
184 .from(repositories)
185 .where(
186 and(
187 eq(repositories.ownerId, owner.id),
188 eq(repositories.name, repoName)
189 )
190 )
191 .limit(1);
192 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
193
194 await db.insert(webhooks).values({
195 repositoryId: repo.id,
196 url,
197 secret,
198 events,
199 });
200
201 return c.redirect(
202 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+added`
203 );
204 }
205);
206
207// Delete webhook
208webhookRoutes.post(
209 "/:owner/:repo/settings/webhooks/:id/delete",
210 requireAuth,
211 async (c) => {
212 const { owner: ownerName, repo: repoName, id } = c.req.param();
213
214 await db.delete(webhooks).where(eq(webhooks.id, id));
215
216 return c.redirect(
217 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+deleted`
218 );
219 }
220);
221
222export default webhookRoutes;
223
224/**
225 * Fire webhooks for a repository event.
226 */
227export async function fireWebhooks(
228 repositoryId: string,
229 event: string,
230 payload: Record<string, unknown>
231): Promise<void> {
232 try {
233 const hooks = await db
234 .select()
235 .from(webhooks)
236 .where(eq(webhooks.repositoryId, repositoryId));
237
238 for (const hook of hooks) {
239 if (!hook.isActive) continue;
240 const hookEvents = hook.events.split(",");
241 if (!hookEvents.includes(event)) continue;
242
243 try {
244 const headers: Record<string, string> = {
245 "Content-Type": "application/json",
246 "X-Gluecron-Event": event,
247 };
248
249 if (hook.secret) {
250 const encoder = new TextEncoder();
251 const key = await crypto.subtle.importKey(
252 "raw",
253 encoder.encode(hook.secret),
254 { name: "HMAC", hash: "SHA-256" },
255 false,
256 ["sign"]
257 );
258 const signature = await crypto.subtle.sign(
259 "HMAC",
260 key,
261 encoder.encode(JSON.stringify(payload))
262 );
263 headers["X-Gluecron-Signature"] =
264 "sha256=" +
265 Array.from(new Uint8Array(signature))
266 .map((b) => b.toString(16).padStart(2, "0"))
267 .join("");
268 }
269
270 const res = await fetch(hook.url, {
271 method: "POST",
272 headers,
273 body: JSON.stringify(payload),
274 signal: AbortSignal.timeout(10000),
275 });
276
277 await db
278 .update(webhooks)
279 .set({
280 lastDeliveredAt: new Date(),
281 lastStatus: res.status,
282 })
283 .where(eq(webhooks.id, hook.id));
284 } catch (err) {
285 console.error(`[webhook] delivery failed for ${hook.url}:`, err);
286 await db
287 .update(webhooks)
288 .set({
289 lastDeliveredAt: new Date(),
290 lastStatus: 0,
291 })
292 .where(eq(webhooks.id, hook.id));
293 }
294 }
295 } catch (err) {
296 console.error("[webhook] failed to query webhooks:", err);
297 }
298}
Addedsrc/views/components.tsx+383−0View fileUnifiedSplit
1import type { FC } from "hono/jsx";
2import { html } from "hono/html";
3import type { GitCommit, GitTreeEntry, GitDiffFile } from "../git/repository";
4import type { Repository } from "../db/schema";
5
6export const RepoHeader: FC<{
7 owner: string;
8 repo: string;
9 starCount?: number;
10 starred?: boolean;
11 forkCount?: number;
12 currentUser?: string | null;
13 forkedFrom?: string | null;
14}> = ({ owner, repo, starCount, starred, forkCount, currentUser, forkedFrom }) => (
15 <div class="repo-header">
16 <div>
17 <div style="display: flex; align-items: center; gap: 8px; font-size: 20px">
18 <a href={`/${owner}`} class="owner">
19 {owner}
20 </a>
21 <span class="separator">/</span>
22 <a href={`/${owner}/${repo}`} class="name">
23 {repo}
24 </a>
25 </div>
26 {forkedFrom && (
27 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
28 forked from <a href={`/${forkedFrom}`}>{forkedFrom}</a>
29 </div>
30 )}
31 </div>
32 <div class="repo-header-actions">
33 {currentUser && currentUser !== owner && (
34 <form method="POST" action={`/${owner}/${repo}/fork`} style="display:inline">
35 <button type="submit" class="star-btn">
36 {"\u2442"} Fork {forkCount !== undefined && forkCount > 0 ? forkCount : ""}
37 </button>
38 </form>
39 )}
40 {starCount !== undefined && (
41 currentUser ? (
42 <form method="POST" action={`/${owner}/${repo}/star`} style="display:inline">
43 <button
44 type="submit"
45 class={`star-btn${starred ? " starred" : ""}`}
46 >
47 {starred ? "\u2605" : "\u2606"} {starCount}
48 </button>
49 </form>
50 ) : (
51 <span class="star-btn">
52 {"\u2606"} {starCount}
53 </span>
54 )
55 )}
56 </div>
57 </div>
58);
59
60export const RepoNav: FC<{
61 owner: string;
62 repo: string;
63 active: "code" | "commits" | "issues" | "pulls";
64}> = ({ owner, repo, active }) => (
65 <div class="repo-nav">
66 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
67 Code
68 </a>
69 <a
70 href={`/${owner}/${repo}/issues`}
71 class={active === "issues" ? "active" : ""}
72 >
73 Issues
74 </a>
75 <a
76 href={`/${owner}/${repo}/pulls`}
77 class={active === "pulls" ? "active" : ""}
78 >
79 Pull Requests
80 </a>
81 <a
82 href={`/${owner}/${repo}/commits`}
83 class={active === "commits" ? "active" : ""}
84 >
85 Commits
86 </a>
87 </div>
88);
89
90export const BranchSwitcher: FC<{
91 owner: string;
92 repo: string;
93 currentRef: string;
94 branches: string[];
95 pathType: "tree" | "blob" | "commits";
96 subPath?: string;
97}> = ({ owner, repo, currentRef, branches, pathType, subPath }) => {
98 if (branches.length <= 1) {
99 return <div class="branch-selector">{currentRef}</div>;
100 }
101
102 return (
103 <div class="branch-dropdown">
104 <button class="branch-selector" type="button">
105 {currentRef} &#9662;
106 </button>
107 <div class="branch-dropdown-content">
108 {branches.map((branch) => {
109 let href: string;
110 if (pathType === "commits") {
111 href = `/${owner}/${repo}/commits/${branch}`;
112 } else if (subPath) {
113 href = `/${owner}/${repo}/${pathType}/${branch}/${subPath}`;
114 } else {
115 href = `/${owner}/${repo}/tree/${branch}`;
116 }
117 return (
118 <a
119 href={href}
120 class={branch === currentRef ? "active-branch" : ""}
121 >
122 {branch}
123 </a>
124 );
125 })}
126 </div>
127 </div>
128 );
129};
130
131export const Breadcrumb: FC<{
132 owner: string;
133 repo: string;
134 ref: string;
135 path: string;
136}> = ({ owner, repo, ref, path }) => {
137 const parts = path.split("/").filter(Boolean);
138 const crumbs: { name: string; href: string }[] = [
139 { name: repo, href: `/${owner}/${repo}/tree/${ref}` },
140 ];
141 let accumulated = "";
142 for (const part of parts) {
143 accumulated += (accumulated ? "/" : "") + part;
144 crumbs.push({
145 name: part,
146 href: `/${owner}/${repo}/tree/${ref}/${accumulated}`,
147 });
148 }
149 return (
150 <div class="breadcrumb">
151 {crumbs.map((crumb, i) => (
152 <>
153 {i > 0 && <span>/</span>}
154 {i === crumbs.length - 1 ? (
155 <strong>{crumb.name}</strong>
156 ) : (
157 <a href={crumb.href}>{crumb.name}</a>
158 )}
159 </>
160 ))}
161 </div>
162 );
163};
164
165export const FileTable: FC<{
166 entries: GitTreeEntry[];
167 owner: string;
168 repo: string;
169 ref: string;
170 path: string;
171}> = ({ entries, owner, repo, ref, path }) => (
172 <table class="file-table">
173 <tbody>
174 {entries.map((entry) => {
175 const fullPath = path ? `${path}/${entry.name}` : entry.name;
176 const href =
177 entry.type === "tree"
178 ? `/${owner}/${repo}/tree/${ref}/${fullPath}`
179 : `/${owner}/${repo}/blob/${ref}/${fullPath}`;
180 return (
181 <tr>
182 <td class="file-icon">
183 {entry.type === "tree" ? "\u{1F4C1}" : "\u{1F4C4}"}
184 </td>
185 <td class="file-name">
186 <a href={href}>{entry.name}</a>
187 </td>
188 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
189 {entry.size !== undefined ? formatSize(entry.size) : ""}
190 </td>
191 </tr>
192 );
193 })}
194 </tbody>
195 </table>
196);
197
198export const HighlightedCode: FC<{
199 highlightedHtml: string;
200 lineCount: number;
201}> = ({ highlightedHtml, lineCount }) => {
202 const lineNums = Array.from({ length: lineCount }, (_, i) => i + 1);
203 return (
204 <div class="blob-code">
205 <table>
206 <tbody>
207 <tr>
208 <td class="line-num" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
209 <pre style="margin: 0; line-height: 1.6; font-size: 13px">
210 {lineNums.map((n) => (
211 <>
212 <span>{n}</span>
213 {"\n"}
214 </>
215 ))}
216 </pre>
217 </td>
218 <td class="line-content" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
219 <pre style="margin: 0; line-height: 1.6; font-size: 13px">{html([highlightedHtml] as unknown as TemplateStringsArray)}</pre>
220 </td>
221 </tr>
222 </tbody>
223 </table>
224 </div>
225 );
226};
227
228export const PlainCode: FC<{ lines: string[] }> = ({ lines }) => (
229 <div class="blob-code">
230 <table>
231 <tbody>
232 {lines.map((line, i) => (
233 <tr>
234 <td class="line-num">{i + 1}</td>
235 <td class="line-content">{line}</td>
236 </tr>
237 ))}
238 </tbody>
239 </table>
240 </div>
241);
242
243export const CommitList: FC<{
244 commits: GitCommit[];
245 owner: string;
246 repo: string;
247}> = ({ commits, owner, repo }) => (
248 <div class="commit-list">
249 {commits.map((commit) => (
250 <div class="commit-item">
251 <div>
252 <div class="commit-message">
253 <a href={`/${owner}/${repo}/commit/${commit.sha}`}>
254 {commit.message}
255 </a>
256 </div>
257 <div class="commit-meta">
258 {commit.author} committed {formatRelativeDate(commit.date)}
259 </div>
260 </div>
261 <a
262 href={`/${owner}/${repo}/commit/${commit.sha}`}
263 class="commit-sha"
264 >
265 {commit.sha.slice(0, 7)}
266 </a>
267 </div>
268 ))}
269 </div>
270);
271
272export const DiffView: FC<{ raw: string; files: GitDiffFile[] }> = ({
273 raw,
274 files,
275}) => {
276 const sections = parseDiff(raw);
277
278 return (
279 <div class="diff-view">
280 <div style="margin-bottom: 16px; font-size: 14px; color: var(--text-muted);">
281 Showing{" "}
282 <strong style="color: var(--text)">{files.length}</strong> changed
283 file{files.length !== 1 ? "s" : ""} with{" "}
284 <span class="stat-add">
285 +{files.reduce((s, f) => s + f.additions, 0)}
286 </span>{" "}
287 and{" "}
288 <span class="stat-del">
289 -{files.reduce((s, f) => s + f.deletions, 0)}
290 </span>
291 </div>
292 {sections.map((section) => (
293 <div class="diff-file">
294 <div class="diff-file-header">{section.path}</div>
295 <div class="diff-content">
296 {section.lines.map((line) => {
297 let cls = "line";
298 if (line.startsWith("+")) cls += " line-add";
299 else if (line.startsWith("-")) cls += " line-del";
300 else if (line.startsWith("@@")) cls += " line-hunk";
301 return <span class={cls}>{line + "\n"}</span>;
302 })}
303 </div>
304 </div>
305 ))}
306 </div>
307 );
308};
309
310export const RepoCard: FC<{ repo: Repository; ownerName: string }> = ({
311 repo,
312 ownerName,
313}) => (
314 <div class="card">
315 <h3>
316 <a href={`/${ownerName}/${repo.name}`}>{repo.name}</a>
317 </h3>
318 {repo.description && <p>{repo.description}</p>}
319 <div class="card-meta">
320 {repo.isPrivate && <span class="badge">Private</span>}
321 <span>{"\u2606"} {repo.starCount}</span>
322 {repo.pushedAt && (
323 <span>Updated {formatRelativeDate(repo.pushedAt.toString())}</span>
324 )}
325 </div>
326 </div>
327);
328
329function parseDiff(raw: string): Array<{ path: string; lines: string[] }> {
330 const sections: Array<{ path: string; lines: string[] }> = [];
331 const diffRegex = /^diff --git a\/(.+?) b\/.+$/;
332 let current: { path: string; lines: string[] } | null = null;
333
334 for (const line of raw.split("\n")) {
335 const match = line.match(diffRegex);
336 if (match) {
337 if (current) sections.push(current);
338 current = { path: match[1], lines: [] };
339 continue;
340 }
341 if (current && !line.startsWith("diff --git")) {
342 if (
343 line.startsWith("index ") ||
344 line.startsWith("--- ") ||
345 line.startsWith("+++ ") ||
346 line.startsWith("new file") ||
347 line.startsWith("deleted file") ||
348 line.startsWith("old mode") ||
349 line.startsWith("new mode")
350 ) {
351 continue;
352 }
353 current.lines.push(line);
354 }
355 }
356 if (current) sections.push(current);
357 return sections;
358}
359
360function formatSize(bytes: number): string {
361 if (bytes < 1024) return `${bytes} B`;
362 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
363 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
364}
365
366function formatRelativeDate(dateStr: string): string {
367 const date = new Date(dateStr);
368 const now = new Date();
369 const diffMs = now.getTime() - date.getTime();
370 const diffMins = Math.floor(diffMs / 60000);
371 if (diffMins < 1) return "just now";
372 if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
373 const diffHours = Math.floor(diffMins / 60);
374 if (diffHours < 24)
375 return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
376 const diffDays = Math.floor(diffHours / 24);
377 if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
378 return date.toLocaleDateString("en-US", {
379 month: "short",
380 day: "numeric",
381 year: "numeric",
382 });
383}
Addedsrc/views/layout.tsx+538−0View fileUnifiedSplit
1import type { FC, PropsWithChildren } from "hono/jsx";
2import type { User } from "../db/schema";
3import { hljsThemeCss } from "../lib/highlight";
4
5export const Layout: FC<
6 PropsWithChildren<{ title?: string; user?: User | null }>
7> = ({ children, title, user }) => {
8 return (
9 <html lang="en">
10 <head>
11 <meta charset="UTF-8" />
12 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
13 <title>{title ? `${title} — gluecron` : "gluecron"}</title>
14 <style>{css}</style>
15 <style>{hljsThemeCss}</style>
16 </head>
17 <body>
18 <header>
19 <nav>
20 <a href="/" class="logo">
21 gluecron
22 </a>
23 <div class="nav-right">
24 <a href="/explore" class="nav-link">
25 Explore
26 </a>
27 {user ? (
28 <>
29 <a href="/new" class="btn btn-sm btn-primary">
30 + New
31 </a>
32 <a href={`/${user.username}`} class="nav-user">
33 {user.displayName || user.username}
34 </a>
35 <a href="/settings" class="nav-link">
36 Settings
37 </a>
38 <a href="/logout" class="nav-link">
39 Sign out
40 </a>
41 </>
42 ) : (
43 <>
44 <a href="/login" class="nav-link">
45 Sign in
46 </a>
47 <a href="/register" class="btn btn-sm btn-primary">
48 Register
49 </a>
50 </>
51 )}
52 </div>
53 </nav>
54 </header>
55 <main>{children}</main>
56 <footer>
57 <span>gluecron — AI-native code intelligence</span>
58 </footer>
59 </body>
60 </html>
61 );
62};
63
64const css = `
65 :root {
66 --bg: #0d1117;
67 --bg-secondary: #161b22;
68 --bg-tertiary: #21262d;
69 --border: #30363d;
70 --text: #e6edf3;
71 --text-muted: #8b949e;
72 --text-link: #58a6ff;
73 --accent: #1f6feb;
74 --accent-hover: #388bfd;
75 --green: #3fb950;
76 --red: #f85149;
77 --yellow: #d29922;
78 --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
79 --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
80 --radius: 6px;
81 }
82
83 * { margin: 0; padding: 0; box-sizing: border-box; }
84
85 body {
86 font-family: var(--font-sans);
87 background: var(--bg);
88 color: var(--text);
89 line-height: 1.5;
90 min-height: 100vh;
91 display: flex;
92 flex-direction: column;
93 }
94
95 a { color: var(--text-link); text-decoration: none; }
96 a:hover { text-decoration: underline; }
97
98 header {
99 border-bottom: 1px solid var(--border);
100 padding: 12px 24px;
101 background: var(--bg-secondary);
102 }
103
104 header nav {
105 display: flex;
106 align-items: center;
107 justify-content: space-between;
108 max-width: 1200px;
109 margin: 0 auto;
110 }
111 .logo { font-size: 20px; font-weight: 700; color: var(--text); }
112 .logo:hover { text-decoration: none; color: var(--text-link); }
113
114 .nav-right { display: flex; align-items: center; gap: 16px; }
115 .nav-link { color: var(--text-muted); font-size: 14px; }
116 .nav-link:hover { color: var(--text); text-decoration: none; }
117 .nav-user { color: var(--text); font-weight: 600; font-size: 14px; }
118 .nav-user:hover { color: var(--text-link); text-decoration: none; }
119
120 main { max-width: 1200px; margin: 0 auto; padding: 24px; flex: 1; width: 100%; }
121
122 footer {
123 border-top: 1px solid var(--border);
124 padding: 16px 24px;
125 text-align: center;
126 color: var(--text-muted);
127 font-size: 13px;
128 }
129
130 /* Buttons */
131 .btn {
132 display: inline-flex;
133 align-items: center;
134 gap: 6px;
135 padding: 8px 16px;
136 border-radius: var(--radius);
137 font-size: 14px;
138 font-weight: 500;
139 border: 1px solid var(--border);
140 background: var(--bg-tertiary);
141 color: var(--text);
142 cursor: pointer;
143 text-decoration: none;
144 line-height: 1.4;
145 }
146 .btn:hover { background: var(--border); text-decoration: none; }
147 .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
148 .btn-primary:hover { background: var(--accent-hover); }
149 .btn-danger { background: transparent; border-color: var(--red); color: var(--red); }
150 .btn-danger:hover { background: rgba(248, 81, 73, 0.15); }
151 .btn-sm { padding: 4px 12px; font-size: 13px; }
152
153 /* Forms */
154 .form-group { margin-bottom: 16px; }
155 .form-group label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 6px; color: var(--text); }
156 .form-group input, .form-group textarea, .form-group select {
157 width: 100%;
158 padding: 8px 12px;
159 background: var(--bg);
160 border: 1px solid var(--border);
161 border-radius: var(--radius);
162 color: var(--text);
163 font-size: 14px;
164 font-family: var(--font-sans);
165 }
166 .form-group input:focus, .form-group textarea:focus, .form-group select:focus {
167 outline: none;
168 border-color: var(--accent);
169 box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.3);
170 }
171 .input-disabled { opacity: 0.5; cursor: not-allowed; }
172
173 /* Auth */
174 .auth-container { max-width: 400px; margin: 40px auto; }
175 .auth-container h2 { margin-bottom: 20px; font-size: 24px; }
176 .auth-error {
177 background: rgba(248, 81, 73, 0.1);
178 border: 1px solid var(--red);
179 color: var(--red);
180 padding: 8px 12px;
181 border-radius: var(--radius);
182 margin-bottom: 16px;
183 font-size: 14px;
184 }
185 .auth-success {
186 background: rgba(63, 185, 80, 0.1);
187 border: 1px solid var(--green);
188 color: var(--green);
189 padding: 8px 12px;
190 border-radius: var(--radius);
191 margin-bottom: 16px;
192 font-size: 14px;
193 }
194 .auth-switch { margin-top: 16px; font-size: 14px; color: var(--text-muted); }
195
196 /* Settings */
197 .settings-container { max-width: 600px; }
198 .settings-container h2 { margin-bottom: 20px; font-size: 24px; }
199 .settings-container h3 { font-size: 18px; margin-bottom: 12px; }
200 .ssh-keys-list { margin-bottom: 24px; }
201 .ssh-key-item {
202 display: flex;
203 justify-content: space-between;
204 align-items: center;
205 padding: 12px 16px;
206 border: 1px solid var(--border);
207 border-radius: var(--radius);
208 margin-bottom: 8px;
209 background: var(--bg-secondary);
210 }
211 .ssh-key-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
212 .ssh-key-meta code { font-size: 11px; background: var(--bg-tertiary); padding: 1px 6px; border-radius: 3px; margin-right: 8px; }
213
214 /* Repo header */
215 .repo-header {
216 display: flex;
217 align-items: center;
218 gap: 8px;
219 margin-bottom: 16px;
220 font-size: 20px;
221 }
222 .repo-header .owner { color: var(--text-link); }
223 .repo-header .separator { color: var(--text-muted); }
224 .repo-header .name { color: var(--text-link); font-weight: 600; }
225 .repo-header-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
226
227 .repo-nav {
228 display: flex;
229 gap: 0;
230 border-bottom: 1px solid var(--border);
231 margin-bottom: 20px;
232 }
233 .repo-nav a {
234 padding: 8px 16px;
235 color: var(--text-muted);
236 border-bottom: 2px solid transparent;
237 font-size: 14px;
238 }
239 .repo-nav a:hover { text-decoration: none; color: var(--text); }
240 .repo-nav a.active { color: var(--text); border-bottom-color: var(--accent); }
241
242 .breadcrumb { display: flex; gap: 4px; align-items: center; margin-bottom: 16px; color: var(--text-muted); font-size: 14px; }
243 .breadcrumb a { color: var(--text-link); }
244
245 .file-table { width: 100%; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
246 .file-table tr { border-bottom: 1px solid var(--border); }
247 .file-table tr:last-child { border-bottom: none; }
248 .file-table td { padding: 8px 16px; font-size: 14px; }
249 .file-table tr:hover { background: var(--bg-secondary); }
250 .file-icon { width: 20px; color: var(--text-muted); }
251 .file-name a { color: var(--text); }
252 .file-name a:hover { color: var(--text-link); text-decoration: underline; }
253
254 .blob-view {
255 border: 1px solid var(--border);
256 border-radius: var(--radius);
257 overflow: hidden;
258 }
259 .blob-header {
260 background: var(--bg-secondary);
261 padding: 8px 16px;
262 border-bottom: 1px solid var(--border);
263 font-size: 13px;
264 color: var(--text-muted);
265 display: flex;
266 justify-content: space-between;
267 align-items: center;
268 }
269 .blob-code {
270 overflow-x: auto;
271 font-family: var(--font-mono);
272 font-size: 13px;
273 line-height: 1.6;
274 }
275 .blob-code table { width: 100%; border-collapse: collapse; }
276 .blob-code .line-num {
277 width: 1%;
278 min-width: 50px;
279 padding: 0 12px;
280 text-align: right;
281 color: var(--text-muted);
282 user-select: none;
283 white-space: nowrap;
284 border-right: 1px solid var(--border);
285 }
286 .blob-code .line-content { padding: 0 12px; white-space: pre; }
287
288 .commit-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
289 .commit-item {
290 display: flex;
291 justify-content: space-between;
292 align-items: center;
293 padding: 12px 16px;
294 border-bottom: 1px solid var(--border);
295 }
296 .commit-item:last-child { border-bottom: none; }
297 .commit-item:hover { background: var(--bg-secondary); }
298 .commit-message { font-size: 14px; font-weight: 500; }
299 .commit-meta { font-size: 12px; color: var(--text-muted); }
300 .commit-sha {
301 font-family: var(--font-mono);
302 font-size: 12px;
303 padding: 2px 8px;
304 background: var(--bg-tertiary);
305 border: 1px solid var(--border);
306 border-radius: var(--radius);
307 color: var(--text-link);
308 }
309
310 .diff-view { margin-top: 16px; }
311 .diff-file {
312 border: 1px solid var(--border);
313 border-radius: var(--radius);
314 margin-bottom: 16px;
315 overflow: hidden;
316 }
317 .diff-file-header {
318 background: var(--bg-secondary);
319 padding: 8px 16px;
320 border-bottom: 1px solid var(--border);
321 font-family: var(--font-mono);
322 font-size: 13px;
323 }
324 .diff-content {
325 overflow-x: auto;
326 font-family: var(--font-mono);
327 font-size: 13px;
328 line-height: 1.6;
329 }
330 .diff-content .line-add { background: rgba(63, 185, 80, 0.15); color: var(--green); }
331 .diff-content .line-del { background: rgba(248, 81, 73, 0.1); color: var(--red); }
332 .diff-content .line-hunk { background: rgba(56, 139, 253, 0.1); color: var(--text-link); }
333 .diff-content .line { padding: 0 12px; white-space: pre; display: block; }
334
335 .stat-add { color: var(--green); font-weight: 600; }
336 .stat-del { color: var(--red); font-weight: 600; }
337
338 .empty-state {
339 text-align: center;
340 padding: 60px 20px;
341 color: var(--text-muted);
342 }
343 .empty-state h2 { font-size: 24px; margin-bottom: 8px; color: var(--text); }
344 .empty-state pre {
345 text-align: left;
346 display: inline-block;
347 background: var(--bg-secondary);
348 padding: 16px 24px;
349 border-radius: var(--radius);
350 border: 1px solid var(--border);
351 font-family: var(--font-mono);
352 font-size: 13px;
353 margin-top: 16px;
354 line-height: 1.8;
355 }
356
357 .badge {
358 display: inline-block;
359 padding: 2px 8px;
360 border-radius: 12px;
361 font-size: 12px;
362 font-weight: 500;
363 background: var(--bg-tertiary);
364 border: 1px solid var(--border);
365 color: var(--text-muted);
366 }
367
368 .branch-selector {
369 display: inline-flex;
370 align-items: center;
371 gap: 4px;
372 padding: 4px 12px;
373 background: var(--bg-tertiary);
374 border: 1px solid var(--border);
375 border-radius: var(--radius);
376 font-size: 13px;
377 color: var(--text);
378 margin-bottom: 12px;
379 position: relative;
380 }
381
382 .branch-dropdown {
383 position: relative;
384 display: inline-block;
385 margin-bottom: 12px;
386 }
387 .branch-dropdown-content {
388 display: none;
389 position: absolute;
390 top: 100%;
391 left: 0;
392 z-index: 10;
393 min-width: 200px;
394 background: var(--bg-secondary);
395 border: 1px solid var(--border);
396 border-radius: var(--radius);
397 margin-top: 4px;
398 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
399 }
400 .branch-dropdown:hover .branch-dropdown-content,
401 .branch-dropdown:focus-within .branch-dropdown-content { display: block; }
402 .branch-dropdown-content a {
403 display: block;
404 padding: 8px 12px;
405 font-size: 13px;
406 color: var(--text);
407 border-bottom: 1px solid var(--border);
408 }
409 .branch-dropdown-content a:last-child { border-bottom: none; }
410 .branch-dropdown-content a:hover { background: var(--bg-tertiary); text-decoration: none; }
411 .branch-dropdown-content a.active-branch { color: var(--text-link); font-weight: 600; }
412
413 .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
414 .card {
415 border: 1px solid var(--border);
416 border-radius: var(--radius);
417 padding: 16px;
418 background: var(--bg-secondary);
419 transition: border-color 0.15s;
420 }
421 .card:hover { border-color: var(--text-muted); }
422 .card h3 { font-size: 16px; margin-bottom: 4px; }
423 .card h3 a { color: var(--text-link); }
424 .card p { font-size: 13px; color: var(--text-muted); }
425 .card-meta { display: flex; gap: 16px; margin-top: 12px; font-size: 12px; color: var(--text-muted); }
426 .card-meta span { display: flex; align-items: center; gap: 4px; }
427
428 .star-btn {
429 display: inline-flex;
430 align-items: center;
431 gap: 6px;
432 padding: 4px 12px;
433 background: var(--bg-tertiary);
434 border: 1px solid var(--border);
435 border-radius: var(--radius);
436 color: var(--text);
437 font-size: 13px;
438 cursor: pointer;
439 }
440 .star-btn:hover { background: var(--border); text-decoration: none; }
441 .star-btn.starred { color: var(--yellow); border-color: var(--yellow); }
442
443 .user-profile {
444 display: flex;
445 gap: 32px;
446 margin-bottom: 32px;
447 }
448 .user-avatar {
449 width: 96px;
450 height: 96px;
451 border-radius: 50%;
452 background: var(--bg-tertiary);
453 border: 1px solid var(--border);
454 display: flex;
455 align-items: center;
456 justify-content: center;
457 font-size: 40px;
458 color: var(--text-muted);
459 flex-shrink: 0;
460 }
461 .user-info h2 { font-size: 24px; margin-bottom: 2px; }
462 .user-info .username { font-size: 16px; color: var(--text-muted); }
463 .user-info .bio { font-size: 14px; color: var(--text-muted); margin-top: 8px; }
464
465 .new-repo-form { max-width: 600px; }
466 .new-repo-form h2 { margin-bottom: 20px; }
467 .visibility-options { display: flex; gap: 12px; margin-bottom: 16px; }
468 .visibility-option {
469 flex: 1;
470 padding: 12px;
471 border: 1px solid var(--border);
472 border-radius: var(--radius);
473 background: var(--bg-secondary);
474 cursor: pointer;
475 text-align: center;
476 }
477 .visibility-option:has(input:checked) { border-color: var(--accent); background: rgba(31, 111, 235, 0.1); }
478 .visibility-option input { display: none; }
479 .visibility-option .vis-label { font-size: 14px; font-weight: 500; }
480 .visibility-option .vis-desc { font-size: 12px; color: var(--text-muted); }
481
482 /* Issues */
483 .issue-tabs { display: flex; gap: 16px; }
484 .issue-tabs a { color: var(--text-muted); font-size: 14px; font-weight: 500; }
485 .issue-tabs a:hover { color: var(--text); text-decoration: none; }
486 .issue-tabs a.active { color: var(--text); }
487
488 .issue-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
489 .issue-item {
490 display: flex;
491 gap: 12px;
492 align-items: flex-start;
493 padding: 12px 16px;
494 border-bottom: 1px solid var(--border);
495 }
496 .issue-item:last-child { border-bottom: none; }
497 .issue-item:hover { background: var(--bg-secondary); }
498 .issue-state-icon { font-size: 16px; padding-top: 2px; }
499 .state-open { color: var(--green); }
500 .state-closed { color: #986ee2; }
501 .issue-title { font-size: 15px; font-weight: 600; }
502 .issue-title a { color: var(--text); }
503 .issue-title a:hover { color: var(--text-link); }
504 .issue-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
505
506 .issue-badge {
507 display: inline-flex;
508 align-items: center;
509 gap: 4px;
510 padding: 4px 12px;
511 border-radius: 20px;
512 font-size: 13px;
513 font-weight: 500;
514 }
515 .badge-open { background: rgba(63, 185, 80, 0.15); color: var(--green); border: 1px solid var(--green); }
516 .badge-closed { background: rgba(152, 110, 226, 0.15); color: #986ee2; border: 1px solid #986ee2; }
517 .badge-merged { background: rgba(152, 110, 226, 0.15); color: #986ee2; border: 1px solid #986ee2; }
518 .state-merged { color: #986ee2; }
519 .ai-review { border-color: var(--accent); }
520
521 .issue-detail { max-width: 900px; }
522 .issue-comment-box {
523 border: 1px solid var(--border);
524 border-radius: var(--radius);
525 margin-bottom: 16px;
526 overflow: hidden;
527 }
528 .comment-header {
529 background: var(--bg-secondary);
530 padding: 8px 16px;
531 border-bottom: 1px solid var(--border);
532 font-size: 13px;
533 color: var(--text-muted);
534 }
535
536 /* Search */
537 .search-results .diff-file { margin-bottom: 12px; }
538`;
Addedtsconfig.json+25−0View fileUnifiedSplit
1{
2 "compilerOptions": {
3 "target": "ES2022",
4 "module": "ES2022",
5 "moduleResolution": "bundler",
6 "jsx": "react-jsx",
7 "jsxImportSource": "hono/jsx",
8 "strict": true,
9 "esModuleInterop": true,
10 "skipLibCheck": true,
11 "forceConsistentCasingInFileNames": true,
12 "resolveJsonModule": true,
13 "declaration": true,
14 "declarationMap": true,
15 "sourceMap": true,
16 "outDir": "./dist",
17 "rootDir": "./src",
18 "paths": {
19 "@/*": ["./src/*"]
20 },
21 "types": ["bun-types"]
22 },
23 "include": ["src/**/*.ts", "src/**/*.tsx"],
24 "exclude": ["node_modules", "dist"]
25}
026