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

Merge branch 'main' into gatetest/auto-fix-1776590685143

Dictation App committed on April 19, 2026Parents: 76581d3 4c8d5b9
17 files changed+1442551c9ede45890db2148058681de3cc10f9ca67b2ab
17 changed files+1442−55
Modified.env.example+1−1View fileUnifiedSplit
11DATABASE_URL=postgresql://user:password@host/gluecron
22GIT_REPOS_PATH=./repos
33PORT=3000
4GATETEST_URL=https://gatetest.ai/api/scan/run
4GATETEST_URL=https://gatetest.ai/api/events/push
55GATETEST_API_KEY=
66# Inbound GateTest callback auth — set one so GateTest can POST results back.
77# See GATETEST_HOOK.md for payload + endpoint details.
ModifiedCLAUDE.md+1−1View fileUnifiedSplit
9393
9494## Integrations
9595
96- **GateTest:** POST `https://gatetest.ai/api/scan/run` on every `git push`
96- **GateTest:** POST `https://gatetest.ai/api/events/push` on every `git push`
9797- **Crontech:** POST `https://crontech.ai/api/trpc/tenant.deploy` on push to main
9898- **Webhooks:** POST to registered URLs on push/issue/PR/star events with HMAC signatures
9999
ModifiedDEPLOY.md+1−1View fileUnifiedSplit
8686GIT_REPOS_PATH=/data/repos
8787PORT=3000
8888NODE_ENV=production
89GATETEST_URL=https://gatetest.ai/api/scan/run
89GATETEST_URL=https://gatetest.ai/api/events/push
9090CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
9191EOF
9292
Modifieddocker-compose.yml+1−1View fileUnifiedSplit
88 - GIT_REPOS_PATH=/data/repos
99 - PORT=3000
1010 - NODE_ENV=production
11 - GATETEST_URL=https://gatetest.ai/api/scan/run
11 - GATETEST_URL=https://gatetest.ai/api/events/push
1212 - CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
1313 volumes:
1414 - git-repos:/data/repos
Modifieddocs/ops/DEPLOYMENT_RUNBOOK.md+1−1View fileUnifiedSplit
121121
122122| Variable | Required? | Example | Notes |
123123|---|---|---|---|
124| `GATETEST_URL` | **Yes** (for integration) | `https://gatetest.io/api/scan/run` | Default `https://gatetest.ai/api/scan/run` — override to production host. |
124| `GATETEST_URL` | **Yes** (for integration) | `https://gatetest.ai/api/events/push` | Default `https://gatetest.ai/api/events/push`. |
125125| `GATETEST_API_KEY` | **Yes** | `gtk_...` | Outbound bearer token sent to GateTest. |
126126| `GATETEST_CALLBACK_SECRET` | **Yes** | *(random 32-byte hex)* | Bearer token GateTest uses when posting scan results back to Gluecron (`/hooks/gatetest`). |
127127| `GATETEST_HMAC_SECRET` | **Yes** | *(random 32-byte hex)* | HMAC signing secret for GateTest → Gluecron callbacks. |
Addeddrizzle/0001_add_admin.sql+3−0View fileUnifiedSplit
1-- Add admin flag to users table.
2-- First registered user becomes admin automatically (handled in app code).
3ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE NOT NULL;
Addedsrc/__tests__/api-v2-gatetest.test.ts+374−0View fileUnifiedSplit
1/**
2 * API v2 — GateTest integration endpoints.
3 *
4 * These tests cover the additive endpoints introduced for GateTest's
5 * GluecronBridge:
6 * - recursive tree listing (cap 50k + truncation)
7 * - base64 content reads (PNG round-trip)
8 * - PR-comment POST auth check
9 * - POST /git/refs conflict + unknown-sha
10 * - PUT /contents/:path sha-mismatch
11 * - commit-status v2 alias
12 *
13 * Git-layer tests use real bare repos on disk; HTTP-layer tests use Hono's
14 * in-memory request dispatcher without a DB connection (matching the style
15 * of src/__tests__/api-v2.test.ts).
16 */
17
18import { describe, it, expect, beforeAll, afterAll } from "bun:test";
19import { join } from "path";
20import { rm, mkdir } from "fs/promises";
21import app from "../app";
22import { clearRateLimitStore } from "../middleware/rate-limit";
23import {
24 initBareRepo,
25 getRepoPath,
26 getDefaultBranchFresh,
27 getTreeRecursive,
28 catBlobBytes,
29 refExists,
30 objectExists,
31 updateRef,
32 writeBlob,
33 createOrUpdateFileOnBranch,
34 getBlobShaAtPath,
35 resolveRef,
36} from "../git/repository";
37
38const TEST_REPOS = join(import.meta.dir, "../../.test-repos-gatetest-" + Date.now());
39
40beforeAll(async () => {
41 process.env.GIT_REPOS_PATH = TEST_REPOS;
42 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
43 clearRateLimitStore();
44 await rm(TEST_REPOS, { recursive: true, force: true });
45 await mkdir(TEST_REPOS, { recursive: true });
46});
47
48afterAll(async () => {
49 await rm(TEST_REPOS, { recursive: true, force: true });
50});
51
52// ---------------------------------------------------------------------------
53// Helpers to build a real bare repo with a seeded commit
54// ---------------------------------------------------------------------------
55
56async function run(cmd: string[], cwd: string) {
57 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
58 const out = (await new Response(proc.stdout).text()).trim();
59 await proc.exited;
60 return out;
61}
62
63async function seedRepo(
64 owner: string,
65 name: string,
66 files: Array<{ path: string; bytes: Uint8Array | string }>,
67 opts: { branch?: string } = {}
68) {
69 const branch = opts.branch ?? "main";
70 await initBareRepo(owner, name);
71 const bare = getRepoPath(owner, name);
72
73 const work = join(TEST_REPOS, "_work_" + Math.random().toString(16).slice(2));
74 await mkdir(work, { recursive: true });
75 await run(["git", "clone", bare, work], TEST_REPOS);
76 await run(["git", "config", "user.email", "test@gluecron.com"], work);
77 await run(["git", "config", "user.name", "Test User"], work);
78 await run(["git", "checkout", "-B", branch], work);
79
80 for (const f of files) {
81 const full = join(work, f.path);
82 const dir = full.substring(0, full.lastIndexOf("/"));
83 if (dir && dir !== work) await mkdir(dir, { recursive: true });
84 const data = typeof f.bytes === "string" ? new TextEncoder().encode(f.bytes) : f.bytes;
85 await Bun.write(full, data);
86 }
87
88 await run(["git", "add", "-A"], work);
89 await run(["git", "commit", "-m", "seed"], work);
90 await run(["git", "push", "-u", "origin", branch], work);
91 await rm(work, { recursive: true, force: true });
92}
93
94// ---------------------------------------------------------------------------
95// 1. getDefaultBranchFresh
96// ---------------------------------------------------------------------------
97
98describe("git — getDefaultBranchFresh", () => {
99 it("returns 'main' for a freshly initialised bare repo", async () => {
100 await initBareRepo("u1", "r1");
101 const branch = await getDefaultBranchFresh("u1", "r1");
102 expect(branch).toBe("main");
103 });
104
105 it("strips refs/heads/ prefix from HEAD's symbolic ref", async () => {
106 await initBareRepo("u2", "r2");
107 const bare = getRepoPath("u2", "r2");
108 await run(["git", "symbolic-ref", "HEAD", "refs/heads/develop"], bare);
109 const branch = await getDefaultBranchFresh("u2", "r2");
110 expect(branch).toBe("develop");
111 });
112});
113
114// ---------------------------------------------------------------------------
115// 2. Recursive tree
116// ---------------------------------------------------------------------------
117
118describe("git — getTreeRecursive", () => {
119 it("walks all blobs and trees under the ref", async () => {
120 await seedRepo("u3", "r3", [
121 { path: "README.md", bytes: "# hi" },
122 { path: "src/a.ts", bytes: "export const a = 1" },
123 { path: "src/sub/b.ts", bytes: "export const b = 2" },
124 ]);
125 const result = await getTreeRecursive("u3", "r3", "main");
126 expect(result).not.toBeNull();
127 expect(result!.truncated).toBe(false);
128 const paths = result!.tree.map((e) => e.path);
129 expect(paths).toContain("README.md");
130 expect(paths).toContain("src");
131 expect(paths).toContain("src/a.ts");
132 expect(paths).toContain("src/sub");
133 expect(paths).toContain("src/sub/b.ts");
134 // blob entries carry a size
135 const blob = result!.tree.find((e) => e.path === "src/a.ts");
136 expect(blob?.type).toBe("blob");
137 expect(typeof blob?.size).toBe("number");
138 });
139
140 it("truncates + sets truncated=true when over-cap", async () => {
141 await seedRepo("u4", "r4", [
142 { path: "a.txt", bytes: "1" },
143 { path: "b.txt", bytes: "2" },
144 { path: "c.txt", bytes: "3" },
145 { path: "d.txt", bytes: "4" },
146 ]);
147 const result = await getTreeRecursive("u4", "r4", "main", 2);
148 expect(result).not.toBeNull();
149 expect(result!.truncated).toBe(true);
150 expect(result!.tree.length).toBe(2);
151 expect(result!.totalCount).toBeGreaterThanOrEqual(4);
152 });
153
154 it("returns null for a ref that does not resolve", async () => {
155 await initBareRepo("u5", "r5");
156 const result = await getTreeRecursive("u5", "r5", "no-such-branch");
157 expect(result).toBeNull();
158 });
159});
160
161// ---------------------------------------------------------------------------
162// 3. catBlobBytes — binary round-trip (PNG magic)
163// ---------------------------------------------------------------------------
164
165describe("git — catBlobBytes / base64 round-trip", () => {
166 it("preserves PNG magic bytes through git storage", async () => {
167 // Minimal 1x1 transparent PNG.
168 const pngBytes = new Uint8Array([
169 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
170 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
171 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
172 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
173 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
174 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
175 ]);
176 await seedRepo("u6", "r6", [{ path: "pixel.png", bytes: pngBytes }]);
177
178 const result = await catBlobBytes("u6", "r6", "main", "pixel.png");
179 expect(result).not.toBeNull();
180 expect(result!.size).toBe(pngBytes.length);
181 // First 8 bytes are the PNG magic signature.
182 for (let i = 0; i < 8; i++) expect(result!.bytes[i]).toBe(pngBytes[i]);
183
184 const base64 = Buffer.from(result!.bytes).toString("base64");
185 const decoded = Buffer.from(base64, "base64");
186 expect(decoded.length).toBe(pngBytes.length);
187 for (let i = 0; i < pngBytes.length; i++) expect(decoded[i]).toBe(pngBytes[i]);
188 });
189
190 it("returns null when the path is missing", async () => {
191 await seedRepo("u7", "r7", [{ path: "x.txt", bytes: "hi" }]);
192 const result = await catBlobBytes("u7", "r7", "main", "no-such-file");
193 expect(result).toBeNull();
194 });
195});
196
197// ---------------------------------------------------------------------------
198// 4. refExists, objectExists, updateRef
199// ---------------------------------------------------------------------------
200
201describe("git — ref + object helpers", () => {
202 it("refExists returns true for existing refs, false otherwise", async () => {
203 await seedRepo("u8", "r8", [{ path: "f.txt", bytes: "hi" }]);
204 expect(await refExists("u8", "r8", "refs/heads/main")).toBe(true);
205 expect(await refExists("u8", "r8", "refs/heads/nope")).toBe(false);
206 });
207
208 it("objectExists validates reachable shas", async () => {
209 await seedRepo("u9", "r9", [{ path: "f.txt", bytes: "hi" }]);
210 const sha = await resolveRef("u9", "r9", "HEAD");
211 expect(sha).not.toBeNull();
212 expect(await objectExists("u9", "r9", sha!)).toBe(true);
213 expect(await objectExists("u9", "r9", "0".repeat(40))).toBe(false);
214 });
215
216 it("updateRef points a new branch at an existing sha", async () => {
217 await seedRepo("u10", "r10", [{ path: "f.txt", bytes: "hi" }]);
218 const sha = await resolveRef("u10", "r10", "HEAD");
219 expect(await updateRef("u10", "r10", "refs/heads/feature-x", sha!)).toBe(true);
220 expect(await refExists("u10", "r10", "refs/heads/feature-x")).toBe(true);
221 });
222});
223
224// ---------------------------------------------------------------------------
225// 5. writeBlob + createOrUpdateFileOnBranch + sha-mismatch
226// ---------------------------------------------------------------------------
227
228describe("git — createOrUpdateFileOnBranch", () => {
229 it("appends a new file and moves the branch ref", async () => {
230 await seedRepo("u11", "r11", [{ path: "README.md", bytes: "# hi" }]);
231 const parent = await resolveRef("u11", "r11", "refs/heads/main");
232
233 const result = await createOrUpdateFileOnBranch({
234 owner: "u11",
235 name: "r11",
236 branch: "main",
237 filePath: "src/new.ts",
238 bytes: new TextEncoder().encode("export const x = 1;\n"),
239 message: "add new.ts",
240 authorName: "Test User",
241 authorEmail: "test@gluecron.com",
242 expectBlobSha: null,
243 });
244
245 expect("error" in result).toBe(false);
246 if ("error" in result) return;
247 expect(result.parentSha).toBe(parent);
248 expect(result.commitSha).not.toBe(parent);
249
250 // The branch ref now points at the new commit.
251 const head = await resolveRef("u11", "r11", "refs/heads/main");
252 expect(head).toBe(result.commitSha);
253
254 // And the new blob is present on the branch.
255 const blobSha = await getBlobShaAtPath("u11", "r11", "main", "src/new.ts");
256 expect(blobSha).toBe(result.blobSha);
257 });
258
259 it("updates an existing file in place", async () => {
260 await seedRepo("u12", "r12", [{ path: "README.md", bytes: "v1" }]);
261 const oldBlob = await getBlobShaAtPath("u12", "r12", "main", "README.md");
262 expect(oldBlob).not.toBeNull();
263
264 const result = await createOrUpdateFileOnBranch({
265 owner: "u12",
266 name: "r12",
267 branch: "main",
268 filePath: "README.md",
269 bytes: new TextEncoder().encode("v2"),
270 message: "bump README",
271 authorName: "Test User",
272 authorEmail: "test@gluecron.com",
273 expectBlobSha: oldBlob,
274 });
275
276 expect("error" in result).toBe(false);
277 if ("error" in result) return;
278 const newBlob = await getBlobShaAtPath("u12", "r12", "main", "README.md");
279 expect(newBlob).toBe(result.blobSha);
280 expect(newBlob).not.toBe(oldBlob);
281 });
282
283 it("returns sha-mismatch when the optimistic sha check fails", async () => {
284 await seedRepo("u13", "r13", [{ path: "README.md", bytes: "v1" }]);
285 const result = await createOrUpdateFileOnBranch({
286 owner: "u13",
287 name: "r13",
288 branch: "main",
289 filePath: "README.md",
290 bytes: new TextEncoder().encode("v2"),
291 message: "bump",
292 authorName: "Test User",
293 authorEmail: "test@gluecron.com",
294 expectBlobSha: "0".repeat(40), // never matches
295 });
296 expect("error" in result).toBe(true);
297 if ("error" in result) expect(result.error).toBe("sha-mismatch");
298 });
299
300 it("writeBlob produces a deterministic 40-hex sha", async () => {
301 await initBareRepo("u14", "r14");
302 const sha = await writeBlob("u14", "r14", new TextEncoder().encode("hello"));
303 expect(sha).toMatch(/^[0-9a-f]{40}$/);
304 });
305});
306
307// ---------------------------------------------------------------------------
308// 6. HTTP — auth/validation on new routes (no DB required)
309// ---------------------------------------------------------------------------
310
311function apiUrl(path: string): string {
312 return `/api/v2${path}`;
313}
314
315function jsonHeaders(extra: Record<string, string> = {}): Record<string, string> {
316 return { "Content-Type": "application/json", ...extra };
317}
318
319describe("API v2 — new routes auth + validation", () => {
320 it("POST /pulls/:n/comments without auth returns 401", async () => {
321 const res = await app.request(apiUrl("/repos/nobody/nothing/pulls/1/comments"), {
322 method: "POST",
323 headers: jsonHeaders(),
324 body: JSON.stringify({ body: "hello" }),
325 });
326 expect(res.status).toBe(401);
327 });
328
329 it("POST /git/refs without auth returns 401", async () => {
330 const res = await app.request(apiUrl("/repos/nobody/nothing/git/refs"), {
331 method: "POST",
332 headers: jsonHeaders(),
333 body: JSON.stringify({ ref: "refs/heads/x", sha: "a".repeat(40) }),
334 });
335 expect(res.status).toBe(401);
336 });
337
338 it("PUT /contents/:path without auth returns 401", async () => {
339 const res = await app.request(apiUrl("/repos/nobody/nothing/contents/foo.txt"), {
340 method: "PUT",
341 headers: jsonHeaders(),
342 body: JSON.stringify({
343 message: "m",
344 content: Buffer.from("hi").toString("base64"),
345 branch: "main",
346 }),
347 });
348 expect(res.status).toBe(401);
349 });
350
351 it("POST v2 /statuses/:sha without auth returns 401", async () => {
352 const res = await app.request(
353 apiUrl("/repos/nobody/nothing/statuses/" + "a".repeat(40)),
354 {
355 method: "POST",
356 headers: jsonHeaders(),
357 body: JSON.stringify({ state: "success" }),
358 }
359 );
360 expect(res.status).toBe(401);
361 });
362
363 it("GET tree?recursive=1 on missing repo returns 404 or 500", async () => {
364 const res = await app.request(apiUrl("/repos/nobody/nothing/tree/main?recursive=1"));
365 expect([404, 500]).toContain(res.status);
366 });
367
368 it("GET contents?encoding=base64 on missing repo returns 404 or 500", async () => {
369 const res = await app.request(
370 apiUrl("/repos/nobody/nothing/contents/any?encoding=base64")
371 );
372 expect([404, 500]).toContain(res.status);
373 });
374});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
2727import insightRoutes from "./routes/insights";
2828import dashboardRoutes from "./routes/dashboard";
2929import legalRoutes from "./routes/legal";
30import importRoutes from "./routes/import";
3031import webRoutes from "./routes/web";
3132import hookRoutes from "./routes/hooks";
3233import eventsRoutes from "./routes/events";
215216// Legal pages (terms, privacy, AUP)
216217app.route("/", legalRoutes);
217218
219// GitHub import / migration
220app.route("/", importRoutes);
221
218222// Explore page
219223app.route("/", exploreRoutes);
220224
Modifiedsrc/db/schema.ts+1−0View fileUnifiedSplit
2525 // Block I7 — weekly digest opt-in.
2626 notifyEmailDigestWeekly: boolean("notify_email_digest_weekly").default(false).notNull(),
2727 lastDigestSentAt: timestamp("last_digest_sent_at"),
28 isAdmin: boolean("is_admin").default(false).notNull(),
2829 createdAt: timestamp("created_at").defaultNow().notNull(),
2930 updatedAt: timestamp("updated_at").defaultNow().notNull(),
3031});
Modifiedsrc/git/repository.ts+369−0View fileUnifiedSplit
560560 return blob?.content || null;
561561 });
562562}
563
564/* -------------------------------------------------------------------------- */
565/* Extra plumbing used by API v2 write-endpoints for the GateTest integration */
566/* -------------------------------------------------------------------------- */
567
568/**
569 * Read default branch directly from HEAD on the given repo dir. Used by API
570 * v2 where we want a cache-free fresh read. Returns `"main"` when HEAD is
571 * missing, detached or unreadable. Safe on bare repos.
572 */
573export async function getDefaultBranchFresh(
574 owner: string,
575 name: string
576): Promise<string> {
577 const path = repoPath(owner, name);
578 const { stdout, exitCode } = await exec(
579 ["git", "symbolic-ref", "HEAD"],
580 { cwd: path }
581 );
582 if (exitCode !== 0) return "main";
583 const ref = stdout.trim();
584 if (!ref) return "main";
585 return ref.replace(/^refs\/heads\//, "") || "main";
586}
587
588export interface RecursiveTreeEntry {
589 path: string;
590 type: "blob" | "tree";
591 sha: string;
592 mode: string;
593 size?: number;
594}
595
596export interface RecursiveTreeResult {
597 tree: RecursiveTreeEntry[];
598 truncated: boolean;
599 totalCount: number;
600}
601
602/**
603 * Recursive ls-tree (blobs + trees). Caps at `maxEntries` (default 50_000)
604 * to avoid monorepo memory blowups — sets `truncated=true` when exceeded.
605 */
606export async function getTreeRecursive(
607 owner: string,
608 name: string,
609 ref: string,
610 maxEntries = 50_000
611): Promise<RecursiveTreeResult | null> {
612 const path = repoPath(owner, name);
613 // `-t` includes tree entries; `-l` adds size column for blobs.
614 const { stdout, exitCode } = await exec(
615 ["git", "ls-tree", "-r", "-t", "-l", "--full-tree", ref],
616 { cwd: path }
617 );
618 if (exitCode !== 0) return null;
619
620 const lines = stdout.split("\n").filter(Boolean);
621 const totalCount = lines.length;
622 const truncated = totalCount > maxEntries;
623 const sliced = truncated ? lines.slice(0, maxEntries) : lines;
624
625 const tree: RecursiveTreeEntry[] = [];
626 for (const line of sliced) {
627 // Format: <mode> SP <type> SP <sha> SP (<size>|-) TAB <path>
628 const m = line.match(
629 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\s+(-|\d+)\t(.+)$/
630 );
631 if (!m) continue;
632 const type = m[2];
633 if (type === "commit") continue; // submodule — skip
634 tree.push({
635 mode: m[1],
636 type: type as "blob" | "tree",
637 sha: m[3],
638 size: m[4] === "-" ? undefined : parseInt(m[4], 10),
639 path: m[5],
640 });
641 }
642
643 return { tree, truncated, totalCount };
644}
645
646/** Raw bytes of a blob by sha (for base64 API responses). */
647export async function catBlobBytes(
648 owner: string,
649 name: string,
650 ref: string,
651 filePath: string
652): Promise<{ bytes: Uint8Array; size: number; sha: string } | null> {
653 const path = repoPath(owner, name);
654 // Resolve blob sha first.
655 const { stdout: shaOut, exitCode: shaCode } = await exec(
656 ["git", "rev-parse", `${ref}:${filePath}`],
657 { cwd: path }
658 );
659 if (shaCode !== 0) return null;
660 const sha = shaOut.trim();
661 if (!sha) return null;
662
663 const { stdout: sizeOut } = await exec(
664 ["git", "cat-file", "-s", sha],
665 { cwd: path }
666 );
667 const size = parseInt(sizeOut.trim(), 10) || 0;
668
669 const proc = Bun.spawn(["git", "cat-file", "blob", sha], {
670 cwd: path,
671 stdout: "pipe",
672 stderr: "pipe",
673 });
674 const bytes = new Uint8Array(await new Response(proc.stdout).arrayBuffer());
675 const exitCode = await proc.exited;
676 if (exitCode !== 0) return null;
677 return { bytes, size, sha };
678}
679
680/** Returns true iff `git show-ref --verify --quiet <ref>` succeeds. */
681export async function refExists(
682 owner: string,
683 name: string,
684 ref: string
685): Promise<boolean> {
686 const path = repoPath(owner, name);
687 const { exitCode } = await exec(
688 ["git", "show-ref", "--verify", "--quiet", ref],
689 { cwd: path }
690 );
691 return exitCode === 0;
692}
693
694/** Returns true iff the given object is reachable in the repo. */
695export async function objectExists(
696 owner: string,
697 name: string,
698 sha: string
699): Promise<boolean> {
700 const path = repoPath(owner, name);
701 const { exitCode } = await exec(
702 ["git", "cat-file", "-e", sha],
703 { cwd: path }
704 );
705 return exitCode === 0;
706}
707
708/** Point `<ref>` (fully-qualified, e.g. `refs/heads/x`) at `<sha>`. */
709export async function updateRef(
710 owner: string,
711 name: string,
712 ref: string,
713 sha: string,
714 oldSha?: string
715): Promise<boolean> {
716 const path = repoPath(owner, name);
717 const args = oldSha
718 ? ["git", "update-ref", ref, sha, oldSha]
719 : ["git", "update-ref", ref, sha];
720 const { exitCode } = await exec(args, { cwd: path });
721 return exitCode === 0;
722}
723
724/** Hash + write a blob from bytes; returns the 40-hex blob sha. */
725export async function writeBlob(
726 owner: string,
727 name: string,
728 bytes: Uint8Array
729): Promise<string | null> {
730 const path = repoPath(owner, name);
731 const proc = Bun.spawn(["git", "hash-object", "-w", "--stdin"], {
732 cwd: path,
733 stdin: "pipe",
734 stdout: "pipe",
735 stderr: "pipe",
736 });
737 if (proc.stdin) {
738 proc.stdin.write(bytes);
739 proc.stdin.end();
740 }
741 const out = (await new Response(proc.stdout).text()).trim();
742 const exitCode = await proc.exited;
743 if (exitCode !== 0 || !/^[0-9a-f]{40}$/.test(out)) return null;
744 return out;
745}
746
747/**
748 * Read the blob sha at a given path on a ref, or null if missing.
749 * Used for optimistic-concurrency checks on PUT /contents.
750 */
751export async function getBlobShaAtPath(
752 owner: string,
753 name: string,
754 ref: string,
755 filePath: string
756): Promise<string | null> {
757 const path = repoPath(owner, name);
758 const { stdout, exitCode } = await exec(
759 ["git", "rev-parse", `${ref}:${filePath}`],
760 { cwd: path }
761 );
762 if (exitCode !== 0) return null;
763 const sha = stdout.trim();
764 return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
765}
766
767/**
768 * Create or update a file at `filePath` on the branch `branch` with the
769 * given bytes. Uses a transient index file (`GIT_INDEX_FILE`) to let git
770 * handle sub-tree rewriting correctly; `mktree` alone cannot flatten paths
771 * containing `/` into nested tree objects.
772 *
773 * read-tree HEAD (empty if branch is new)
774 * update-index --add --cacheinfo 100644,<blob>,<path>
775 * write-tree
776 * commit-tree + update-ref
777 *
778 * Mirrors the plumbing flow used by the web editor (src/routes/editor.tsx)
779 * but is safe for arbitrary subpaths. Returns the new commit sha on success.
780 */
781export async function createOrUpdateFileOnBranch(input: {
782 owner: string;
783 name: string;
784 branch: string;
785 filePath: string;
786 bytes: Uint8Array;
787 message: string;
788 authorName: string;
789 authorEmail: string;
790 expectBlobSha?: string | null;
791}): Promise<
792 | { commitSha: string; blobSha: string; parentSha: string | null }
793 | { error: "sha-mismatch" | "write-failed" }
794> {
795 const {
796 owner,
797 name,
798 branch,
799 filePath,
800 bytes,
801 message,
802 authorName,
803 authorEmail,
804 expectBlobSha,
805 } = input;
806 const path = repoPath(owner, name);
807 const fullRef = `refs/heads/${branch}`;
808
809 // Resolve current parent + existing blob sha at that path.
810 let parentSha: string | null = null;
811 let existingBlobSha: string | null = null;
812 const { stdout: parentOut, exitCode: parentCode } = await exec(
813 ["git", "rev-parse", "--verify", fullRef],
814 { cwd: path }
815 );
816 if (parentCode === 0) {
817 parentSha = parentOut.trim();
818 const { stdout: blobOut, exitCode: blobCode } = await exec(
819 ["git", "rev-parse", `${branch}:${filePath}`],
820 { cwd: path }
821 );
822 if (blobCode === 0) existingBlobSha = blobOut.trim();
823 }
824
825 // Optimistic concurrency: if caller supplied a sha, it must match current.
826 if (typeof expectBlobSha === "string" && expectBlobSha.length > 0) {
827 if (existingBlobSha !== expectBlobSha) return { error: "sha-mismatch" };
828 }
829
830 // Write the new blob.
831 const blobSha = await writeBlob(owner, name, bytes);
832 if (!blobSha) return { error: "write-failed" };
833
834 // Use a temporary index file so we don't disturb whatever index the repo
835 // already has (and so parallel writes don't stomp on each other).
836 const tmpIndex = join(path, `index.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`);
837 const envWithIndex = {
838 ...process.env,
839 GIT_INDEX_FILE: tmpIndex,
840 GIT_AUTHOR_NAME: authorName,
841 GIT_AUTHOR_EMAIL: authorEmail,
842 GIT_COMMITTER_NAME: authorName,
843 GIT_COMMITTER_EMAIL: authorEmail,
844 };
845
846 const cleanup = async () => {
847 try {
848 const { unlink } = await import("fs/promises");
849 await unlink(tmpIndex);
850 } catch {
851 /* ignore */
852 }
853 };
854
855 try {
856 // Seed the temporary index from the parent tree (if any).
857 if (parentSha) {
858 const { exitCode: readCode } = await exec(
859 ["git", "read-tree", parentSha],
860 { cwd: path, env: envWithIndex }
861 );
862 if (readCode !== 0) {
863 await cleanup();
864 return { error: "write-failed" };
865 }
866 }
867
868 // Add / replace our path.
869 const { exitCode: updCode } = await exec(
870 [
871 "git",
872 "update-index",
873 "--add",
874 "--cacheinfo",
875 `100644,${blobSha},${filePath}`,
876 ],
877 { cwd: path, env: envWithIndex }
878 );
879 if (updCode !== 0) {
880 await cleanup();
881 return { error: "write-failed" };
882 }
883
884 // Write the tree object.
885 const { stdout: treeOut, exitCode: wtCode } = await exec(
886 ["git", "write-tree"],
887 { cwd: path, env: envWithIndex }
888 );
889 const newTreeSha = treeOut.trim();
890 if (wtCode !== 0 || !/^[0-9a-f]{40}$/.test(newTreeSha)) {
891 await cleanup();
892 return { error: "write-failed" };
893 }
894
895 // Create the commit object.
896 const commitArgs = parentSha
897 ? ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message]
898 : ["git", "commit-tree", newTreeSha, "-m", message];
899 const commitProc = Bun.spawn(commitArgs, {
900 cwd: path,
901 stdout: "pipe",
902 stderr: "pipe",
903 env: envWithIndex,
904 });
905 const commitSha = (await new Response(commitProc.stdout).text()).trim();
906 const commitExit = await commitProc.exited;
907 if (commitExit !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
908 await cleanup();
909 return { error: "write-failed" };
910 }
911
912 // Move the branch.
913 const ok = await updateRef(
914 owner,
915 name,
916 fullRef,
917 commitSha,
918 parentSha || undefined
919 );
920 if (!ok) {
921 await cleanup();
922 return { error: "write-failed" };
923 }
924
925 await cleanup();
926 return { commitSha, blobSha, parentSha };
927 } catch {
928 await cleanup();
929 return { error: "write-failed" };
930 }
931}
Modifiedsrc/lib/config.ts+1−1View fileUnifiedSplit
1111 return process.env.GIT_REPOS_PATH || join(process.cwd(), "repos");
1212 },
1313 get gatetestUrl() {
14 return process.env.GATETEST_URL || "https://gatetest.ai/api/scan/run";
14 return process.env.GATETEST_URL || "https://gatetest.ai/api/events/push";
1515 },
1616 get gatetestApiKey() {
1717 return process.env.GATETEST_API_KEY || "";
Addedsrc/middleware/admin.ts+30−0View fileUnifiedSplit
1/**
2 * Admin middleware — blocks non-admin users.
3 * Must be used AFTER softAuth or requireAuth.
4 */
5
6import { createMiddleware } from "hono/factory";
7import type { AuthEnv } from "./auth";
8
9export const requireAdmin = createMiddleware<AuthEnv>(async (c, next) => {
10 const user = c.get("user");
11
12 if (!user) {
13 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
14 }
15
16 if (!user.isAdmin) {
17 return c.html(
18 `<html><body style="background:#0d1117;color:#e6edf3;font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh">
19 <div style="text-align:center">
20 <h1>403</h1>
21 <p>Admin access required.</p>
22 <a href="/" style="color:#58a6ff">Go home</a>
23 </div>
24 </body></html>`,
25 403
26 );
27 }
28
29 return next();
30});
Modifiedsrc/routes/api-v2.ts+255−4View fileUnifiedSplit
2626import {
2727 listBranches,
2828 getDefaultBranch,
29 getDefaultBranchFresh,
2930 getTree,
31 getTreeRecursive,
3032 getBlob,
3133 getCommit,
3234 listCommits,
3537 repoExists,
3638 initBareRepo,
3739 resolveRef,
40 catBlobBytes,
41 refExists,
42 objectExists,
43 updateRef,
44 createOrUpdateFileOnBranch,
3845} from "../git/repository";
3946import { apiAuth, requireApiAuth, requireScope } from "../middleware/api-auth";
4047import type { ApiAuthEnv } from "../middleware/api-auth";
4148import { apiRateLimit, searchRateLimit } from "../middleware/rate-limit";
49import { postCommitStatusHandler } from "./commit-statuses";
4250
4351const apiv2 = new Hono<ApiAuthEnv>().basePath("/api/v2");
4452
184192 return c.json({ error: "Not found" }, 404);
185193 }
186194
187 return c.json(resolved.repo);
195 // Cache-free fresh read of HEAD's ref — needed by GateTest.
196 const defaultBranch = await getDefaultBranchFresh(owner, repo);
197
198 return c.json({
199 ...(resolved.repo as any),
200 defaultBranch,
201 owner: {
202 id: (resolved.owner as any).id,
203 login: (resolved.owner as any).username,
204 },
205 });
188206});
189207
190208apiv2.patch("/repos/:owner/:repo", requireApiAuth, requireScope("repo"), async (c) => {
280298 const { owner, repo } = c.req.param();
281299 const ref = c.req.param("ref");
282300 const path = c.req.query("path") || "";
301 const recursive = c.req.query("recursive");
283302
284303 if (!(await repoExists(owner, repo))) {
285304 return c.json({ error: "Not found" }, 404);
286305 }
287306
307 if (recursive === "1" || recursive === "true") {
308 const result = await getTreeRecursive(owner, repo, ref, 50_000);
309 if (!result) return c.json({ error: "Ref not found" }, 404);
310 return c.json(result);
311 }
312
288313 const tree = await getTree(owner, repo, ref, path);
289314 return c.json(tree);
290315});
291316
317const CONTENTS_MAX_BYTES = 10 * 1024 * 1024;
318
292319apiv2.get("/repos/:owner/:repo/contents/:path{.+$}", async (c) => {
293320 const { owner, repo } = c.req.param();
294321 const filePath = c.req.param("path");
295322 const ref = c.req.query("ref") || "HEAD";
323 const encoding = c.req.query("encoding") || "utf8";
296324
297325 if (!(await repoExists(owner, repo))) {
298326 return c.json({ error: "Not found" }, 404);
299327 }
300328
329 if (encoding === "base64") {
330 const got = await catBlobBytes(owner, repo, ref, filePath);
331 if (!got) return c.json({ error: "File not found" }, 404);
332 if (got.size > CONTENTS_MAX_BYTES) {
333 return c.json(
334 { error: `File too large (${got.size} bytes, max ${CONTENTS_MAX_BYTES})` },
335 413
336 );
337 }
338 const content = Buffer.from(got.bytes).toString("base64");
339 return c.json({
340 path: filePath,
341 size: got.size,
342 sha: got.sha,
343 encoding: "base64",
344 content,
345 });
346 }
347
301348 const blob = await getBlob(owner, repo, ref, filePath);
302349 if (!blob) return c.json({ error: "File not found" }, 404);
350 if (blob.size > CONTENTS_MAX_BYTES) {
351 return c.json(
352 { error: `File too large (${blob.size} bytes, max ${CONTENTS_MAX_BYTES})` },
353 413
354 );
355 }
303356
304357 return c.json({
305358 path: filePath,
306359 size: blob.size,
307360 isBinary: blob.isBinary,
308361 content: blob.isBinary ? null : blob.content,
309 encoding: blob.isBinary ? null : "utf-8",
362 encoding: blob.isBinary ? null : "utf8",
310363 });
311364});
312365
535588 });
536589});
537590
591// ─── PR Comments (GateTest integration) ────────────────────────────────────
592
593apiv2.post(
594 "/repos/:owner/:repo/pulls/:number/comments",
595 requireApiAuth,
596 requireScope("repo"),
597 async (c) => {
598 const { owner, repo } = c.req.param();
599 const num = parseInt(c.req.param("number"), 10);
600 const user = c.get("user")!;
601
602 let body: { body?: string } = {};
603 try {
604 body = await c.req.json();
605 } catch {
606 body = {};
607 }
608 if (!body.body?.trim()) {
609 return c.json({ error: "Comment body is required" }, 400);
610 }
611
612 const resolved = await resolveRepo(owner, repo);
613 if (!resolved) return c.json({ error: "Not found" }, 404);
614 if (user.id !== (resolved.owner as any).id) {
615 return c.json({ error: "Forbidden" }, 403);
616 }
617
618 const [pr] = await db
619 .select()
620 .from(pullRequests)
621 .where(
622 and(
623 eq(pullRequests.repositoryId, (resolved.repo as any).id),
624 eq(pullRequests.number, num)
625 )
626 )
627 .limit(1);
628 if (!pr) return c.json({ error: "PR not found" }, 404);
629
630 const [comment] = await db
631 .insert(prComments)
632 .values({
633 pullRequestId: pr.id,
634 authorId: user.id,
635 body: body.body.trim(),
636 })
637 .returning();
638
639 return c.json({ ok: true, comment }, 201);
640 }
641);
642
643// ─── Git refs — create branch / tag from sha ────────────────────────────────
644
645apiv2.post(
646 "/repos/:owner/:repo/git/refs",
647 requireApiAuth,
648 requireScope("repo"),
649 async (c) => {
650 const { owner, repo } = c.req.param();
651 const user = c.get("user")!;
652
653 let body: { ref?: string; sha?: string } = {};
654 try {
655 body = await c.req.json();
656 } catch {
657 body = {};
658 }
659 const ref = body.ref?.trim();
660 const sha = body.sha?.trim();
661
662 if (!ref || !/^refs\/(heads|tags)\/.+/.test(ref)) {
663 return c.json({ error: "ref must be of the form refs/heads/... or refs/tags/..." }, 400);
664 }
665 if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
666 return c.json({ error: "sha must be a 40-char lowercase hex string" }, 400);
667 }
668
669 const resolved = await resolveRepo(owner, repo);
670 if (!resolved) return c.json({ error: "Not found" }, 404);
671 if (user.id !== (resolved.owner as any).id) {
672 return c.json({ error: "Forbidden" }, 403);
673 }
674
675 // Verify sha reachable.
676 if (!(await objectExists(owner, repo, sha))) {
677 return c.json({ error: "sha not found in repository" }, 400);
678 }
679
680 // Conflict check: if ref already exists, the existing sha must match.
681 if (await refExists(owner, repo, ref)) {
682 const existing = await resolveRef(owner, repo, ref);
683 if (existing !== sha) {
684 return c.json({ error: "ref already exists", existing }, 409);
685 }
686 }
687
688 const ok = await updateRef(owner, repo, ref, sha);
689 if (!ok) return c.json({ error: "Failed to create ref" }, 500);
690
691 return c.json({ ok: true, ref, sha }, 201);
692 }
693);
694
695// ─── Contents PUT — create/update a file via git plumbing ────────────────────
696
697apiv2.put(
698 "/repos/:owner/:repo/contents/:path{.+$}",
699 requireApiAuth,
700 requireScope("repo"),
701 async (c) => {
702 const { owner, repo } = c.req.param();
703 const filePath = c.req.param("path");
704 const user = c.get("user")!;
705
706 let body: {
707 message?: string;
708 content?: string;
709 branch?: string;
710 sha?: string | null;
711 } = {};
712 try {
713 body = await c.req.json();
714 } catch {
715 body = {};
716 }
717
718 const message = body.message?.trim();
719 const branch = body.branch?.trim();
720 const base64 = body.content;
721
722 if (!message) return c.json({ error: "message is required" }, 400);
723 if (!branch) return c.json({ error: "branch is required" }, 400);
724 if (typeof base64 !== "string") return c.json({ error: "content (base64) is required" }, 400);
725
726 let bytes: Uint8Array;
727 try {
728 bytes = new Uint8Array(Buffer.from(base64, "base64"));
729 } catch {
730 return c.json({ error: "content is not valid base64" }, 400);
731 }
732 if (bytes.length > CONTENTS_MAX_BYTES) {
733 return c.json({ error: "File too large" }, 413);
734 }
735
736 const resolved = await resolveRepo(owner, repo);
737 if (!resolved) return c.json({ error: "Not found" }, 404);
738 if (user.id !== (resolved.owner as any).id) {
739 return c.json({ error: "Forbidden" }, 403);
740 }
741
742 const result = await createOrUpdateFileOnBranch({
743 owner,
744 name: repo,
745 branch,
746 filePath,
747 bytes,
748 message,
749 authorName: (user as any).displayName || user.username,
750 authorEmail: user.email,
751 expectBlobSha: body.sha ?? null,
752 });
753
754 if ("error" in result) {
755 if (result.error === "sha-mismatch") {
756 return c.json({ error: "sha does not match current blob at path" }, 409);
757 }
758 return c.json({ error: "Failed to write file" }, 500);
759 }
760
761 return c.json(
762 {
763 ok: true,
764 commit: { sha: result.commitSha, message },
765 content: { path: filePath, sha: result.blobSha },
766 },
767 201
768 );
769 }
770);
771
772// ─── v2 alias for commit-status POST ─────────────────────────────────────────
773// Reuses the handler from src/routes/commit-statuses.ts (v1 mount).
774apiv2.post(
775 "/repos/:owner/:repo/statuses/:sha",
776 requireApiAuth,
777 requireScope("repo"),
778 postCommitStatusHandler
779);
780
538781// ─── Stars ──────────────────────────────────────────────────────────────────
539782
540783apiv2.put("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
8091052 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
8101053 },
8111054 files: {
812 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree",
813 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents",
1055 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree (supports ?recursive=1)",
1056 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents (supports ?encoding=base64)",
1057 "PUT /api/v2/repos/:owner/:repo/contents/:path": "Create or update a file on a branch",
1058 },
1059 git: {
1060 "POST /api/v2/repos/:owner/:repo/git/refs": "Create a branch or tag pointing at a sha",
1061 },
1062 statuses: {
1063 "POST /api/v2/repos/:owner/:repo/statuses/:sha": "Post commit status (v2 alias)",
8141064 },
8151065 issues: {
8161066 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
8231073 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
8241074 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
8251075 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
1076 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
8261077 },
8271078 stars: {
8281079 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
Modifiedsrc/routes/auth.tsx+8−2View fileUnifiedSplit
44
55import { Hono } from "hono";
66import { setCookie, deleteCookie, getCookie } from "hono/cookie";
7import { and, eq, isNull } from "drizzle-orm";
7import { and, eq, isNull, sql } from "drizzle-orm";
88import { db } from "../db";
99import {
1010 users,
140140
141141 const passwordHash = await hashPassword(password);
142142
143 // First user ever registered becomes admin automatically
144 const [userCount] = await db
145 .select({ count: sql`count(*)::int` })
146 .from(users);
147 const isFirstUser = (userCount?.count as number) === 0;
148
143149 const [user] = await db
144150 .insert(users)
145 .values({ username, email, passwordHash })
151 .values({ username, email, passwordHash, isAdmin: isFirstUser })
146152 .returning();
147153
148154 // Create session
Modifiedsrc/routes/commit-statuses.ts+52−43View fileUnifiedSplit
5656// ---------------------------------------------------------------------------
5757// POST status
5858// ---------------------------------------------------------------------------
59statuses.post(
60 "/api/v1/repos/:owner/:repo/statuses/:sha",
61 softAuth,
62 requireAuth,
63 async (c) => {
64 const { owner: ownerName, repo: repoName, sha } = c.req.param();
65 const user = c.get("user")!;
66 if (!isValidSha(sha)) {
67 return c.json({ error: "Invalid sha" }, 400);
68 }
69 const resolved = await resolveRepo(ownerName, repoName);
70 if (!resolved) return c.json({ error: "Repository not found" }, 404);
71 if (resolved.owner.id !== user.id) {
72 return c.json({ error: "Forbidden" }, 403);
73 }
59/**
60 * Handler body for POST <prefix>/repos/:owner/:repo/statuses/:sha — shared
61 * between the v1 mount here (session/OAuth/PAT via softAuth+requireAuth) and
62 * the v2 alias in `src/routes/api-v2.ts` (PAT via apiAuth+requireApiAuth +
63 * `repo` scope). The behaviour is identical; the only difference is which
64 * middleware stack authenticates the user.
65 */
66export async function postCommitStatusHandler(c: any) {
67 const { owner: ownerName, repo: repoName, sha } = c.req.param();
68 const user = c.get("user")!;
69 if (!isValidSha(sha)) {
70 return c.json({ error: "Invalid sha" }, 400);
71 }
72 const resolved = await resolveRepo(ownerName, repoName);
73 if (!resolved) return c.json({ error: "Repository not found" }, 404);
74 if (resolved.owner.id !== user.id) {
75 return c.json({ error: "Forbidden" }, 403);
76 }
7477
75 let body: any = {};
76 try {
77 body = await c.req.json();
78 } catch {
79 body = {};
80 }
78 let body: any = {};
79 try {
80 body = await c.req.json();
81 } catch {
82 body = {};
83 }
8184
82 const state = body.state;
83 if (!isValidState(state)) {
84 return c.json(
85 {
86 error:
87 "Invalid state; must be one of pending, success, failure, error",
88 },
89 400
90 );
91 }
85 const state = body.state;
86 if (!isValidState(state)) {
87 return c.json(
88 {
89 error:
90 "Invalid state; must be one of pending, success, failure, error",
91 },
92 400
93 );
94 }
9295
93 const row = await setStatus({
94 repositoryId: resolved.repo.id,
95 commitSha: sha,
96 state,
97 context: body.context ?? body.Context ?? "default",
98 description: body.description ?? null,
99 targetUrl: body.target_url ?? body.targetUrl ?? null,
100 creatorId: user.id,
101 });
96 const row = await setStatus({
97 repositoryId: resolved.repo.id,
98 commitSha: sha,
99 state,
100 context: body.context ?? body.Context ?? "default",
101 description: body.description ?? null,
102 targetUrl: body.target_url ?? body.targetUrl ?? null,
103 creatorId: user.id,
104 });
102105
103 if (!row) return c.json({ error: "Could not save status" }, 500);
106 if (!row) return c.json({ error: "Could not save status" }, 500);
104107
105 return c.json({ ok: true, status: row });
106 }
108 return c.json({ ok: true, status: row });
109}
110
111statuses.post(
112 "/api/v1/repos/:owner/:repo/statuses/:sha",
113 softAuth,
114 requireAuth,
115 postCommitStatusHandler
107116);
108117
109118// ---------------------------------------------------------------------------
Addedsrc/routes/import.tsx+337−0View fileUnifiedSplit
1/**
2 * GitHub Import — automatic migration from GitHub to gluecron.
3 *
4 * Developer connects GitHub, gluecron pulls ALL their repos
5 * automatically. Issues, descriptions, branches — everything.
6 * One click. Walk away. Come back to everything migrated.
7 */
8
9import { Hono } from "hono";
10import { eq } from "drizzle-orm";
11import { db } from "../db";
12import { repositories, users } from "../db/schema";
13import { Layout } from "../views/layout";
14import { softAuth, requireAuth } from "../middleware/auth";
15import { requireAdmin } from "../middleware/admin";
16import type { AuthEnv } from "../middleware/auth";
17import { config } from "../lib/config";
18import { mkdir } from "fs/promises";
19import { join } from "path";
20
21const importRoutes = new Hono<AuthEnv>();
22
23importRoutes.use("*", softAuth);
24
25interface GitHubRepo {
26 name: string;
27 full_name: string;
28 description: string | null;
29 private: boolean;
30 clone_url: string;
31 default_branch: string;
32 stargazers_count: number;
33 fork: boolean;
34 language: string | null;
35}
36
37// ─── IMPORT PAGE ─────────────────────────────────────────────
38
39importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
40 const user = c.get("user")!;
41 const success = c.req.query("success");
42 const error = c.req.query("error");
43 const imported = c.req.query("imported");
44
45 return c.html(
46 <Layout title="Import from GitHub" user={user}>
47 <div style="max-width: 700px">
48 <h2 style="margin-bottom: 4px">Import from GitHub</h2>
49 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
50 Migrate your repositories from GitHub to gluecron automatically.
51 All branches, all history, all code — one click.
52 </p>
53 {success && (
54 <div class="auth-success">
55 {decodeURIComponent(success)}
56 {imported && (
57 <div style="margin-top: 8px">
58 Successfully imported {decodeURIComponent(imported)} repositories.
59 </div>
60 )}
61 </div>
62 )}
63 {error && (
64 <div class="auth-error">{decodeURIComponent(error)}</div>
65 )}
66
67 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px">
68 <h3 style="margin-bottom: 12px">Option 1: Import by username</h3>
69 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
70 Import all public repositories from a GitHub user or organization.
71 </p>
72 <form method="POST" action="/import/github/user">
73 <div style="display: flex; gap: 8px">
74 <input
75 type="text"
76 name="github_username"
77 required
78 placeholder="GitHub username or org"
79 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
80 />
81 <button type="submit" class="btn btn-primary">
82 Import all repos
83 </button>
84 </div>
85 </form>
86 </div>
87
88 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px">
89 <h3 style="margin-bottom: 12px">Option 2: Import single repo</h3>
90 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
91 Import a specific repository by URL.
92 </p>
93 <form method="POST" action="/import/github/repo">
94 <div style="display: flex; gap: 8px">
95 <input
96 type="text"
97 name="repo_url"
98 required
99 placeholder="https://github.com/owner/repo"
100 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
101 />
102 <button type="submit" class="btn btn-primary">
103 Import
104 </button>
105 </div>
106 </form>
107 </div>
108
109 <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px">
110 <h3 style="margin-bottom: 12px">Option 3: Import with token (private repos)</h3>
111 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
112 Use a GitHub personal access token to import private repositories too.
113 Generate one at github.com → Settings → Developer settings → Personal access tokens.
114 </p>
115 <form method="POST" action="/import/github/user">
116 <div class="form-group">
117 <input
118 type="text"
119 name="github_username"
120 required
121 placeholder="GitHub username"
122 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; width: 100%"
123 />
124 </div>
125 <div class="form-group">
126 <input
127 type="password"
128 name="github_token"
129 placeholder="ghp_xxxxxxxxxxxx (GitHub personal access token)"
130 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; font-family: var(--font-mono); width: 100%"
131 />
132 </div>
133 <button type="submit" class="btn btn-primary">
134 Import all repos (public + private)
135 </button>
136 </form>
137 </div>
138 </div>
139 </Layout>
140 );
141});
142
143// ─── IMPORT ALL REPOS FROM GITHUB USER ───────────────────────
144
145importRoutes.post("/import/github/user", requireAuth, requireAdmin, async (c) => {
146 const user = c.get("user")!;
147 const body = await c.req.parseBody();
148 const githubUsername = String(body.github_username || "").trim();
149 const githubToken = String(body.github_token || "").trim() || null;
150
151 if (!githubUsername) {
152 return c.redirect("/import?error=GitHub+username+is+required");
153 }
154
155 try {
156 // Fetch repos from GitHub API
157 const headers: Record<string, string> = {
158 Accept: "application/vnd.github.v3+json",
159 "User-Agent": "gluecron/1.0",
160 };
161 if (githubToken) {
162 headers.Authorization = `Bearer ${githubToken}`;
163 }
164
165 const repos: GitHubRepo[] = [];
166 let page = 1;
167 while (true) {
168 const url = githubToken
169 ? `https://api.github.com/user/repos?per_page=100&page=${page}&affiliation=owner`
170 : `https://api.github.com/users/${githubUsername}/repos?per_page=100&page=${page}`;
171
172 const res = await fetch(url, { headers });
173 if (!res.ok) {
174 const errText = await res.text();
175 return c.redirect(
176 `/import?error=${encodeURIComponent(`GitHub API error (${res.status}): ${errText.slice(0, 100)}`)}`
177 );
178 }
179 const batch: GitHubRepo[] = await res.json();
180 if (batch.length === 0) break;
181 repos.push(...batch);
182 page++;
183 if (page > 10) break; // safety limit: 1000 repos
184 }
185
186 if (repos.length === 0) {
187 return c.redirect("/import?error=No+repositories+found+for+this+user");
188 }
189
190 // Import each repo
191 let imported = 0;
192 let skipped = 0;
193
194 for (const ghRepo of repos) {
195 // Check if already exists
196 const [existing] = await db
197 .select()
198 .from(repositories)
199 .where(
200 eq(repositories.name, ghRepo.name)
201 )
202 .limit(1);
203
204 if (existing && existing.ownerId === user.id) {
205 skipped++;
206 continue;
207 }
208
209 try {
210 await importSingleRepo(user, ghRepo, githubToken);
211 imported++;
212 } catch (err) {
213 console.error(`[import] failed to import ${ghRepo.full_name}:`, err);
214 }
215 }
216
217 return c.redirect(
218 `/import?success=Import+complete&imported=${imported}+imported%2C+${skipped}+skipped+(already+exist)`
219 );
220 } catch (err) {
221 console.error("[import] error:", err);
222 return c.redirect(
223 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
224 );
225 }
226});
227
228// ─── IMPORT SINGLE REPO BY URL ───────────────────────────────
229
230importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) => {
231 const user = c.get("user")!;
232 const body = await c.req.parseBody();
233 const repoUrl = String(body.repo_url || "").trim();
234
235 if (!repoUrl) {
236 return c.redirect("/import?error=Repository+URL+is+required");
237 }
238
239 // Parse GitHub URL
240 const match = repoUrl.match(
241 /github\.com\/([^/]+)\/([^/.]+)/
242 );
243 if (!match) {
244 return c.redirect("/import?error=Invalid+GitHub+URL");
245 }
246
247 const [, ghOwner, ghRepo] = match;
248
249 try {
250 // Fetch repo info
251 const res = await fetch(
252 `https://api.github.com/repos/${ghOwner}/${ghRepo}`,
253 {
254 headers: {
255 Accept: "application/vnd.github.v3+json",
256 "User-Agent": "gluecron/1.0",
257 },
258 }
259 );
260
261 if (!res.ok) {
262 return c.redirect("/import?error=Repository+not+found+on+GitHub");
263 }
264
265 const ghRepoData: GitHubRepo = await res.json();
266
267 await importSingleRepo(user, ghRepoData, null);
268
269 return c.redirect(
270 `/${user.username}/${ghRepoData.name}`
271 );
272 } catch (err) {
273 console.error("[import] error:", err);
274 return c.redirect(
275 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
276 );
277 }
278});
279
280// ─── CORE IMPORT FUNCTION ────────────────────────────────────
281
282async function importSingleRepo(
283 user: { id: string; username: string },
284 ghRepo: GitHubRepo,
285 token: string | null
286): Promise<void> {
287 const destPath = join(
288 config.gitReposPath,
289 user.username,
290 `${ghRepo.name}.git`
291 );
292
293 // Ensure parent directory exists
294 await mkdir(join(config.gitReposPath, user.username), { recursive: true });
295
296 // Clone bare from GitHub (with token if provided for private repos)
297 let cloneUrl = ghRepo.clone_url;
298 if (token) {
299 // Inject token into URL for private repo access
300 cloneUrl = cloneUrl.replace(
301 "https://github.com/",
302 `https://${token}@github.com/`
303 );
304 }
305
306 console.log(`[import] cloning ${ghRepo.full_name} -> ${destPath}`);
307
308 const proc = Bun.spawn(
309 ["git", "clone", "--bare", "--mirror", cloneUrl, destPath],
310 {
311 stdout: "pipe",
312 stderr: "pipe",
313 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
314 }
315 );
316 const stderr = await new Response(proc.stderr).text();
317 const exitCode = await proc.exited;
318
319 if (exitCode !== 0) {
320 throw new Error(`git clone failed: ${stderr}`);
321 }
322
323 // Insert into database
324 await db.insert(repositories).values({
325 name: ghRepo.name,
326 ownerId: user.id,
327 description: ghRepo.description,
328 isPrivate: ghRepo.private,
329 defaultBranch: ghRepo.default_branch || "main",
330 diskPath: destPath,
331 starCount: 0,
332 });
333
334 console.log(`[import] ${ghRepo.full_name} imported successfully`);
335}
336
337export default importRoutes;
Modifiedsrc/views/layout.tsx+3−0View fileUnifiedSplit
6363 <a href="/dashboard" class="nav-link" style="font-weight: 600">
6464 Dashboard
6565 </a>
66 <a href="/import" class="nav-link">
67 Import
68 </a>
6669 <a href="/new" class="btn btn-sm btn-primary">
6770 + New
6871 </a>
6972