From 1bca7df5ab5a41c570f5989e5d83be297b7338b8 Mon Sep 17 00:00:00 2001 From: ARUNAVO RAY Date: Sun, 15 Mar 2026 08:22:44 +0530 Subject: [PATCH] feat: import repo topics and description into Gitea (#224) * lib: sync repo topics and descriptions * lib: harden metadata sync for existing repos --- src/lib/gitea.ts | 346 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 290 insertions(+), 56 deletions(-) diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index 0a6e127..ce4fa19 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -374,6 +374,161 @@ export const checkRepoLocation = async ({ return { present: false, actualOwner: expectedOwner }; }; +const sanitizeTopicForGitea = (topic: string): string => + topic + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, ""); + +const normalizeTopicsForGitea = ( + topics: string[], + topicPrefix?: string +): string[] => { + const normalizedPrefix = topicPrefix ? sanitizeTopicForGitea(topicPrefix) : ""; + const transformedTopics = topics + .map((topic) => sanitizeTopicForGitea(topic)) + .filter((topic) => topic.length > 0) + .map((topic) => (normalizedPrefix ? `${normalizedPrefix}-${topic}` : topic)); + + return [...new Set(transformedTopics)]; +}; + +const getSourceRepositoryCoordinates = (repository: Repository) => { + const delimiterIndex = repository.fullName.indexOf("/"); + if ( + delimiterIndex > 0 && + delimiterIndex < repository.fullName.length - 1 + ) { + return { + owner: repository.fullName.slice(0, delimiterIndex), + repo: repository.fullName.slice(delimiterIndex + 1), + }; + } + + return { + owner: repository.owner, + repo: repository.name, + }; +}; + +const fetchGitHubTopics = async ({ + octokit, + repository, +}: { + octokit: Octokit; + repository: Repository; +}): Promise => { + const { owner, repo } = getSourceRepositoryCoordinates(repository); + + try { + const response = await octokit.request("GET /repos/{owner}/{repo}/topics", { + owner, + repo, + headers: { + Accept: "application/vnd.github+json", + }, + }); + + const names = (response.data as { names?: unknown }).names; + if (!Array.isArray(names)) { + console.warn( + `[Metadata] Unexpected topics payload for ${repository.fullName}; skipping topic sync.` + ); + return null; + } + + return names.filter((topic): topic is string => typeof topic === "string"); + } catch (error) { + console.warn( + `[Metadata] Failed to fetch topics from GitHub for ${repository.fullName}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + } +}; + +const syncRepositoryMetadataToGitea = async ({ + config, + octokit, + repository, + giteaOwner, + giteaRepoName, + giteaToken, +}: { + config: Partial; + octokit: Octokit; + repository: Repository; + giteaOwner: string; + giteaRepoName: string; + giteaToken: string; +}): Promise => { + const giteaBaseUrl = config.giteaConfig?.url; + if (!giteaBaseUrl) { + return; + } + + const repoApiUrl = `${giteaBaseUrl}/api/v1/repos/${giteaOwner}/${giteaRepoName}`; + const authHeaders = { + Authorization: `token ${giteaToken}`, + }; + const description = repository.description?.trim() || ""; + + try { + await httpPatch( + repoApiUrl, + { description }, + authHeaders + ); + console.log( + `[Metadata] Synced description for ${repository.fullName} to ${giteaOwner}/${giteaRepoName}` + ); + } catch (error) { + console.warn( + `[Metadata] Failed to sync description for ${repository.fullName} to ${giteaOwner}/${giteaRepoName}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + if (config.giteaConfig?.addTopics === false) { + return; + } + + const sourceTopics = await fetchGitHubTopics({ octokit, repository }); + if (sourceTopics === null) { + console.warn( + `[Metadata] Skipping topic sync for ${repository.fullName} because GitHub topics could not be fetched.` + ); + return; + } + + const topics = normalizeTopicsForGitea( + sourceTopics, + config.giteaConfig?.topicPrefix + ); + + try { + await httpPut( + `${repoApiUrl}/topics`, + { topics }, + authHeaders + ); + console.log( + `[Metadata] Synced ${topics.length} topic(s) for ${repository.fullName} to ${giteaOwner}/${giteaRepoName}` + ); + } catch (error) { + console.warn( + `[Metadata] Failed to sync topics for ${repository.fullName} to ${giteaOwner}/${giteaRepoName}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +}; + export const mirrorGithubRepoToGitea = async ({ octokit, repository, @@ -465,36 +620,66 @@ export const mirrorGithubRepoToGitea = async ({ }); if (isExisting) { - console.log( - `Repository ${targetRepoName} already exists in Gitea under ${repoOwner}. Updating database status.` - ); - - // Update database to reflect that the repository is already mirrored - await db - .update(repositories) - .set({ - status: repoStatusEnum.parse("mirrored"), - updatedAt: new Date(), - lastMirrored: new Date(), - errorMessage: null, - mirroredLocation: `${repoOwner}/${targetRepoName}`, - }) - .where(eq(repositories.id, repository.id!)); - - // Append log for "mirrored" status - await createMirrorJob({ - userId: config.userId, - repositoryId: repository.id, - repositoryName: repository.name, - message: `Repository ${repository.name} already exists in Gitea`, - details: `Repository ${repository.name} was found to already exist in Gitea under ${repoOwner} and database status was updated.`, - status: "mirrored", + const { getGiteaRepoInfo, handleExistingNonMirrorRepo } = await import("./gitea-enhanced"); + const existingRepoInfo = await getGiteaRepoInfo({ + config, + owner: repoOwner, + repoName: targetRepoName, }); - console.log( - `Repository ${repository.name} database status updated to mirrored` - ); - return; + if (existingRepoInfo && !existingRepoInfo.mirror) { + console.log(`Repository ${targetRepoName} exists but is not a mirror. Handling...`); + await handleExistingNonMirrorRepo({ + config, + repository, + repoInfo: existingRepoInfo, + strategy: "delete", // Can be configured: "skip", "delete", or "rename" + }); + } else if (existingRepoInfo?.mirror) { + console.log( + `Repository ${targetRepoName} already exists in Gitea under ${repoOwner}. Updating database status.` + ); + + await syncRepositoryMetadataToGitea({ + config, + octokit, + repository, + giteaOwner: repoOwner, + giteaRepoName: targetRepoName, + giteaToken: decryptedConfig.giteaConfig.token, + }); + + // Update database to reflect that the repository is already mirrored + await db + .update(repositories) + .set({ + status: repoStatusEnum.parse("mirrored"), + updatedAt: new Date(), + lastMirrored: new Date(), + errorMessage: null, + mirroredLocation: `${repoOwner}/${targetRepoName}`, + }) + .where(eq(repositories.id, repository.id!)); + + // Append log for "mirrored" status + await createMirrorJob({ + userId: config.userId, + repositoryId: repository.id, + repositoryName: repository.name, + message: `Repository ${repository.name} already exists in Gitea`, + details: `Repository ${repository.name} was found to already exist in Gitea under ${repoOwner} and database status was updated.`, + status: "mirrored", + }); + + console.log( + `Repository ${repository.name} database status updated to mirrored` + ); + return; + } else { + console.warn( + `[Mirror] Repository ${repoOwner}/${targetRepoName} exists but mirror status could not be verified. Continuing with mirror creation flow.` + ); + } } console.log(`Mirroring repository ${repository.name}`); @@ -648,6 +833,15 @@ export const mirrorGithubRepoToGitea = async ({ } ); + await syncRepositoryMetadataToGitea({ + config, + octokit, + repository, + giteaOwner: repoOwner, + giteaRepoName: targetRepoName, + giteaToken: decryptedConfig.giteaConfig.token, + }); + const metadataState = parseRepositoryMetadataState(repository.metadata); let metadataUpdated = false; const skipMetadataForStarred = @@ -1094,36 +1288,66 @@ export async function mirrorGitHubRepoToGiteaOrg({ }); if (isExisting) { - console.log( - `Repository ${targetRepoName} already exists in Gitea organization ${orgName}. Updating database status.` - ); - - // Update database to reflect that the repository is already mirrored - await db - .update(repositories) - .set({ - status: repoStatusEnum.parse("mirrored"), - updatedAt: new Date(), - lastMirrored: new Date(), - errorMessage: null, - mirroredLocation: `${orgName}/${targetRepoName}`, - }) - .where(eq(repositories.id, repository.id!)); - - // Create a mirror job log entry - await createMirrorJob({ - userId: config.userId, - repositoryId: repository.id, - repositoryName: repository.name, - message: `Repository ${targetRepoName} already exists in Gitea organization ${orgName}`, - details: `Repository ${targetRepoName} was found to already exist in Gitea organization ${orgName} and database status was updated.`, - status: "mirrored", + const { getGiteaRepoInfo, handleExistingNonMirrorRepo } = await import("./gitea-enhanced"); + const existingRepoInfo = await getGiteaRepoInfo({ + config, + owner: orgName, + repoName: targetRepoName, }); - console.log( - `Repository ${targetRepoName} database status updated to mirrored in organization ${orgName}` - ); - return; + if (existingRepoInfo && !existingRepoInfo.mirror) { + console.log(`Repository ${targetRepoName} exists but is not a mirror. Handling...`); + await handleExistingNonMirrorRepo({ + config, + repository, + repoInfo: existingRepoInfo, + strategy: "delete", // Can be configured: "skip", "delete", or "rename" + }); + } else if (existingRepoInfo?.mirror) { + console.log( + `Repository ${targetRepoName} already exists in Gitea organization ${orgName}. Updating database status.` + ); + + await syncRepositoryMetadataToGitea({ + config, + octokit, + repository, + giteaOwner: orgName, + giteaRepoName: targetRepoName, + giteaToken: decryptedConfig.giteaConfig.token, + }); + + // Update database to reflect that the repository is already mirrored + await db + .update(repositories) + .set({ + status: repoStatusEnum.parse("mirrored"), + updatedAt: new Date(), + lastMirrored: new Date(), + errorMessage: null, + mirroredLocation: `${orgName}/${targetRepoName}`, + }) + .where(eq(repositories.id, repository.id!)); + + // Create a mirror job log entry + await createMirrorJob({ + userId: config.userId, + repositoryId: repository.id, + repositoryName: repository.name, + message: `Repository ${targetRepoName} already exists in Gitea organization ${orgName}`, + details: `Repository ${targetRepoName} was found to already exist in Gitea organization ${orgName} and database status was updated.`, + status: "mirrored", + }); + + console.log( + `Repository ${targetRepoName} database status updated to mirrored in organization ${orgName}` + ); + return; + } else { + console.warn( + `[Mirror] Repository ${orgName}/${targetRepoName} exists but mirror status could not be verified. Continuing with mirror creation flow.` + ); + } } console.log( @@ -1182,6 +1406,7 @@ export async function mirrorGitHubRepoToGiteaOrg({ wiki: shouldMirrorWiki || false, lfs: config.giteaConfig?.lfs || false, private: repository.isPrivate, + description: repository.description?.trim() || "", }; // Add authentication for private repositories @@ -1204,6 +1429,15 @@ export async function mirrorGitHubRepoToGiteaOrg({ } ); + await syncRepositoryMetadataToGitea({ + config, + octokit, + repository, + giteaOwner: orgName, + giteaRepoName: targetRepoName, + giteaToken: decryptedConfig.giteaConfig.token, + }); + const metadataState = parseRepositoryMetadataState(repository.metadata); let metadataUpdated = false; const skipMetadataForStarred =