import { repoStatusEnum, type RepositoryVisibility, type RepoStatus, } from "@/types/Repository"; import { membershipRoleEnum } from "@/types/organizations"; import { Octokit } from "@octokit/rest"; import type { Config } from "@/types/config"; import type { Organization, Repository } from "./db/schema"; import { httpPost, httpGet, httpDelete, httpPut, httpPatch } from "./http-client"; import { createMirrorJob } from "./helpers"; import { db, organizations, repositories } from "./db"; import { eq, and } from "drizzle-orm"; import { decryptConfigTokens } from "./utils/config-encryption"; import { formatDateShort } from "./utils"; import { parseRepositoryMetadataState, serializeRepositoryMetadataState, } from "./metadata-state"; /** * Helper function to get organization configuration including destination override */ export const getOrganizationConfig = async ({ orgName, userId, }: { orgName: string; userId: string; }): Promise => { try { const result = await db .select() .from(organizations) .where(and(eq(organizations.name, orgName), eq(organizations.userId, userId))) .limit(1); if (!result[0]) { return null; } // Validate and cast the membershipRole to ensure type safety const rawOrg = result[0]; const membershipRole = membershipRoleEnum.parse(rawOrg.membershipRole); const status = repoStatusEnum.parse(rawOrg.status); return { ...rawOrg, membershipRole, status, } as Organization; } catch (error) { console.error(`Error fetching organization config for ${orgName}:`, error); return null; } }; /** * Enhanced async version of getGiteaRepoOwner that supports organization overrides */ export const getGiteaRepoOwnerAsync = async ({ config, repository, }: { config: Partial; repository: Repository; }): Promise => { if (!config.githubConfig || !config.giteaConfig) { throw new Error("GitHub or Gitea config is required."); } if (!config.giteaConfig.defaultOwner) { throw new Error("Gitea username is required."); } if (!config.userId) { throw new Error("User ID is required for organization overrides."); } // Check if repository is starred - starred repos always go to starredReposOrg (highest priority) if (repository.isStarred) { return config.githubConfig.starredReposOrg || "starred"; } // Check for repository-specific override (second highest priority) if (repository.destinationOrg) { console.log(`Using repository override: ${repository.fullName} -> ${repository.destinationOrg}`); return repository.destinationOrg; } // Check for organization-specific override if (repository.organization) { const orgConfig = await getOrganizationConfig({ orgName: repository.organization, userId: config.userId, }); if (orgConfig?.destinationOrg) { console.log(`Using organization override: ${repository.organization} -> ${orgConfig.destinationOrg}`); return orgConfig.destinationOrg; } } // For personal repos (not organization repos), fall back to the default strategy // Fall back to existing strategy logic return getGiteaRepoOwner({ config, repository }); }; export const getGiteaRepoOwner = ({ config, repository, }: { config: Partial; repository: Repository; }): string => { if (!config.githubConfig || !config.giteaConfig) { throw new Error("GitHub or Gitea config is required."); } if (!config.giteaConfig.defaultOwner) { throw new Error("Gitea username is required."); } // Check if repository is starred - starred repos always go to starredReposOrg if (repository.isStarred) { return config.githubConfig.starredReposOrg || "starred"; } // Get the mirror strategy - use preserveOrgStructure for backward compatibility const mirrorStrategy = config.githubConfig.mirrorStrategy || (config.giteaConfig.preserveOrgStructure ? "preserve" : "flat-user"); switch (mirrorStrategy) { case "preserve": // Keep GitHub structure - org repos go to same org, personal repos to user (or override) if (repository.organization) { return repository.organization; } // Use personal repos override if configured, otherwise use username return config.giteaConfig.defaultOwner; case "single-org": // All non-starred repos go to the destination organization if (config.giteaConfig.organization) { return config.giteaConfig.organization; } // Fallback to username if no organization specified return config.giteaConfig.defaultOwner; case "flat-user": // All non-starred repos go under the user account return config.giteaConfig.defaultOwner; case "mixed": // Mixed mode: personal repos to single org, organization repos preserve structure if (repository.organization) { // Organization repos preserve their structure return repository.organization; } // Personal repos go to configured organization (same as single-org) if (config.giteaConfig.organization) { return config.giteaConfig.organization; } // Fallback to username if no organization specified return config.giteaConfig.defaultOwner; default: // Default fallback return config.giteaConfig.defaultOwner; } }; export const isRepoPresentInGitea = async ({ config, owner, repoName, }: { config: Partial; owner: string; repoName: string; }): Promise => { try { if (!config.giteaConfig?.url || !config.giteaConfig?.token) { throw new Error("Gitea config is required."); } // Decrypt config tokens for API usage const decryptedConfig = decryptConfigTokens(config as Config); // Check if the repository exists at the specified owner location const response = await fetch( `${config.giteaConfig.url}/api/v1/repos/${owner}/${repoName}`, { headers: { Authorization: `token ${decryptedConfig.giteaConfig.token}`, }, } ); return response.ok; } catch (error) { console.error("Error checking if repo exists in Gitea:", error); return false; } }; /** * Check if a repository is currently being mirrored (in-progress state in database) * This prevents race conditions where multiple concurrent operations try to mirror the same repo */ export const isRepoCurrentlyMirroring = async ({ config, repoName, expectedLocation, }: { config: Partial; repoName: string; expectedLocation?: string; // Format: "owner/repo" }): Promise => { try { if (!config.userId) { return false; } const { or } = await import("drizzle-orm"); // Check database for any repository with "mirroring" or "syncing" status const inProgressRepos = await db .select() .from(repositories) .where( and( eq(repositories.userId, config.userId), eq(repositories.name, repoName), // Check for in-progress statuses or( eq(repositories.status, "mirroring"), eq(repositories.status, "syncing") ) ) ); if (inProgressRepos.length > 0) { // Check if any of the in-progress repos are stale (stuck for > 2 hours) const TWO_HOURS_MS = 2 * 60 * 60 * 1000; const now = new Date().getTime(); const activeRepos = inProgressRepos.filter((repo) => { if (!repo.updatedAt) return true; // No timestamp, assume active const updatedTime = new Date(repo.updatedAt).getTime(); const isStale = (now - updatedTime) > TWO_HOURS_MS; if (isStale) { console.warn( `[Idempotency] Repository ${repo.name} has been in "${repo.status}" status for over 2 hours. ` + `Considering it stale and allowing retry.` ); } return !isStale; }); if (activeRepos.length === 0) { console.log( `[Idempotency] All in-progress operations for ${repoName} are stale (>2h). Allowing retry.` ); return false; } // If we have an expected location, verify it matches if (expectedLocation) { const matchingRepo = activeRepos.find( (repo) => repo.mirroredLocation === expectedLocation ); if (matchingRepo) { console.log( `[Idempotency] Repository ${repoName} is already being mirrored at ${expectedLocation}` ); return true; } } else { console.log( `[Idempotency] Repository ${repoName} is already being mirrored (${activeRepos.length} in-progress operations found)` ); return true; } } return false; } catch (error) { console.error("Error checking if repo is currently mirroring:", error); console.error("Error details:", error); return false; } }; /** * Helper function to check if a repository exists in Gitea. * First checks the recorded mirroredLocation, then falls back to the expected location. */ export const checkRepoLocation = async ({ config, repository, expectedOwner, }: { config: Partial; repository: Repository; expectedOwner: string; }): Promise<{ present: boolean; actualOwner: string }> => { // First check if we have a recorded mirroredLocation and if the repo exists there if ( repository.mirroredLocation && repository.mirroredLocation.trim() !== "" ) { const [mirroredOwner] = repository.mirroredLocation.split("/"); if (mirroredOwner) { const mirroredPresent = await isRepoPresentInGitea({ config, owner: mirroredOwner, repoName: repository.name, }); if (mirroredPresent) { console.log( `Repository found at recorded mirrored location: ${repository.mirroredLocation}` ); return { present: true, actualOwner: mirroredOwner }; } } } // If not found at the recorded location, check the expected location const present = await isRepoPresentInGitea({ config, owner: expectedOwner, repoName: repository.name, }); if (present) { return { present: true, actualOwner: expectedOwner }; } // Repository not found at any location return { present: false, actualOwner: expectedOwner }; }; export const mirrorGithubRepoToGitea = async ({ octokit, repository, config, }: { octokit: Octokit; repository: Repository; config: Partial; }): Promise => { try { if (!config.userId || !config.githubConfig || !config.giteaConfig) { throw new Error("github config and gitea config are required."); } if (!config.giteaConfig.defaultOwner) { throw new Error("Gitea username is required."); } // Decrypt config tokens for API usage const decryptedConfig = decryptConfigTokens(config as Config); // Get the correct owner based on the strategy (with organization overrides) let repoOwner = await getGiteaRepoOwnerAsync({ config, repository }); // Determine the actual repository name to use (handle duplicates for starred repos) let targetRepoName = repository.name; if (repository.isStarred && config.githubConfig) { // Extract GitHub owner from full_name (format: owner/repo) const githubOwner = repository.fullName.split('/')[0]; targetRepoName = await generateUniqueRepoName({ config, orgName: repoOwner, baseName: repository.name, githubOwner, strategy: config.githubConfig.starredDuplicateStrategy, }); if (targetRepoName !== repository.name) { console.log( `Starred repo ${repository.fullName} will be mirrored as ${repoOwner}/${targetRepoName} to avoid naming conflict` ); } } // IDEMPOTENCY CHECK: Check if this repo is already being mirrored const expectedLocation = `${repoOwner}/${targetRepoName}`; const isCurrentlyMirroring = await isRepoCurrentlyMirroring({ config, repoName: targetRepoName, expectedLocation, }); if (isCurrentlyMirroring) { console.log( `[Idempotency] Skipping ${repository.fullName} - already being mirrored to ${expectedLocation}` ); // Don't throw an error, just return to allow other repos to continue return; } const isExisting = await isRepoPresentInGitea({ config, owner: repoOwner, repoName: targetRepoName, }); 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", }); console.log( `Repository ${repository.name} database status updated to mirrored` ); return; } console.log(`Mirroring repository ${repository.name}`); // DOUBLE-CHECK: Final idempotency check right before updating status // This catches race conditions in the small window between first check and status update const finalCheck = await isRepoCurrentlyMirroring({ config, repoName: targetRepoName, expectedLocation, }); if (finalCheck) { console.log( `[Idempotency] Race condition detected - ${repository.fullName} is now being mirrored by another process. Skipping.` ); return; } // Mark repos as "mirroring" in DB // CRITICAL: Set mirroredLocation NOW (not after success) so idempotency checks work // This becomes the "target location" - where we intend to mirror to // Without this, the idempotency check can't detect concurrent operations on first mirror await db .update(repositories) .set({ status: repoStatusEnum.parse("mirroring"), mirroredLocation: expectedLocation, updatedAt: new Date(), }) .where(eq(repositories.id, repository.id!)); // Append log for "mirroring" status await createMirrorJob({ userId: config.userId, repositoryId: repository.id, repositoryName: repository.name, message: `Started mirroring repository: ${repository.name}`, details: `Repository ${repository.name} is now in the mirroring state.`, status: "mirroring", }); // Use clean clone URL without embedded credentials (Forgejo 12+ security requirement) const cloneAddress = repository.cloneUrl; const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`; // Handle organization creation if needed for single-org, preserve strategies, or starred repos if (repoOwner !== config.giteaConfig.defaultOwner) { // Need to create the organization if it doesn't exist try { await getOrCreateGiteaOrg({ orgName: repoOwner, config, }); } catch (orgError) { console.error(`Failed to create/access organization ${repoOwner}: ${orgError instanceof Error ? orgError.message : String(orgError)}`); // Check if we should fallback to user account if (orgError instanceof Error && (orgError.message.includes('Permission denied') || orgError.message.includes('Authentication failed') || orgError.message.includes('does not have permission'))) { console.warn(`[Fallback] Organization creation/access failed. Attempting to mirror to user account instead.`); // Update the repository owner to use the user account repoOwner = config.giteaConfig.defaultOwner; // Log this fallback in the database await db .update(repositories) .set({ errorMessage: `Organization creation failed, using user account. ${orgError.message}`, updatedAt: new Date(), }) .where(eq(repositories.id, repository.id!)); } else { // Re-throw if it's not a permission issue throw orgError; } } } // Check if repository already exists as a non-mirror const { getGiteaRepoInfo, handleExistingNonMirrorRepo } = await import("./gitea-enhanced"); const existingRepo = await getGiteaRepoInfo({ config, owner: repoOwner, repoName: targetRepoName, }); if (existingRepo && !existingRepo.mirror) { console.log(`Repository ${targetRepoName} exists but is not a mirror. Handling...`); // Handle the existing non-mirror repository await handleExistingNonMirrorRepo({ config, repository, repoInfo: existingRepo, strategy: "delete", // Can be configured: "skip", "delete", or "rename" }); // After handling, proceed with mirror creation console.log(`Proceeding with mirror creation for ${targetRepoName}`); } // Prepare migration payload // For private repos, use separate auth fields instead of embedding credentials in URL // This is required for Forgejo 12+ which rejects URLs with embedded credentials // Skip wiki for starred repos if starredCodeOnly is enabled const shouldMirrorWiki = config.giteaConfig?.wiki && !(repository.isStarred && config.githubConfig?.starredCodeOnly); const migratePayload: any = { clone_addr: cloneAddress, repo_name: targetRepoName, mirror: true, mirror_interval: config.giteaConfig?.mirrorInterval || "8h", wiki: shouldMirrorWiki || false, lfs: config.giteaConfig?.lfs || false, private: repository.isPrivate, repo_owner: repoOwner, description: "", service: "git", }; // 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 response = await httpPost( apiUrl, migratePayload, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); const metadataState = parseRepositoryMetadataState(repository.metadata); let metadataUpdated = false; const skipMetadataForStarred = repository.isStarred && config.githubConfig?.starredCodeOnly; // Mirror releases if enabled (always allowed to rerun for updates) const shouldMirrorReleases = !!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred; console.log( `[Metadata] Release mirroring check: mirrorReleases=${config.giteaConfig?.mirrorReleases}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorReleases=${shouldMirrorReleases}` ); if (shouldMirrorReleases) { try { await mirrorGitHubReleasesToGitea({ config, octokit, repository, giteaOwner: repoOwner, giteaRepoName: targetRepoName, }); metadataState.components.releases = true; metadataUpdated = true; console.log( `[Metadata] Successfully mirrored releases for ${repository.name}` ); } catch (error) { console.error( `[Metadata] Failed to mirror releases for ${repository.name}: ${ error instanceof Error ? error.message : String(error) }` ); // Continue with other operations even if releases fail } } // Determine metadata operations to avoid duplicates const shouldMirrorIssuesThisRun = !!config.giteaConfig?.mirrorIssues && !skipMetadataForStarred && !metadataState.components.issues; console.log( `[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, alreadyMirrored=${metadataState.components.issues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssuesThisRun}` ); if (shouldMirrorIssuesThisRun) { try { await mirrorGitRepoIssuesToGitea({ config, octokit, repository, giteaOwner: repoOwner, giteaRepoName: targetRepoName, }); metadataState.components.issues = true; metadataState.components.labels = true; metadataUpdated = true; console.log( `[Metadata] Successfully mirrored issues for ${repository.name}` ); } catch (error) { console.error( `[Metadata] Failed to mirror issues for ${repository.name}: ${ error instanceof Error ? error.message : String(error) }` ); // Continue with other metadata operations even if issues fail } } else if (config.giteaConfig?.mirrorIssues && metadataState.components.issues) { console.log( `[Metadata] Issues already mirrored for ${repository.name}; skipping to avoid duplicates` ); } const shouldMirrorPullRequests = !!config.giteaConfig?.mirrorPullRequests && !skipMetadataForStarred && !metadataState.components.pullRequests; console.log( `[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, alreadyMirrored=${metadataState.components.pullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}` ); if (shouldMirrorPullRequests) { try { await mirrorGitRepoPullRequestsToGitea({ config, octokit, repository, giteaOwner: repoOwner, giteaRepoName: targetRepoName, }); metadataState.components.pullRequests = true; metadataUpdated = true; console.log( `[Metadata] Successfully mirrored pull requests for ${repository.name}` ); } catch (error) { console.error( `[Metadata] Failed to mirror pull requests for ${repository.name}: ${ error instanceof Error ? error.message : String(error) }` ); // Continue with other metadata operations even if PRs fail } } else if ( config.giteaConfig?.mirrorPullRequests && metadataState.components.pullRequests ) { console.log( `[Metadata] Pull requests already mirrored for ${repository.name}; skipping` ); } const shouldMirrorLabels = !!config.giteaConfig?.mirrorLabels && !skipMetadataForStarred && !shouldMirrorIssuesThisRun && !metadataState.components.labels; console.log( `[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, alreadyMirrored=${metadataState.components.labels}, issuesRunning=${shouldMirrorIssuesThisRun}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}` ); if (shouldMirrorLabels) { try { await mirrorGitRepoLabelsToGitea({ config, octokit, repository, giteaOwner: repoOwner, giteaRepoName: targetRepoName, }); metadataState.components.labels = true; metadataUpdated = true; console.log( `[Metadata] Successfully mirrored labels for ${repository.name}` ); } catch (error) { console.error( `[Metadata] Failed to mirror labels for ${repository.name}: ${ error instanceof Error ? error.message : String(error) }` ); // Continue with other metadata operations even if labels fail } } else if (config.giteaConfig?.mirrorLabels && metadataState.components.labels) { console.log( `[Metadata] Labels already mirrored for ${repository.name}; skipping` ); } const shouldMirrorMilestones = !!config.giteaConfig?.mirrorMilestones && !skipMetadataForStarred && !metadataState.components.milestones; console.log( `[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, alreadyMirrored=${metadataState.components.milestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}` ); if (shouldMirrorMilestones) { try { await mirrorGitRepoMilestonesToGitea({ config, octokit, repository, giteaOwner: repoOwner, giteaRepoName: targetRepoName, }); metadataState.components.milestones = true; metadataUpdated = true; console.log( `[Metadata] Successfully mirrored milestones for ${repository.name}` ); } catch (error) { console.error( `[Metadata] Failed to mirror milestones for ${repository.name}: ${ error instanceof Error ? error.message : String(error) }` ); // Continue with other metadata operations even if milestones fail } } else if ( config.giteaConfig?.mirrorMilestones && metadataState.components.milestones ) { console.log( `[Metadata] Milestones already mirrored for ${repository.name}; skipping` ); } if (metadataUpdated) { metadataState.lastSyncedAt = new Date().toISOString(); } console.log(`Repository ${repository.name} mirrored successfully as ${targetRepoName}`); // Mark repos as "mirrored" in DB await db .update(repositories) .set({ status: repoStatusEnum.parse("mirrored"), updatedAt: new Date(), lastMirrored: new Date(), errorMessage: null, mirroredLocation: `${repoOwner}/${targetRepoName}`, metadata: metadataUpdated ? serializeRepositoryMetadataState(metadataState) : repository.metadata ?? null, }) .where(eq(repositories.id, repository.id!)); // Append log for "mirrored" status await createMirrorJob({ userId: config.userId, repositoryId: repository.id, repositoryName: repository.name, message: `Successfully mirrored repository: ${repository.name}${targetRepoName !== repository.name ? ` as ${targetRepoName}` : ''}`, details: `Repository ${repository.fullName} was mirrored to Gitea at ${repoOwner}/${targetRepoName}.`, status: "mirrored", }); return response.data; } catch (error) { console.error( `Error while mirroring repository ${repository.name}: ${ error instanceof Error ? error.message : String(error) }` ); // Mark repos as "failed" in DB await db .update(repositories) .set({ status: repoStatusEnum.parse("failed"), updatedAt: new Date(), errorMessage: error instanceof Error ? error.message : "Unknown error", }) .where(eq(repositories.id, repository.id!)); // Append log for failure await createMirrorJob({ userId: config.userId ?? "", // userId is going to be there anyways repositoryId: repository.id, repositoryName: repository.name, message: `Failed to mirror repository: ${repository.name}`, details: `Repository ${repository.name} failed to mirror. Error: ${ error instanceof Error ? error.message : "Unknown error" }`, status: "failed", }); if (error instanceof Error) { throw new Error(`Failed to mirror repository: ${error.message}`); } throw new Error("Failed to mirror repository: An unknown error occurred."); } }; export async function getOrCreateGiteaOrg({ orgName, orgId, config, }: { orgId?: string; //db id orgName: string; config: Partial; }): Promise { // Import the enhanced version with retry logic const { getOrCreateGiteaOrgEnhanced } = await import("./gitea-enhanced"); try { return await getOrCreateGiteaOrgEnhanced({ orgName, orgId, config, maxRetries: 3, retryDelay: 100, }); } catch (error) { // Re-throw with original function name for backward compatibility if (error instanceof Error) { throw new Error(`Error in getOrCreateGiteaOrg: ${error.message}`); } throw error; } } /** * Generate a unique repository name for starred repos with duplicate names */ async function generateUniqueRepoName({ config, orgName, baseName, githubOwner, strategy, }: { config: Partial; orgName: string; baseName: string; githubOwner: string; strategy?: string; }): Promise { const duplicateStrategy = strategy || "suffix"; // First check if base name is available const baseExists = await isRepoPresentInGitea({ config, owner: orgName, repoName: baseName, }); if (!baseExists) { return baseName; } // Generate name based on strategy let candidateName: string; let attempt = 0; const maxAttempts = 10; while (attempt < maxAttempts) { switch (duplicateStrategy) { case "prefix": // Prefix with owner: owner-reponame candidateName = attempt === 0 ? `${githubOwner}-${baseName}` : `${githubOwner}-${baseName}-${attempt}`; break; case "owner-org": // This would require creating sub-organizations, not supported in this PR // Fall back to suffix strategy case "suffix": default: // Suffix with owner: reponame-owner candidateName = attempt === 0 ? `${baseName}-${githubOwner}` : `${baseName}-${githubOwner}-${attempt}`; break; } const exists = await isRepoPresentInGitea({ config, owner: orgName, repoName: candidateName, }); if (!exists) { console.log(`Found unique name for duplicate starred repo: ${candidateName}`); return candidateName; } attempt++; } // SECURITY FIX: Prevent infinite duplicate creation // Instead of falling back to timestamp (which creates infinite duplicates), // throw an error to prevent hundreds of duplicate repos console.error(`Failed to find unique name for ${baseName} after ${maxAttempts} attempts`); console.error(`Organization: ${orgName}, GitHub Owner: ${githubOwner}, Strategy: ${duplicateStrategy}`); throw new Error( `Unable to generate unique repository name for "${baseName}". ` + `All ${maxAttempts} naming attempts resulted in conflicts. ` + `Please manually resolve the naming conflict or adjust your duplicate strategy.` ); } export async function mirrorGitHubRepoToGiteaOrg({ octokit, config, repository, giteaOrgId, orgName, }: { octokit: Octokit; config: Partial; repository: Repository; giteaOrgId: number; orgName: string; }) { try { if ( !config.giteaConfig?.url || !config.giteaConfig?.token || !config.userId ) { throw new Error("Gitea config is required."); } // Decrypt config tokens for API usage const decryptedConfig = decryptConfigTokens(config as Config); // Determine the actual repository name to use (handle duplicates for starred repos) let targetRepoName = repository.name; if (repository.isStarred && config.githubConfig) { // Extract GitHub owner from full_name (format: owner/repo) const githubOwner = repository.fullName.split('/')[0]; targetRepoName = await generateUniqueRepoName({ config, orgName, baseName: repository.name, githubOwner, strategy: config.githubConfig.starredDuplicateStrategy, }); if (targetRepoName !== repository.name) { console.log( `Starred repo ${repository.fullName} will be mirrored as ${orgName}/${targetRepoName} to avoid naming conflict` ); } } // IDEMPOTENCY CHECK: Check if this repo is already being mirrored const expectedLocation = `${orgName}/${targetRepoName}`; const isCurrentlyMirroring = await isRepoCurrentlyMirroring({ config, repoName: targetRepoName, expectedLocation, }); if (isCurrentlyMirroring) { console.log( `[Idempotency] Skipping ${repository.fullName} - already being mirrored to ${expectedLocation}` ); // Don't throw an error, just return to allow other repos to continue return; } const isExisting = await isRepoPresentInGitea({ config, owner: orgName, repoName: targetRepoName, }); 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", }); console.log( `Repository ${targetRepoName} database status updated to mirrored in organization ${orgName}` ); return; } console.log( `Mirroring repository ${repository.fullName} to organization ${orgName} as ${targetRepoName}` ); // Use clean clone URL without embedded credentials (Forgejo 12+ security requirement) const cloneAddress = repository.cloneUrl; // DOUBLE-CHECK: Final idempotency check right before updating status // This catches race conditions in the small window between first check and status update const finalCheck = await isRepoCurrentlyMirroring({ config, repoName: targetRepoName, expectedLocation, }); if (finalCheck) { console.log( `[Idempotency] Race condition detected - ${repository.fullName} is now being mirrored by another process. Skipping.` ); return; } // Mark repos as "mirroring" in DB // CRITICAL: Set mirroredLocation NOW (not after success) so idempotency checks work // This becomes the "target location" - where we intend to mirror to // Without this, the idempotency check can't detect concurrent operations on first mirror await db .update(repositories) .set({ status: repoStatusEnum.parse("mirroring"), mirroredLocation: expectedLocation, updatedAt: new Date(), }) .where(eq(repositories.id, repository.id!)); // Note: "mirroring" status events are handled by the concurrency system // to avoid duplicate events during batch operations const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`; // Prepare migration payload // For private repos, use separate auth fields instead of embedding credentials in URL // This is required for Forgejo 12+ which rejects URLs with embedded credentials // Skip wiki for starred repos if starredCodeOnly is enabled const shouldMirrorWiki = config.giteaConfig?.wiki && !(repository.isStarred && config.githubConfig?.starredCodeOnly); const migratePayload: any = { clone_addr: cloneAddress, uid: giteaOrgId, repo_name: targetRepoName, mirror: true, mirror_interval: config.giteaConfig?.mirrorInterval || "8h", wiki: shouldMirrorWiki || false, lfs: config.giteaConfig?.lfs || false, private: repository.isPrivate, }; // 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 migrateRes = await httpPost( apiUrl, migratePayload, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); const metadataState = parseRepositoryMetadataState(repository.metadata); let metadataUpdated = false; const skipMetadataForStarred = repository.isStarred && config.githubConfig?.starredCodeOnly; const shouldMirrorReleases = !!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred; console.log( `[Metadata] Release mirroring check: mirrorReleases=${config.giteaConfig?.mirrorReleases}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorReleases=${shouldMirrorReleases}` ); if (shouldMirrorReleases) { try { await mirrorGitHubReleasesToGitea({ config, octokit, repository, giteaOwner: orgName, giteaRepoName: targetRepoName, }); metadataState.components.releases = true; metadataUpdated = true; console.log( `[Metadata] Successfully mirrored releases for ${repository.name}` ); } catch (error) { console.error( `[Metadata] Failed to mirror releases for ${repository.name}: ${ error instanceof Error ? error.message : String(error) }` ); // Continue with other operations even if releases fail } } const shouldMirrorIssuesThisRun = !!config.giteaConfig?.mirrorIssues && !skipMetadataForStarred && !metadataState.components.issues; console.log( `[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, alreadyMirrored=${metadataState.components.issues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssuesThisRun}` ); if (shouldMirrorIssuesThisRun) { try { await mirrorGitRepoIssuesToGitea({ config, octokit, repository, giteaOwner: orgName, giteaRepoName: targetRepoName, }); metadataState.components.issues = true; metadataState.components.labels = true; metadataUpdated = true; console.log( `[Metadata] Successfully mirrored issues for ${repository.name} to org ${orgName}/${targetRepoName}` ); } catch (error) { console.error( `[Metadata] Failed to mirror issues for ${repository.name} to org ${orgName}/${targetRepoName}: ${ error instanceof Error ? error.message : String(error) }` ); // Continue with other metadata operations even if issues fail } } else if ( config.giteaConfig?.mirrorIssues && metadataState.components.issues ) { console.log( `[Metadata] Issues already mirrored for ${repository.name}; skipping` ); } const shouldMirrorPullRequests = !!config.giteaConfig?.mirrorPullRequests && !skipMetadataForStarred && !metadataState.components.pullRequests; console.log( `[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, alreadyMirrored=${metadataState.components.pullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}` ); if (shouldMirrorPullRequests) { try { await mirrorGitRepoPullRequestsToGitea({ config, octokit, repository, giteaOwner: orgName, giteaRepoName: targetRepoName, }); metadataState.components.pullRequests = true; metadataUpdated = true; console.log( `[Metadata] Successfully mirrored pull requests for ${repository.name} to org ${orgName}/${targetRepoName}` ); } catch (error) { console.error( `[Metadata] Failed to mirror pull requests for ${repository.name} to org ${orgName}/${targetRepoName}: ${ error instanceof Error ? error.message : String(error) }` ); // Continue with other metadata operations even if PRs fail } } else if ( config.giteaConfig?.mirrorPullRequests && metadataState.components.pullRequests ) { console.log( `[Metadata] Pull requests already mirrored for ${repository.name}; skipping` ); } const shouldMirrorLabels = !!config.giteaConfig?.mirrorLabels && !skipMetadataForStarred && !shouldMirrorIssuesThisRun && !metadataState.components.labels; console.log( `[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, alreadyMirrored=${metadataState.components.labels}, issuesRunning=${shouldMirrorIssuesThisRun}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}` ); if (shouldMirrorLabels) { try { await mirrorGitRepoLabelsToGitea({ config, octokit, repository, giteaOwner: orgName, giteaRepoName: targetRepoName, }); metadataState.components.labels = true; metadataUpdated = true; console.log( `[Metadata] Successfully mirrored labels for ${repository.name} to org ${orgName}/${targetRepoName}` ); } catch (error) { console.error( `[Metadata] Failed to mirror labels for ${repository.name} to org ${orgName}/${targetRepoName}: ${ error instanceof Error ? error.message : String(error) }` ); // Continue with other metadata operations even if labels fail } } else if ( config.giteaConfig?.mirrorLabels && metadataState.components.labels ) { console.log( `[Metadata] Labels already mirrored for ${repository.name}; skipping` ); } const shouldMirrorMilestones = !!config.giteaConfig?.mirrorMilestones && !skipMetadataForStarred && !metadataState.components.milestones; console.log( `[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, alreadyMirrored=${metadataState.components.milestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}` ); if (shouldMirrorMilestones) { try { await mirrorGitRepoMilestonesToGitea({ config, octokit, repository, giteaOwner: orgName, giteaRepoName: targetRepoName, }); metadataState.components.milestones = true; metadataUpdated = true; console.log( `[Metadata] Successfully mirrored milestones for ${repository.name} to org ${orgName}/${targetRepoName}` ); } catch (error) { console.error( `[Metadata] Failed to mirror milestones for ${repository.name} to org ${orgName}/${targetRepoName}: ${ error instanceof Error ? error.message : String(error) }` ); // Continue with other metadata operations even if milestones fail } } else if ( config.giteaConfig?.mirrorMilestones && metadataState.components.milestones ) { console.log( `[Metadata] Milestones already mirrored for ${repository.name}; skipping` ); } if (metadataUpdated) { metadataState.lastSyncedAt = new Date().toISOString(); } console.log( `Repository ${repository.name} mirrored successfully to organization ${orgName} as ${targetRepoName}` ); // Mark repos as "mirrored" in DB await db .update(repositories) .set({ status: repoStatusEnum.parse("mirrored"), updatedAt: new Date(), lastMirrored: new Date(), errorMessage: null, mirroredLocation: `${orgName}/${targetRepoName}`, metadata: metadataUpdated ? serializeRepositoryMetadataState(metadataState) : repository.metadata ?? null, }) .where(eq(repositories.id, repository.id!)); //create a mirror job await createMirrorJob({ userId: config.userId, repositoryId: repository.id, repositoryName: repository.name, message: `Repository ${repository.name} mirrored successfully${targetRepoName !== repository.name ? ` as ${targetRepoName}` : ''}`, details: `Repository ${repository.fullName} was mirrored to Gitea at ${orgName}/${targetRepoName}`, status: "mirrored", }); return migrateRes.data; } catch (error) { console.error( `Error while mirroring repository ${repository.name}: ${ error instanceof Error ? error.message : String(error) }` ); // Mark repos as "failed" in DB await db .update(repositories) .set({ status: repoStatusEnum.parse("failed"), updatedAt: new Date(), errorMessage: error instanceof Error ? error.message : "Unknown error", }) .where(eq(repositories.id, repository.id!)); // Append log for failure await createMirrorJob({ userId: config.userId || "", // userId is going to be there anyways repositoryId: repository.id, repositoryName: repository.name, message: `Failed to mirror repository: ${repository.name}`, details: `Repository ${repository.name} failed to mirror. Error: ${ error instanceof Error ? error.message : "Unknown error" }`, status: "failed", }); if (error instanceof Error) { throw new Error(`Failed to mirror repository: ${error.message}`); } throw new Error("Failed to mirror repository: An unknown error occurred."); } } export async function mirrorGitHubOrgRepoToGiteaOrg({ config, octokit, repository, orgName, }: { config: Partial; octokit: Octokit; repository: Repository; orgName: string; }) { try { if (!config.giteaConfig?.url || !config.giteaConfig?.token) { throw new Error("Gitea config is required."); } const giteaOrgId = await getOrCreateGiteaOrg({ orgName, config, }); await mirrorGitHubRepoToGiteaOrg({ octokit, config, repository, giteaOrgId, orgName, }); } catch (error) { if (error instanceof Error) { throw new Error(`Failed to mirror repository: ${error.message}`); } throw new Error("Failed to mirror repository: An unknown error occurred."); } } export async function mirrorGitHubOrgToGitea({ organization, octokit, config, }: { organization: Organization; octokit: Octokit; config: Partial; }) { try { if ( !config.userId || !config.id || !config.githubConfig?.token || !config.giteaConfig?.url ) { throw new Error("Config, GitHub token and Gitea URL are required."); } console.log(`Mirroring organization ${organization.name}`); //mark the org as "mirroring" in DB await db .update(organizations) .set({ isIncluded: true, status: repoStatusEnum.parse("mirroring"), updatedAt: new Date(), }) .where(eq(organizations.id, organization.id!)); // Append log for "mirroring" status await createMirrorJob({ userId: config.userId, organizationId: organization.id, organizationName: organization.name, message: `Started mirroring organization: ${organization.name}`, details: `Organization ${organization.name} is now in the mirroring state.`, status: repoStatusEnum.parse("mirroring"), }); // Get the mirror strategy - use preserveOrgStructure for backward compatibility const mirrorStrategy = config.githubConfig?.mirrorStrategy || (config.giteaConfig?.preserveOrgStructure ? "preserve" : "flat-user"); let giteaOrgId: number; let targetOrgName: string; // Determine the target organization based on strategy if (mirrorStrategy === "single-org" && config.giteaConfig?.organization) { // For single-org strategy, use the configured destination organization targetOrgName = config.giteaConfig.organization || config.giteaConfig.defaultOwner; giteaOrgId = await getOrCreateGiteaOrg({ orgId: organization.id, orgName: targetOrgName, config, }); console.log(`Using single organization strategy: all repos will go to ${targetOrgName}`); } else if (mirrorStrategy === "preserve") { // For preserve strategy, create/use an org with the same name as GitHub targetOrgName = organization.name; giteaOrgId = await getOrCreateGiteaOrg({ orgId: organization.id, orgName: targetOrgName, config, }); } else { // For flat-user strategy, we shouldn't create organizations at all // Skip organization creation and let individual repos be handled by getGiteaRepoOwner console.log(`Using flat-user strategy: repos will be placed under user account`); targetOrgName = config.giteaConfig?.defaultOwner || ""; } //query the db with the org name and get the repos const orgRepos = await db .select() .from(repositories) .where(eq(repositories.organization, organization.name)); if (orgRepos.length === 0) { console.log( `No repositories found for organization ${organization.name} - marking as successfully mirrored` ); } else { console.log( `Mirroring ${orgRepos.length} repositories for organization ${organization.name}` ); // Import the processWithRetry function const { processWithRetry } = await import("@/lib/utils/concurrency"); // Process repositories in parallel with concurrency control await processWithRetry( orgRepos, async (repo) => { // Prepare repository data const repoData = { ...repo, status: repo.status as RepoStatus, visibility: repo.visibility as RepositoryVisibility, lastMirrored: repo.lastMirrored ?? undefined, errorMessage: repo.errorMessage ?? undefined, organization: repo.organization ?? undefined, forkedFrom: repo.forkedFrom ?? undefined, mirroredLocation: repo.mirroredLocation || "", }; // Log the start of mirroring console.log( `Starting mirror for repository: ${repo.name} from GitHub org ${organization.name}` ); // Mirror the repository based on strategy if (mirrorStrategy === "flat-user") { // For flat-user strategy, mirror directly to user account await mirrorGithubRepoToGitea({ octokit, repository: repoData, config, }); } else { // For preserve and single-org strategies, use organization await mirrorGitHubRepoToGiteaOrg({ octokit, config, repository: repoData, giteaOrgId: giteaOrgId!, orgName: targetOrgName, }); } return repo; }, { concurrencyLimit: 3, // Process 3 repositories at a time maxRetries: 2, retryDelay: 2000, onProgress: (completed, total, result) => { const percentComplete = Math.round((completed / total) * 100); if (result) { console.log( `Mirrored repository "${result.name}" in organization ${organization.name} (${completed}/${total}, ${percentComplete}%)` ); } }, onRetry: (repo, error, attempt) => { console.log( `Retrying repository ${repo.name} in organization ${organization.name} (attempt ${attempt}): ${error.message}` ); }, } ); } console.log(`Organization ${organization.name} mirrored successfully`); // Mark org as "mirrored" in DB await db .update(organizations) .set({ status: repoStatusEnum.parse("mirrored"), updatedAt: new Date(), lastMirrored: new Date(), errorMessage: null, }) .where(eq(organizations.id, organization.id!)); // Append log for "mirrored" status await createMirrorJob({ userId: config.userId, organizationId: organization.id, organizationName: organization.name, message: `Successfully mirrored organization: ${organization.name}`, details: orgRepos.length === 0 ? `Organization ${organization.name} was processed successfully (no repositories found).` : `Organization ${organization.name} was mirrored to Gitea with ${orgRepos.length} repositories.`, status: repoStatusEnum.parse("mirrored"), }); } catch (error) { console.error( `Error while mirroring organization ${organization.name}: ${ error instanceof Error ? error.message : String(error) }` ); // Mark org as "failed" in DB await db .update(organizations) .set({ status: repoStatusEnum.parse("failed"), updatedAt: new Date(), errorMessage: error instanceof Error ? error.message : "Unknown error", }) .where(eq(organizations.id, organization.id!)); // Append log for failure await createMirrorJob({ userId: config.userId || "", // userId is going to be there anyways organizationId: organization.id, organizationName: organization.name, message: `Failed to mirror organization: ${organization.name}`, details: `Organization ${organization.name} failed to mirror. Error: ${ error instanceof Error ? error.message : "Unknown error" }`, status: repoStatusEnum.parse("failed"), }); if (error instanceof Error) { throw new Error(`Failed to mirror repository: ${error.message}`); } throw new Error("Failed to mirror repository: An unknown error occurred."); } } export const syncGiteaRepo = async ({ config, repository, }: { config: Partial; repository: Repository; }) => { // Use the enhanced sync function that handles non-mirror repos const { syncGiteaRepoEnhanced } = await import("./gitea-enhanced"); try { return await syncGiteaRepoEnhanced({ config, repository }); } catch (error) { // Re-throw with original function name for backward compatibility if (error instanceof Error) { throw new Error(`Failed to sync repository: ${error.message}`); } throw new Error("Failed to sync repository: An unknown error occurred."); } }; export const mirrorGitRepoIssuesToGitea = async ({ config, octokit, repository, giteaOwner, giteaRepoName, }: { config: Partial; octokit: Octokit; repository: Repository; giteaOwner: string; giteaRepoName?: string; }) => { //things covered here are- issue, title, body, labels, comments and assignees if ( !config.githubConfig?.token || !config.giteaConfig?.token || !config.giteaConfig?.url || !config.giteaConfig?.defaultOwner ) { throw new Error("Missing GitHub or Gitea configuration."); } // Decrypt config tokens for API usage const decryptedConfig = decryptConfigTokens(config as Config); // Use provided giteaRepoName or fall back to repository.name const repoName = giteaRepoName || repository.name; // Log configuration details for debugging console.log(`[Issues] Starting issue mirroring for repository ${repository.name} as ${repoName}`); console.log(`[Issues] Gitea URL: ${config.giteaConfig!.url}`); console.log(`[Issues] Gitea Owner: ${giteaOwner}`); console.log(`[Issues] Gitea Default Owner: ${config.giteaConfig!.defaultOwner}`); // Verify the repository exists in Gitea before attempting to mirror metadata console.log(`[Issues] Verifying repository ${repoName} exists at ${giteaOwner}`); const repoExists = await isRepoPresentInGitea({ config, owner: giteaOwner, repoName: repoName, }); if (!repoExists) { console.error(`[Issues] Repository ${repoName} not found at ${giteaOwner}. Cannot mirror issues.`); throw new Error(`Repository ${repoName} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`); } const [owner, repo] = repository.fullName.split("/"); // Fetch GitHub issues const issues = await octokit.paginate( octokit.rest.issues.listForRepo, { owner, repo, state: "all", per_page: 100, sort: "created", direction: "asc", }, (res) => res.data ); // Filter out pull requests const filteredIssues = issues.filter((issue) => !(issue as any).pull_request); console.log( `Mirroring ${filteredIssues.length} issues from ${repository.fullName}` ); if (filteredIssues.length === 0) { console.log(`No issues to mirror for ${repository.fullName}`); return; } // Get existing labels from Gitea const giteaLabelsRes = await httpGet( `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels`, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); const giteaLabels = giteaLabelsRes.data; const labelMap = new Map( giteaLabels.map((label: any) => [label.name, label.id]) ); // Import the processWithRetry function const { processWithRetry } = await import("@/lib/utils/concurrency"); const rawIssueConcurrency = config.giteaConfig?.issueConcurrency ?? 3; const issueConcurrencyLimit = Number.isFinite(rawIssueConcurrency) ? Math.max(1, Math.floor(rawIssueConcurrency)) : 1; if (issueConcurrencyLimit > 1) { console.warn( `[Issues] Concurrency is set to ${issueConcurrencyLimit}. This may lead to out-of-order issue creation in Gitea but is faster.` ); } // Process issues in parallel with concurrency control await processWithRetry( filteredIssues, async (issue) => { const githubLabelNames = issue.labels ?.map((l) => (typeof l === "string" ? l : l.name)) .filter((l): l is string => !!l) || []; const giteaLabelIds: number[] = []; // Resolve or create labels in Gitea for (const name of githubLabelNames) { if (labelMap.has(name)) { giteaLabelIds.push(labelMap.get(name)!); } else { try { const created = await httpPost( `${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/labels`, { name, color: "#ededed" }, // Default color { Authorization: `token ${decryptedConfig.giteaConfig!.token}`, } ); labelMap.set(name, created.data.id); giteaLabelIds.push(created.data.id); } catch (labelErr) { console.error( `Failed to create label "${name}" in Gitea: ${labelErr}` ); } } } const originalAssignees = issue.assignees && issue.assignees.length > 0 ? `\n\nOriginally assigned to: ${issue.assignees .map((a) => `@${a.login}`) .join(", ")} on GitHub.` : ""; const issueAuthor = issue.user?.login ?? "unknown"; const issueCreatedOn = formatDateShort(issue.created_at); const issueOriginHeader = `Originally created by @${issueAuthor} on GitHub${ issueCreatedOn ? ` (${issueCreatedOn})` : "" }.`; const issuePayload: any = { title: issue.title, body: `${issueOriginHeader}${originalAssignees}\n\n${issue.body ?? ""}`, closed: issue.state === "closed", labels: giteaLabelIds, }; // Create the issue in Gitea const createdIssue = await httpPost( `${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues`, issuePayload, { Authorization: `token ${decryptedConfig.giteaConfig!.token}`, } ); // Clone comments const comments = await octokit.paginate( octokit.rest.issues.listComments, { owner, repo, issue_number: issue.number, per_page: 100, }, (res) => res.data ); // Ensure comments are applied in chronological order to preserve discussion flow const sortedComments = comments .slice() .sort( (a, b) => new Date(a.created_at || 0).getTime() - new Date(b.created_at || 0).getTime() ); // Process comments sequentially to preserve historical ordering if (sortedComments.length > 0) { await processWithRetry( sortedComments, async (comment) => { const commenter = comment.user?.login ?? "unknown"; const commentDate = formatDateShort(comment.created_at); const commentHeader = `@${commenter} commented on GitHub${ commentDate ? ` (${commentDate})` : "" }:`; await httpPost( `${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${createdIssue.data.number}/comments`, { body: `${commentHeader}\n\n${comment.body ?? ""}`, }, { Authorization: `token ${decryptedConfig.giteaConfig!.token}`, } ); return comment; }, { concurrencyLimit: 1, maxRetries: 2, retryDelay: 1000, onRetry: (_comment, error, attempt) => { console.log( `Retrying comment (attempt ${attempt}): ${error.message}` ); }, } ); } return issue; }, { concurrencyLimit: issueConcurrencyLimit, maxRetries: 2, retryDelay: 2000, onProgress: (completed, total, result) => { const percentComplete = Math.round((completed / total) * 100); if (result) { console.log( `Mirrored issue "${result.title}" (${completed}/${total}, ${percentComplete}%)` ); } }, onRetry: (issue, error, attempt) => { console.log( `Retrying issue "${issue.title}" (attempt ${attempt}): ${error.message}` ); }, } ); console.log( `Completed mirroring ${filteredIssues.length} issues for ${repository.fullName}` ); }; export async function mirrorGitHubReleasesToGitea({ octokit, repository, config, giteaOwner, giteaRepoName, }: { octokit: Octokit; repository: Repository; config: Partial; giteaOwner?: string; giteaRepoName?: string; }) { if ( !config.giteaConfig?.defaultOwner || !config.giteaConfig?.token || !config.giteaConfig?.url ) { throw new Error("Gitea config is incomplete for mirroring releases."); } // Decrypt config tokens for API usage const decryptedConfig = decryptConfigTokens(config as Config); // Determine target owner/repo in Gitea (supports renamed repos) const repoOwner = giteaOwner || (await getGiteaRepoOwnerAsync({ config, repository })); const repoName = giteaRepoName || repository.name; // Verify the repository exists in Gitea before attempting to mirror releases console.log(`[Releases] Verifying repository ${repoName} exists at ${repoOwner}`); const repoExists = await isRepoPresentInGitea({ config, owner: repoOwner, repoName: repoName, }); if (!repoExists) { console.error(`[Releases] Repository ${repository.name} not found at ${repoOwner}. Cannot mirror releases.`); throw new Error(`Repository ${repository.name} does not exist in Gitea at ${repoOwner}. Please ensure the repository is mirrored first.`); } // Get release limit from config (default to 10) const releaseLimit = config.giteaConfig?.releaseLimit || 10; const releases = await octokit.rest.repos.listReleases({ owner: repository.owner, repo: repository.name, per_page: releaseLimit, // Only fetch the latest N releases }); console.log(`[Releases] Found ${releases.data.length} releases (limited to latest ${releaseLimit}) to mirror for ${repository.fullName}`); if (releases.data.length === 0) { console.log(`[Releases] No releases to mirror for ${repository.fullName}`); return; } let mirroredCount = 0; let skippedCount = 0; const getReleaseTimestamp = (release: typeof releases.data[number]) => { // Use published_at first (when the release was published on GitHub) // Fall back to created_at (when the git tag was created) only if published_at is missing // This matches GitHub's sorting behavior and handles cases where multiple tags // point to the same commit but have different publish dates const sourceDate = release.published_at ?? release.created_at ?? ""; const timestamp = sourceDate ? new Date(sourceDate).getTime() : 0; return Number.isFinite(timestamp) ? timestamp : 0; }; // Capture the latest releases, then process them oldest-to-newest so Gitea mirrors keep chronological order const releasesToProcess = releases.data .slice() .sort((a, b) => getReleaseTimestamp(b) - getReleaseTimestamp(a)) .slice(0, releaseLimit) .sort((a, b) => getReleaseTimestamp(a) - getReleaseTimestamp(b)); console.log(`[Releases] Processing ${releasesToProcess.length} releases in chronological order (oldest to newest by published date)`); releasesToProcess.forEach((rel, idx) => { const publishedDate = new Date(rel.published_at || rel.created_at); const createdDate = new Date(rel.created_at); const dateInfo = rel.published_at !== rel.created_at ? `published ${publishedDate.toISOString()} (tag created ${createdDate.toISOString()})` : `published ${publishedDate.toISOString()}`; console.log(`[Releases] ${idx + 1}. ${rel.tag_name} - ${dateInfo}`); }); // Check if existing releases in Gitea are in the wrong order // If so, we need to delete and recreate them to fix the ordering let needsRecreation = false; try { const existingReleasesResponse = await httpGet( `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases?per_page=100`, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ).catch(() => null); if (existingReleasesResponse && existingReleasesResponse.data && Array.isArray(existingReleasesResponse.data)) { const existingReleases = existingReleasesResponse.data; if (existingReleases.length > 0) { console.log(`[Releases] Found ${existingReleases.length} existing releases in Gitea, checking chronological order...`); // Create a map of tag_name to expected chronological index (0 = oldest, n = newest) const expectedOrder = new Map(); releasesToProcess.forEach((rel, idx) => { expectedOrder.set(rel.tag_name, idx); }); // Check if existing releases are in the correct order based on created_unix // Gitea sorts by created_unix DESC, so newer releases should have higher created_unix values const releasesThatShouldExist = existingReleases.filter(r => expectedOrder.has(r.tag_name)); if (releasesThatShouldExist.length > 1) { for (let i = 0; i < releasesThatShouldExist.length - 1; i++) { const current = releasesThatShouldExist[i]; const next = releasesThatShouldExist[i + 1]; const currentExpectedIdx = expectedOrder.get(current.tag_name)!; const nextExpectedIdx = expectedOrder.get(next.tag_name)!; // Since Gitea returns releases sorted by created_unix DESC: // - Earlier releases in the list should have HIGHER expected indices (newer) // - Later releases in the list should have LOWER expected indices (older) if (currentExpectedIdx < nextExpectedIdx) { console.log(`[Releases] ⚠️ Incorrect ordering detected: ${current.tag_name} (index ${currentExpectedIdx}) appears before ${next.tag_name} (index ${nextExpectedIdx})`); needsRecreation = true; break; } } } if (needsRecreation) { console.log(`[Releases] ⚠️ Releases are in incorrect chronological order. Will delete and recreate all releases.`); // Delete all existing releases that we're about to recreate for (const existingRelease of releasesThatShouldExist) { try { console.log(`[Releases] Deleting incorrectly ordered release: ${existingRelease.tag_name}`); await httpDelete( `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${existingRelease.id}`, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); } catch (deleteError) { console.error(`[Releases] Failed to delete release ${existingRelease.tag_name}: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`); } } console.log(`[Releases] ✅ Deleted ${releasesThatShouldExist.length} releases. Will recreate in correct chronological order.`); } else { console.log(`[Releases] ✅ Existing releases are in correct chronological order.`); } } } } catch (orderCheckError) { console.warn(`[Releases] Could not verify release order: ${orderCheckError instanceof Error ? orderCheckError.message : String(orderCheckError)}`); // Continue with normal processing } for (const release of releasesToProcess) { try { // Check if release already exists (skip check if we just deleted all releases) const existingReleasesResponse = needsRecreation ? null : await httpGet( `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/tags/${release.tag_name}`, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ).catch(() => null); // Prepare release body with GitHub original date header const githubPublishedDate = release.published_at || release.created_at; const githubTagCreatedDate = release.created_at; let githubDateHeader = ''; if (githubPublishedDate) { githubDateHeader = `> 📅 **Originally published on GitHub:** ${new Date(githubPublishedDate).toUTCString()}`; // If the tag was created on a different date than the release was published, // show both dates (helps with repos that create multiple tags from the same commit) if (release.published_at && release.created_at && release.published_at !== release.created_at) { githubDateHeader += `\n> 🏷️ **Git tag created:** ${new Date(githubTagCreatedDate).toUTCString()}`; } githubDateHeader += '\n\n'; } const originalReleaseNote = release.body || ""; const releaseNote = githubDateHeader + originalReleaseNote; if (existingReleasesResponse) { // Update existing release if the changelog/body differs const existingRelease = existingReleasesResponse.data; const existingNote = existingRelease.body || ""; if (existingNote !== releaseNote || existingRelease.name !== (release.name || release.tag_name)) { console.log(`[Releases] Updating existing release ${release.tag_name} with new changelog/title`); await httpPut( `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${existingRelease.id}`, { tag_name: release.tag_name, target: release.target_commitish, title: release.name || release.tag_name, body: releaseNote, draft: release.draft, prerelease: release.prerelease, }, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); if (originalReleaseNote) { console.log(`[Releases] Updated changelog for ${release.tag_name} (${originalReleaseNote.length} characters + GitHub date header)`); } else { console.log(`[Releases] Updated release ${release.tag_name} with GitHub date header`); } mirroredCount++; } else { console.log(`[Releases] Release ${release.tag_name} already up-to-date, skipping`); skippedCount++; } continue; } // Create new release with changelog/body content (includes GitHub date header) if (originalReleaseNote) { console.log(`[Releases] Including changelog for ${release.tag_name} (${originalReleaseNote.length} characters + GitHub date header)`); } else { console.log(`[Releases] Creating release ${release.tag_name} with GitHub date header (no changelog)`); } const createReleaseResponse = await httpPost( `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases`, { tag_name: release.tag_name, target: release.target_commitish, title: release.name || release.tag_name, body: releaseNote, draft: release.draft, prerelease: release.prerelease, }, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); // Mirror release assets if they exist if (release.assets && release.assets.length > 0) { console.log(`[Releases] Mirroring ${release.assets.length} assets for release ${release.tag_name}`); for (const asset of release.assets) { try { // Download the asset from GitHub console.log(`[Releases] Downloading asset: ${asset.name} (${asset.size} bytes)`); const assetResponse = await fetch(asset.browser_download_url, { headers: { 'Accept': 'application/octet-stream', 'Authorization': `token ${decryptedConfig.githubConfig.token}`, }, }); if (!assetResponse.ok) { console.error(`[Releases] Failed to download asset ${asset.name}: ${assetResponse.statusText}`); continue; } const assetData = await assetResponse.arrayBuffer(); // Upload the asset to Gitea release const formData = new FormData(); formData.append('attachment', new Blob([assetData]), asset.name); const uploadResponse = await fetch( `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${createReleaseResponse.data.id}/assets?name=${encodeURIComponent(asset.name)}`, { method: 'POST', headers: { 'Authorization': `token ${decryptedConfig.giteaConfig.token}`, }, body: formData, } ); if (uploadResponse.ok) { console.log(`[Releases] Successfully uploaded asset: ${asset.name}`); } else { const errorText = await uploadResponse.text(); console.error(`[Releases] Failed to upload asset ${asset.name}: ${errorText}`); } } catch (assetError) { console.error(`[Releases] Error processing asset ${asset.name}: ${assetError instanceof Error ? assetError.message : String(assetError)}`); } } } mirroredCount++; const noteInfo = originalReleaseNote ? ` with ${originalReleaseNote.length} character changelog` : " without changelog"; console.log(`[Releases] Successfully mirrored release: ${release.tag_name}${noteInfo}`); // Add delay to ensure proper timestamp ordering in Gitea // Gitea sorts releases by created_unix DESC, and all releases created in quick succession // will have nearly identical timestamps. The 1-second delay ensures proper chronological order. console.log(`[Releases] Waiting 1 second to ensure proper timestamp ordering in Gitea...`); await new Promise(resolve => setTimeout(resolve, 1000)); } catch (error) { console.error(`[Releases] Failed to mirror release ${release.tag_name}: ${error instanceof Error ? error.message : String(error)}`); } } console.log(`✅ Mirrored/Updated ${mirroredCount} releases to Gitea (${skippedCount} already up-to-date)`); } export async function mirrorGitRepoPullRequestsToGitea({ config, octokit, repository, giteaOwner, giteaRepoName, }: { config: Partial; octokit: Octokit; repository: Repository; giteaOwner: string; giteaRepoName?: string; }) { if ( !config.githubConfig?.token || !config.giteaConfig?.token || !config.giteaConfig?.url || !config.giteaConfig?.defaultOwner ) { throw new Error("Missing GitHub or Gitea configuration."); } // Decrypt config tokens for API usage const decryptedConfig = decryptConfigTokens(config as Config); // Use provided giteaRepoName or fall back to repository.name const repoName = giteaRepoName || repository.name; // Log configuration details for debugging console.log(`[Pull Requests] Starting PR mirroring for repository ${repository.name} as ${repoName}`); console.log(`[Pull Requests] Gitea URL: ${config.giteaConfig!.url}`); console.log(`[Pull Requests] Gitea Owner: ${giteaOwner}`); console.log(`[Pull Requests] Gitea Default Owner: ${config.giteaConfig!.defaultOwner}`); // Verify the repository exists in Gitea before attempting to mirror metadata console.log(`[Pull Requests] Verifying repository ${repoName} exists at ${giteaOwner}`); const repoExists = await isRepoPresentInGitea({ config, owner: giteaOwner, repoName: repoName, }); if (!repoExists) { console.error(`[Pull Requests] Repository ${repoName} not found at ${giteaOwner}. Cannot mirror PRs.`); throw new Error(`Repository ${repoName} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`); } const [owner, repo] = repository.fullName.split("/"); // Fetch GitHub pull requests const pullRequests = await octokit.paginate( octokit.rest.pulls.list, { owner, repo, state: "all", per_page: 100, sort: "created", direction: "asc", }, (res) => res.data ); console.log( `Mirroring ${pullRequests.length} pull requests from ${repository.fullName}` ); if (pullRequests.length === 0) { console.log(`No pull requests to mirror for ${repository.fullName}`); return; } // Note: Gitea doesn't have a direct API to create pull requests from external sources // Pull requests are typically created through Git operations // For now, we'll create them as issues with a special label // Get existing labels from Gitea and ensure "pull-request" label exists const giteaLabelsRes = await httpGet( `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels`, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); const giteaLabels = giteaLabelsRes.data; const labelMap = new Map( giteaLabels.map((label: any) => [label.name, label.id]) ); // Ensure "pull-request" label exists let pullRequestLabelId: number | null = null; if (labelMap.has("pull-request")) { pullRequestLabelId = labelMap.get("pull-request")!; } else { try { const created = await httpPost( `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels`, { name: "pull-request", color: "#0366d6", description: "Mirrored from GitHub Pull Request" }, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); pullRequestLabelId = created.data.id; } catch (error) { console.error(`Failed to create "pull-request" label in Gitea: ${error}`); // Continue without labels if creation fails } } const { processWithRetry } = await import("@/lib/utils/concurrency"); const rawPullConcurrency = config.giteaConfig?.pullRequestConcurrency ?? 5; const pullRequestConcurrencyLimit = Number.isFinite(rawPullConcurrency) ? Math.max(1, Math.floor(rawPullConcurrency)) : 1; if (pullRequestConcurrencyLimit > 1) { console.warn( `[Pull Requests] Concurrency is set to ${pullRequestConcurrencyLimit}. This may lead to out-of-order pull request mirroring in Gitea.` ); } let successCount = 0; let failedCount = 0; await processWithRetry( pullRequests, async (pr) => { try { // Fetch additional PR data for rich metadata const [prDetail, commits, files] = await Promise.all([ octokit.rest.pulls.get({ owner, repo, pull_number: pr.number }), octokit.rest.pulls.listCommits({ owner, repo, pull_number: pr.number, per_page: 10 }), octokit.rest.pulls.listFiles({ owner, repo, pull_number: pr.number, per_page: 100 }) ]); // Build rich PR body with metadata let richBody = `## 📋 Pull Request Information\n\n`; richBody += `**Original PR:** ${pr.html_url}\n`; richBody += `**Author:** [@${pr.user?.login}](${pr.user?.html_url})\n`; richBody += `**Created:** ${new Date(pr.created_at).toLocaleDateString()}\n`; richBody += `**Status:** ${pr.state === 'closed' ? (pr.merged_at ? '✅ Merged' : '❌ Closed') : '🔄 Open'}\n`; if (pr.merged_at) { richBody += `**Merged:** ${new Date(pr.merged_at).toLocaleDateString()}\n`; richBody += `**Merged by:** [@${prDetail.data.merged_by?.login}](${prDetail.data.merged_by?.html_url})\n`; } richBody += `\n**Base:** \`${pr.base.ref}\` ← **Head:** \`${pr.head.ref}\`\n`; richBody += `\n---\n\n`; // Add commit history (up to 10 commits) if (commits.data.length > 0) { richBody += `### 📝 Commits (${commits.data.length}${commits.data.length >= 10 ? '+' : ''})\n\n`; commits.data.slice(0, 10).forEach(commit => { const shortSha = commit.sha.substring(0, 7); richBody += `- [\`${shortSha}\`](${commit.html_url}) ${commit.commit.message.split('\n')[0]}\n`; }); if (commits.data.length > 10) { richBody += `\n_...and ${commits.data.length - 10} more commits_\n`; } richBody += `\n`; } // Add file changes summary if (files.data.length > 0) { const additions = prDetail.data.additions || 0; const deletions = prDetail.data.deletions || 0; const changedFiles = prDetail.data.changed_files || files.data.length; richBody += `### 📊 Changes\n\n`; richBody += `**${changedFiles} file${changedFiles !== 1 ? 's' : ''} changed** `; richBody += `(+${additions} additions, -${deletions} deletions)\n\n`; // List changed files (up to 20) richBody += `
\nView changed files\n\n`; files.data.slice(0, 20).forEach(file => { const changeIndicator = file.status === 'added' ? '➕' : file.status === 'removed' ? '➖' : '📝'; richBody += `${changeIndicator} \`${file.filename}\` (+${file.additions} -${file.deletions})\n`; }); if (files.data.length > 20) { richBody += `\n_...and ${files.data.length - 20} more files_\n`; } richBody += `\n
\n\n`; } // Add original PR description richBody += `### 📄 Description\n\n`; richBody += pr.body || '_No description provided_'; richBody += `\n\n---\n`; richBody += `\n🔄 This issue represents a GitHub Pull Request. `; richBody += `It cannot be merged through Gitea due to API limitations.`; // Prepare issue title with status indicator const statusPrefix = pr.merged_at ? '[MERGED] ' : (pr.state === 'closed' ? '[CLOSED] ' : ''); const issueTitle = `[PR #${pr.number}] ${statusPrefix}${pr.title}`; const issueData = { title: issueTitle, body: richBody, labels: pullRequestLabelId ? [pullRequestLabelId] : [], closed: pr.state === "closed" || pr.merged_at !== null, }; console.log(`[Pull Requests] Creating enriched issue for PR #${pr.number}: ${pr.title}`); await httpPost( `${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues`, issueData, { Authorization: `token ${decryptedConfig.giteaConfig!.token}`, } ); successCount++; console.log(`[Pull Requests] ✅ Successfully created issue for PR #${pr.number}`); } catch (apiError) { // If the detailed fetch fails, fall back to basic PR info console.log(`[Pull Requests] Falling back to basic info for PR #${pr.number} due to error: ${apiError}`); const basicIssueData = { title: `[PR #${pr.number}] ${pr.title}`, body: `**Original Pull Request:** ${pr.html_url}\n\n**State:** ${pr.state}\n**Merged:** ${pr.merged_at ? 'Yes' : 'No'}\n\n---\n\n${pr.body || 'No description provided'}`, labels: pullRequestLabelId ? [pullRequestLabelId] : [], closed: pr.state === "closed" || pr.merged_at !== null, }; try { await httpPost( `${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues`, basicIssueData, { Authorization: `token ${decryptedConfig.giteaConfig!.token}`, } ); successCount++; console.log(`[Pull Requests] ✅ Created basic issue for PR #${pr.number}`); } catch (error) { failedCount++; console.error( `[Pull Requests] ❌ Failed to mirror PR #${pr.number}: ${error instanceof Error ? error.message : String(error)}` ); } } }, { concurrencyLimit: pullRequestConcurrencyLimit, maxRetries: 3, retryDelay: 1000, } ); console.log(`✅ Mirrored ${successCount}/${pullRequests.length} pull requests to Gitea as enriched issues (${failedCount} failed)`); } export async function mirrorGitRepoLabelsToGitea({ config, octokit, repository, giteaOwner, giteaRepoName, }: { config: Partial; octokit: Octokit; repository: Repository; giteaOwner: string; giteaRepoName?: string; }) { if ( !config.githubConfig?.token || !config.giteaConfig?.token || !config.giteaConfig?.url ) { throw new Error("Missing GitHub or Gitea configuration."); } // Decrypt config tokens for API usage const decryptedConfig = decryptConfigTokens(config as Config); // Use provided giteaRepoName or fall back to repository.name const repoName = giteaRepoName || repository.name; // Verify the repository exists in Gitea before attempting to mirror metadata console.log(`[Labels] Verifying repository ${repoName} exists at ${giteaOwner}`); const repoExists = await isRepoPresentInGitea({ config, owner: giteaOwner, repoName: repoName, }); if (!repoExists) { console.error(`[Labels] Repository ${repoName} not found at ${giteaOwner}. Cannot mirror labels.`); throw new Error(`Repository ${repoName} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`); } const [owner, repo] = repository.fullName.split("/"); // Fetch GitHub labels const labels = await octokit.paginate( octokit.rest.issues.listLabelsForRepo, { owner, repo, per_page: 100, }, (res) => res.data ); console.log(`Mirroring ${labels.length} labels from ${repository.fullName}`); if (labels.length === 0) { console.log(`No labels to mirror for ${repository.fullName}`); return; } // Get existing labels from Gitea const giteaLabelsRes = await httpGet( `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels`, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); const existingLabels = new Set( giteaLabelsRes.data.map((label: any) => label.name) ); let mirroredCount = 0; for (const label of labels) { if (!existingLabels.has(label.name)) { try { await httpPost( `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels`, { name: label.name, color: `#${label.color}`, description: label.description || "", }, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); mirroredCount++; } catch (error) { console.error( `Failed to mirror label "${label.name}": ${error instanceof Error ? error.message : String(error)}` ); } } } console.log(`✅ Mirrored ${mirroredCount} new labels to Gitea`); } export async function mirrorGitRepoMilestonesToGitea({ config, octokit, repository, giteaOwner, giteaRepoName, }: { config: Partial; octokit: Octokit; repository: Repository; giteaOwner: string; giteaRepoName?: string; }) { if ( !config.githubConfig?.token || !config.giteaConfig?.token || !config.giteaConfig?.url ) { throw new Error("Missing GitHub or Gitea configuration."); } // Decrypt config tokens for API usage const decryptedConfig = decryptConfigTokens(config as Config); // Use provided giteaRepoName or fall back to repository.name const repoName = giteaRepoName || repository.name; // Verify the repository exists in Gitea before attempting to mirror metadata console.log(`[Milestones] Verifying repository ${repoName} exists at ${giteaOwner}`); const repoExists = await isRepoPresentInGitea({ config, owner: giteaOwner, repoName: repoName, }); if (!repoExists) { console.error(`[Milestones] Repository ${repoName} not found at ${giteaOwner}. Cannot mirror milestones.`); throw new Error(`Repository ${repoName} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`); } const [owner, repo] = repository.fullName.split("/"); // Fetch GitHub milestones const milestones = await octokit.paginate( octokit.rest.issues.listMilestones, { owner, repo, state: "all", per_page: 100, }, (res) => res.data ); console.log(`Mirroring ${milestones.length} milestones from ${repository.fullName}`); if (milestones.length === 0) { console.log(`No milestones to mirror for ${repository.fullName}`); return; } // Get existing milestones from Gitea const giteaMilestonesRes = await httpGet( `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/milestones`, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); const existingMilestones = new Set( giteaMilestonesRes.data.map((milestone: any) => milestone.title) ); let mirroredCount = 0; for (const milestone of milestones) { if (!existingMilestones.has(milestone.title)) { try { await httpPost( `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/milestones`, { title: milestone.title, description: milestone.description || "", due_on: milestone.due_on, state: milestone.state, }, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); mirroredCount++; } catch (error) { console.error( `Failed to mirror milestone "${milestone.title}": ${error instanceof Error ? error.message : String(error)}` ); } } } console.log(`✅ Mirrored ${mirroredCount} new milestones to Gitea`); } /** * Create a simple Gitea client object with base URL and token */ export function createGiteaClient(url: string, token: string) { return { url, token }; } /** * Delete a repository from Gitea */ export async function deleteGiteaRepo( client: { url: string; token: string }, owner: string, repo: string ): Promise { try { const response = await httpDelete( `${client.url}/api/v1/repos/${owner}/${repo}`, { Authorization: `token ${client.token}`, } ); if (response.status >= 400) { throw new Error(`Failed to delete repository ${owner}/${repo}: ${response.status} ${response.statusText}`); } console.log(`Successfully deleted repository ${owner}/${repo} from Gitea`); } catch (error) { console.error(`Error deleting repository ${owner}/${repo}:`, error); throw error; } } /** * Archive a repository in Gitea * * IMPORTANT: This function NEVER deletes data. It only marks repositories as archived. * - For regular repos: Uses Gitea's archive feature (makes read-only) * - For mirror repos: Renames with [ARCHIVED] prefix (Gitea doesn't allow archiving mirrors) * * This ensures backups are preserved even when the GitHub source disappears. */ export async function archiveGiteaRepo( client: { url: string; token: string }, owner: string, repo: string ): Promise { try { // Helper: sanitize to Gitea's AlphaDashDot rule const sanitizeRepoNameAlphaDashDot = (name: string): string => { // Replace anything that's not [A-Za-z0-9.-] with '-' const base = name.replace(/[^A-Za-z0-9.-]+/g, "-").replace(/-+/g, "-"); // Trim leading/trailing separators and dots for safety return base.replace(/^[.-]+/, "").replace(/[.-]+$/, ""); }; // First, check if this is a mirror repository const repoResponse = await httpGet( `${client.url}/api/v1/repos/${owner}/${repo}`, { Authorization: `token ${client.token}`, } ); if (!repoResponse.data) { console.warn(`[Archive] Repository ${owner}/${repo} not found in Gitea. Skipping.`); return; } if (repoResponse.data?.mirror) { console.log(`[Archive] Repository ${owner}/${repo} is a mirror. Using safe rename strategy.`); // IMPORTANT: Gitea API doesn't allow archiving mirror repositories // According to Gitea source code, attempting to archive a mirror returns: // "repo is a mirror, cannot archive/un-archive" (422 Unprocessable Entity) // // Our solution: Rename the repo to clearly mark it as orphaned // This preserves all data while indicating the repo is no longer actively synced const currentName = repoResponse.data.name; // Skip if already marked as archived const normalizedName = currentName.toLowerCase(); if ( currentName.startsWith('[ARCHIVED]') || normalizedName.startsWith('archived-') ) { console.log(`[Archive] Repository ${owner}/${repo} already marked as archived. Skipping.`); return; } // Use a safe prefix and sanitize the name to satisfy AlphaDashDot rule let archivedName = `archived-${sanitizeRepoNameAlphaDashDot(currentName)}`; const currentDesc = repoResponse.data.description || ''; const archiveNotice = `\n\n⚠️ ARCHIVED: Original GitHub repository no longer exists. Preserved as backup on ${new Date().toISOString()}`; // Only add notice if not already present const newDescription = currentDesc.includes('⚠️ ARCHIVED:') ? currentDesc : currentDesc + archiveNotice; try { await httpPatch( `${client.url}/api/v1/repos/${owner}/${repo}`, { name: archivedName, description: newDescription, }, { Authorization: `token ${client.token}`, 'Content-Type': 'application/json', } ); } catch (e: any) { // If rename fails (e.g., 422 AlphaDashDot or name conflict), attempt a timestamped fallback const ts = new Date().toISOString().replace(/[-:T.]/g, "").slice(0, 14); archivedName = `archived-${ts}-${sanitizeRepoNameAlphaDashDot(currentName)}`; try { await httpPatch( `${client.url}/api/v1/repos/${owner}/${repo}`, { name: archivedName, description: newDescription, }, { Authorization: `token ${client.token}`, 'Content-Type': 'application/json', } ); } catch (e2) { // If this also fails, log but don't throw - data remains preserved console.error(`[Archive] Failed to rename mirror repository ${owner}/${repo}:`, e2); console.log(`[Archive] Repository ${owner}/${repo} remains accessible but not marked as archived`); return; } } console.log(`[Archive] Successfully marked mirror repository ${owner}/${repo} as archived (renamed to ${archivedName})`); // Also try to reduce sync frequency to prevent unnecessary API calls // This is optional - if it fails, the repo is still preserved try { await httpPatch( `${client.url}/api/v1/repos/${owner}/${archivedName}`, { mirror_interval: "0h", // Disable automatic syncing; manual sync is still available }, { Authorization: `token ${client.token}`, 'Content-Type': 'application/json', } ); console.log(`[Archive] Disabled automatic syncs for ${owner}/${archivedName}; manual sync only`); } catch (intervalError) { // Non-critical - repo is still preserved even if we can't change interval console.debug(`[Archive] Could not disable mirror interval (non-critical):`, intervalError); } } else { // For non-mirror repositories, use Gitea's native archive feature // This makes the repository read-only but preserves all data console.log(`[Archive] Archiving regular repository ${owner}/${repo}`); const response = await httpPatch( `${client.url}/api/v1/repos/${owner}/${repo}`, { archived: true, }, { Authorization: `token ${client.token}`, 'Content-Type': 'application/json', } ); if (response.status >= 400) { // If archive fails, log but data is still preserved in Gitea console.error(`[Archive] Failed to archive repository ${owner}/${repo}: ${response.status}`); console.log(`[Archive] Repository ${owner}/${repo} remains accessible but not marked as archived`); return; } console.log(`[Archive] Successfully archived repository ${owner}/${repo} (now read-only)`); } } catch (error) { // Even on error, the repository data is preserved in Gitea // We just couldn't mark it as archived console.error(`[Archive] Could not mark repository ${owner}/${repo} as archived:`, error); console.log(`[Archive] Repository ${owner}/${repo} data is preserved but not marked as archived`); // Don't throw - we want cleanup to continue for other repos } }