diff --git a/lib/github/__tests__/resolveSubmodulePath.test.ts b/lib/github/__tests__/resolveSubmodulePath.test.ts new file mode 100644 index 00000000..882cc32a --- /dev/null +++ b/lib/github/__tests__/resolveSubmodulePath.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resolveSubmodulePath } from "../resolveSubmodulePath"; +import { getRepoGitModules } from "../getRepoGitModules"; + +vi.mock("../getRepoGitModules", () => ({ + getRepoGitModules: vi.fn(), +})); + +describe("resolveSubmodulePath", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns submodule repo and relative path for submodule files", async () => { + vi.mocked(getRepoGitModules).mockResolvedValue([ + { + path: ".openclaw/workspace/orgs/my-org", + url: "https://github.com/recoupable/org-my-org-abc123", + }, + ]); + + const result = await resolveSubmodulePath({ + githubRepo: "https://github.com/user/repo", + path: ".openclaw/workspace/orgs/my-org/artist.md", + }); + + expect(result).toEqual({ + githubRepo: "https://github.com/recoupable/org-my-org-abc123", + path: "artist.md", + }); + }); + + it("returns original values for non-submodule paths", async () => { + vi.mocked(getRepoGitModules).mockResolvedValue([ + { + path: ".openclaw/workspace/orgs/my-org", + url: "https://github.com/recoupable/org-my-org-abc123", + }, + ]); + + const result = await resolveSubmodulePath({ + githubRepo: "https://github.com/user/repo", + path: "README.md", + }); + + expect(result).toEqual({ + githubRepo: "https://github.com/user/repo", + path: "README.md", + }); + }); + + it("returns original values when getRepoGitModules returns null", async () => { + vi.mocked(getRepoGitModules).mockResolvedValue(null); + + const result = await resolveSubmodulePath({ + githubRepo: "https://github.com/user/repo", + path: ".openclaw/workspace/orgs/my-org/artist.md", + }); + + expect(result).toEqual({ + githubRepo: "https://github.com/user/repo", + path: ".openclaw/workspace/orgs/my-org/artist.md", + }); + }); + + it("returns original values for invalid GitHub URL", async () => { + const result = await resolveSubmodulePath({ + githubRepo: "not-a-valid-url", + path: "some/file.ts", + }); + + expect(result).toEqual({ + githubRepo: "not-a-valid-url", + path: "some/file.ts", + }); + expect(getRepoGitModules).not.toHaveBeenCalled(); + }); + + it("handles nested paths within submodule", async () => { + vi.mocked(getRepoGitModules).mockResolvedValue([ + { + path: ".openclaw/workspace/orgs/my-org", + url: "https://github.com/recoupable/org-abc", + }, + ]); + + const result = await resolveSubmodulePath({ + githubRepo: "https://github.com/user/repo", + path: ".openclaw/workspace/orgs/my-org/data/deep/file.json", + }); + + expect(result).toEqual({ + githubRepo: "https://github.com/recoupable/org-abc", + path: "data/deep/file.json", + }); + }); + + it("matches the longest submodule path when paths overlap", async () => { + vi.mocked(getRepoGitModules).mockResolvedValue([ + { path: "orgs", url: "https://github.com/recoupable/orgs-parent" }, + { path: "orgs/specific", url: "https://github.com/recoupable/orgs-specific" }, + ]); + + const result = await resolveSubmodulePath({ + githubRepo: "https://github.com/user/repo", + path: "orgs/specific/file.md", + }); + + expect(result).toEqual({ + githubRepo: "https://github.com/recoupable/orgs-specific", + path: "file.md", + }); + }); + + it("does not match path that only shares a prefix but not a directory boundary", async () => { + vi.mocked(getRepoGitModules).mockResolvedValue([ + { path: "orgs/my-org", url: "https://github.com/recoupable/org-abc" }, + ]); + + const result = await resolveSubmodulePath({ + githubRepo: "https://github.com/user/repo", + path: "orgs/my-org-extra/file.md", + }); + + expect(result).toEqual({ + githubRepo: "https://github.com/user/repo", + path: "orgs/my-org-extra/file.md", + }); + }); + + it("passes correct params to getRepoGitModules", async () => { + vi.mocked(getRepoGitModules).mockResolvedValue([]); + + await resolveSubmodulePath({ + githubRepo: "https://github.com/my-owner/my-repo", + path: "some/file.ts", + }); + + expect(getRepoGitModules).toHaveBeenCalledWith({ + owner: "my-owner", + repo: "my-repo", + branch: "main", + }); + }); +}); diff --git a/lib/github/resolveSubmodulePath.ts b/lib/github/resolveSubmodulePath.ts new file mode 100644 index 00000000..7c3f60ed --- /dev/null +++ b/lib/github/resolveSubmodulePath.ts @@ -0,0 +1,47 @@ +import { parseGitHubRepoUrl } from "./parseGitHubRepoUrl"; +import { getRepoGitModules } from "./getRepoGitModules"; + +/** + * Resolves a file path that may be inside a git submodule. + * If the path falls within a submodule, returns the submodule's repo URL + * and the relative path within it. Otherwise returns the original values. + * + * @param githubRepo - The parent GitHub repository URL + * @param path - The file path to resolve + * @returns The resolved repo URL and path + */ +export async function resolveSubmodulePath({ + githubRepo, + path, +}: { + githubRepo: string; + path: string; +}): Promise<{ githubRepo: string; path: string }> { + const repoInfo = parseGitHubRepoUrl(githubRepo); + if (!repoInfo) return { githubRepo, path }; + + const submodules = await getRepoGitModules({ + owner: repoInfo.owner, + repo: repoInfo.repo, + branch: "main", + }); + + if (!submodules) return { githubRepo, path }; + + let bestMatch: { subPath: string; url: string } | null = null; + for (const sub of submodules) { + if ( + path.startsWith(sub.path + "/") && + (!bestMatch || sub.path.length > bestMatch.subPath.length) + ) { + bestMatch = { subPath: sub.path, url: sub.url }; + } + } + + if (!bestMatch) return { githubRepo, path }; + + return { + githubRepo: bestMatch.url, + path: path.slice(bestMatch.subPath.length + 1), + }; +} diff --git a/lib/sandbox/__tests__/getSandboxesFileHandler.test.ts b/lib/sandbox/__tests__/getSandboxesFileHandler.test.ts index fc63cdfe..69690ac0 100644 --- a/lib/sandbox/__tests__/getSandboxesFileHandler.test.ts +++ b/lib/sandbox/__tests__/getSandboxesFileHandler.test.ts @@ -6,6 +6,7 @@ import { getSandboxesFileHandler } from "../getSandboxesFileHandler"; import { validateGetSandboxesFileRequest } from "../validateGetSandboxesFileRequest"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; import { getRawFileContent } from "@/lib/github/getRawFileContent"; +import { resolveSubmodulePath } from "@/lib/github/resolveSubmodulePath"; vi.mock("../validateGetSandboxesFileRequest", () => ({ validateGetSandboxesFileRequest: vi.fn(), @@ -19,6 +20,10 @@ vi.mock("@/lib/github/getRawFileContent", () => ({ getRawFileContent: vi.fn(), })); +vi.mock("@/lib/github/resolveSubmodulePath", () => ({ + resolveSubmodulePath: vi.fn(), +})); + /** * Creates a mock NextRequest for testing. * @@ -35,6 +40,8 @@ function createMockRequest(path = "src/index.ts"): NextRequest { describe("getSandboxesFileHandler", () => { beforeEach(() => { vi.clearAllMocks(); + // Default: resolveSubmodulePath passes through unchanged + vi.mocked(resolveSubmodulePath).mockImplementation(async (params) => params); }); it("returns error response when validation fails", async () => { @@ -167,7 +174,7 @@ describe("getSandboxesFileHandler", () => { expect(selectAccountSnapshots).toHaveBeenCalledWith("org_123"); }); - it("calls getRawFileContent with correct params", async () => { + it("calls resolveSubmodulePath then getRawFileContent with resolved params", async () => { vi.mocked(validateGetSandboxesFileRequest).mockResolvedValue({ accountIds: ["acc_123"], path: "src/utils/helper.ts", @@ -188,9 +195,47 @@ describe("getSandboxesFileHandler", () => { const request = createMockRequest("src/utils/helper.ts"); await getSandboxesFileHandler(request); + expect(resolveSubmodulePath).toHaveBeenCalledWith({ + githubRepo: "https://github.com/user/repo", + path: "src/utils/helper.ts", + }); expect(getRawFileContent).toHaveBeenCalledWith({ githubRepo: "https://github.com/user/repo", path: "src/utils/helper.ts", }); }); + + it("fetches from submodule repo when path is inside a submodule", async () => { + vi.mocked(validateGetSandboxesFileRequest).mockResolvedValue({ + accountIds: ["acc_123"], + path: ".openclaw/workspace/orgs/my-org/artist.md", + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + account_id: "acc_123", + snapshot_id: "snap_abc", + 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(resolveSubmodulePath).mockResolvedValue({ + githubRepo: "https://github.com/recoupable/org-my-org-abc123", + path: "artist.md", + }); + vi.mocked(getRawFileContent).mockResolvedValue({ + content: "# Artist Info", + }); + + const request = createMockRequest(".openclaw/workspace/orgs/my-org/artist.md"); + const response = await getSandboxesFileHandler(request); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.content).toBe("# Artist Info"); + expect(getRawFileContent).toHaveBeenCalledWith({ + githubRepo: "https://github.com/recoupable/org-my-org-abc123", + path: "artist.md", + }); + }); }); diff --git a/lib/sandbox/getSandboxesFileHandler.ts b/lib/sandbox/getSandboxesFileHandler.ts index 9625830c..519483da 100644 --- a/lib/sandbox/getSandboxesFileHandler.ts +++ b/lib/sandbox/getSandboxesFileHandler.ts @@ -3,6 +3,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateGetSandboxesFileRequest } from "./validateGetSandboxesFileRequest"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; import { getRawFileContent } from "@/lib/github/getRawFileContent"; +import { resolveSubmodulePath } from "@/lib/github/resolveSubmodulePath"; /** * Handler for retrieving file contents from a sandbox's GitHub repository. @@ -45,7 +46,8 @@ export async function getSandboxesFileHandler(request: NextRequest): Promise