Skip to content
Closed
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
90 changes: 90 additions & 0 deletions lib/github/__tests__/createGithubRepo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

import { createGithubRepo } from "../createGithubRepo";

const mockGetExistingGithubRepo = vi.fn();

vi.mock("../getExistingGithubRepo", () => ({
getExistingGithubRepo: (...args: unknown[]) => mockGetExistingGithubRepo(...args),
}));

describe("createGithubRepo", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn());
vi.stubEnv("GITHUB_TOKEN", "test-token");
});

it("creates a repo and returns html_url", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({ html_url: "https://github.com/recoupable/my-account-uuid123" }),
{ status: 201 },
),
);

const result = await createGithubRepo("My Account", "uuid123");

expect(result).toBe("https://github.com/recoupable/my-account-uuid123");
expect(fetch).toHaveBeenCalledWith(
"https://api.github.com/orgs/recoupable/repos",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ name: "my-account-uuid123", private: true }),
}),
);
});

it("falls back to getExistingGithubRepo on 422", async () => {
vi.mocked(fetch).mockResolvedValue(new Response("Already exists", { status: 422 }));
mockGetExistingGithubRepo.mockResolvedValue("https://github.com/recoupable/my-account-uuid123");

const result = await createGithubRepo("My Account", "uuid123");

expect(result).toBe("https://github.com/recoupable/my-account-uuid123");
expect(mockGetExistingGithubRepo).toHaveBeenCalledWith("my-account-uuid123");
});

it("returns undefined when GITHUB_TOKEN is missing", async () => {
vi.stubEnv("GITHUB_TOKEN", "");
delete process.env.GITHUB_TOKEN;

const result = await createGithubRepo("My Account", "uuid123");

expect(result).toBeUndefined();
});

it("returns undefined on non-422 error", async () => {
vi.mocked(fetch).mockResolvedValue(new Response("Server error", { status: 500 }));

const result = await createGithubRepo("My Account", "uuid123");

expect(result).toBeUndefined();
});

it("returns undefined on fetch error", async () => {
vi.mocked(fetch).mockRejectedValue(new Error("Network error"));

const result = await createGithubRepo("My Account", "uuid123");

expect(result).toBeUndefined();
});

it("sanitizes account name for repo name", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({ html_url: "https://github.com/recoupable/my-account-uuid123" }),
{ status: 201 },
),
);

await createGithubRepo("My @Account!", "uuid123");

expect(fetch).toHaveBeenCalledWith(
"https://api.github.com/orgs/recoupable/repos",
expect.objectContaining({
body: JSON.stringify({ name: "my-account-uuid123", private: true }),
}),
);
});
});
68 changes: 68 additions & 0 deletions lib/github/__tests__/createOrgGithubRepo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

import { createOrgGithubRepo } from "../createOrgGithubRepo";

const mockGetExistingGithubRepo = vi.fn();

vi.mock("../getExistingGithubRepo", () => ({
getExistingGithubRepo: (...args: unknown[]) => mockGetExistingGithubRepo(...args),
}));

describe("createOrgGithubRepo", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn());
vi.stubEnv("GITHUB_TOKEN", "test-token");
});

it("creates a repo with auto_init and org- prefix", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({ html_url: "https://github.com/recoupable/org-my-org-org123" }),
{ status: 201 },
),
);

const result = await createOrgGithubRepo("My Org", "org123");

expect(result).toBe("https://github.com/recoupable/org-my-org-org123");
expect(fetch).toHaveBeenCalledWith(
"https://api.github.com/orgs/recoupable/repos",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
name: "org-my-org-org123",
private: true,
auto_init: true,
}),
}),
);
});

it("falls back to getExistingGithubRepo on 422", async () => {
vi.mocked(fetch).mockResolvedValue(new Response("Already exists", { status: 422 }));
mockGetExistingGithubRepo.mockResolvedValue("https://github.com/recoupable/org-my-org-org123");

const result = await createOrgGithubRepo("My Org", "org123");

expect(result).toBe("https://github.com/recoupable/org-my-org-org123");
expect(mockGetExistingGithubRepo).toHaveBeenCalledWith("org-my-org-org123");
});

it("returns undefined when GITHUB_TOKEN is missing", async () => {
vi.stubEnv("GITHUB_TOKEN", "");
delete process.env.GITHUB_TOKEN;

const result = await createOrgGithubRepo("My Org", "org123");

expect(result).toBeUndefined();
});

it("returns undefined on non-422 error", async () => {
vi.mocked(fetch).mockResolvedValue(new Response("Server error", { status: 500 }));

const result = await createOrgGithubRepo("My Org", "org123");

expect(result).toBeUndefined();
});
});
56 changes: 56 additions & 0 deletions lib/github/__tests__/getExistingGithubRepo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

import { getExistingGithubRepo } from "../getExistingGithubRepo";

describe("getExistingGithubRepo", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn());
vi.stubEnv("GITHUB_TOKEN", "test-token");
});

it("returns html_url on success", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ html_url: "https://github.com/recoupable/my-repo" }), {
status: 200,
}),
);

const result = await getExistingGithubRepo("my-repo");

expect(result).toBe("https://github.com/recoupable/my-repo");
expect(fetch).toHaveBeenCalledWith(
"https://api.github.com/repos/recoupable/my-repo",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer test-token",
}),
}),
);
});

it("returns undefined when GITHUB_TOKEN is missing", async () => {
vi.stubEnv("GITHUB_TOKEN", "");
delete process.env.GITHUB_TOKEN;

const result = await getExistingGithubRepo("my-repo");

expect(result).toBeUndefined();
});

it("returns undefined on non-ok response", async () => {
vi.mocked(fetch).mockResolvedValue(new Response("Not found", { status: 404 }));

const result = await getExistingGithubRepo("missing-repo");

expect(result).toBeUndefined();
});

it("returns undefined on fetch error", async () => {
vi.mocked(fetch).mockRejectedValue(new Error("Network error"));

const result = await getExistingGithubRepo("my-repo");

expect(result).toBeUndefined();
});
});
37 changes: 37 additions & 0 deletions lib/github/__tests__/sanitizeRepoName.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";

import { sanitizeRepoName } from "../sanitizeRepoName";

describe("sanitizeRepoName", () => {
it("lowercases the name", () => {
expect(sanitizeRepoName("MyRepo")).toBe("myrepo");
});

it("replaces spaces with hyphens", () => {
expect(sanitizeRepoName("my repo name")).toBe("my-repo-name");
});

it("replaces special characters with hyphens", () => {
expect(sanitizeRepoName("my@repo!name")).toBe("my-repo-name");
});

it("collapses multiple hyphens", () => {
expect(sanitizeRepoName("my---repo")).toBe("my-repo");
});

it("trims leading and trailing hyphens", () => {
expect(sanitizeRepoName("-my-repo-")).toBe("my-repo");
});

it("returns 'account' for empty string", () => {
expect(sanitizeRepoName("")).toBe("account");
});

it("returns 'account' for string of only special chars", () => {
expect(sanitizeRepoName("@#$%")).toBe("account");
});

it("handles already valid names", () => {
expect(sanitizeRepoName("valid-name")).toBe("valid-name");
});
});
19 changes: 19 additions & 0 deletions lib/github/createGithubRepo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { sanitizeRepoName } from "./sanitizeRepoName";
import { createRepoInOrg } from "./createRepoInOrg";

/**
* Creates a private GitHub repository in the recoupable organization.
*
* @param accountName - The account display name
* @param accountId - The account UUID
* @returns The repository HTML URL, or undefined on error
*/
export async function createGithubRepo(
accountName: string,
accountId: string,
): Promise<string | undefined> {
const sanitizedName = sanitizeRepoName(accountName);
const repoName = `${sanitizedName}-${accountId}`;

return createRepoInOrg({ repoName });
}
22 changes: 22 additions & 0 deletions lib/github/createOrgGithubRepo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { sanitizeRepoName } from "./sanitizeRepoName";
import { createRepoInOrg } from "./createRepoInOrg";

/**
* Creates a private GitHub repository for an organization in the
* recoupable GitHub organization.
*
* Repo naming: `org-{sanitizedName}-{orgId}`
*
* @param orgName - The organization display name
* @param orgId - The organization UUID
* @returns The repository HTML URL, or undefined on error
*/
export async function createOrgGithubRepo(
orgName: string,
orgId: string,
): Promise<string | undefined> {
const sanitizedName = sanitizeRepoName(orgName);
const repoName = `org-${sanitizedName}-${orgId}`;

return createRepoInOrg({ repoName, autoInit: true });
}
63 changes: 63 additions & 0 deletions lib/github/createRepoInOrg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { getExistingGithubRepo } from "./getExistingGithubRepo";

const GITHUB_ORG = "recoupable";

interface CreateRepoInOrgOptions {
repoName: string;
autoInit?: boolean;
}

/**
* Creates a private GitHub repository in the recoupable organization.
* Falls back to fetching the existing repo URL on 422 (already exists).
*
* @param options - The repo name and optional auto_init flag
* @returns The repository HTML URL, or undefined on error
*/
export async function createRepoInOrg(options: CreateRepoInOrgOptions): Promise<string | undefined> {
const { repoName, autoInit } = options;

const token = process.env.GITHUB_TOKEN;

if (!token) {
console.error("Missing GITHUB_TOKEN environment variable");
return undefined;
}

try {
const response = await fetch(`https://api.github.com/orgs/${GITHUB_ORG}/repos`, {
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
body: JSON.stringify({
name: repoName,
private: true,
...(autoInit ? { auto_init: true } : {}),
}),
});

if (!response.ok) {
if (response.status === 422) {
return getExistingGithubRepo(repoName);
}

console.error("Failed to create GitHub repo", {
repoName,
status: response.status,
});
return undefined;
}

const data = (await response.json()) as { html_url: string };
return data.html_url;
} catch (error) {
console.error("Error creating GitHub repo", {
repoName,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
Loading