diff --git a/lib/github/__tests__/createRepository.test.ts b/lib/github/__tests__/createRepository.test.ts new file mode 100644 index 000000000..ee9e013ac --- /dev/null +++ b/lib/github/__tests__/createRepository.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createRepository } from "@/lib/github/createRepository"; +import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; + +vi.mock("@/lib/github/getServiceGithubToken", () => ({ + getServiceGithubToken: vi.fn(() => "tok"), +})); + +describe("createRepository", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getServiceGithubToken).mockReturnValue("tok"); + }); + + it("returns failure when GITHUB_TOKEN is missing", async () => { + vi.mocked(getServiceGithubToken).mockReturnValue(undefined); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const result = await createRepository({ name: "id-1" }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/GITHUB_TOKEN/i); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("rejects invalid names without hitting the network", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const result = await createRepository({ name: "bad name with spaces" }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/invalid/i); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("POSTs to /orgs/recoupable/repos with hard-coded private=true + auto_init=true", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + html_url: "https://github.com/recoupable/id-1", + }), + { status: 201, headers: { "content-type": "application/json" } }, + ), + ); + + const result = await createRepository({ name: "id-1" }); + + expect(result).toEqual({ + success: true, + repoUrl: "https://github.com/recoupable/id-1", + }); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.github.com/orgs/recoupable/repos"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + name: "id-1", + private: true, + auto_init: true, + }); + }); + + it("returns name-conflict error on 422", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 422 })); + + const result = await createRepository({ name: "id-1" }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/already exists/i); + }); + + it("returns permission-denied error on 403", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 403 })); + + const result = await createRepository({ name: "id-1" }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/permission/i); + }); + + it("returns network-error on fetch rejection", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("ECONNRESET")); + + const result = await createRepository({ name: "id-1" }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/network/i); + }); +}); diff --git a/lib/github/__tests__/repositoryExists.test.ts b/lib/github/__tests__/repositoryExists.test.ts new file mode 100644 index 000000000..66853591a --- /dev/null +++ b/lib/github/__tests__/repositoryExists.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { repositoryExists } from "@/lib/github/repositoryExists"; +import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; + +vi.mock("@/lib/github/getServiceGithubToken", () => ({ + getServiceGithubToken: vi.fn(() => "tok"), +})); + +describe("repositoryExists", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getServiceGithubToken).mockReturnValue("tok"); + }); + + it("returns null when GITHUB_TOKEN is missing", async () => { + vi.mocked(getServiceGithubToken).mockReturnValue(undefined); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const result = await repositoryExists({ repo: "id-1" }); + + expect(result).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns true on 200 and calls GET /repos/recoupable/", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 200 })); + + const result = await repositoryExists({ repo: "id-1" }); + + expect(result).toBe(true); + expect(fetchSpy).toHaveBeenCalledWith( + "https://api.github.com/repos/recoupable/id-1", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer tok", + }), + }), + ); + }); + + it("returns false on 404", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 404 })); + + expect(await repositoryExists({ repo: "missing" })).toBe(false); + }); + + it("returns null on other statuses (auth, rate limit, etc.)", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 403 })); + + expect(await repositoryExists({ repo: "rate-limited" })).toBeNull(); + }); + + it("returns null on network failure", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("ECONNRESET")); + + expect(await repositoryExists({ repo: "anything" })).toBeNull(); + }); +}); diff --git a/lib/github/createRepository.ts b/lib/github/createRepository.ts new file mode 100644 index 000000000..c6eebaf03 --- /dev/null +++ b/lib/github/createRepository.ts @@ -0,0 +1,95 @@ +import { RECOUPABLE_GITHUB_OWNER } from "@/lib/recoupable/githubOwner"; +import { getServiceGithubToken } from "./getServiceGithubToken"; + +export interface CreateRepositoryResult { + success: boolean; + /** GitHub UI URL (`html_url`). */ + repoUrl?: string; + /** Human-readable error message; only set when `success` is false. */ + error?: string; +} + +/** + * Create a workspace repository under the Recoupable GitHub org. + * + * Hard-coded conventions (per PR #618 review — KISS / YAGNI): + * - owner = `recoupable` (no other owner makes sense; see + * `RECOUPABLE_GITHUB_OWNER`). + * - private = true (matches the 153 legacy workspace repos that + * pre-date this code path — keeps the fleet uniform; clones from + * sandboxes auth via the GITHUB_TOKEN service token). + * - description = none (GitHub doesn't render anything meaningful + * for these per-account repos). + * - token = read once from `GITHUB_TOKEN` via + * `getServiceGithubToken` (single source of truth — callers no + * longer thread the token through). + * + * `auto_init: true` so the repo has an initial `main` branch the + * sandbox can `git clone`. Without it, cloning a 0-commit repo fails. + * + * Uses plain `fetch` to match recoup-api's existing `lib/github/*` + * style (no Octokit dependency). + */ +export async function createRepository(params: { name: string }): Promise { + const { name } = params; + + const token = getServiceGithubToken(); + if (!token) { + console.error("[createRepository] GITHUB_TOKEN missing"); + return { success: false, error: "GITHUB_TOKEN missing" }; + } + + if (!/^[\w.-]+$/.test(name)) { + return { + success: false, + error: + "Invalid repository name. Use only letters, numbers, hyphens, underscores, and periods.", + }; + } + + try { + const response = await fetch(`https://api.github.com/orgs/${RECOUPABLE_GITHUB_OWNER}/repos`, { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ + name, + private: true, + auto_init: true, + }), + }); + + if (response.status === 201) { + const data = (await response.json()) as { html_url: string }; + return { success: true, repoUrl: data.html_url }; + } + + if (response.status === 422) { + return { + success: false, + error: "Repository name already exists or is invalid", + }; + } + if (response.status === 403) { + return { success: false, error: "Permission denied" }; + } + + let body = ""; + try { + body = await response.text(); + } catch { + body = ""; + } + console.error( + `[createRepository] unexpected status ${response.status} for ${RECOUPABLE_GITHUB_OWNER}/${name}: ${body}`, + ); + return { success: false, error: `GitHub returned ${response.status}` }; + } catch (error) { + console.error("[createRepository] network error:", error); + return { success: false, error: "Network error talking to GitHub" }; + } +} diff --git a/lib/github/repositoryExists.ts b/lib/github/repositoryExists.ts new file mode 100644 index 000000000..62e4e5f16 --- /dev/null +++ b/lib/github/repositoryExists.ts @@ -0,0 +1,46 @@ +import { RECOUPABLE_GITHUB_OWNER } from "@/lib/recoupable/githubOwner"; +import { getServiceGithubToken } from "./getServiceGithubToken"; + +/** + * Returns `true` if `recoupable/` exists, `false` if 404, `null` + * on any other failure (auth, rate limit, network, missing token). + * Lets callers distinguish "doesn't exist yet" from "couldn't reach + * GitHub" before attempting destructive ops like create. + * + * Owner is hard-coded to `recoupable` and the GitHub token is read + * from the environment (per PR #618 review — single source of truth). + */ +export async function repositoryExists(params: { repo: string }): Promise { + const { repo } = params; + + const token = getServiceGithubToken(); + if (!token) { + console.error("[repositoryExists] GITHUB_TOKEN missing"); + return null; + } + + try { + const response = await fetch( + `https://api.github.com/repos/${RECOUPABLE_GITHUB_OWNER}/${repo}`, + { + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + + if (response.status === 200) return true; + if (response.status === 404) return false; + + console.error( + `[repositoryExists] unexpected status ${response.status} for ${RECOUPABLE_GITHUB_OWNER}/${repo}`, + ); + return null; + } catch (error) { + console.error("[repositoryExists] network error:", error); + return null; + } +} diff --git a/lib/recoupable/__tests__/ensurePersonalRepo.test.ts b/lib/recoupable/__tests__/ensurePersonalRepo.test.ts new file mode 100644 index 000000000..5ea6ca1f8 --- /dev/null +++ b/lib/recoupable/__tests__/ensurePersonalRepo.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; +import { repositoryExists } from "@/lib/github/repositoryExists"; +import { createRepository } from "@/lib/github/createRepository"; + +vi.mock("@/lib/github/repositoryExists", () => ({ + repositoryExists: vi.fn(), +})); +vi.mock("@/lib/github/createRepository", () => ({ + createRepository: vi.fn(), +})); + +const accountId = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; + +describe("ensurePersonalRepo", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the existing repo URL when recoupable/ already exists", async () => { + vi.mocked(repositoryExists).mockResolvedValue(true); + + const result = await ensurePersonalRepo({ accountId }); + + expect(result).toBe(`https://github.com/recoupable/${accountId}`); + expect(createRepository).not.toHaveBeenCalled(); + }); + + it("returns null when the existence check fails for non-404 reasons", async () => { + vi.mocked(repositoryExists).mockResolvedValue(null); + + expect(await ensurePersonalRepo({ accountId })).toBeNull(); + expect(createRepository).not.toHaveBeenCalled(); + }); + + it("creates a fresh repo when none exists and returns its URL", async () => { + vi.mocked(repositoryExists).mockResolvedValue(false); + vi.mocked(createRepository).mockResolvedValue({ + success: true, + repoUrl: `https://github.com/recoupable/${accountId}`, + }); + + const result = await ensurePersonalRepo({ accountId }); + + expect(createRepository).toHaveBeenCalledWith({ name: accountId }); + expect(result).toBe(`https://github.com/recoupable/${accountId}`); + }); + + it("returns null when creation outright fails", async () => { + vi.mocked(repositoryExists).mockResolvedValue(false); + vi.mocked(createRepository).mockResolvedValue({ + success: false, + error: "Permission denied", + }); + + expect(await ensurePersonalRepo({ accountId })).toBeNull(); + }); +}); diff --git a/lib/recoupable/__tests__/extractOrgId.test.ts b/lib/recoupable/__tests__/extractOrgId.test.ts index c38232c4c..dc161cc52 100644 --- a/lib/recoupable/__tests__/extractOrgId.test.ts +++ b/lib/recoupable/__tests__/extractOrgId.test.ts @@ -38,6 +38,18 @@ describe("extractOrgId", () => { ); }); + it("extracts the UUID from a bare new-naming repo (recoupable/)", () => { + expect(extractOrgId("https://github.com/recoupable/fb678396-a68f-4294-ae50-b8cacf9ce77b")).toBe( + "fb678396-a68f-4294-ae50-b8cacf9ce77b", + ); + }); + + it("accepts a bare new-naming repo name", () => { + expect(extractOrgId("fb678396-a68f-4294-ae50-b8cacf9ce77b")).toBe( + "fb678396-a68f-4294-ae50-b8cacf9ce77b", + ); + }); + it("returns null for non-Recoupable clone URLs", () => { expect( extractOrgId( diff --git a/lib/recoupable/ensurePersonalRepo.ts b/lib/recoupable/ensurePersonalRepo.ts new file mode 100644 index 000000000..0b60f3bcc --- /dev/null +++ b/lib/recoupable/ensurePersonalRepo.ts @@ -0,0 +1,48 @@ +import { createRepository } from "@/lib/github/createRepository"; +import { repositoryExists } from "@/lib/github/repositoryExists"; +import { RECOUPABLE_GITHUB_OWNER } from "./githubOwner"; + +/** + * Idempotently ensure an account has a workspace repo at the + * canonical `recoupable/` location. + * + * 1. If `recoupable/` already exists → return its URL. + * 2. Otherwise create it with `auto_init: true` so the sandbox has + * a `main` branch to clone, and return the new clone URL. + * + * Returns `null` only when the GitHub helpers can't get a service + * token or repo creation outright fails — the caller surfaces that + * as a 502. The single-string return matches what callers actually + * consume (the clone URL); owner / repo name are trivially + * recoverable from the URL if ever needed. + * + * Legacy `-` repos were renamed to the canonical + * `` shape in a one-time backfill (see PR #618), so the + * runtime never needs to look for a legacy name — every workspace + * either already lives at `recoupable/` or doesn't exist + * yet. + */ +export async function ensurePersonalRepo(params: { accountId: string }): Promise { + const repoName = params.accountId; + const repoUrl = `https://github.com/${RECOUPABLE_GITHUB_OWNER}/${repoName}`; + + const existing = await repositoryExists({ repo: repoName }); + + if (existing === null) { + console.error(`[ensurePersonalRepo] failed to check ${RECOUPABLE_GITHUB_OWNER}/${repoName}`); + return null; + } + + if (existing) { + return repoUrl; + } + + const created = await createRepository({ name: repoName }); + + if (!created.success || !created.repoUrl) { + console.error(`[ensurePersonalRepo] createRepository failed: ${created.error ?? "unknown"}`); + return null; + } + + return created.repoUrl; +} diff --git a/lib/recoupable/extractOrgId.ts b/lib/recoupable/extractOrgId.ts index ac30985c5..174fb63c9 100644 --- a/lib/recoupable/extractOrgId.ts +++ b/lib/recoupable/extractOrgId.ts @@ -1,6 +1,15 @@ import { extractOrgRepoName } from "@/lib/recoupable/extractOrgRepoName"; -const UUID_TAIL_PATTERN = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i; +const UUID_RAW = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +/** + * Match the trailing UUID either bare (new naming: ``) or after + * a slug + dash (legacy: `-` / `org--`). Both + * shapes coexist until the migration script renames every legacy repo + * — and even after, `sessions.clone_url` rows from before the rename + * still resolve via GitHub's redirect, so this parser keeps working + * for old rows forever. + */ +const UUID_TAIL_PATTERN = new RegExp(`(?:^|-)(${UUID_RAW})$`, "i"); /** * Extracts the organization UUID from a Recoupable org clone URL or diff --git a/lib/recoupable/githubOwner.ts b/lib/recoupable/githubOwner.ts new file mode 100644 index 000000000..a87ee71a2 --- /dev/null +++ b/lib/recoupable/githubOwner.ts @@ -0,0 +1,13 @@ +/** + * The GitHub organization that owns every Recoupable workspace repo, + * both per-Recoupable-org repos (`recoupable/org--`) and + * per-account personal repos (`recoupable/-`). + * + * Single source of truth so renames or alternate-environment overrides + * stay in lockstep across builders, parsers, and GitHub API callers. + * Mirrors open-agents `apps/web/lib/recoupable/github-owner.ts` and + * chat `lib/recoupable/githubOwner.ts` — all three surfaces must + * agree on this value, otherwise sandboxes try to clone from + * different orgs depending on which surface created them. + */ +export const RECOUPABLE_GITHUB_OWNER = "recoupable"; diff --git a/lib/sessions/__tests__/buildSessionInsertRow.test.ts b/lib/sessions/__tests__/buildSessionInsertRow.test.ts index a786871f5..6d54d0e3c 100644 --- a/lib/sessions/__tests__/buildSessionInsertRow.test.ts +++ b/lib/sessions/__tests__/buildSessionInsertRow.test.ts @@ -3,7 +3,12 @@ import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; describe("buildSessionInsertRow", () => { it("returns sane defaults for an empty body", () => { - const row = buildSessionInsertRow({ body: {}, accountId: "acc-1", title: "Berlin" }); + const row = buildSessionInsertRow({ + body: {}, + accountId: "acc-1", + title: "Berlin", + cloneUrl: null, + }); expect(row.account_id).toBe("acc-1"); expect(row.title).toBe("Berlin"); expect(row.status).toBe("running"); @@ -15,14 +20,12 @@ describe("buildSessionInsertRow", () => { expect(row.id).toMatch(/^[0-9a-f-]{36}$/i); }); - it("forwards branch + clone fields verbatim", () => { + it("writes the resolved cloneUrl onto clone_url", () => { const row = buildSessionInsertRow({ - body: { - branch: "main", - cloneUrl: "https://github.com/recoupable/ai.git", - }, + body: { branch: "main" }, accountId: "acc-1", title: "Berlin", + cloneUrl: "https://github.com/recoupable/ai.git", }); expect(row.branch).toBe("main"); expect(row.clone_url).toBe("https://github.com/recoupable/ai.git"); @@ -33,6 +36,7 @@ describe("buildSessionInsertRow", () => { body: { sandboxType: "vercel" }, accountId: "acc-1", title: "Berlin", + cloneUrl: null, }); expect(row.sandbox_state).toEqual({ type: "vercel" }); }); diff --git a/lib/sessions/__tests__/createSessionHandler.persistence.test.ts b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts index 038889a3d..66e52963d 100644 --- a/lib/sessions/__tests__/createSessionHandler.persistence.test.ts +++ b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts @@ -5,6 +5,7 @@ import { insertSession } from "@/lib/supabase/sessions/insertSession"; import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById"; import { insertChat } from "@/lib/supabase/chats/insertChat"; import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; +import { resolveSessionCloneUrl } from "@/lib/sessions/resolveSessionCloneUrl"; import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; @@ -22,6 +23,9 @@ vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); vi.mock("@/lib/sessions/resolveSessionTitle", () => ({ resolveSessionTitle: vi.fn(async () => "Anchorage"), })); +vi.mock("@/lib/sessions/resolveSessionCloneUrl", () => ({ + resolveSessionCloneUrl: vi.fn(async () => ({ ok: true, cloneUrl: null })), +})); const okValidated = (overrides: { body?: object; accountId?: string } = {}) => ({ body: overrides.body ?? {}, @@ -95,6 +99,34 @@ describe("createSessionHandler — persistence", () => { expect(deleteSessionById).toHaveBeenCalledWith("sess_rollback"); }); + it("returns 502 when resolveSessionCloneUrl fails (e.g. personal repo provisioning blew up)", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(resolveSessionCloneUrl).mockResolvedValueOnce({ + ok: false, + error: "Failed to provision personal repository", + }); + + const res = await createSessionHandler(makeCreateSessionReq({})); + expect(res.status).toBe(502); + expect(insertSession).not.toHaveBeenCalled(); + }); + + it("forwards the resolved cloneUrl onto the inserted session row", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(resolveSessionCloneUrl).mockResolvedValueOnce({ + ok: true, + cloneUrl: "https://github.com/recoupable/sweetman-acc-uuid-1", + }); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow()); + vi.mocked(insertChat).mockResolvedValue(baseChatRow()); + + await createSessionHandler(makeCreateSessionReq({})); + + expect(vi.mocked(insertSession).mock.calls[0][0].clone_url).toBe( + "https://github.com/recoupable/sweetman-acc-uuid-1", + ); + }); + it("logs an orphan-session error when rollback also fails", async () => { vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ id: "sess_orphan" })); diff --git a/lib/sessions/__tests__/createSessionHandler.test.ts b/lib/sessions/__tests__/createSessionHandler.test.ts index a239ccd82..cada68e71 100644 --- a/lib/sessions/__tests__/createSessionHandler.test.ts +++ b/lib/sessions/__tests__/createSessionHandler.test.ts @@ -18,6 +18,9 @@ vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); vi.mock("@/lib/sessions/resolveSessionTitle", () => ({ resolveSessionTitle: vi.fn(async () => "Anchorage"), })); +vi.mock("@/lib/sessions/resolveSessionCloneUrl", () => ({ + resolveSessionCloneUrl: vi.fn(async () => ({ ok: true, cloneUrl: null })), +})); describe("createSessionHandler — short-circuits on validation failure", () => { beforeEach(() => vi.clearAllMocks()); diff --git a/lib/sessions/__tests__/resolveSessionCloneUrl.test.ts b/lib/sessions/__tests__/resolveSessionCloneUrl.test.ts new file mode 100644 index 000000000..ed2104789 --- /dev/null +++ b/lib/sessions/__tests__/resolveSessionCloneUrl.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { resolveSessionCloneUrl } from "@/lib/sessions/resolveSessionCloneUrl"; +import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; +import type { AuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/recoupable/ensurePersonalRepo", () => ({ + ensurePersonalRepo: vi.fn(), +})); + +const accountId = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; +const orgId = "0a0a0a0a-a0a0-4a0a-8a0a-aaaaaaaaaaaa"; +const baseAuth: AuthContext = { + accountId, + orgId: null, + authToken: "tok", +}; + +describe("resolveSessionCloneUrl", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses the body cloneUrl when provided, regardless of org state", async () => { + const result = await resolveSessionCloneUrl({ + bodyCloneUrl: "https://github.com/recoupable/org-foo-id-1", + auth: { ...baseAuth, orgId }, + }); + + expect(result).toEqual({ + ok: true, + cloneUrl: "https://github.com/recoupable/org-foo-id-1", + }); + expect(ensurePersonalRepo).not.toHaveBeenCalled(); + }); + + it("provisions the user's workspace when no body cloneUrl and no org", async () => { + vi.mocked(ensurePersonalRepo).mockResolvedValue(`https://github.com/recoupable/${accountId}`); + + const result = await resolveSessionCloneUrl({ + bodyCloneUrl: undefined, + auth: baseAuth, + }); + + expect(result).toEqual({ + ok: true, + cloneUrl: `https://github.com/recoupable/${accountId}`, + }); + expect(ensurePersonalRepo).toHaveBeenCalledWith({ accountId }); + }); + + it("provisions the ORG's workspace when no body cloneUrl and an org is bound", async () => { + vi.mocked(ensurePersonalRepo).mockResolvedValue(`https://github.com/recoupable/${orgId}`); + + const result = await resolveSessionCloneUrl({ + bodyCloneUrl: undefined, + auth: { ...baseAuth, orgId }, + }); + + expect(result).toEqual({ + ok: true, + cloneUrl: `https://github.com/recoupable/${orgId}`, + }); + // Keyed on orgId (organizations are accounts), not the caller's accountId. + expect(ensurePersonalRepo).toHaveBeenCalledWith({ accountId: orgId }); + }); + + it("returns an error when ensurePersonalRepo fails", async () => { + vi.mocked(ensurePersonalRepo).mockResolvedValue(null); + + const result = await resolveSessionCloneUrl({ + bodyCloneUrl: undefined, + auth: baseAuth, + }); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); +}); diff --git a/lib/sessions/buildSessionInsertRow.ts b/lib/sessions/buildSessionInsertRow.ts index 8c718f57f..de6dfbce3 100644 --- a/lib/sessions/buildSessionInsertRow.ts +++ b/lib/sessions/buildSessionInsertRow.ts @@ -6,6 +6,13 @@ interface BuildSessionInsertRowInput { body: CreateSessionBody; accountId: string; title: string; + /** + * Final clone URL resolved by `resolveSessionCloneUrl`. When `null`, + * the session row stores no `clone_url` — matches the prior + * `body.cloneUrl ?? null` behavior for callers that don't (yet) + * trigger personal-repo provisioning. + */ + cloneUrl: string | null; } /** @@ -21,14 +28,14 @@ interface BuildSessionInsertRowInput { * @returns A row ready to pass to `insertSession`. */ export function buildSessionInsertRow(input: BuildSessionInsertRowInput): TablesInsert<"sessions"> { - const { body, accountId, title } = input; + const { body, accountId, title, cloneUrl } = input; return { id: generateUUID(), account_id: accountId, title, status: "running", branch: body.branch ?? null, - clone_url: body.cloneUrl ?? null, + clone_url: cloneUrl, global_skill_refs: [], sandbox_state: { type: body.sandboxType ?? "vercel" }, lifecycle_state: "provisioning", diff --git a/lib/sessions/createSessionHandler.ts b/lib/sessions/createSessionHandler.ts index d27b0b9e1..0dc416634 100644 --- a/lib/sessions/createSessionHandler.ts +++ b/lib/sessions/createSessionHandler.ts @@ -3,6 +3,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { generateUUID } from "@/lib/uuid/generateUUID"; import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; +import { resolveSessionCloneUrl } from "@/lib/sessions/resolveSessionCloneUrl"; import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; import { failedToCreateSession } from "@/lib/sessions/failedToCreateSession"; import { insertSession } from "@/lib/supabase/sessions/insertSession"; @@ -37,8 +38,24 @@ export async function createSessionHandler(request: NextRequest): Promise` workspace exists + * and use that URL. The provisioning is the same for personal + * sessions and org-bound sessions because organizations are + * themselves accounts (`auth.orgId` IS an account_id — see + * `account_organization_ids.organization` joining `accounts`). + * When an org is bound, the workspace is keyed on `auth.orgId`; + * otherwise on the user's own `auth.accountId`. + */ +export async function resolveSessionCloneUrl(params: { + bodyCloneUrl: string | undefined; + auth: AuthContext; +}): Promise { + const { bodyCloneUrl, auth } = params; + + if (bodyCloneUrl) { + return { ok: true, cloneUrl: bodyCloneUrl }; + } + + const workspaceAccountId = auth.orgId ?? auth.accountId; + const cloneUrl = await ensurePersonalRepo({ + accountId: workspaceAccountId, + }); + + if (!cloneUrl) { + return { + ok: false, + cloneUrl: null, + error: "Failed to provision workspace repository", + }; + } + + return { ok: true, cloneUrl }; +}