diff --git a/src/lib/env-config-loader.ts b/src/lib/env-config-loader.ts index 497545a..825895c 100644 --- a/src/lib/env-config-loader.ts +++ b/src/lib/env-config-loader.ts @@ -308,6 +308,13 @@ export async function initializeConfigFromEnv(): Promise { mirrorPullRequests: envConfig.mirror.mirrorPullRequests ?? existingConfig?.[0]?.giteaConfig?.mirrorPullRequests ?? false, mirrorLabels: envConfig.mirror.mirrorLabels ?? existingConfig?.[0]?.giteaConfig?.mirrorLabels ?? false, mirrorMilestones: envConfig.mirror.mirrorMilestones ?? existingConfig?.[0]?.giteaConfig?.mirrorMilestones ?? false, + // Backup options — preserve existing values so UI-configured settings survive restart + backupStrategy: existingConfig?.[0]?.giteaConfig?.backupStrategy ?? 'on-force-push', + backupBeforeSync: existingConfig?.[0]?.giteaConfig?.backupBeforeSync ?? true, + backupRetentionCount: existingConfig?.[0]?.giteaConfig?.backupRetentionCount ?? 5, + backupRetentionDays: existingConfig?.[0]?.giteaConfig?.backupRetentionDays ?? 30, + backupDirectory: existingConfig?.[0]?.giteaConfig?.backupDirectory || undefined, + blockSyncOnBackupFailure: existingConfig?.[0]?.giteaConfig?.blockSyncOnBackupFailure ?? true, }; // Build schedule config with support for interval as string or number diff --git a/src/lib/gitea-enhanced.test.ts b/src/lib/gitea-enhanced.test.ts index c1cd2e7..b2c4533 100644 --- a/src/lib/gitea-enhanced.test.ts +++ b/src/lib/gitea-enhanced.test.ts @@ -789,7 +789,7 @@ describe("Enhanced Gitea Operations", () => { expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled(); }); - test("continues incremental issue and PR syncing when metadata was previously synced", async () => { + test("skips issues and PRs when metadata shows they were already synced", async () => { const config: Partial = { userId: "user123", githubConfig: { @@ -848,9 +848,10 @@ describe("Enhanced Gitea Operations", () => { } ); + // All metadata components were previously synced, so none should be called again expect(mockMirrorGitHubReleasesToGitea).not.toHaveBeenCalled(); - expect(mockMirrorGitRepoIssuesToGitea).toHaveBeenCalledTimes(1); - expect(mockMirrorGitRepoPullRequestsToGitea).toHaveBeenCalledTimes(1); + expect(mockMirrorGitRepoIssuesToGitea).not.toHaveBeenCalled(); + expect(mockMirrorGitRepoPullRequestsToGitea).not.toHaveBeenCalled(); expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled(); expect(mockMirrorGitRepoMilestonesToGitea).not.toHaveBeenCalled(); }); diff --git a/src/lib/gitea-enhanced.ts b/src/lib/gitea-enhanced.ts index 2580801..893fa76 100644 --- a/src/lib/gitea-enhanced.ts +++ b/src/lib/gitea-enhanced.ts @@ -556,6 +556,9 @@ export async function syncGiteaRepoEnhanced({ } // Update mirror interval if needed + // NOTE: Gitea/Forgejo's PATCH /repos/{owner}/{repo} API does not support + // updating mirror credentials (mirror_username/mirror_password). Repos that + // were originally migrated without credentials must be deleted and re-mirrored. if (config.giteaConfig?.mirrorInterval) { try { console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`); @@ -603,10 +606,12 @@ export async function syncGiteaRepoEnhanced({ !!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred; const shouldMirrorIssuesThisRun = !!config.giteaConfig?.mirrorIssues && - !skipMetadataForStarred; + !skipMetadataForStarred && + !metadataState.components.issues; const shouldMirrorPullRequests = !!config.giteaConfig?.mirrorPullRequests && - !skipMetadataForStarred; + !skipMetadataForStarred && + !metadataState.components.pullRequests; const shouldMirrorLabels = !!config.giteaConfig?.mirrorLabels && !skipMetadataForStarred && @@ -680,6 +685,13 @@ export async function syncGiteaRepoEnhanced({ ); } } + } else if ( + config.giteaConfig?.mirrorIssues && + metadataState.components.issues + ) { + console.log( + `[Sync] Issues already mirrored for ${repository.name}; skipping to avoid duplicates` + ); } if (shouldMirrorPullRequests) { @@ -710,6 +722,13 @@ export async function syncGiteaRepoEnhanced({ ); } } + } else if ( + config.giteaConfig?.mirrorPullRequests && + metadataState.components.pullRequests + ) { + console.log( + `[Sync] Pull requests already mirrored for ${repository.name}; skipping` + ); } if (shouldMirrorLabels) { diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index 87d4dd8..1cf4bbb 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -815,8 +815,10 @@ export const mirrorGithubRepoToGitea = async ({ service: "git", }; - // Add authentication for private repositories - if (repository.isPrivate) { + // Always send authentication credentials so Gitea/Forgejo stores them + // for subsequent mirror fetches. This prevents "terminal prompts disabled" + // errors on public repos and raises GitHub API rate limits. + { const githubOwner = ( config.githubConfig as typeof config.githubConfig & { @@ -1501,10 +1503,13 @@ export async function mirrorGitHubRepoToGiteaOrg({ lfs: config.giteaConfig?.lfs || false, private: repository.isPrivate, description: repository.description?.trim() || "", + service: "git", }; - // Add authentication for private repositories - if (repository.isPrivate) { + // Always send authentication credentials so Gitea/Forgejo stores them + // for subsequent mirror fetches. This prevents "terminal prompts disabled" + // errors on public repos and raises GitHub API rate limits. + { const githubOwner = ( config.githubConfig as typeof config.githubConfig & { @@ -2715,7 +2720,7 @@ export async function mirrorGitHubReleasesToGitea({ 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( + await httpPatch( `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${existingRelease.id}`, { tag_name: release.tag_name, @@ -2829,6 +2834,71 @@ export async function mirrorGitHubReleasesToGitea({ } console.log(`✅ Mirrored/Updated ${mirroredCount} releases to Gitea (${skippedCount} already up-to-date)`); + + // Enforce release retention limit by removing the oldest excess releases from Gitea + try { + // Paginate to fetch ALL Gitea releases (API max is 100 per page) + const allGiteaReleases: Array<{ id: number; tag_name: string; created_at: string }> = []; + let cleanupPage = 1; + while (true) { + const pageResponse = await httpGet( + `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases?per_page=100&page=${cleanupPage}`, + { + Authorization: `token ${decryptedConfig.giteaConfig.token}`, + } + ).catch(() => null); + + if (!pageResponse?.data || !Array.isArray(pageResponse.data) || pageResponse.data.length === 0) { + break; + } + + allGiteaReleases.push(...pageResponse.data); + + if (pageResponse.data.length < 100) { + break; + } + cleanupPage++; + } + + if (allGiteaReleases.length > releaseLimit) { + const excessCount = allGiteaReleases.length - releaseLimit; + + // Sort by created_at ascending (oldest first) so we delete the oldest excess + const sorted = [...allGiteaReleases].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ); + + const toDelete = sorted.slice(0, excessCount); + + console.log( + `[Releases] Enforcing retention limit (${releaseLimit}): ${allGiteaReleases.length} releases found, removing ${toDelete.length} oldest excess release(s)` + ); + + for (const excess of toDelete) { + try { + await httpDelete( + `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${excess.id}`, + { + Authorization: `token ${decryptedConfig.giteaConfig.token}`, + } + ); + console.log(`[Releases] Deleted excess release: ${excess.tag_name}`); + } catch (deleteError) { + console.error( + `[Releases] Failed to delete excess release ${excess.tag_name}: ${ + deleteError instanceof Error ? deleteError.message : String(deleteError) + }` + ); + } + } + } + } catch (cleanupError) { + console.warn( + `[Releases] Release retention cleanup failed: ${ + cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + }` + ); + } } export async function mirrorGitRepoPullRequestsToGitea({ diff --git a/src/lib/utils/mirror-source-auth.test.ts b/src/lib/utils/mirror-source-auth.test.ts index 49654be..910c3f0 100644 --- a/src/lib/utils/mirror-source-auth.test.ts +++ b/src/lib/utils/mirror-source-auth.test.ts @@ -52,12 +52,12 @@ describe("buildGithubSourceAuthPayload", () => { expect(auth.auth_token).toBe("ghp_trimmed"); }); - test("throws when token is missing", () => { - expect(() => - buildGithubSourceAuthPayload({ - token: " ", - githubUsername: "user", - }) - ).toThrow("GitHub token is required to mirror private repositories."); + test("returns empty object when token is missing", () => { + const result = buildGithubSourceAuthPayload({ + token: " ", + githubUsername: "user", + }); + + expect(result).toEqual({}); }); }); diff --git a/src/lib/utils/mirror-source-auth.ts b/src/lib/utils/mirror-source-auth.ts index 293b5a4..94b4deb 100644 --- a/src/lib/utils/mirror-source-auth.ts +++ b/src/lib/utils/mirror-source-auth.ts @@ -11,6 +11,8 @@ export interface GithubSourceAuthPayload { auth_token: string; } +export type GithubSourceAuthPayloadOrEmpty = GithubSourceAuthPayload | Record; + const DEFAULT_GITHUB_AUTH_USERNAME = "x-access-token"; function normalize(value?: string | null): string { @@ -18,18 +20,19 @@ function normalize(value?: string | null): string { } /** - * Build source credentials for private GitHub repository mirroring. + * Build source credentials for GitHub repository mirroring. * GitHub expects username + token-as-password over HTTPS (not the GitLab-style "oauth2" username). + * Returns an empty object when no token is available, allowing callers to use it unconditionally. */ export function buildGithubSourceAuthPayload({ token, githubOwner, githubUsername, repositoryOwner, -}: BuildGithubSourceAuthPayloadParams): GithubSourceAuthPayload { +}: BuildGithubSourceAuthPayloadParams): GithubSourceAuthPayloadOrEmpty { const normalizedToken = normalize(token); if (!normalizedToken) { - throw new Error("GitHub token is required to mirror private repositories."); + return {}; } const authUsername =