diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index 475315c..87d4dd8 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -13,6 +13,7 @@ import { db, organizations, repositories } from "./db"; import { eq, and, ne } from "drizzle-orm"; import { decryptConfigTokens } from "./utils/config-encryption"; import { formatDateShort } from "./utils"; +import { buildGithubSourceAuthPayload } from "./utils/mirror-source-auth"; import { parseRepositoryMetadataState, serializeRepositoryMetadataState, @@ -816,14 +817,22 @@ export const mirrorGithubRepoToGitea = async ({ // Add authentication for private repositories if (repository.isPrivate) { - if (!config.githubConfig.token) { - throw new Error( - "GitHub token is required to mirror private repositories." - ); - } - // Use separate auth fields (required for Forgejo 12+ compatibility) - migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username - migratePayload.auth_token = decryptedConfig.githubConfig.token; + const githubOwner = + ( + config.githubConfig as typeof config.githubConfig & { + owner?: string; + } + ).owner || ""; + + Object.assign( + migratePayload, + buildGithubSourceAuthPayload({ + token: decryptedConfig.githubConfig.token, + githubOwner, + githubUsername: config.githubConfig.username, + repositoryOwner: repository.owner, + }) + ); } // Track whether the Gitea migrate call succeeded so the catch block @@ -1496,14 +1505,22 @@ export async function mirrorGitHubRepoToGiteaOrg({ // Add authentication for private repositories if (repository.isPrivate) { - if (!config.githubConfig?.token) { - throw new Error( - "GitHub token is required to mirror private repositories." - ); - } - // Use separate auth fields (required for Forgejo 12+ compatibility) - migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username - migratePayload.auth_token = decryptedConfig.githubConfig.token; + const githubOwner = + ( + config.githubConfig as typeof config.githubConfig & { + owner?: string; + } + )?.owner || ""; + + Object.assign( + migratePayload, + buildGithubSourceAuthPayload({ + token: decryptedConfig.githubConfig?.token, + githubOwner, + githubUsername: config.githubConfig?.username, + repositoryOwner: repository.owner, + }) + ); } let migrateSucceeded = false; diff --git a/src/lib/utils/mirror-source-auth.test.ts b/src/lib/utils/mirror-source-auth.test.ts new file mode 100644 index 0000000..49654be --- /dev/null +++ b/src/lib/utils/mirror-source-auth.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { buildGithubSourceAuthPayload } from "./mirror-source-auth"; + +describe("buildGithubSourceAuthPayload", () => { + test("uses configured owner when available", () => { + const auth = buildGithubSourceAuthPayload({ + token: "ghp_test_token", + githubOwner: "ConfiguredOwner", + githubUsername: "fallback-user", + repositoryOwner: "repo-owner", + }); + + expect(auth).toEqual({ + auth_username: "ConfiguredOwner", + auth_password: "ghp_test_token", + auth_token: "ghp_test_token", + }); + }); + + test("falls back to configured username then repository owner", () => { + const authFromUsername = buildGithubSourceAuthPayload({ + token: "token1", + githubUsername: "configured-user", + repositoryOwner: "repo-owner", + }); + + expect(authFromUsername.auth_username).toBe("configured-user"); + + const authFromRepoOwner = buildGithubSourceAuthPayload({ + token: "token2", + repositoryOwner: "repo-owner", + }); + + expect(authFromRepoOwner.auth_username).toBe("repo-owner"); + }); + + test("uses x-access-token as last-resort username", () => { + const auth = buildGithubSourceAuthPayload({ + token: "ghp_test_token", + }); + + expect(auth.auth_username).toBe("x-access-token"); + }); + + test("trims token whitespace", () => { + const auth = buildGithubSourceAuthPayload({ + token: " ghp_trimmed ", + githubUsername: "user", + }); + + expect(auth.auth_password).toBe("ghp_trimmed"); + expect(auth.auth_token).toBe("ghp_trimmed"); + }); + + test("throws when token is missing", () => { + expect(() => + buildGithubSourceAuthPayload({ + token: " ", + githubUsername: "user", + }) + ).toThrow("GitHub token is required to mirror private repositories."); + }); +}); diff --git a/src/lib/utils/mirror-source-auth.ts b/src/lib/utils/mirror-source-auth.ts new file mode 100644 index 0000000..293b5a4 --- /dev/null +++ b/src/lib/utils/mirror-source-auth.ts @@ -0,0 +1,46 @@ +interface BuildGithubSourceAuthPayloadParams { + token?: string | null; + githubOwner?: string | null; + githubUsername?: string | null; + repositoryOwner?: string | null; +} + +export interface GithubSourceAuthPayload { + auth_username: string; + auth_password: string; + auth_token: string; +} + +const DEFAULT_GITHUB_AUTH_USERNAME = "x-access-token"; + +function normalize(value?: string | null): string { + return typeof value === "string" ? value.trim() : ""; +} + +/** + * Build source credentials for private GitHub repository mirroring. + * GitHub expects username + token-as-password over HTTPS (not the GitLab-style "oauth2" username). + */ +export function buildGithubSourceAuthPayload({ + token, + githubOwner, + githubUsername, + repositoryOwner, +}: BuildGithubSourceAuthPayloadParams): GithubSourceAuthPayload { + const normalizedToken = normalize(token); + if (!normalizedToken) { + throw new Error("GitHub token is required to mirror private repositories."); + } + + const authUsername = + normalize(githubOwner) || + normalize(githubUsername) || + normalize(repositoryOwner) || + DEFAULT_GITHUB_AUTH_USERNAME; + + return { + auth_username: authUsername, + auth_password: normalizedToken, + auth_token: normalizedToken, + }; +}