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
119 changes: 119 additions & 0 deletions lib/github/__tests__/getRepoFileTree.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
41 changes: 41 additions & 0 deletions lib/github/__tests__/parseGitHubRepoUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
71 changes: 71 additions & 0 deletions lib/github/getRepoFileTree.ts
Original file line number Diff line number Diff line change
@@ -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<FileTreeEntry[] | null> {
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;
}
}
29 changes: 29 additions & 0 deletions lib/github/parseGitHubRepoUrl.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading