Commit23d1a81unknown_key
feat: spec-to-PR v2 (real AI pipeline) + collaborator/team permissions
feat: spec-to-PR v2 (real AI pipeline) + collaborator/team permissions Spec-to-PR v2 — the experimental backend stub is replaced by a full pipeline: - src/lib/spec-context.ts — score repo paths against the spec, collect top-N relevant files (cap 500 paths, 3KB per file, binary-skip). - src/lib/spec-ai.ts — Claude call + response parser with forbidden-path filter (layout.tsx, drizzle/, legal/, LICENSE, .github/), malformed-JSON recovery, 50KB prompt cap. - src/lib/spec-git.ts — plumbing-only branch creation in bare repos (read-tree -> hash-object -> update-index -> write-tree -> commit-tree -> update-ref). Rejects .. traversal and no-op trees. - src/lib/spec-to-pr.ts — composes the three libs, inserts a draft PR row. Collaborators / permissions — first per-repo role model: - drizzle/0035_repo_collaborators.sql + schema row (read/write/admin, invited/ accepted timestamps, unique on repo+user). - src/middleware/repo-access.ts — resolveRepoAccess() + requireRepoAccess(level) with owner > admin > write > read > none hierarchy, public-repo fallback. - src/routes/collaborators.tsx — GET list / POST add / POST remove, owner-only, auto-accept on invite (no email flow yet). - Mounted in app.tsx; "Manage collaborators" link on repo settings. Tests: 19 new spec-* pass, 5 repo-access pass. Full suite 171/61 (fail count is the known sandbox hono/jsx/jsx-dev-runtime install gap, identical to baseline). https://claude.ai/code/session_017Do52tMX1P9jPTWXhEohXH
16 files changed+2338−4423d1a81455ada89498ce732022c81f3187bbb4b4
16 changed files+2338−44
Addeddrizzle/0035_repo_collaborators.sql+32−0View fileUnifiedSplit
@@ -0,0 +1,32 @@
1-- Collaborators — per-repo role grants (read / write / admin).
2--
3-- A user granted access to a repository beyond ownership. Roles are
4-- hierarchical: admin implies write, write implies read. `invited_by` tracks
5-- who added them (nulled once that user is deleted); `accepted_at` is null
6-- until the invitee explicitly accepts. Unique per (repo, user) so a given
7-- user has at most one role per repo.
8
9CREATE TABLE IF NOT EXISTS "repo_collaborators" (
10 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
11 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
12 "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
13 "role" text NOT NULL DEFAULT 'read' CHECK (role IN ('read', 'write', 'admin')),
14 "invited_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
15 "invited_at" timestamp NOT NULL DEFAULT now(),
16 "accepted_at" timestamp
17);
18
19--> statement-breakpoint
20
21CREATE UNIQUE INDEX IF NOT EXISTS "repo_collaborators_repo_user_uq"
22 ON "repo_collaborators" ("repository_id", "user_id");
23
24--> statement-breakpoint
25
26CREATE INDEX IF NOT EXISTS "repo_collaborators_repo_idx"
27 ON "repo_collaborators" ("repository_id");
28
29--> statement-breakpoint
30
31CREATE INDEX IF NOT EXISTS "repo_collaborators_user_idx"
32 ON "repo_collaborators" ("user_id");
Addedsrc/__tests__/collaborators.test.ts+44−0View fileUnifiedSplit
@@ -0,0 +1,44 @@
1/**
2 * Collaborator management — route auth smoke.
3 *
4 * The routes in src/routes/collaborators.tsx are owner-only. These two
5 * smoke tests pin down the externally-observable auth contract:
6 * - unauthenticated GET redirects to /login (requireAuth)
7 * - an authed *non-owner* gets a 403 (or is bounced away — the 302→/login
8 * path is also acceptable if the DB is unavailable and the middleware
9 * can't resolve the session cookie)
10 *
11 * We intentionally don't spin up a real session; we rely on the middleware
12 * contract already covered by api-tokens.test.ts — requireAuth redirects
13 * when no valid session cookie is present.
14 */
15
16import { describe, it, expect } from "bun:test";
17import app from "../app";
18
19describe("collaborators — auth guard", () => {
20 it("GET /:owner/:repo/settings/collaborators without auth redirects to /login", async () => {
21 const res = await app.request(
22 "/somebody/some-repo/settings/collaborators"
23 );
24 expect(res.status).toBe(302);
25 expect(res.headers.get("location") || "").toContain("/login");
26 });
27
28 it("GET as an authed non-owner returns 403 or redirects away", async () => {
29 // We can't easily mint a real session without touching the DB, so we
30 // stub by sending a bogus session cookie. The middleware will fail to
31 // resolve it and redirect to /login — which is the "redirects away"
32 // branch the requirement allows. If a DB is configured and somehow the
33 // cookie resolves to a different user, we'd see a 403 from the
34 // inline owner check. Either outcome proves the route is not wide open.
35 const res = await app.request(
36 "/some-owner/some-repo/settings/collaborators",
37 { headers: { cookie: "session=not-a-real-token" } }
38 );
39 expect([302, 403, 404]).toContain(res.status);
40 if (res.status === 302) {
41 expect(res.headers.get("location") || "").toContain("/login");
42 }
43 });
44});
Addedsrc/__tests__/repo-access.test.ts+146−0View fileUnifiedSplit
@@ -0,0 +1,146 @@
1/**
2 * Unit tests for src/middleware/repo-access.ts#resolveRepoAccess.
3 *
4 * We stub `../db` with `mock.module` (same pattern as import-verify.test.ts)
5 * so we never touch Neon. The fake `db.select(...)` chain returns per-test
6 * configurable rows — one for the `repositories` lookup (owner check) and
7 * one for the `repo_collaborators` lookup. We inspect which table the query
8 * is `.from()`-ing to decide which row to return.
9 *
10 * See import-verify.test.ts for the reasoning behind the defensive defaults
11 * (`mock.module` registrations don't unwind across files in a single
12 * `bun test` run, so we reset state in `afterAll`).
13 */
14
15import { describe, it, expect, mock, afterAll } from "bun:test";
16
17type RepoRow = { id: string; ownerId: string } | undefined;
18type CollabRow = { role: "read" | "write" | "admin" } | undefined;
19
20let _nextRepoRow: RepoRow;
21let _nextCollabRow: CollabRow;
22// The last Drizzle table handle passed to `.from(...)` — we peek at it when
23// `.limit(1)` resolves so we can return the correct shape for this query.
24let _lastFrom: any = null;
25
26const _chain: any = {
27 from: (table: any) => {
28 _lastFrom = table;
29 return _chain;
30 },
31 leftJoin: () => _chain,
32 innerJoin: () => _chain,
33 rightJoin: () => _chain,
34 where: () => _chain,
35 orderBy: () => _chain,
36 groupBy: () => _chain,
37 limit: async () => {
38 // Heuristic: the schema defines tables as plain objects whose keys
39 // include the column definitions. We distinguish repositories from
40 // repo_collaborators by the presence of `ownerId` vs `acceptedAt`.
41 const t = _lastFrom;
42 if (t && typeof t === "object") {
43 if ("acceptedAt" in t || "invitedAt" in t) {
44 return _nextCollabRow ? [_nextCollabRow] : [];
45 }
46 if ("ownerId" in t || "diskPath" in t) {
47 return _nextRepoRow ? [_nextRepoRow] : [];
48 }
49 }
50 return [];
51 },
52};
53
54const _fakeDb = {
55 db: {
56 select: () => _chain,
57 insert: () => _chain,
58 update: () => _chain,
59 delete: () => _chain,
60 },
61 getDb: () => ({
62 select: () => _chain,
63 insert: () => _chain,
64 update: () => _chain,
65 delete: () => _chain,
66 }),
67};
68mock.module("../db", () => _fakeDb);
69
70afterAll(() => {
71 _nextRepoRow = undefined;
72 _nextCollabRow = undefined;
73 _lastFrom = null;
74});
75
76const REPO_ID = "11111111-1111-1111-1111-111111111111";
77const OWNER_ID = "22222222-2222-2222-2222-222222222222";
78const OTHER_USER_ID = "33333333-3333-3333-3333-333333333333";
79
80describe("resolveRepoAccess", () => {
81 it('owner returns "owner"', async () => {
82 _nextRepoRow = { id: REPO_ID, ownerId: OWNER_ID };
83 _nextCollabRow = undefined;
84 const { resolveRepoAccess } = await import("../middleware/repo-access");
85 const access = await resolveRepoAccess({
86 repoId: REPO_ID,
87 userId: OWNER_ID,
88 isPublic: false,
89 });
90 expect(access).toBe("owner");
91 });
92
93 it("collaborator with accepted invite returns their role", async () => {
94 // Viewer is NOT the owner (so the owner check falls through), but they
95 // have an accepted "write" collaborator row.
96 _nextRepoRow = { id: REPO_ID, ownerId: OWNER_ID };
97 _nextCollabRow = { role: "write" };
98 const { resolveRepoAccess } = await import("../middleware/repo-access");
99 const access = await resolveRepoAccess({
100 repoId: REPO_ID,
101 userId: OTHER_USER_ID,
102 isPublic: false,
103 });
104 expect(access).toBe("write");
105 });
106
107 it("pending invite (acceptedAt=null) does NOT grant access", async () => {
108 // The middleware filters `acceptedAt IS NOT NULL` in the WHERE clause,
109 // so a pending row would never be returned by the real DB — we simulate
110 // that by returning no collaborator row. The user should fall through
111 // to the public/private fallback; here the repo is private, so "none".
112 _nextRepoRow = { id: REPO_ID, ownerId: OWNER_ID };
113 _nextCollabRow = undefined;
114 const { resolveRepoAccess } = await import("../middleware/repo-access");
115 const access = await resolveRepoAccess({
116 repoId: REPO_ID,
117 userId: OTHER_USER_ID,
118 isPublic: false,
119 });
120 expect(access).toBe("none");
121 });
122
123 it('public repo + no collaborator row returns "read"', async () => {
124 _nextRepoRow = { id: REPO_ID, ownerId: OWNER_ID };
125 _nextCollabRow = undefined;
126 const { resolveRepoAccess } = await import("../middleware/repo-access");
127 const access = await resolveRepoAccess({
128 repoId: REPO_ID,
129 userId: OTHER_USER_ID,
130 isPublic: true,
131 });
132 expect(access).toBe("read");
133 });
134
135 it('private repo + no collaborator row returns "none"', async () => {
136 _nextRepoRow = { id: REPO_ID, ownerId: OWNER_ID };
137 _nextCollabRow = undefined;
138 const { resolveRepoAccess } = await import("../middleware/repo-access");
139 const access = await resolveRepoAccess({
140 repoId: REPO_ID,
141 userId: OTHER_USER_ID,
142 isPublic: false,
143 });
144 expect(access).toBe("none");
145 });
146});
Addedsrc/__tests__/spec-ai.test.ts+254−0View fileUnifiedSplit
@@ -0,0 +1,254 @@
1/**
2 * Tests for spec-to-PR v2, part 2 — the Claude call + response parser.
3 *
4 * The Anthropic SDK captures `globalThis.fetch` at client construction, so
5 * every test that installs a stub must first call `_resetClientForTests()`.
6 */
7
8import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9import {
10 _resetClientForTests,
11 generateSpecEdits,
12 isForbiddenPath,
13 parseAiJsonResponse,
14 validateEdit,
15} from "../lib/spec-ai";
16
17const origFetch = globalThis.fetch;
18const origKey = process.env.ANTHROPIC_API_KEY;
19
20/**
21 * Install a fake fetch that returns a single Anthropic-shaped messages.create
22 * response with the provided text body.
23 */
24function installAnthropicFetch(textBody: string | (() => string)): void {
25 // @ts-expect-error — override global fetch for the duration of the test
26 globalThis.fetch = async (
27 _input: RequestInfo | URL,
28 _init: RequestInit = {}
29 ): Promise<Response> => {
30 const text = typeof textBody === "function" ? textBody() : textBody;
31 const payload = {
32 id: "msg_test",
33 type: "message",
34 role: "assistant",
35 model: "claude-sonnet-4-6",
36 content: [{ type: "text", text }],
37 stop_reason: "end_turn",
38 stop_sequence: null,
39 usage: { input_tokens: 1, output_tokens: 1 },
40 };
41 return new Response(JSON.stringify(payload), {
42 status: 200,
43 headers: { "content-type": "application/json" },
44 });
45 };
46}
47
48function restoreEnv(): void {
49 globalThis.fetch = origFetch;
50 if (origKey === undefined) {
51 delete process.env.ANTHROPIC_API_KEY;
52 } else {
53 process.env.ANTHROPIC_API_KEY = origKey;
54 }
55 _resetClientForTests();
56}
57
58// ---------------------------------------------------------------------------
59// Pure helpers — quick sanity checks alongside the main 4 tests.
60// ---------------------------------------------------------------------------
61
62describe("lib/spec-ai — pure helpers", () => {
63 it("isForbiddenPath flags locked files", () => {
64 expect(isForbiddenPath("BUILD_BIBLE.md")).toBe(true);
65 expect(isForbiddenPath("src/views/layout.tsx")).toBe(true);
66 expect(isForbiddenPath("drizzle/0001_init.sql")).toBe(true);
67 expect(isForbiddenPath("legal/terms.md")).toBe(true);
68 expect(isForbiddenPath("LICENSE")).toBe(true);
69 expect(isForbiddenPath(".github/workflows/ci.yml")).toBe(true);
70 });
71
72 it("isForbiddenPath lets ordinary source paths through", () => {
73 expect(isForbiddenPath("src/lib/foo.ts")).toBe(false);
74 expect(isForbiddenPath("src/routes/api.ts")).toBe(false);
75 });
76
77 it("validateEdit rejects unsafe paths", () => {
78 expect(validateEdit({ action: "edit", path: "/etc/passwd", content: "" })).toBe(false);
79 expect(validateEdit({ action: "edit", path: "../foo", content: "" })).toBe(false);
80 expect(validateEdit({ action: "edit", path: "", content: "" })).toBe(false);
81 });
82
83 it("validateEdit accepts a well-formed edit", () => {
84 expect(
85 validateEdit({ action: "edit", path: "src/lib/foo.ts", content: "x" })
86 ).toBe(true);
87 expect(
88 validateEdit({ action: "delete", path: "src/old.ts" })
89 ).toBe(true);
90 });
91
92 it("parseAiJsonResponse strips ```json fences", () => {
93 const parsed = parseAiJsonResponse('```json\n{"a":1}\n```');
94 expect(parsed).toEqual({ a: 1 });
95 });
96
97 it("parseAiJsonResponse parses raw JSON", () => {
98 const parsed = parseAiJsonResponse('{"b":2}');
99 expect(parsed).toEqual({ b: 2 });
100 });
101
102 it("parseAiJsonResponse returns null on garbage", () => {
103 expect(parseAiJsonResponse("not json at all")).toBeNull();
104 });
105});
106
107// ---------------------------------------------------------------------------
108// The 4 required tests.
109// ---------------------------------------------------------------------------
110
111describe("lib/spec-ai — generateSpecEdits", () => {
112 beforeEach(() => {
113 _resetClientForTests();
114 });
115
116 afterEach(() => {
117 restoreEnv();
118 });
119
120 it("returns ok:false when ANTHROPIC_API_KEY missing", async () => {
121 delete process.env.ANTHROPIC_API_KEY;
122 const result = await generateSpecEdits({
123 spec: "add a greeting function",
124 fileList: ["src/index.ts"],
125 relevantFiles: [{ path: "src/index.ts", content: "// hi" }],
126 defaultBranch: "main",
127 });
128 expect(result.ok).toBe(false);
129 if (result.ok === false) {
130 expect(result.error).toContain("ANTHROPIC_API_KEY");
131 }
132 });
133
134 it("parses a well-formed response", async () => {
135 process.env.ANTHROPIC_API_KEY = "test-key";
136 installAnthropicFetch(
137 JSON.stringify({
138 summary: "add greet()",
139 edits: [
140 {
141 action: "create",
142 path: "src/lib/greet.ts",
143 content: "export const greet = () => 'hi';",
144 },
145 {
146 action: "edit",
147 path: "src/index.ts",
148 content: "import { greet } from './lib/greet';\ngreet();",
149 },
150 { action: "delete", path: "src/old.ts" },
151 ],
152 })
153 );
154
155 const result = await generateSpecEdits({
156 spec: "add a greeting",
157 fileList: ["src/index.ts"],
158 relevantFiles: [{ path: "src/index.ts", content: "// hi" }],
159 defaultBranch: "main",
160 });
161
162 expect(result.ok).toBe(true);
163 if (result.ok === true) {
164 expect(result.summary).toBe("add greet()");
165 expect(result.edits).toHaveLength(3);
166 expect(result.edits[0]).toEqual({
167 action: "create",
168 path: "src/lib/greet.ts",
169 content: "export const greet = () => 'hi';",
170 });
171 expect(result.edits[2]).toEqual({ action: "delete", path: "src/old.ts" });
172 }
173 });
174
175 // We chose "drop the forbidden edit, keep the ok:true envelope" — the
176 // caller can compare input vs output length if they want to detect this.
177 // If Claude proposes ONLY forbidden edits, the caller receives
178 // `{ok:true, edits:[], summary:"AI proposed no changes"}`.
179 it("rejects edits targeting forbidden paths (silently dropped)", async () => {
180 process.env.ANTHROPIC_API_KEY = "test-key";
181 installAnthropicFetch(
182 JSON.stringify({
183 summary: "mixed forbidden + allowed",
184 edits: [
185 {
186 action: "edit",
187 path: "BUILD_BIBLE.md",
188 content: "should be dropped",
189 },
190 {
191 action: "edit",
192 path: "src/views/layout.tsx",
193 content: "should also be dropped",
194 },
195 {
196 action: "edit",
197 path: "drizzle/0001_init.sql",
198 content: "dropped",
199 },
200 {
201 action: "edit",
202 path: "LICENSE",
203 content: "dropped",
204 },
205 {
206 action: "edit",
207 path: ".github/workflows/ci.yml",
208 content: "dropped",
209 },
210 {
211 action: "create",
212 path: "src/lib/ok.ts",
213 content: "export const ok = 1;",
214 },
215 ],
216 })
217 );
218
219 const result = await generateSpecEdits({
220 spec: "touch forbidden files",
221 fileList: ["BUILD_BIBLE.md", "LICENSE"],
222 relevantFiles: [],
223 defaultBranch: "main",
224 });
225
226 expect(result.ok).toBe(true);
227 if (result.ok === true) {
228 // Exactly one allowed edit survives.
229 expect(result.edits).toHaveLength(1);
230 expect(result.edits[0].path).toBe("src/lib/ok.ts");
231 // No edit points at a forbidden path.
232 for (const e of result.edits) {
233 expect(isForbiddenPath(e.path)).toBe(false);
234 }
235 }
236 });
237
238 it("handles malformed JSON response", async () => {
239 process.env.ANTHROPIC_API_KEY = "test-key";
240 installAnthropicFetch("this is not JSON, sorry");
241
242 const result = await generateSpecEdits({
243 spec: "whatever",
244 fileList: [],
245 relevantFiles: [],
246 defaultBranch: "main",
247 });
248
249 expect(result.ok).toBe(false);
250 if (result.ok === false) {
251 expect(result.error).toBe("AI returned invalid JSON");
252 }
253 });
254});
Addedsrc/__tests__/spec-context.test.ts+73−0View fileUnifiedSplit
@@ -0,0 +1,73 @@
1import { describe, it, expect } from "bun:test";
2import { join } from "path";
3import { buildSpecContext, scoreFile } from "../lib/spec-context";
4
5describe("buildSpecContext", () => {
6 it("returns ok:false for nonexistent repo path", async () => {
7 const bogus = join(
8 "/tmp",
9 `spec-context-does-not-exist-${Date.now()}-${Math.random()}`
10 );
11 const result = await buildSpecContext({
12 repoDiskPath: bogus,
13 spec: "add a new auth endpoint",
14 });
15 expect(result.ok).toBe(false);
16 if (!result.ok) {
17 expect(typeof result.error).toBe("string");
18 expect(result.error.length).toBeGreaterThan(0);
19 }
20 });
21});
22
23describe("scoreFile", () => {
24 it("scores keyword-matched filenames higher than unrelated ones", () => {
25 const tokens = ["auth", "login", "session"];
26 const hot = scoreFile("src/routes/auth.ts", tokens);
27 const cold = scoreFile("src/views/layout.tsx", tokens);
28 expect(hot).toBeGreaterThan(cold);
29
30 // The spec word "login" matching a filename should also dominate over a
31 // fully unrelated path with no token overlap.
32 const loginHit = scoreFile("src/pages/login-form.ts", tokens);
33 const unrelated = scoreFile("src/utils/strings.ts", tokens);
34 expect(loginHit).toBeGreaterThan(unrelated);
35 });
36});
37
38describe("scoreFile ranking & caps", () => {
39 it("caps file list at 500 and relevantFiles at maxRelevantFiles", async () => {
40 // We can exercise the ranking/cap logic without a real git repo by
41 // simulating the scoring step directly. This covers the public contract
42 // that `scoreFile` + downstream sort gives a stable ranking, and the
43 // numeric caps declared in the module are respected.
44 const tokens = ["widget"];
45 const paths: string[] = [];
46 for (let i = 0; i < 750; i++) {
47 // Mix in some matches so sorting has signal.
48 paths.push(i % 7 === 0 ? `src/widget-${i}.ts` : `src/file-${i}.ts`);
49 }
50 const capped = paths.slice(0, 500);
51 expect(capped.length).toBe(500);
52
53 const scored = capped
54 .map((p) => ({ p, s: scoreFile(p, tokens) }))
55 .sort((a, b) => {
56 if (b.s !== a.s) return b.s - a.s;
57 return a.p.length - b.p.length;
58 });
59
60 // Top of ranking should be a widget-matched path.
61 expect(scored[0].p).toContain("widget");
62
63 // Applying the maxRelevantFiles cap produces exactly that many entries.
64 const maxRelevantFiles = 20;
65 const top = scored.slice(0, maxRelevantFiles);
66 expect(top.length).toBe(maxRelevantFiles);
67
68 // And every top entry should score at least as high as any dropped one.
69 const tailMax = Math.max(...scored.slice(maxRelevantFiles).map((x) => x.s));
70 const topMin = Math.min(...top.map((x) => x.s));
71 expect(topMin).toBeGreaterThanOrEqual(tailMax);
72 });
73});
Addedsrc/__tests__/spec-git.test.ts+60−0View fileUnifiedSplit
@@ -0,0 +1,60 @@
1/**
2 * Tests for src/lib/spec-git.ts. These exercise the early-return paths
3 * (missing repo, bad path, empty edits) without touching the disk — the
4 * function should refuse to proceed before shelling out to git.
5 */
6import { describe, it, expect } from "bun:test";
7import { applyEditsToNewBranch } from "../lib/spec-git";
8
9describe("applyEditsToNewBranch", () => {
10 it("returns ok:false when repo path does not exist", async () => {
11 const result = await applyEditsToNewBranch({
12 repoDiskPath: "/tmp/gluecron-spec-git-nonexistent-" + Date.now(),
13 baseRef: "main",
14 edits: [{ action: "create", path: "hello.txt", content: "hi" }],
15 branchName: "spec/hello",
16 commitMessage: "add hello",
17 authorName: "Tester",
18 authorEmail: "tester@example.com",
19 });
20 expect(result.ok).toBe(false);
21 if (!result.ok) {
22 expect(typeof result.error).toBe("string");
23 expect(result.error.length).toBeGreaterThan(0);
24 }
25 });
26
27 it("rejects path traversal", async () => {
28 const result = await applyEditsToNewBranch({
29 repoDiskPath: "/tmp/does-not-matter",
30 baseRef: "main",
31 edits: [
32 { action: "create", path: "../../etc/passwd", content: "pwn" },
33 ],
34 branchName: "spec/traversal",
35 commitMessage: "bad",
36 authorName: "Tester",
37 authorEmail: "tester@example.com",
38 });
39 expect(result.ok).toBe(false);
40 if (!result.ok) {
41 expect(result.error).toContain("..");
42 }
43 });
44
45 it("rejects empty edits array with ok:false", async () => {
46 const result = await applyEditsToNewBranch({
47 repoDiskPath: "/tmp/does-not-matter",
48 baseRef: "main",
49 edits: [],
50 branchName: "spec/empty",
51 commitMessage: "nothing",
52 authorName: "Tester",
53 authorEmail: "tester@example.com",
54 });
55 expect(result.ok).toBe(false);
56 if (!result.ok) {
57 expect(result.error).toMatch(/no edits|empty/i);
58 }
59 });
60});
Modifiedsrc/__tests__/spec-to-pr.test.ts+22−5View fileUnifiedSplit
@@ -1,6 +1,11 @@
11import { describe, it, expect, afterEach } from "bun:test";
22import { createSpecPR } from "../lib/spec-to-pr";
33
4/**
5 * The real pipeline (context → AI → git → PR insert) lives in
6 * `spec-context`, `spec-ai`, and `spec-git` tests. Here we only cover the
7 * fail-fast guards that don't require DB/disk/AI.
8 */
49describe("createSpecPR", () => {
510 const originalKey = process.env.ANTHROPIC_API_KEY;
611
@@ -11,15 +16,27 @@ describe("createSpecPR", () => {
1116
1217 it("returns ok:false when ANTHROPIC_API_KEY is missing", async () => {
1318 delete process.env.ANTHROPIC_API_KEY;
14 const result = await createSpecPR({ repoId: 1, spec: "test", userId: 1 });
19 const result = await createSpecPR({
20 repoId: "00000000-0000-0000-0000-000000000000",
21 spec: "test",
22 userId: "00000000-0000-0000-0000-000000000000",
23 });
1524 expect(result.ok).toBe(false);
16 expect(result.error).toContain("ANTHROPIC_API_KEY");
25 if (!result.ok) {
26 expect(result.error).toContain("ANTHROPIC_API_KEY");
27 }
1728 });
1829
19 it("returns ok:false with a clear experimental notice when key is set", async () => {
30 it("returns ok:false when spec is empty", async () => {
2031 process.env.ANTHROPIC_API_KEY = "fake-key-for-testing";
21 const result = await createSpecPR({ repoId: -999, spec: "test", userId: 1 });
32 const result = await createSpecPR({
33 repoId: "00000000-0000-0000-0000-000000000000",
34 spec: " ",
35 userId: "00000000-0000-0000-0000-000000000000",
36 });
2237 expect(result.ok).toBe(false);
23 expect(result.error).toBeTruthy();
38 if (!result.ok) {
39 expect(result.error).toContain("empty");
40 }
2441 });
2542});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -15,6 +15,7 @@ import settingsRoutes from "./routes/settings";
1515import settings2faRoutes from "./routes/settings-2fa";
1616import issueRoutes from "./routes/issues";
1717import repoSettings from "./routes/repo-settings";
18import collaboratorRoutes from "./routes/collaborators";
1819import compareRoutes from "./routes/compare";
1920import pullRoutes from "./routes/pulls";
2021import editorRoutes from "./routes/editor";
@@ -185,6 +186,9 @@ app.route("/", notificationRoutes);
185186// Repo settings (description, visibility, delete)
186187app.route("/", repoSettings);
187188
189// Repo collaborators (add/list/remove)
190app.route("/", collaboratorRoutes);
191
188192// Webhooks management
189193app.route("/", webhookRoutes);
190194
Modifiedsrc/db/schema.ts+42−0View fileUnifiedSplit
@@ -2359,3 +2359,45 @@ export const commitStatuses = pgTable(
23592359);
23602360
23612361export type CommitStatus = typeof commitStatuses.$inferSelect;
2362
2363// ---------------------------------------------------------------------------
2364// Collaborators — per-repo role grants (read / write / admin).
2365// ---------------------------------------------------------------------------
2366
2367/**
2368 * A user granted access to a repository beyond ownership. Roles are
2369 * hierarchical — 'admin' implies write, write implies read. `invitedBy` tracks
2370 * who added them (nullable once that user is deleted); `acceptedAt` is null
2371 * until the invitee explicitly accepts. Unique per (repo, user) so a given
2372 * user has at most one role per repo.
2373 */
2374export const repoCollaborators = pgTable(
2375 "repo_collaborators",
2376 {
2377 id: uuid("id").primaryKey().defaultRandom(),
2378 repositoryId: uuid("repository_id")
2379 .notNull()
2380 .references(() => repositories.id, { onDelete: "cascade" }),
2381 userId: uuid("user_id")
2382 .notNull()
2383 .references(() => users.id, { onDelete: "cascade" }),
2384 role: text("role", { enum: ["read", "write", "admin"] })
2385 .notNull()
2386 .default("read"),
2387 invitedBy: uuid("invited_by").references(() => users.id, {
2388 onDelete: "set null",
2389 }),
2390 invitedAt: timestamp("invited_at").defaultNow().notNull(),
2391 acceptedAt: timestamp("accepted_at"),
2392 },
2393 (table) => [
2394 uniqueIndex("repo_collaborators_repo_user_uq").on(
2395 table.repositoryId,
2396 table.userId
2397 ),
2398 index("repo_collaborators_repo_idx").on(table.repositoryId),
2399 index("repo_collaborators_user_idx").on(table.userId),
2400 ]
2401);
2402
2403export type RepoCollaborator = typeof repoCollaborators.$inferSelect;
Addedsrc/lib/spec-ai.ts+388−0View fileUnifiedSplit
@@ -0,0 +1,388 @@
1/**
2 * spec-to-PR v2, part 2 — Claude API call + response parser.
3 *
4 * Given a user spec plus a compact view of the repository (file list +
5 * relevant file contents), asks Claude for a minimal set of file edits that
6 * implement the spec. The response is parsed, validated, and returned as a
7 * discriminated union so the caller (spec-to-PR pipeline) can decide what
8 * to do next.
9 *
10 * Contract:
11 * - Never throws. Every failure path returns `{ok:false, error:string}`.
12 * - Never invents or installs dependencies. Never edits forbidden paths.
13 * - "no edits" is a valid successful result — caller decides policy.
14 *
15 * Client pattern cribbed from `src/lib/ai-review.ts` (direct `@anthropic-ai/sdk`
16 * + per-call `client.messages.create`), kept intentionally consistent with the
17 * rest of the `ai-*` modules.
18 */
19
20import Anthropic from "@anthropic-ai/sdk";
21import { config } from "./config";
22
23// ---------------------------------------------------------------------------
24// Public types
25// ---------------------------------------------------------------------------
26
27export type FileEdit =
28 | { action: "create"; path: string; content: string }
29 | { action: "edit"; path: string; content: string }
30 | { action: "delete"; path: string };
31
32export type SpecAIResult =
33 | { ok: true; edits: FileEdit[]; summary: string }
34 | { ok: false; error: string };
35
36export interface GenerateSpecEditsArgs {
37 spec: string;
38 fileList: string[];
39 relevantFiles: Array<{ path: string; content: string }>;
40 defaultBranch: string;
41 /** Model override. Default: `claude-sonnet-4-6` as specified by the caller. */
42 model?: string;
43}
44
45// ---------------------------------------------------------------------------
46// Tunables
47// ---------------------------------------------------------------------------
48
49/** Total prompt size cap, in bytes. Matches the v2 spec. */
50const MAX_PROMPT_BYTES = 50_000;
51/** Hard cap on file list lines. */
52const MAX_FILE_LIST_LINES = 500;
53/** Default model — spec says `claude-sonnet-4-6`. */
54const DEFAULT_MODEL = "claude-sonnet-4-6";
55
56/**
57 * Paths we will never let Claude edit, regardless of what it returns.
58 * Matches substrings/prefixes — see `isForbiddenPath`.
59 */
60const FORBIDDEN_PATTERNS: Array<string | RegExp> = [
61 "BUILD_BIBLE.md",
62 "src/views/layout.tsx",
63 /^drizzle\//,
64 /^legal\//,
65 "LICENSE",
66 /^\.github\//,
67];
68
69// ---------------------------------------------------------------------------
70// Public helpers (exported so tests can poke at the pure bits)
71// ---------------------------------------------------------------------------
72
73/**
74 * True if `path` targets a protected area of the tree that Claude must not
75 * touch. Rejects edits as a defence-in-depth check in addition to the
76 * instruction in the system prompt.
77 */
78export function isForbiddenPath(path: string): boolean {
79 if (!path) return true;
80 for (const pat of FORBIDDEN_PATTERNS) {
81 if (typeof pat === "string") {
82 if (path === pat) return true;
83 } else if (pat.test(path)) {
84 return true;
85 }
86 }
87 return false;
88}
89
90/**
91 * True if `path` is a safe, relative, non-traversing filesystem path.
92 * Rejects absolute paths, `..` traversal, backslashes, and empty strings.
93 */
94export function isSafeRelativePath(path: string): boolean {
95 if (typeof path !== "string") return false;
96 if (!path) return false;
97 if (path.startsWith("/")) return false;
98 if (path.includes("\\")) return false;
99 const parts = path.split("/");
100 for (const part of parts) {
101 if (part === "" || part === "." || part === "..") return false;
102 }
103 return true;
104}
105
106/**
107 * Structural + policy validation for a single edit. Returns true only if:
108 * - `action` is one of create / edit / delete
109 * - `path` is a safe relative path and not forbidden
110 * - `content` is a string for create / edit
111 */
112export function validateEdit(edit: unknown): edit is FileEdit {
113 if (!edit || typeof edit !== "object") return false;
114 const e = edit as Record<string, unknown>;
115 const action = e.action;
116 const path = e.path;
117 if (typeof path !== "string") return false;
118 if (!isSafeRelativePath(path)) return false;
119 if (isForbiddenPath(path)) return false;
120 if (action === "create" || action === "edit") {
121 return typeof e.content === "string";
122 }
123 if (action === "delete") {
124 return true;
125 }
126 return false;
127}
128
129/**
130 * Parse a Claude response body (which may be wrapped in ```json / ``` fences or
131 * contain surrounding prose) into a JSON object.
132 *
133 * Returns `null` on any parse failure.
134 */
135export function parseAiJsonResponse(text: string): unknown | null {
136 if (typeof text !== "string" || !text) return null;
137 let trimmed = text.trim();
138
139 // Strip leading / trailing triple-backtick fences, optionally tagged "json".
140 const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
141 if (fenceMatch) {
142 trimmed = fenceMatch[1].trim();
143 }
144
145 try {
146 return JSON.parse(trimmed);
147 } catch {
148 // Fall back to first balanced {...} block if any.
149 const braceMatch = trimmed.match(/\{[\s\S]*\}/);
150 if (braceMatch) {
151 try {
152 return JSON.parse(braceMatch[0]);
153 } catch {
154 return null;
155 }
156 }
157 return null;
158 }
159}
160
161/**
162 * Build the system prompt. Kept as an exported helper so it can be inspected
163 * from tests without calling Claude.
164 */
165export function buildSystemPrompt(): string {
166 return [
167 "You are an AI coding assistant working inside a repo.",
168 "Given a user spec and the repo's file tree + relevant files, produce file edits that implement the spec.",
169 "",
170 "Rules:",
171 "- Minimal scope — implement one feature at a time.",
172 "- Never edit tests, CI configs, BUILD_BIBLE.md, locked files, or license files.",
173 "- Never invent new dependencies.",
174 "- Keep edits surgical and focused on the spec.",
175 "",
176 "Respond with ONLY a JSON object matching this TypeScript type, with no prose, no markdown fences, no commentary:",
177 "",
178 "{",
179 ' summary: string,',
180 " edits: Array<{ action: 'create' | 'edit' | 'delete', path: string, content?: string }>",
181 "}",
182 "",
183 "For create and edit actions, `content` is required and must be the full file contents.",
184 "For delete actions, omit `content`.",
185 "Paths must be relative (no leading /, no ..).",
186 ].join("\n");
187}
188
189/**
190 * Build the user prompt, fitting inside `MAX_PROMPT_BYTES`.
191 *
192 * Truncation strategy, in order:
193 * 1. Cap file list at `MAX_FILE_LIST_LINES`.
194 * 2. If still over budget, drop trailing file-list entries.
195 * 3. If still over, drop lowest-ranked (last) relevant files.
196 *
197 * `relevantFiles` is assumed to be pre-sorted by the caller in descending
198 * score order (most relevant first). We only ever drop from the tail.
199 */
200export function buildUserPrompt(args: GenerateSpecEditsArgs): string {
201 const { spec, defaultBranch } = args;
202 let fileList = args.fileList.slice(0, MAX_FILE_LIST_LINES);
203 const relevant = args.relevantFiles.slice();
204
205 const header = () =>
206 [
207 `Default branch: ${defaultBranch}`,
208 "",
209 "User spec:",
210 spec,
211 "",
212 ].join("\n");
213
214 const render = (): string => {
215 const parts: string[] = [header()];
216 parts.push("Repository file list:");
217 parts.push("```");
218 parts.push(fileList.join("\n"));
219 parts.push("```");
220 parts.push("");
221 if (relevant.length > 0) {
222 parts.push("Relevant files:");
223 parts.push("");
224 for (const f of relevant) {
225 parts.push("```" + (f.path || ""));
226 parts.push(f.content || "");
227 parts.push("```");
228 parts.push("");
229 }
230 }
231 return parts.join("\n");
232 };
233
234 let out = render();
235 if (byteLen(out) <= MAX_PROMPT_BYTES) return out;
236
237 // 1. Trim file list.
238 while (fileList.length > 0 && byteLen(render()) > MAX_PROMPT_BYTES) {
239 fileList = fileList.slice(0, Math.max(0, fileList.length - 10));
240 }
241 out = render();
242 if (byteLen(out) <= MAX_PROMPT_BYTES) return out;
243
244 // 2. Drop lowest-scoring (tail) relevant files one at a time.
245 while (relevant.length > 0 && byteLen(render()) > MAX_PROMPT_BYTES) {
246 relevant.pop();
247 }
248
249 return render();
250}
251
252function byteLen(s: string): number {
253 // Bun / Node both expose Buffer; fall back to a UTF-8 estimate otherwise.
254 try {
255 return Buffer.byteLength(s, "utf8");
256 } catch {
257 return s.length;
258 }
259}
260
261// ---------------------------------------------------------------------------
262// Anthropic client (local to this module — matches ai-review.ts pattern)
263// ---------------------------------------------------------------------------
264
265let _client: Anthropic | null = null;
266
267function getClient(): Anthropic {
268 if (!_client) {
269 _client = new Anthropic({ apiKey: config.anthropicApiKey });
270 }
271 return _client;
272}
273
274/**
275 * Drop the cached Anthropic client. Only intended for tests that need to
276 * swap `globalThis.fetch` between calls — the SDK captures `fetch` at
277 * client construction time, so reusing a client would pin the stubbed
278 * fetch from an earlier test.
279 *
280 * @internal
281 */
282export function _resetClientForTests(): void {
283 _client = null;
284}
285
286// ---------------------------------------------------------------------------
287// Main entry point
288// ---------------------------------------------------------------------------
289
290/**
291 * Ask Claude to propose file edits that implement `spec`.
292 *
293 * Never throws — returns a discriminated union. On validation failure a
294 * proposed edit is silently dropped; if *every* proposed edit is rejected
295 * the result is still `{ok:true, edits:[], summary:"..."}` so the caller
296 * can distinguish "AI produced nothing usable" from "AI / transport error".
297 */
298export async function generateSpecEdits(
299 args: GenerateSpecEditsArgs
300): Promise<SpecAIResult> {
301 if (!config.anthropicApiKey) {
302 return { ok: false, error: "ANTHROPIC_API_KEY required" };
303 }
304
305 const model = args.model || DEFAULT_MODEL;
306
307 let systemPrompt: string;
308 let userPrompt: string;
309 try {
310 systemPrompt = buildSystemPrompt();
311 userPrompt = buildUserPrompt(args);
312 } catch (err) {
313 return {
314 ok: false,
315 error: `prompt construction failed: ${errMessage(err)}`,
316 };
317 }
318
319 let rawText: string;
320 try {
321 const client = getClient();
322 const message = await client.messages.create({
323 model,
324 max_tokens: 4096,
325 temperature: 0.2,
326 system: systemPrompt,
327 messages: [{ role: "user", content: userPrompt }],
328 });
329 rawText = "";
330 for (const block of message.content) {
331 if (block.type === "text") {
332 rawText += block.text;
333 }
334 }
335 } catch (err) {
336 return { ok: false, error: `AI call failed: ${errMessage(err)}` };
337 }
338
339 const parsed = parseAiJsonResponse(rawText);
340 if (!parsed || typeof parsed !== "object") {
341 return { ok: false, error: "AI returned invalid JSON" };
342 }
343
344 const obj = parsed as Record<string, unknown>;
345 const summaryRaw = obj.summary;
346 const editsRaw = obj.edits;
347 const summary =
348 typeof summaryRaw === "string" && summaryRaw.trim()
349 ? summaryRaw.trim()
350 : "";
351
352 if (!Array.isArray(editsRaw)) {
353 return { ok: false, error: "AI returned invalid JSON" };
354 }
355
356 const edits: FileEdit[] = [];
357 for (const candidate of editsRaw) {
358 if (validateEdit(candidate)) {
359 edits.push(candidate);
360 }
361 // Forbidden / malformed edits are silently dropped. The caller can look
362 // at `edits.length` vs the original `editsRaw.length` if it cares.
363 }
364
365 if (edits.length === 0) {
366 return {
367 ok: true,
368 edits: [],
369 summary: summary || "AI proposed no changes",
370 };
371 }
372
373 return {
374 ok: true,
375 edits,
376 summary: summary || "AI proposed changes",
377 };
378}
379
380function errMessage(err: unknown): string {
381 if (err instanceof Error) return err.message;
382 if (typeof err === "string") return err;
383 try {
384 return JSON.stringify(err);
385 } catch {
386 return "unknown error";
387 }
388}
Addedsrc/lib/spec-context.ts+262−0View fileUnifiedSplit
@@ -0,0 +1,262 @@
1/**
2 * Spec context reader for spec-to-PR.
3 *
4 * Given a bare repo on disk and a natural-language spec, produce a bounded
5 * "prompt context" package: a capped file list plus the highest-scoring
6 * source files (by keyword overlap with the spec), each content-truncated.
7 *
8 * Design:
9 * - All git access is via `Bun.spawn(["git", "-C", repoDiskPath, ...])` —
10 * argv form, no shell.
11 * - We never throw: any git/IO failure is returned as `{ok:false, error}`.
12 * - Scoring is deliberately cheap (token overlap + a couple of small boosts)
13 * because this runs synchronously before an LLM call on every request and
14 * must stay predictable/cheap.
15 * - Binary files are skipped by the classic NUL-byte heuristic so we don't
16 * waste token budget on images/archives.
17 */
18export type SpecContext = {
19 fileList: string[];
20 relevantFiles: Array<{ path: string; content: string }>;
21 defaultBranch: string;
22 totalSizeBytes: number;
23};
24
25export type BuildSpecContextArgs = {
26 repoDiskPath: string;
27 spec: string;
28 defaultBranch?: string;
29 maxRelevantFiles?: number;
30 maxFileBytes?: number;
31};
32
33export type BuildSpecContextResult =
34 | { ok: true; context: SpecContext }
35 | { ok: false; error: string };
36
37const STOP_WORDS = new Set([
38 "add",
39 "the",
40 "and",
41 "for",
42 "from",
43 "with",
44 "this",
45 "that",
46]);
47
48const CODE_EXTENSIONS = new Set([
49 "ts",
50 "tsx",
51 "js",
52 "jsx",
53 "py",
54 "go",
55 "rs",
56 "rb",
57 "java",
58 "php",
59]);
60
61const BOOST_NAMES = new Set(["readme", "index", "main", "app"]);
62
63const MAX_FILE_LIST = 500;
64const DEFAULT_MAX_RELEVANT = 20;
65const DEFAULT_MAX_BYTES = 3000;
66const BINARY_SNIFF_BYTES = 8000;
67
68/** Tokenize a spec into lowercase alphanumeric words of length ≥3, stop-words removed. */
69function tokenize(spec: string): string[] {
70 const out: string[] = [];
71 const seen = new Set<string>();
72 for (const raw of spec.toLowerCase().split(/[^a-z0-9]+/)) {
73 if (raw.length < 3) continue;
74 if (STOP_WORDS.has(raw)) continue;
75 if (seen.has(raw)) continue;
76 seen.add(raw);
77 out.push(raw);
78 }
79 return out;
80}
81
82/**
83 * Score a path against a set of spec tokens. Higher = more relevant.
84 *
85 * Exported so the unit tests can exercise scoring without building a real
86 * git repo on disk.
87 */
88export function scoreFile(path: string, tokens: string[]): number {
89 const lower = path.toLowerCase();
90 const parts = lower.split(/[\/\\._-]+/).filter(Boolean);
91 let score = 0;
92 for (const tok of tokens) {
93 for (const part of parts) {
94 if (part === tok) score += 2;
95 else if (part.includes(tok)) score += 1;
96 }
97 }
98 // Boost well-known "entry-point"-ish filenames.
99 for (const part of parts) {
100 if (BOOST_NAMES.has(part)) {
101 score += 1;
102 break;
103 }
104 }
105 // Boost common code-file extensions.
106 const dot = lower.lastIndexOf(".");
107 if (dot !== -1) {
108 const ext = lower.slice(dot + 1);
109 if (CODE_EXTENSIONS.has(ext)) score += 0.5;
110 }
111 return score;
112}
113
114async function runGit(
115 repoDiskPath: string,
116 args: string[]
117): Promise<{ ok: true; stdout: string } | { ok: false; error: string }> {
118 try {
119 const proc = Bun.spawn(["git", "-C", repoDiskPath, ...args], {
120 stdout: "pipe",
121 stderr: "pipe",
122 });
123 const [stdout, stderr] = await Promise.all([
124 new Response(proc.stdout).text(),
125 new Response(proc.stderr).text(),
126 ]);
127 const exitCode = await proc.exited;
128 if (exitCode !== 0) {
129 return {
130 ok: false,
131 error: (stderr || `git ${args[0]} exited ${exitCode}`).trim(),
132 };
133 }
134 return { ok: true, stdout };
135 } catch (err) {
136 return {
137 ok: false,
138 error: err instanceof Error ? err.message : String(err),
139 };
140 }
141}
142
143async function runGitBytes(
144 repoDiskPath: string,
145 args: string[]
146): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; error: string }> {
147 try {
148 const proc = Bun.spawn(["git", "-C", repoDiskPath, ...args], {
149 stdout: "pipe",
150 stderr: "pipe",
151 });
152 const [bytes, stderr] = await Promise.all([
153 new Response(proc.stdout).arrayBuffer(),
154 new Response(proc.stderr).text(),
155 ]);
156 const exitCode = await proc.exited;
157 if (exitCode !== 0) {
158 return {
159 ok: false,
160 error: (stderr || `git ${args[0]} exited ${exitCode}`).trim(),
161 };
162 }
163 return { ok: true, bytes: new Uint8Array(bytes) };
164 } catch (err) {
165 return {
166 ok: false,
167 error: err instanceof Error ? err.message : String(err),
168 };
169 }
170}
171
172function looksBinary(bytes: Uint8Array): boolean {
173 const n = Math.min(bytes.length, BINARY_SNIFF_BYTES);
174 for (let i = 0; i < n; i++) {
175 if (bytes[i] === 0) return true;
176 }
177 return false;
178}
179
180export async function buildSpecContext(
181 args: BuildSpecContextArgs
182): Promise<BuildSpecContextResult> {
183 const {
184 repoDiskPath,
185 spec,
186 maxRelevantFiles = DEFAULT_MAX_RELEVANT,
187 maxFileBytes = DEFAULT_MAX_BYTES,
188 } = args;
189
190 // 1. Resolve default branch. We trust `defaultBranch` if passed, otherwise
191 // ask the repo for its symbolic HEAD.
192 let defaultBranch = args.defaultBranch;
193 if (!defaultBranch) {
194 const head = await runGit(repoDiskPath, [
195 "symbolic-ref",
196 "--short",
197 "HEAD",
198 ]);
199 if (!head.ok) return { ok: false, error: head.error };
200 defaultBranch = head.stdout.trim();
201 if (!defaultBranch) {
202 return { ok: false, error: "could not resolve default branch" };
203 }
204 }
205
206 // 2. Full file list on the branch. Cap before scoring to keep work bounded
207 // on monorepos.
208 const tree = await runGit(repoDiskPath, [
209 "ls-tree",
210 "-r",
211 defaultBranch,
212 "--name-only",
213 ]);
214 if (!tree.ok) return { ok: false, error: tree.error };
215
216 const allPaths = tree.stdout
217 .split("\n")
218 .map((s) => s.trim())
219 .filter((s) => s.length > 0);
220 const fileList = allPaths.slice(0, MAX_FILE_LIST);
221
222 // 3. Score & rank. Ties broken by shorter path (likely more central).
223 const tokens = tokenize(spec);
224 const scored = fileList
225 .map((path) => ({ path, score: scoreFile(path, tokens) }))
226 .sort((a, b) => {
227 if (b.score !== a.score) return b.score - a.score;
228 return a.path.length - b.path.length;
229 });
230
231 // 4. Fetch content for the top N. Skip binaries, truncate oversized files.
232 const relevantFiles: Array<{ path: string; content: string }> = [];
233 let totalSizeBytes = 0;
234 const TRUNC_MARKER = "\n...[truncated]";
235
236 for (const { path } of scored) {
237 if (relevantFiles.length >= maxRelevantFiles) break;
238 const blob = await runGitBytes(repoDiskPath, [
239 "show",
240 `${defaultBranch}:${path}`,
241 ]);
242 if (!blob.ok) continue; // submodule, missing, etc. — just skip
243 if (looksBinary(blob.bytes)) continue;
244
245 let content = new TextDecoder("utf-8", { fatal: false }).decode(blob.bytes);
246 if (content.length > maxFileBytes) {
247 content = content.slice(0, maxFileBytes) + TRUNC_MARKER;
248 }
249 relevantFiles.push({ path, content });
250 totalSizeBytes += content.length;
251 }
252
253 return {
254 ok: true,
255 context: {
256 fileList,
257 relevantFiles,
258 defaultBranch,
259 totalSizeBytes,
260 },
261 };
262}
Addedsrc/lib/spec-git.ts+312−0View fileUnifiedSplit
@@ -0,0 +1,312 @@
1/**
2 * spec-git — apply a batch of AI-proposed file edits to a new branch in a bare
3 * git repo using plumbing only (no working tree, no `git add`, no `git commit`).
4 *
5 * Pattern cribbed from `src/lib/demo-seed.ts` (`writeInitialCommit`): transient
6 * GIT_INDEX_FILE, hash-object → update-index → write-tree → commit-tree →
7 * update-ref. Extended here to:
8 * - seed the index from a base tree (`read-tree`) so unrelated paths survive,
9 * - support delete edits via `update-index --remove`,
10 * - fail if the target branch already exists,
11 * - validate each edit path as defense in depth (reject `..`, absolute, empty).
12 *
13 * Never throws. Always cleans up the temp index in a `finally`.
14 */
15import { unlink } from "fs/promises";
16
17export type FileEdit =
18 | { action: "create"; path: string; content: string }
19 | { action: "edit"; path: string; content: string }
20 | { action: "delete"; path: string };
21
22export type ApplyEditsResult =
23 | {
24 ok: true;
25 branchName: string;
26 commitSha: string;
27 filesChanged: string[];
28 }
29 | { ok: false; error: string };
30
31interface GitResult {
32 stdout: string;
33 stderr: string;
34 exitCode: number;
35}
36
37/**
38 * Run a git subprocess safely. Never throws — errors surface via exitCode=-1.
39 */
40async function runGit(
41 args: string[],
42 cwd: string,
43 opts?: { stdin?: string | Uint8Array; env?: Record<string, string> }
44): Promise<GitResult> {
45 try {
46 const proc = Bun.spawn(["git", ...args], {
47 cwd,
48 stdout: "pipe",
49 stderr: "pipe",
50 stdin: opts?.stdin !== undefined ? "pipe" : undefined,
51 env: { ...process.env, ...(opts?.env || {}) },
52 });
53 if (opts?.stdin !== undefined && proc.stdin) {
54 const bytes =
55 typeof opts.stdin === "string"
56 ? new TextEncoder().encode(opts.stdin)
57 : opts.stdin;
58 (proc.stdin as any).write(bytes);
59 (proc.stdin as any).end();
60 }
61 const [stdout, stderr] = await Promise.all([
62 new Response(proc.stdout).text(),
63 new Response(proc.stderr).text(),
64 ]);
65 const exitCode = await proc.exited;
66 return { stdout: stdout.trim(), stderr, exitCode };
67 } catch (err: any) {
68 return { stdout: "", stderr: String(err?.message || err), exitCode: -1 };
69 }
70}
71
72/**
73 * Reject paths that could escape the repo tree. Caller may have already
74 * validated — this is defense in depth.
75 */
76function validatePath(p: string): string | null {
77 if (typeof p !== "string") return "path must be a string";
78 if (p.length === 0) return "path is empty";
79 if (p.startsWith("/")) return `path is absolute: ${p}`;
80 // Reject any `..` segment.
81 const segments = p.split("/");
82 for (const seg of segments) {
83 if (seg === "..") return `path contains '..' segment: ${p}`;
84 if (seg === "") return `path has empty segment: ${p}`;
85 }
86 return null;
87}
88
89const SHA_RE = /^[0-9a-f]{40}$/;
90
91export async function applyEditsToNewBranch(args: {
92 repoDiskPath: string;
93 baseRef: string;
94 edits: FileEdit[];
95 branchName: string;
96 commitMessage: string;
97 authorName: string;
98 authorEmail: string;
99}): Promise<ApplyEditsResult> {
100 const {
101 repoDiskPath,
102 baseRef,
103 edits,
104 branchName,
105 commitMessage,
106 authorName,
107 authorEmail,
108 } = args;
109
110 // 0. Empty edits → refuse (don't manufacture empty commits).
111 if (!Array.isArray(edits) || edits.length === 0) {
112 return { ok: false, error: "no edits supplied" };
113 }
114
115 // 0b. Validate every path up front.
116 for (const edit of edits) {
117 const bad = validatePath(edit.path);
118 if (bad) return { ok: false, error: bad };
119 }
120
121 // 0c. Basic branch-name sanity.
122 if (!branchName || branchName.includes(" ") || branchName.startsWith("-")) {
123 return { ok: false, error: `invalid branch name: ${branchName}` };
124 }
125
126 // 0d. Basic commit-message presence.
127 if (!commitMessage || !commitMessage.trim()) {
128 return { ok: false, error: "commit message is empty" };
129 }
130
131 // 1. Confirm repo path exists by probing `git rev-parse --git-dir`.
132 const probe = await runGit(["rev-parse", "--git-dir"], repoDiskPath);
133 if (probe.exitCode !== 0) {
134 return {
135 ok: false,
136 error: `repo path invalid: ${probe.stderr.trim() || "rev-parse failed"}`,
137 };
138 }
139
140 // 2. Refuse if target branch already exists.
141 const existing = await runGit(
142 ["rev-parse", "--verify", `refs/heads/${branchName}`],
143 repoDiskPath
144 );
145 if (existing.exitCode === 0) {
146 return { ok: false, error: "branch already exists" };
147 }
148
149 // 3. Resolve base commit sha and base tree sha.
150 const baseSha = await runGit(["rev-parse", baseRef], repoDiskPath);
151 if (baseSha.exitCode !== 0 || !SHA_RE.test(baseSha.stdout)) {
152 return {
153 ok: false,
154 error: `base ref not found: ${baseRef}`,
155 };
156 }
157 const baseTree = await runGit(
158 ["rev-parse", `${baseRef}^{tree}`],
159 repoDiskPath
160 );
161 if (baseTree.exitCode !== 0 || !SHA_RE.test(baseTree.stdout)) {
162 return {
163 ok: false,
164 error: `could not resolve base tree for ${baseRef}`,
165 };
166 }
167
168 // 4. Allocate a transient index file. Keep it inside the repo dir so it
169 // lives on the same filesystem as the object store.
170 const tmpIndex = `${repoDiskPath}/index.spec-git.${process.pid}.${Date.now()}.${Math.random()
171 .toString(36)
172 .slice(2)}`;
173 const baseEnv: Record<string, string> = {
174 GIT_INDEX_FILE: tmpIndex,
175 GIT_AUTHOR_NAME: authorName,
176 GIT_AUTHOR_EMAIL: authorEmail,
177 GIT_COMMITTER_NAME: authorName,
178 GIT_COMMITTER_EMAIL: authorEmail,
179 };
180
181 try {
182 // 5. Seed the transient index from the base tree.
183 const readTree = await runGit(
184 ["read-tree", baseTree.stdout],
185 repoDiskPath,
186 { env: baseEnv }
187 );
188 if (readTree.exitCode !== 0) {
189 return {
190 ok: false,
191 error: `read-tree failed: ${readTree.stderr.trim()}`,
192 };
193 }
194
195 // 6. Apply each edit to the transient index.
196 const filesChanged: string[] = [];
197 for (const edit of edits) {
198 if (edit.action === "delete") {
199 const rm = await runGit(
200 ["update-index", "--remove", edit.path],
201 repoDiskPath,
202 { env: baseEnv }
203 );
204 if (rm.exitCode !== 0) {
205 return {
206 ok: false,
207 error: `update-index --remove failed for ${edit.path}: ${rm.stderr.trim()}`,
208 };
209 }
210 filesChanged.push(edit.path);
211 continue;
212 }
213
214 // create or edit — both hash the content and add to the index.
215 const hashed = await runGit(
216 ["hash-object", "-w", "--stdin"],
217 repoDiskPath,
218 { stdin: edit.content }
219 );
220 if (hashed.exitCode !== 0 || !SHA_RE.test(hashed.stdout)) {
221 return {
222 ok: false,
223 error: `hash-object failed for ${edit.path}: ${hashed.stderr.trim()}`,
224 };
225 }
226 const blobSha = hashed.stdout;
227 const add = await runGit(
228 [
229 "update-index",
230 "--add",
231 "--cacheinfo",
232 `100644,${blobSha},${edit.path}`,
233 ],
234 repoDiskPath,
235 { env: baseEnv }
236 );
237 if (add.exitCode !== 0) {
238 return {
239 ok: false,
240 error: `update-index --add failed for ${edit.path}: ${add.stderr.trim()}`,
241 };
242 }
243 filesChanged.push(edit.path);
244 }
245
246 // 7. write-tree → new tree sha.
247 const wt = await runGit(["write-tree"], repoDiskPath, { env: baseEnv });
248 if (wt.exitCode !== 0 || !SHA_RE.test(wt.stdout)) {
249 return {
250 ok: false,
251 error: `write-tree failed: ${wt.stderr.trim()}`,
252 };
253 }
254 const newTreeSha = wt.stdout;
255
256 // 8. If the new tree equals the base tree, nothing actually changed —
257 // don't create an empty commit.
258 if (newTreeSha === baseTree.stdout) {
259 return { ok: false, error: "no changes produced (tree identical)" };
260 }
261
262 // 9. commit-tree with the base commit as parent.
263 const commit = await runGit(
264 [
265 "commit-tree",
266 newTreeSha,
267 "-p",
268 baseSha.stdout,
269 "-m",
270 commitMessage,
271 ],
272 repoDiskPath,
273 { env: baseEnv }
274 );
275 if (commit.exitCode !== 0 || !SHA_RE.test(commit.stdout)) {
276 return {
277 ok: false,
278 error: `commit-tree failed: ${commit.stderr.trim()}`,
279 };
280 }
281 const commitSha = commit.stdout;
282
283 // 10. update-ref — create the new branch atomically. The `""` old-value
284 // argument tells git to only create if the ref doesn't yet exist.
285 const upd = await runGit(
286 ["update-ref", `refs/heads/${branchName}`, commitSha, ""],
287 repoDiskPath
288 );
289 if (upd.exitCode !== 0) {
290 return {
291 ok: false,
292 error: `update-ref failed: ${upd.stderr.trim()}`,
293 };
294 }
295
296 return {
297 ok: true,
298 branchName,
299 commitSha,
300 filesChanged,
301 };
302 } catch (err: any) {
303 return { ok: false, error: String(err?.message || err) };
304 } finally {
305 // Always remove the transient index file, success or failure.
306 try {
307 await unlink(tmpIndex);
308 } catch {
309 /* ignore — file may never have been created */
310 }
311 }
312}
Modifiedsrc/lib/spec-to-pr.ts+157−39View fileUnifiedSplit
@@ -1,67 +1,185 @@
11/**
22 * Spec-to-PR (experimental).
33 *
4 * Entry point for the "describe a change in English, get a PR" feature. The
5 * full pipeline — read the repo tree, call Claude to produce a patch, run it
6 * through git plumbing, open a PR — is a follow-up patch. This file ships the
7 * backend stub: it validates prerequisites (API key, repo existence) and
8 * returns a structured `{ok:false}` result with a human-readable error that
9 * the UI route surfaces directly.
4 * Pipeline:
5 * 1. Validate prerequisites: API key present, repo exists, user can be
6 * resolved for author metadata.
7 * 2. `buildSpecContext` — read the bare repo, score paths against the spec,
8 * collect a bounded file list + top-N relevant file contents.
9 * 3. `generateSpecEdits` — send that context to Claude; parse + validate the
10 * proposed edits (forbidden paths filtered defence-in-depth).
11 * 4. `applyEditsToNewBranch` — write the edits to a fresh branch via git
12 * plumbing (bare repo, no working tree).
13 * 5. Insert a draft `pullRequests` row pointing base→head.
1014 *
11 * Keeping the stub real (not a throw) lets the UI wire up end-to-end today
12 * and lets us light up the AI path without touching callers.
15 * Every failure mode is funnelled through `{ok:false, error}`. We never throw
16 * — the route (`src/routes/specs.tsx`) renders the error inline.
1317 */
18import { join } from "path";
1419import { eq } from "drizzle-orm";
1520import { db } from "../db";
1621import { repositories, users, pullRequests } from "../db/schema";
17
18export type SpecPRResult = {
19 ok: boolean;
20 prNumber?: number;
21 branchName?: string;
22 filesChanged?: string[];
23 error?: string;
24};
22import { buildSpecContext } from "./spec-context";
23import { generateSpecEdits } from "./spec-ai";
24import { applyEditsToNewBranch } from "./spec-git";
2525
2626export type SpecPRArgs = {
27 repoId: number;
27 repoId: string;
2828 spec: string;
2929 baseRef?: string;
30 userId: number;
30 userId: string;
3131};
3232
33export type SpecPRResult =
34 | {
35 ok: true;
36 prNumber: number;
37 branchName: string;
38 filesChanged: string[];
39 }
40 | { ok: false; error: string };
41
42/** Derive a filesystem-safe branch suffix from the user's spec. */
43function slugify(spec: string): string {
44 const base = spec
45 .toLowerCase()
46 .replace(/[^a-z0-9]+/g, "-")
47 .replace(/^-+|-+$/g, "")
48 .slice(0, 40);
49 return base || "change";
50}
51
52function randomSuffix(): string {
53 return Math.random().toString(16).slice(2, 8);
54}
55
3356export async function createSpecPR(args: SpecPRArgs): Promise<SpecPRResult> {
34 // 1. Require ANTHROPIC_API_KEY — without it the AI step can't run, so we
35 // fail fast rather than doing a pointless DB round-trip.
57 // 1. API key gate. Without it the AI step would fail anyway — bail before
58 // any DB or disk work.
3659 if (!process.env.ANTHROPIC_API_KEY) {
3760 return { ok: false, error: "ANTHROPIC_API_KEY required for spec-to-PR" };
3861 }
3962
40 // 2. Look up repo. If the repo doesn't exist we surface that specifically
41 // so the UI can distinguish "bad id" from "AI not configured".
63 const spec = typeof args.spec === "string" ? args.spec.trim() : "";
64 if (!spec) return { ok: false, error: "spec is empty" };
65
66 // 2. Resolve repo + owner so we can build the on-disk path.
67 let repoRow: {
68 id: string;
69 name: string;
70 defaultBranch: string | null;
71 ownerName: string | null;
72 } | undefined;
4273 try {
4374 const rows = await db
44 .select()
75 .select({
76 id: repositories.id,
77 name: repositories.name,
78 defaultBranch: repositories.defaultBranch,
79 ownerName: users.username,
80 })
4581 .from(repositories)
46 .where(eq(repositories.id, args.repoId as unknown as string))
82 .leftJoin(users, eq(users.id, repositories.ownerId))
83 .where(eq(repositories.id, args.repoId))
4784 .limit(1);
48 if (rows.length === 0) return { ok: false, error: "repo not found" };
49 } catch (err) {
85 repoRow = rows[0];
86 } catch {
5087 return { ok: false, error: "db lookup failed" };
5188 }
89 if (!repoRow || !repoRow.ownerName) {
90 return { ok: false, error: "repo not found" };
91 }
5292
53 // Touch the remaining imports so they aren't flagged as unused and so that
54 // the eventual implementation (user lookup for PR author, pullRequests
55 // insert) doesn't need an import change in the follow-up patch.
56 void users;
57 void pullRequests;
93 // 3. Resolve the author — needed for the commit-tree call and the PR row.
94 let authorRow: { username: string; email: string | null } | undefined;
95 try {
96 const rows = await db
97 .select({ username: users.username, email: users.email })
98 .from(users)
99 .where(eq(users.id, args.userId))
100 .limit(1);
101 authorRow = rows[0];
102 } catch {
103 return { ok: false, error: "db lookup failed" };
104 }
105 if (!authorRow) return { ok: false, error: "author not found" };
106
107 const base = process.env.GIT_REPOS_PATH || "./repos";
108 const repoDiskPath = join(base, repoRow.ownerName, `${repoRow.name}.git`);
109 const defaultBranch = repoRow.defaultBranch || "main";
110 const baseRef = (args.baseRef && args.baseRef.trim()) || defaultBranch;
111
112 // 4. Build context.
113 const ctx = await buildSpecContext({
114 repoDiskPath,
115 spec,
116 defaultBranch: baseRef,
117 });
118 if (!ctx.ok) {
119 return { ok: false, error: `context build failed: ${ctx.error}` };
120 }
121
122 // 5. Ask Claude for edits.
123 const ai = await generateSpecEdits({
124 spec,
125 fileList: ctx.context.fileList,
126 relevantFiles: ctx.context.relevantFiles,
127 defaultBranch: ctx.context.defaultBranch,
128 });
129 if (!ai.ok) return { ok: false, error: `AI failed: ${ai.error}` };
130 if (ai.edits.length === 0) {
131 return { ok: false, error: "AI proposed no changes" };
132 }
133
134 // 6. Apply edits to a fresh branch via git plumbing.
135 const branchName = `spec/${slugify(spec)}-${randomSuffix()}`;
136 const commitSubject = ai.summary || `spec: ${spec.slice(0, 60)}`;
137 const commitBody = `Generated by spec-to-PR.\n\nSpec:\n${spec}`;
138 const commitMessage = `${commitSubject}\n\n${commitBody}`;
139
140 const authorEmail =
141 authorRow.email || `${authorRow.username}@users.noreply.gluecron`;
142 const applied = await applyEditsToNewBranch({
143 repoDiskPath,
144 baseRef,
145 edits: ai.edits,
146 branchName,
147 commitMessage,
148 authorName: authorRow.username,
149 authorEmail,
150 });
151 if (!applied.ok) return { ok: false, error: `git apply failed: ${applied.error}` };
58152
59 // 3. v1 stub — feature is experimental. For now, just return a clear
60 // message. Full implementation (read tree, call Claude, git plumbing,
61 // PR insert) is a follow-up. The UI route handles {ok:false} gracefully.
62 return {
63 ok: false,
64 error:
65 "spec-to-PR is experimental and not yet fully implemented. Backend stub only — full AI integration arriving in a follow-up patch.",
66 };
153 // 7. Insert the draft PR row.
154 try {
155 const [pr] = await db
156 .insert(pullRequests)
157 .values({
158 repositoryId: repoRow.id,
159 authorId: args.userId,
160 title: commitSubject.slice(0, 200),
161 body: `${commitBody}\n\nFiles changed:\n${applied.filesChanged
162 .map((p) => `- ${p}`)
163 .join("\n")}`,
164 baseBranch: baseRef,
165 headBranch: applied.branchName,
166 isDraft: true,
167 })
168 .returning();
169 const number = pr?.number;
170 if (typeof number !== "number") {
171 return { ok: false, error: "PR insert returned no number" };
172 }
173 return {
174 ok: true,
175 prNumber: number,
176 branchName: applied.branchName,
177 filesChanged: applied.filesChanged,
178 };
179 } catch (err) {
180 return {
181 ok: false,
182 error: `PR insert failed: ${err instanceof Error ? err.message : String(err)}`,
183 };
184 }
67185}
Addedsrc/middleware/repo-access.ts+221−0View fileUnifiedSplit
@@ -0,0 +1,221 @@
1/**
2 * Repo access middleware — resolves a viewer's effective access level to a
3 * repository and enforces a minimum-level gate on routes.
4 *
5 * Access hierarchy (ordered): none < read < write < admin < owner.
6 *
7 * The repo owner (repositories.ownerId === userId) always resolves to
8 * "owner", regardless of any collaborator row. Beyond that, we look up an
9 * ACCEPTED row in repo_collaborators (acceptedAt IS NOT NULL) and return the
10 * stored role. If no row exists, public repos fall back to "read" for any
11 * viewer (including anonymous); private repos return "none".
12 *
13 * The middleware factory reads `:owner` + `:repo` from the URL, looks up
14 * the repository, computes the access level, stashes both on the context
15 * (so downstream handlers don't re-query), and renders a 403 HTML page via
16 * Layout if the caller's level is below the required minimum.
17 *
18 * Implementation notes:
19 * - Hono and JSX dependencies are loaded via *dynamic* `import()` inside
20 * `requireRepoAccess` so that `resolveRepoAccess` — the pure, unit-
21 * testable half — has no static hono/hono-jsx imports at module load.
22 * This lets `bun test` run the logic tests without needing the full
23 * hono runtime (notably `hono/jsx/jsx-dev-runtime`, which a given
24 * install may not have yet when the schema/parallel agent ships).
25 * - The middleware signature `(c, next) => Promise<Response | void>`
26 * matches Hono's `MiddlewareHandler` structurally without importing
27 * the type; routes can pass this directly to `.use()` / handler chains.
28 */
29
30import { and, eq, isNotNull } from "drizzle-orm";
31import { db } from "../db";
32import { repositories, users, repoCollaborators } from "../db/schema";
33import type { Repository, User } from "../db/schema";
34
35export type RepoAccessLevel = "none" | "read" | "write" | "admin" | "owner";
36
37/**
38 * Ordered access hierarchy. Higher index = more access. Use `ACCESS_RANK`
39 * to compare two levels; callers should prefer {@link satisfiesAccess}.
40 */
41export const ACCESS_RANK: Record<RepoAccessLevel, number> = {
42 none: 0,
43 read: 1,
44 write: 2,
45 admin: 3,
46 owner: 4,
47};
48
49/** True if `actual` meets or exceeds `required`. */
50export function satisfiesAccess(
51 actual: RepoAccessLevel,
52 required: RepoAccessLevel
53): boolean {
54 return ACCESS_RANK[actual] >= ACCESS_RANK[required];
55}
56
57/**
58 * Env type for Hono routes that sit behind `requireRepoAccess`. Extends the
59 * existing auth variables — `user` is populated by softAuth/requireAuth,
60 * `repository` and `repoAccess` are populated here.
61 */
62export type RepoAccessEnv = {
63 Variables: {
64 user: User | null;
65 repository: Repository;
66 repoAccess: RepoAccessLevel;
67 };
68};
69
70/**
71 * Pure access resolution — no HTTP, no context. Exposed so callers (e.g.
72 * API responses, view-layer conditionals, tests) can ask "what can this
73 * user do with this repo?" without running the middleware.
74 */
75export async function resolveRepoAccess(args: {
76 repoId: string;
77 userId: string | null;
78 isPublic: boolean;
79}): Promise<RepoAccessLevel> {
80 const { repoId, userId, isPublic } = args;
81
82 // Anonymous viewer: only public repos grant anything, and only "read".
83 if (!userId) {
84 return isPublic ? "read" : "none";
85 }
86
87 // Owner check — look up the repo row once. If the caller IS the owner,
88 // short-circuit before hitting repo_collaborators.
89 try {
90 const [repo] = await db
91 .select({ ownerId: repositories.ownerId })
92 .from(repositories)
93 .where(eq(repositories.id, repoId))
94 .limit(1);
95
96 if (repo && repo.ownerId === userId) {
97 return "owner";
98 }
99 } catch {
100 // Fall through — if the owner lookup fails we still try the
101 // collaborator path below (which may also fail, in which case we'll
102 // end up at the public/private fallback).
103 }
104
105 // Accepted collaborator row wins over the public fallback.
106 try {
107 const [collab] = await db
108 .select({ role: repoCollaborators.role })
109 .from(repoCollaborators)
110 .where(
111 and(
112 eq(repoCollaborators.repositoryId, repoId),
113 eq(repoCollaborators.userId, userId),
114 isNotNull(repoCollaborators.acceptedAt)
115 )
116 )
117 .limit(1);
118
119 if (collab) {
120 return collab.role as RepoAccessLevel;
121 }
122 } catch {
123 // Ignore — fall through to public/private fallback.
124 }
125
126 return isPublic ? "read" : "none";
127}
128
129/**
130 * Middleware factory: gate a route on a minimum access level.
131 *
132 * Assumes the URL has `:owner` and `:repo` params. Looks up the repository,
133 * 404s if it doesn't exist, resolves the viewer's access, and 403s if the
134 * viewer is below `level`. On success, sets `c.var.repository` and
135 * `c.var.repoAccess` for downstream handlers.
136 *
137 * Returns a bare `(c, next)` async function — structurally compatible with
138 * Hono's `MiddlewareHandler`. Hono is loaded lazily inside the handler so
139 * the unit-testable exports above don't force-import the jsx runtime.
140 */
141export function requireRepoAccess(
142 level: "read" | "write" | "admin"
143): (c: any, next: () => Promise<void>) => Promise<Response | void> {
144 return async (c: any, next: () => Promise<void>) => {
145 const { owner: ownerName, repo: repoName } = c.req.param() as {
146 owner?: string;
147 repo?: string;
148 };
149 const user: User | null = c.get("user") ?? null;
150
151 if (!ownerName || !repoName) {
152 return c.notFound();
153 }
154
155 // Resolve owner -> user row, then repo by (owner, name).
156 const [ownerRow] = await db
157 .select()
158 .from(users)
159 .where(eq(users.username, ownerName))
160 .limit(1);
161
162 if (!ownerRow) {
163 return c.notFound();
164 }
165
166 const [repo] = await db
167 .select()
168 .from(repositories)
169 .where(
170 and(
171 eq(repositories.ownerId, ownerRow.id),
172 eq(repositories.name, repoName)
173 )
174 )
175 .limit(1);
176
177 if (!repo) {
178 return c.notFound();
179 }
180
181 const access = await resolveRepoAccess({
182 repoId: repo.id,
183 userId: user?.id ?? null,
184 isPublic: !repo.isPrivate,
185 });
186
187 if (!satisfiesAccess(access, level)) {
188 const reason =
189 access === "none"
190 ? "You don't have permission to view this repository."
191 : `This action requires ${level} access. You have ${access} access.`;
192 // Lazy-load hono/jsx + Layout so the top of this module is safe to
193 // import from unit tests that can't resolve the jsx runtime.
194 const [{ jsx }, { Layout }] = await Promise.all([
195 import("hono/jsx"),
196 import("../views/layout"),
197 ]);
198 const body = jsx(
199 "div",
200 {
201 style:
202 "max-width: 600px; margin: 80px auto; padding: 24px; text-align: center;",
203 },
204 [
205 jsx("h1", { style: "margin-bottom: 12px" }, ["403 — Access denied"]),
206 jsx("p", { style: "color: var(--muted, #8b949e)" }, [reason]),
207 ]
208 );
209 const page = jsx(
210 Layout as any,
211 { title: "Access denied", user },
212 [body]
213 );
214 return c.html(page, 403);
215 }
216
217 c.set("repoAccess", access);
218 c.set("repository", repo);
219 return next();
220 };
221}
Addedsrc/routes/collaborators.tsx+316−0View fileUnifiedSplit
@@ -0,0 +1,316 @@
1/**
2 * Repository collaborators — add, list, remove.
3 *
4 * Owner-only. v1 auto-accepts the invite (no email flow yet): when the owner
5 * adds a user by username, we insert a `repo_collaborators` row with
6 * `acceptedAt = now()` so the grantee is immediately active.
7 *
8 * Collaborator lifecycle matrix:
9 * - Add: POST /:owner/:repo/settings/collaborators/add
10 * - Remove: POST /:owner/:repo/settings/collaborators/:collaboratorId/remove
11 * - List: GET /:owner/:repo/settings/collaborators
12 *
13 * Middleware: softAuth on all, plus an inline owner-only check that mirrors
14 * `src/routes/repo-settings.tsx` — the owner of the repo (by username) must
15 * match the authed user. Non-owners get 403.
16 */
17
18import { Hono } from "hono";
19import { eq, and } from "drizzle-orm";
20import { db } from "../db";
21import {
22 repositories,
23 users,
24 repoCollaborators,
25} from "../db/schema";
26import { Layout } from "../views/layout";
27import { RepoHeader } from "../views/components";
28import { softAuth, requireAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30import {
31 Container,
32 Form,
33 FormGroup,
34 Input,
35 Select,
36 Button,
37 Alert,
38 EmptyState,
39} from "../views/ui";
40
41const collaboratorRoutes = new Hono<AuthEnv>();
42
43collaboratorRoutes.use("*", softAuth);
44
45/**
46 * Resolve (owner user, repo) from URL params, enforcing the authed user is
47 * the repo owner. Returns `{ owner, repo }` on success, or an already-built
48 * Response on failure (caller should return it directly).
49 */
50async function resolveOwnerRepo(
51 c: any,
52 ownerName: string,
53 repoName: string
54) {
55 const user = c.get("user")!;
56 const [owner] = await db
57 .select()
58 .from(users)
59 .where(eq(users.username, ownerName))
60 .limit(1);
61 if (!owner || owner.id !== user.id) {
62 return {
63 error: c.html(
64 <Layout title="Unauthorized" user={user}>
65 <EmptyState title="Unauthorized">
66 <p>Only the repository owner can manage collaborators.</p>
67 </EmptyState>
68 </Layout>,
69 403
70 ),
71 };
72 }
73 const [repo] = await db
74 .select()
75 .from(repositories)
76 .where(
77 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
78 )
79 .limit(1);
80 if (!repo) {
81 return { error: c.notFound() };
82 }
83 return { owner, repo, user };
84}
85
86// ─── List collaborators ─────────────────────────────────────────────────────
87
88collaboratorRoutes.get(
89 "/:owner/:repo/settings/collaborators",
90 requireAuth,
91 async (c) => {
92 const { owner: ownerName, repo: repoName } = c.req.param();
93 const success = c.req.query("success");
94 const error = c.req.query("error");
95
96 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
97 if ("error" in resolved) return resolved.error;
98 const { repo, user } = resolved;
99
100 // Join collaborators with users to get username + avatar.
101 const rows = await db
102 .select({
103 id: repoCollaborators.id,
104 role: repoCollaborators.role,
105 invitedAt: repoCollaborators.invitedAt,
106 acceptedAt: repoCollaborators.acceptedAt,
107 username: users.username,
108 avatarUrl: users.avatarUrl,
109 userId: users.id,
110 })
111 .from(repoCollaborators)
112 .innerJoin(users, eq(users.id, repoCollaborators.userId))
113 .where(eq(repoCollaborators.repositoryId, repo.id));
114
115 return c.html(
116 <Layout title={`Collaborators — ${ownerName}/${repoName}`} user={user}>
117 <RepoHeader owner={ownerName} repo={repoName} />
118 <Container maxWidth={700}>
119 <h2 style="margin-bottom: 16px">Collaborators</h2>
120 <p style="font-size:14px;color:var(--text-muted);margin-bottom:16px">
121 <a href={`/${ownerName}/${repoName}/settings`}>← Back to settings</a>
122 </p>
123 {success && (
124 <Alert variant="success">{decodeURIComponent(success)}</Alert>
125 )}
126 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
127
128 <div
129 style="margin-bottom: 24px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
130 >
131 <h3 style="margin-bottom: 12px">Add a collaborator</h3>
132 <Form
133 method="post"
134 action={`/${ownerName}/${repoName}/settings/collaborators/add`}
135 >
136 <FormGroup label="Username" htmlFor="username">
137 <Input
138 name="username"
139 id="username"
140 placeholder="github-username"
141 required
142 />
143 </FormGroup>
144 <FormGroup label="Role" htmlFor="role">
145 <Select name="role" id="role" value="read">
146 <option value="read">Read — clone + pull</option>
147 <option value="write">Write — push + merge</option>
148 <option value="admin">Admin — full control</option>
149 </Select>
150 </FormGroup>
151 <Button type="submit" variant="primary">
152 Add collaborator
153 </Button>
154 </Form>
155 </div>
156
157 {rows.length === 0 ? (
158 <EmptyState title="No collaborators yet">
159 <p>
160 Add a collaborator above to grant them access to this
161 repository.
162 </p>
163 </EmptyState>
164 ) : (
165 <div>
166 {rows.map((row) => (
167 <div class="ssh-key-item">
168 <div>
169 <strong>
170 {row.avatarUrl && (
171 <img
172 src={row.avatarUrl}
173 alt=""
174 style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:6px"
175 />
176 )}
177 <a href={`/${row.username}`}>{row.username}</a>
178 </strong>
179 <div class="ssh-key-meta">
180 Role: <strong>{row.role}</strong> | Invited:{" "}
181 {new Date(row.invitedAt).toLocaleDateString()} |{" "}
182 {row.acceptedAt ? (
183 <span style="color: var(--green)">Accepted</span>
184 ) : (
185 <span style="color: var(--yellow)">Pending</span>
186 )}
187 </div>
188 </div>
189 <form
190 method="post"
191 action={`/${ownerName}/${repoName}/settings/collaborators/${row.id}/remove`}
192 onsubmit="return confirm('Remove this collaborator?')"
193 >
194 <Button type="submit" variant="danger" size="sm">
195 Remove
196 </Button>
197 </form>
198 </div>
199 ))}
200 </div>
201 )}
202 </Container>
203 </Layout>
204 );
205 }
206);
207
208// ─── Add collaborator ───────────────────────────────────────────────────────
209
210collaboratorRoutes.post(
211 "/:owner/:repo/settings/collaborators/add",
212 requireAuth,
213 async (c) => {
214 const { owner: ownerName, repo: repoName } = c.req.param();
215 const body = await c.req.parseBody();
216
217 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
218 if ("error" in resolved) return resolved.error;
219 const { repo, user } = resolved;
220
221 const username = String(body.username || "").trim();
222 const roleRaw = String(body.role || "read");
223 const role: "read" | "write" | "admin" =
224 roleRaw === "write" || roleRaw === "admin" ? roleRaw : "read";
225
226 if (!username) {
227 return c.redirect(
228 `/${ownerName}/${repoName}/settings/collaborators?error=Username+required`
229 );
230 }
231
232 const [invitee] = await db
233 .select()
234 .from(users)
235 .where(eq(users.username, username))
236 .limit(1);
237 if (!invitee) {
238 return c.redirect(
239 `/${ownerName}/${repoName}/settings/collaborators?error=User+not+found`
240 );
241 }
242 if (invitee.id === user.id) {
243 return c.redirect(
244 `/${ownerName}/${repoName}/settings/collaborators?error=Owner+is+already+a+collaborator`
245 );
246 }
247
248 // If a row already exists for (repo, user), update the role instead of
249 // erroring. Mirrors the "upsert" contract so the owner can re-invite
250 // with a different role without first removing the prior row.
251 const [existing] = await db
252 .select()
253 .from(repoCollaborators)
254 .where(
255 and(
256 eq(repoCollaborators.repositoryId, repo.id),
257 eq(repoCollaborators.userId, invitee.id)
258 )
259 )
260 .limit(1);
261
262 if (existing) {
263 await db
264 .update(repoCollaborators)
265 .set({ role, acceptedAt: existing.acceptedAt ?? new Date() })
266 .where(eq(repoCollaborators.id, existing.id));
267 return c.redirect(
268 `/${ownerName}/${repoName}/settings/collaborators?success=Role+updated`
269 );
270 }
271
272 await db.insert(repoCollaborators).values({
273 repositoryId: repo.id,
274 userId: invitee.id,
275 role,
276 invitedBy: user.id,
277 acceptedAt: new Date(), // v1 auto-accept; no email invite flow yet
278 });
279
280 return c.redirect(
281 `/${ownerName}/${repoName}/settings/collaborators?success=Collaborator+added`
282 );
283 }
284);
285
286// ─── Remove collaborator ────────────────────────────────────────────────────
287
288collaboratorRoutes.post(
289 "/:owner/:repo/settings/collaborators/:collaboratorId/remove",
290 requireAuth,
291 async (c) => {
292 const { owner: ownerName, repo: repoName, collaboratorId } =
293 c.req.param();
294
295 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
296 if ("error" in resolved) return resolved.error;
297 const { repo } = resolved;
298
299 // Scope the delete to this repo so an owner can't remove a collaborator
300 // from some other repo by crafting a URL.
301 await db
302 .delete(repoCollaborators)
303 .where(
304 and(
305 eq(repoCollaborators.id, collaboratorId),
306 eq(repoCollaborators.repositoryId, repo.id)
307 )
308 );
309
310 return c.redirect(
311 `/${ownerName}/${repoName}/settings/collaborators?success=Collaborator+removed`
312 );
313 }
314);
315
316export default collaboratorRoutes;
Modifiedsrc/routes/repo-settings.tsx+5−0View fileUnifiedSplit
@@ -69,6 +69,11 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
6969 <RepoHeader owner={ownerName} repo={repoName} />
7070 <Container maxWidth={600}>
7171 <h2 style="margin-bottom: 20px">Repository settings</h2>
72 <p style="margin-bottom: 16px">
73 <a href={`/${ownerName}/${repoName}/settings/collaborators`}>
74 Manage collaborators →
75 </a>
76 </p>
7277 {success && (
7378 <Alert variant="success">{decodeURIComponent(success)}</Alert>
7479 )}
7580