Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions lib/github/__tests__/resolveSubmodulePath.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
47 changes: 47 additions & 0 deletions lib/github/resolveSubmodulePath.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
47 changes: 46 additions & 1 deletion lib/sandbox/__tests__/getSandboxesFileHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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.
*
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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",
Expand All @@ -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",
});
});
});
4 changes: 3 additions & 1 deletion lib/sandbox/getSandboxesFileHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -45,7 +46,8 @@ export async function getSandboxesFileHandler(request: NextRequest): Promise<Nex
);
}

const result = await getRawFileContent({ githubRepo, path });
const resolved = await resolveSubmodulePath({ githubRepo, path });
const result = await getRawFileContent(resolved);

if ("error" in result) {
return NextResponse.json(
Expand Down