From 24967bd0949687ac5ef1801d697c3e29a7586665 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Feb 2026 18:56:06 -0500 Subject: [PATCH] feat: add filetree to GET /api/sandboxes response Fetches the recursive file tree from the GitHub API using the account's github_repo URL and includes it in the sandboxes response. The filetree fetch runs in parallel with sandbox status calls for performance. Co-Authored-By: Claude Opus 4.6 --- lib/github/__tests__/getRepoFileTree.test.ts | 119 ++++++++++++++ .../__tests__/parseGitHubRepoUrl.test.ts | 41 +++++ lib/github/getRepoFileTree.ts | 71 +++++++++ lib/github/parseGitHubRepoUrl.ts | 29 ++++ .../__tests__/getSandboxesHandler.test.ts | 145 ++++++++++++++++++ lib/sandbox/getSandboxesHandler.ts | 19 ++- 6 files changed, 416 insertions(+), 8 deletions(-) create mode 100644 lib/github/__tests__/getRepoFileTree.test.ts create mode 100644 lib/github/__tests__/parseGitHubRepoUrl.test.ts create mode 100644 lib/github/getRepoFileTree.ts create mode 100644 lib/github/parseGitHubRepoUrl.ts diff --git a/lib/github/__tests__/getRepoFileTree.test.ts b/lib/github/__tests__/getRepoFileTree.test.ts new file mode 100644 index 00000000..f34dcd62 --- /dev/null +++ b/lib/github/__tests__/getRepoFileTree.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getRepoFileTree } from "../getRepoFileTree"; + +describe("getRepoFileTree", () => { + const originalToken = process.env.GITHUB_TOKEN; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env.GITHUB_TOKEN = "test-token"; + }); + + afterEach(() => { + process.env.GITHUB_TOKEN = originalToken; + }); + + it("returns file tree entries on success", async () => { + vi.spyOn(global, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ default_branch: "main" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + tree: [ + { path: "README.md", type: "blob", sha: "abc123", size: 100 }, + { path: "src", type: "tree", sha: "def456" }, + { path: "src/index.ts", type: "blob", sha: "ghi789", size: 250 }, + ], + }), + { status: 200 }, + ), + ); + + const result = await getRepoFileTree("https://github.com/owner/repo"); + + expect(result).toEqual([ + { path: "README.md", type: "blob", sha: "abc123", size: 100 }, + { path: "src", type: "tree", sha: "def456" }, + { path: "src/index.ts", type: "blob", sha: "ghi789", size: 250 }, + ]); + }); + + it("returns null when GITHUB_TOKEN is not set", async () => { + delete process.env.GITHUB_TOKEN; + + const result = await getRepoFileTree("https://github.com/owner/repo"); + + expect(result).toBeNull(); + }); + + it("returns null when URL parsing fails", async () => { + const fetchSpy = vi.spyOn(global, "fetch"); + + const result = await getRepoFileTree("not-a-valid-url"); + + expect(result).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns null when repo metadata fetch fails", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce( + new Response("Not Found", { status: 404 }), + ); + + const result = await getRepoFileTree("https://github.com/owner/repo"); + + expect(result).toBeNull(); + }); + + it("returns null when tree fetch fails", async () => { + vi.spyOn(global, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ default_branch: "main" }), { status: 200 }), + ) + .mockResolvedValueOnce(new Response("Server Error", { status: 500 })); + + const result = await getRepoFileTree("https://github.com/owner/repo"); + + expect(result).toBeNull(); + }); + + it("returns null when fetch throws", async () => { + vi.spyOn(global, "fetch").mockRejectedValueOnce(new Error("Network error")); + + const result = await getRepoFileTree("https://github.com/owner/repo"); + + expect(result).toBeNull(); + }); + + it("passes correct Authorization header", async () => { + const fetchSpy = vi.spyOn(global, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ default_branch: "main" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ tree: [] }), { status: 200 }), + ); + + await getRepoFileTree("https://github.com/owner/repo"); + + expect(fetchSpy.mock.calls[0][1]?.headers).toEqual( + expect.objectContaining({ Authorization: "Bearer test-token" }), + ); + }); + + it("uses the default branch from repo metadata", async () => { + const fetchSpy = vi.spyOn(global, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ default_branch: "develop" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ tree: [] }), { status: 200 }), + ); + + await getRepoFileTree("https://github.com/owner/repo"); + + expect(fetchSpy.mock.calls[1][0]).toContain("/git/trees/develop?recursive=1"); + }); +}); diff --git a/lib/github/__tests__/parseGitHubRepoUrl.test.ts b/lib/github/__tests__/parseGitHubRepoUrl.test.ts new file mode 100644 index 00000000..32ca0f4d --- /dev/null +++ b/lib/github/__tests__/parseGitHubRepoUrl.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { parseGitHubRepoUrl } from "../parseGitHubRepoUrl"; + +describe("parseGitHubRepoUrl", () => { + it("parses standard GitHub URL", () => { + expect(parseGitHubRepoUrl("https://github.com/Recoupable-Com/my-repo")).toEqual({ + owner: "Recoupable-Com", + repo: "my-repo", + }); + }); + + it("parses URL with trailing slash", () => { + expect(parseGitHubRepoUrl("https://github.com/owner/repo/")).toEqual({ + owner: "owner", + repo: "repo", + }); + }); + + it("parses URL with .git suffix", () => { + expect(parseGitHubRepoUrl("https://github.com/owner/repo.git")).toEqual({ + owner: "owner", + repo: "repo", + }); + }); + + it("returns null for non-GitHub URL", () => { + expect(parseGitHubRepoUrl("https://gitlab.com/owner/repo")).toBeNull(); + }); + + it("returns null for malformed URL", () => { + expect(parseGitHubRepoUrl("not-a-url")).toBeNull(); + }); + + it("returns null for GitHub URL without repo", () => { + expect(parseGitHubRepoUrl("https://github.com/owner")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(parseGitHubRepoUrl("")).toBeNull(); + }); +}); diff --git a/lib/github/getRepoFileTree.ts b/lib/github/getRepoFileTree.ts new file mode 100644 index 00000000..23bac850 --- /dev/null +++ b/lib/github/getRepoFileTree.ts @@ -0,0 +1,71 @@ +import { parseGitHubRepoUrl } from "./parseGitHubRepoUrl"; + +export interface FileTreeEntry { + path: string; + type: "blob" | "tree"; + sha: string; + size?: number; +} + +/** + * Fetches the full recursive file tree for a GitHub repository. + * + * @param githubRepoUrl - A GitHub repository URL + * @returns Array of file tree entries, or null on failure + */ +export async function getRepoFileTree( + githubRepoUrl: string, +): Promise { + const token = process.env.GITHUB_TOKEN; + if (!token) { + console.error("GITHUB_TOKEN environment variable is not set"); + return null; + } + + const repoInfo = parseGitHubRepoUrl(githubRepoUrl); + if (!repoInfo) { + console.error(`Failed to parse GitHub repo URL: ${githubRepoUrl}`); + return null; + } + + const headers = { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "Recoup-API", + }; + + try { + const repoResponse = await fetch( + `https://api.github.com/repos/${repoInfo.owner}/${repoInfo.repo}`, + { headers }, + ); + if (!repoResponse.ok) { + console.error(`GitHub API error fetching repo: ${repoResponse.status}`); + return null; + } + const repoData = (await repoResponse.json()) as { default_branch: string }; + const defaultBranch = repoData.default_branch; + + const treeResponse = await fetch( + `https://api.github.com/repos/${repoInfo.owner}/${repoInfo.repo}/git/trees/${defaultBranch}?recursive=1`, + { headers }, + ); + if (!treeResponse.ok) { + console.error(`GitHub API error fetching tree: ${treeResponse.status}`); + return null; + } + const treeData = (await treeResponse.json()) as { + tree: Array<{ path: string; type: string; sha: string; size?: number }>; + }; + + return treeData.tree.map(entry => ({ + path: entry.path, + type: entry.type as "blob" | "tree", + sha: entry.sha, + ...(entry.size !== undefined && { size: entry.size }), + })); + } catch (error) { + console.error("Error fetching GitHub file tree:", error); + return null; + } +} diff --git a/lib/github/parseGitHubRepoUrl.ts b/lib/github/parseGitHubRepoUrl.ts new file mode 100644 index 00000000..52d49438 --- /dev/null +++ b/lib/github/parseGitHubRepoUrl.ts @@ -0,0 +1,29 @@ +export interface GitHubRepoInfo { + owner: string; + repo: string; +} + +/** + * Parses a GitHub repository URL and extracts the owner and repo name. + * + * @param url - A GitHub repository URL + * @returns The owner and repo, or null if the URL is not valid + */ +export function parseGitHubRepoUrl(url: string): GitHubRepoInfo | null { + try { + const parsed = new URL(url); + if (parsed.hostname !== "github.com") return null; + + const segments = parsed.pathname.split("/").filter(Boolean); + if (segments.length < 2) return null; + + const owner = segments[0]; + const repo = segments[1].replace(/\.git$/, ""); + + if (!owner || !repo) return null; + + return { owner, repo }; + } catch { + return null; + } +} diff --git a/lib/sandbox/__tests__/getSandboxesHandler.test.ts b/lib/sandbox/__tests__/getSandboxesHandler.test.ts index 3c762bde..6c6121f1 100644 --- a/lib/sandbox/__tests__/getSandboxesHandler.test.ts +++ b/lib/sandbox/__tests__/getSandboxesHandler.test.ts @@ -7,6 +7,7 @@ import { validateGetSandboxesRequest } from "../validateGetSandboxesRequest"; import { selectAccountSandboxes } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; import { getSandboxStatus } from "../getSandboxStatus"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { getRepoFileTree } from "@/lib/github/getRepoFileTree"; vi.mock("../validateGetSandboxesRequest", () => ({ validateGetSandboxesRequest: vi.fn(), @@ -24,6 +25,10 @@ vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ selectAccountSnapshots: vi.fn(), })); +vi.mock("@/lib/github/getRepoFileTree", () => ({ + getRepoFileTree: vi.fn(), +})); + /** * Creates a mock NextRequest for testing. * @@ -41,6 +46,8 @@ describe("getSandboxesHandler", () => { vi.clearAllMocks(); // Default mock for selectAccountSnapshots - no snapshot exists vi.mocked(selectAccountSnapshots).mockResolvedValue([]); + // Default mock for getRepoFileTree - no filetree + vi.mocked(getRepoFileTree).mockResolvedValue(null); }); it("returns error response when validation fails", async () => { @@ -70,6 +77,7 @@ describe("getSandboxesHandler", () => { sandboxes: [], snapshot_id: null, github_repo: null, + filetree: null, }); }); @@ -109,6 +117,7 @@ describe("getSandboxesHandler", () => { ], snapshot_id: null, github_repo: null, + filetree: null, }); }); @@ -353,4 +362,140 @@ describe("getSandboxesHandler", () => { expect(selectAccountSnapshots).toHaveBeenCalledWith("acc_123"); }); }); + + describe("filetree field", () => { + it("returns filetree when github_repo exists", async () => { + const mockFiletree = [ + { path: "README.md", type: "blob" as const, sha: "abc123", size: 100 }, + { path: "src", type: "tree" as const, sha: "def456" }, + { path: "src/index.ts", type: "blob" as const, sha: "ghi789", size: 250 }, + ]; + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + accountIds: ["acc_123"], + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([]); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + account_id: "acc_123", + snapshot_id: "snap_abc123", + github_repo: "https://github.com/user/repo", + created_at: "2024-01-01T00:00:00.000Z", + expires_at: "2024-01-08T00:00:00.000Z", + }, + ]); + vi.mocked(getRepoFileTree).mockResolvedValue(mockFiletree); + + const request = createMockRequest(); + const response = await getSandboxesHandler(request); + + const json = await response.json(); + expect(json.filetree).toEqual(mockFiletree); + expect(getRepoFileTree).toHaveBeenCalledWith("https://github.com/user/repo"); + }); + + it("returns null filetree when no github_repo exists", async () => { + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + accountIds: ["acc_123"], + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([]); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + account_id: "acc_123", + snapshot_id: "snap_abc123", + github_repo: null, + created_at: "2024-01-01T00:00:00.000Z", + expires_at: "2024-01-08T00:00:00.000Z", + }, + ]); + + const request = createMockRequest(); + const response = await getSandboxesHandler(request); + + const json = await response.json(); + expect(json.filetree).toBeNull(); + expect(getRepoFileTree).not.toHaveBeenCalled(); + }); + + it("returns null filetree when getRepoFileTree fails", async () => { + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + accountIds: ["acc_123"], + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([]); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + account_id: "acc_123", + snapshot_id: "snap_abc123", + github_repo: "https://github.com/user/repo", + created_at: "2024-01-01T00:00:00.000Z", + expires_at: "2024-01-08T00:00:00.000Z", + }, + ]); + vi.mocked(getRepoFileTree).mockResolvedValue(null); + + const request = createMockRequest(); + const response = await getSandboxesHandler(request); + + const json = await response.json(); + expect(json.filetree).toBeNull(); + }); + + it("fetches filetree in parallel with sandbox statuses", async () => { + const callOrder: string[] = []; + + vi.mocked(validateGetSandboxesRequest).mockResolvedValue({ + accountIds: ["acc_123"], + }); + vi.mocked(selectAccountSandboxes).mockResolvedValue([ + { + id: "record_1", + account_id: "acc_123", + sandbox_id: "sbx_1", + created_at: "2024-01-01T00:00:00.000Z", + }, + ]); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + account_id: "acc_123", + snapshot_id: "snap_abc123", + github_repo: "https://github.com/user/repo", + created_at: "2024-01-01T00:00:00.000Z", + expires_at: "2024-01-08T00:00:00.000Z", + }, + ]); + + vi.mocked(getSandboxStatus).mockImplementation(async () => { + callOrder.push("start:sandbox"); + await new Promise(resolve => setTimeout(resolve, 10)); + callOrder.push("end:sandbox"); + return { + sandboxId: "sbx_1", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }; + }); + + vi.mocked(getRepoFileTree).mockImplementation(async () => { + callOrder.push("start:filetree"); + await new Promise(resolve => setTimeout(resolve, 10)); + callOrder.push("end:filetree"); + return [{ path: "README.md", type: "blob" as const, sha: "abc", size: 10 }]; + }); + + const request = createMockRequest(); + await getSandboxesHandler(request); + + // Both should start before either ends (parallel execution) + const startIndices = callOrder + .map((item, index) => (item.startsWith("start:") ? index : -1)) + .filter(i => i !== -1); + const endIndices = callOrder + .map((item, index) => (item.startsWith("end:") ? index : -1)) + .filter(i => i !== -1); + + const maxStartIndex = Math.max(...startIndices); + const minEndIndex = Math.min(...endIndices); + expect(maxStartIndex).toBeLessThan(minEndIndex); + }); + }); }); diff --git a/lib/sandbox/getSandboxesHandler.ts b/lib/sandbox/getSandboxesHandler.ts index e1baaed4..618e6e3b 100644 --- a/lib/sandbox/getSandboxesHandler.ts +++ b/lib/sandbox/getSandboxesHandler.ts @@ -4,6 +4,7 @@ import { validateGetSandboxesRequest } from "./validateGetSandboxesRequest"; import { selectAccountSandboxes } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; import { getSandboxStatus } from "./getSandboxStatus"; +import { getRepoFileTree } from "@/lib/github/getRepoFileTree"; import type { SandboxCreatedResponse } from "./createSandbox"; /** @@ -35,26 +36,28 @@ export async function getSandboxesHandler(request: NextRequest): Promise getSandboxStatus(record.sandbox_id)), - ); + // Extract snapshot info + const snapshot_id = snapshots[0]?.snapshot_id ?? null; + const github_repo = snapshots[0]?.github_repo ?? null; + + // Fetch live sandbox statuses and filetree in parallel + const [statusResults, filetree] = await Promise.all([ + Promise.all(accountSandboxes.map(record => getSandboxStatus(record.sandbox_id))), + github_repo ? getRepoFileTree(github_repo) : Promise.resolve(null), + ]); // Filter out null results (sandboxes that no longer exist) const sandboxes = statusResults.filter( (status): status is SandboxCreatedResponse => status !== null, ); - // Extract snapshot info - const snapshot_id = snapshots[0]?.snapshot_id ?? null; - const github_repo = snapshots[0]?.github_repo ?? null; - return NextResponse.json( { status: "success", sandboxes, snapshot_id, github_repo, + filetree, }, { status: 200,