From 3bf0ccf2072368f0a05226a9cc017405c56b1e37 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Sun, 26 Oct 2025 08:41:28 +0530 Subject: [PATCH] fix: sync metadata after config toggles --- src/lib/gitea-enhanced.test.ts | 170 +++++++++++++++++ src/lib/gitea-enhanced.ts | 225 +++++++++++++++++++++- src/lib/gitea.ts | 332 +++++++++++++++++++++++++-------- src/lib/metadata-state.ts | 75 ++++++++ 4 files changed, 719 insertions(+), 83 deletions(-) create mode 100644 src/lib/metadata-state.ts diff --git a/src/lib/gitea-enhanced.test.ts b/src/lib/gitea-enhanced.test.ts index 81ee3b2..131dbff 100644 --- a/src/lib/gitea-enhanced.test.ts +++ b/src/lib/gitea-enhanced.test.ts @@ -8,6 +8,10 @@ mock.module("@/lib/helpers", () => ({ })); const mockMirrorGitHubReleasesToGitea = mock(() => Promise.resolve()); +const mockMirrorGitRepoIssuesToGitea = mock(() => Promise.resolve()); +const mockMirrorGitRepoPullRequestsToGitea = mock(() => Promise.resolve()); +const mockMirrorGitRepoLabelsToGitea = mock(() => Promise.resolve()); +const mockMirrorGitRepoMilestonesToGitea = mock(() => Promise.resolve()); const mockGetGiteaRepoOwnerAsync = mock(() => Promise.resolve("starred")); // Mock the database module @@ -128,6 +132,36 @@ const mockHttpGet = mock(async (url: string, headers?: any) => { headers: new Headers() }; } + if (url.includes("/api/v1/repos/starred/metadata-repo")) { + return { + data: { + id: 790, + name: "metadata-repo", + mirror: true, + owner: { login: "starred" }, + mirror_interval: "8h", + private: false, + }, + status: 200, + statusText: "OK", + headers: new Headers(), + }; + } + if (url.includes("/api/v1/repos/starred/already-synced-repo")) { + return { + data: { + id: 791, + name: "already-synced-repo", + mirror: true, + owner: { login: "starred" }, + mirror_interval: "8h", + private: false, + }, + status: 200, + statusText: "OK", + headers: new Headers(), + }; + } if (url.includes("/api/v1/repos/")) { throw new MockHttpError("Not Found", 404, "Not Found"); } @@ -224,6 +258,10 @@ describe("Enhanced Gitea Operations", () => { mockDb.insert.mockClear(); mockDb.update.mockClear(); mockMirrorGitHubReleasesToGitea.mockClear(); + mockMirrorGitRepoIssuesToGitea.mockClear(); + mockMirrorGitRepoPullRequestsToGitea.mockClear(); + mockMirrorGitRepoLabelsToGitea.mockClear(); + mockMirrorGitRepoMilestonesToGitea.mockClear(); mockGetGiteaRepoOwnerAsync.mockClear(); mockGetGiteaRepoOwnerAsync.mockImplementation(() => Promise.resolve("starred")); // Reset tracking variables @@ -426,6 +464,10 @@ describe("Enhanced Gitea Operations", () => { { getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync, mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea, + mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea, + mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea, + mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea, + mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea, } ) ).rejects.toThrow("Repository non-mirror-repo is not a mirror. Cannot sync."); @@ -470,6 +512,10 @@ describe("Enhanced Gitea Operations", () => { { getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync, mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea, + mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea, + mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea, + mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea, + mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea, } ); @@ -482,6 +528,130 @@ describe("Enhanced Gitea Operations", () => { expect(releaseCall.config.githubConfig?.token).toBe("github-token"); expect(releaseCall.octokit).toBeDefined(); }); + + test("mirrors metadata components when enabled and not previously synced", async () => { + const config: Partial = { + userId: "user123", + githubConfig: { + username: "testuser", + token: "github-token", + privateRepositories: true, + mirrorStarred: false, + }, + giteaConfig: { + url: "https://gitea.example.com", + token: "encrypted-token", + defaultOwner: "testuser", + mirrorReleases: true, + mirrorMetadata: true, + mirrorIssues: true, + mirrorPullRequests: true, + mirrorLabels: true, + mirrorMilestones: true, + }, + }; + + const repository: Repository = { + id: "repo789", + name: "metadata-repo", + fullName: "user/metadata-repo", + owner: "user", + cloneUrl: "https://github.com/user/metadata-repo.git", + isPrivate: false, + isStarred: false, + status: repoStatusEnum.parse("mirrored"), + visibility: "public", + userId: "user123", + createdAt: new Date(), + updatedAt: new Date(), + metadata: null, + }; + + await syncGiteaRepoEnhanced( + { config, repository }, + { + getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync, + mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea, + mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea, + mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea, + mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea, + mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea, + } + ); + + expect(mockMirrorGitHubReleasesToGitea).toHaveBeenCalledTimes(1); + expect(mockMirrorGitRepoIssuesToGitea).toHaveBeenCalledTimes(1); + expect(mockMirrorGitRepoPullRequestsToGitea).toHaveBeenCalledTimes(1); + expect(mockMirrorGitRepoMilestonesToGitea).toHaveBeenCalledTimes(1); + // Labels should be skipped because issues already import them + expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled(); + }); + + test("skips metadata mirroring when components already synced", async () => { + const config: Partial = { + userId: "user123", + githubConfig: { + username: "testuser", + token: "github-token", + privateRepositories: true, + mirrorStarred: false, + }, + giteaConfig: { + url: "https://gitea.example.com", + token: "encrypted-token", + defaultOwner: "testuser", + mirrorReleases: false, + mirrorMetadata: true, + mirrorIssues: true, + mirrorPullRequests: true, + mirrorLabels: true, + mirrorMilestones: true, + }, + }; + + const repository: Repository = { + id: "repo790", + name: "already-synced-repo", + fullName: "user/already-synced-repo", + owner: "user", + cloneUrl: "https://github.com/user/already-synced-repo.git", + isPrivate: false, + isStarred: false, + status: repoStatusEnum.parse("mirrored"), + visibility: "public", + userId: "user123", + createdAt: new Date(), + updatedAt: new Date(), + metadata: JSON.stringify({ + components: { + releases: true, + issues: true, + pullRequests: true, + labels: true, + milestones: true, + }, + lastSyncedAt: new Date().toISOString(), + }), + }; + + await syncGiteaRepoEnhanced( + { config, repository }, + { + getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync, + mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea, + mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea, + mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea, + mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea, + mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea, + } + ); + + expect(mockMirrorGitHubReleasesToGitea).not.toHaveBeenCalled(); + expect(mockMirrorGitRepoIssuesToGitea).not.toHaveBeenCalled(); + expect(mockMirrorGitRepoPullRequestsToGitea).not.toHaveBeenCalled(); + expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled(); + expect(mockMirrorGitRepoMilestonesToGitea).not.toHaveBeenCalled(); + }); }); describe("handleExistingNonMirrorRepo", () => { diff --git a/src/lib/gitea-enhanced.ts b/src/lib/gitea-enhanced.ts index 10a00ff..432252b 100644 --- a/src/lib/gitea-enhanced.ts +++ b/src/lib/gitea-enhanced.ts @@ -15,10 +15,18 @@ import { httpPost, httpGet, httpPatch, HttpError } from "./http-client"; import { db, repositories } from "./db"; import { eq } from "drizzle-orm"; import { repoStatusEnum } from "@/types/Repository"; +import { + parseRepositoryMetadataState, + serializeRepositoryMetadataState, +} from "./metadata-state"; type SyncDependencies = { getGiteaRepoOwnerAsync: typeof import("./gitea")["getGiteaRepoOwnerAsync"]; mirrorGitHubReleasesToGitea: typeof import("./gitea")["mirrorGitHubReleasesToGitea"]; + mirrorGitRepoIssuesToGitea: typeof import("./gitea")["mirrorGitRepoIssuesToGitea"]; + mirrorGitRepoPullRequestsToGitea: typeof import("./gitea")["mirrorGitRepoPullRequestsToGitea"]; + mirrorGitRepoLabelsToGitea: typeof import("./gitea")["mirrorGitRepoLabelsToGitea"]; + mirrorGitRepoMilestonesToGitea: typeof import("./gitea")["mirrorGitRepoMilestonesToGitea"]; }; /** @@ -330,36 +338,236 @@ export async function syncGiteaRepoEnhanced({ Authorization: `token ${decryptedConfig.giteaConfig.token}`, }); + const metadataState = parseRepositoryMetadataState(repository.metadata); + let metadataUpdated = false; + const skipMetadataForStarred = + repository.isStarred && config.githubConfig?.starredCodeOnly; + let metadataOctokit: Octokit | null = null; + + const ensureOctokit = (): Octokit | null => { + if (metadataOctokit) { + return metadataOctokit; + } + if (!decryptedConfig.githubConfig?.token) { + return null; + } + metadataOctokit = new Octokit({ + auth: decryptedConfig.githubConfig.token, + }); + return metadataOctokit; + }; + const shouldMirrorReleases = - decryptedConfig.giteaConfig?.mirrorReleases && - !(repository.isStarred && decryptedConfig.githubConfig?.starredCodeOnly); + !!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred; + const shouldMirrorIssuesThisRun = + !!config.giteaConfig?.mirrorIssues && + !skipMetadataForStarred && + !metadataState.components.issues; + const shouldMirrorPullRequests = + !!config.giteaConfig?.mirrorPullRequests && + !skipMetadataForStarred && + !metadataState.components.pullRequests; + const shouldMirrorLabels = + !!config.giteaConfig?.mirrorLabels && + !skipMetadataForStarred && + !shouldMirrorIssuesThisRun && + !metadataState.components.labels; + const shouldMirrorMilestones = + !!config.giteaConfig?.mirrorMilestones && + !skipMetadataForStarred && + !metadataState.components.milestones; if (shouldMirrorReleases) { - if (!decryptedConfig.githubConfig?.token) { + const octokit = ensureOctokit(); + if (!octokit) { console.warn( `[Sync] Skipping release mirroring for ${repository.name}: Missing GitHub token` ); } else { try { - const octokit = new Octokit({ auth: decryptedConfig.githubConfig.token }); await dependencies.mirrorGitHubReleasesToGitea({ - config: decryptedConfig, + config, octokit, repository, giteaOwner: repoOwner, giteaRepoName: repository.name, }); - console.log(`[Sync] Mirrored releases for ${repository.name} after sync`); + metadataState.components.releases = true; + metadataUpdated = true; + console.log( + `[Sync] Mirrored releases for ${repository.name} after sync` + ); } catch (releaseError) { console.error( `[Sync] Failed to mirror releases for ${repository.name}: ${ - releaseError instanceof Error ? releaseError.message : String(releaseError) + releaseError instanceof Error + ? releaseError.message + : String(releaseError) }` ); } } } + if (shouldMirrorIssuesThisRun) { + const octokit = ensureOctokit(); + if (!octokit) { + console.warn( + `[Sync] Skipping issue mirroring for ${repository.name}: Missing GitHub token` + ); + } else { + try { + await dependencies.mirrorGitRepoIssuesToGitea({ + config, + octokit, + repository, + giteaOwner: repoOwner, + giteaRepoName: repository.name, + }); + metadataState.components.issues = true; + metadataState.components.labels = true; + metadataUpdated = true; + console.log( + `[Sync] Mirrored issues for ${repository.name} after sync` + ); + } catch (issueError) { + console.error( + `[Sync] Failed to mirror issues for ${repository.name}: ${ + issueError instanceof Error + ? issueError.message + : String(issueError) + }` + ); + } + } + } else if ( + config.giteaConfig?.mirrorIssues && + metadataState.components.issues + ) { + console.log( + `[Sync] Issues already mirrored for ${repository.name}; skipping` + ); + } + + if (shouldMirrorPullRequests) { + const octokit = ensureOctokit(); + if (!octokit) { + console.warn( + `[Sync] Skipping pull request mirroring for ${repository.name}: Missing GitHub token` + ); + } else { + try { + await dependencies.mirrorGitRepoPullRequestsToGitea({ + config, + octokit, + repository, + giteaOwner: repoOwner, + giteaRepoName: repository.name, + }); + metadataState.components.pullRequests = true; + metadataUpdated = true; + console.log( + `[Sync] Mirrored pull requests for ${repository.name} after sync` + ); + } catch (prError) { + console.error( + `[Sync] Failed to mirror pull requests for ${repository.name}: ${ + prError instanceof Error ? prError.message : String(prError) + }` + ); + } + } + } else if ( + config.giteaConfig?.mirrorPullRequests && + metadataState.components.pullRequests + ) { + console.log( + `[Sync] Pull requests already mirrored for ${repository.name}; skipping` + ); + } + + if (shouldMirrorLabels) { + const octokit = ensureOctokit(); + if (!octokit) { + console.warn( + `[Sync] Skipping label mirroring for ${repository.name}: Missing GitHub token` + ); + } else { + try { + await dependencies.mirrorGitRepoLabelsToGitea({ + config, + octokit, + repository, + giteaOwner: repoOwner, + giteaRepoName: repository.name, + }); + metadataState.components.labels = true; + metadataUpdated = true; + console.log( + `[Sync] Mirrored labels for ${repository.name} after sync` + ); + } catch (labelError) { + console.error( + `[Sync] Failed to mirror labels for ${repository.name}: ${ + labelError instanceof Error + ? labelError.message + : String(labelError) + }` + ); + } + } + } else if ( + config.giteaConfig?.mirrorLabels && + metadataState.components.labels + ) { + console.log( + `[Sync] Labels already mirrored for ${repository.name}; skipping` + ); + } + + if (shouldMirrorMilestones) { + const octokit = ensureOctokit(); + if (!octokit) { + console.warn( + `[Sync] Skipping milestone mirroring for ${repository.name}: Missing GitHub token` + ); + } else { + try { + await dependencies.mirrorGitRepoMilestonesToGitea({ + config, + octokit, + repository, + giteaOwner: repoOwner, + giteaRepoName: repository.name, + }); + metadataState.components.milestones = true; + metadataUpdated = true; + console.log( + `[Sync] Mirrored milestones for ${repository.name} after sync` + ); + } catch (milestoneError) { + console.error( + `[Sync] Failed to mirror milestones for ${repository.name}: ${ + milestoneError instanceof Error + ? milestoneError.message + : String(milestoneError) + }` + ); + } + } + } else if ( + config.giteaConfig?.mirrorMilestones && + metadataState.components.milestones + ) { + console.log( + `[Sync] Milestones already mirrored for ${repository.name}; skipping` + ); + } + + if (metadataUpdated) { + metadataState.lastSyncedAt = new Date().toISOString(); + } + // Mark repo as "synced" in DB await db .update(repositories) @@ -369,6 +577,9 @@ export async function syncGiteaRepoEnhanced({ lastMirrored: new Date(), errorMessage: null, mirroredLocation: `${repoOwner}/${repository.name}`, + metadata: metadataUpdated + ? serializeRepositoryMetadataState(metadataState) + : repository.metadata ?? null, }) .where(eq(repositories.id, repository.id!)); diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index e246c1c..393fb8a 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -13,6 +13,10 @@ 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 @@ -587,12 +591,18 @@ export const mirrorGithubRepoToGitea = async ({ } ); - //mirror releases - // Skip releases for starred repos if starredCodeOnly is enabled - const shouldMirrorReleases = config.giteaConfig?.mirrorReleases && - !(repository.isStarred && config.githubConfig?.starredCodeOnly); + const metadataState = parseRepositoryMetadataState(repository.metadata); + let metadataUpdated = false; + const skipMetadataForStarred = + repository.isStarred && config.githubConfig?.starredCodeOnly; - console.log(`[Metadata] Release mirroring check: mirrorReleases=${config.giteaConfig?.mirrorReleases}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorReleases=${shouldMirrorReleases}`); + // 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 { @@ -603,21 +613,32 @@ export const mirrorGithubRepoToGitea = async ({ giteaOwner: repoOwner, giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored releases for ${repository.name}`); + 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)}`); + 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 } } - // clone issues - // Skip issues for starred repos if starredCodeOnly is enabled - const shouldMirrorIssues = config.giteaConfig?.mirrorIssues && - !(repository.isStarred && config.githubConfig?.starredCodeOnly); - - console.log(`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssues}`); - - if (shouldMirrorIssues) { + // 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, @@ -626,19 +647,34 @@ export const mirrorGithubRepoToGitea = async ({ giteaOwner: repoOwner, giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored issues for ${repository.name}`); + 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)}`); + 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` + ); } - // Mirror pull requests if enabled - // Skip pull requests for starred repos if starredCodeOnly is enabled - const shouldMirrorPullRequests = config.giteaConfig?.mirrorPullRequests && - !(repository.isStarred && config.githubConfig?.starredCodeOnly); + const shouldMirrorPullRequests = + !!config.giteaConfig?.mirrorPullRequests && + !skipMetadataForStarred && + !metadataState.components.pullRequests; - console.log(`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`); + 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 { @@ -649,19 +685,37 @@ export const mirrorGithubRepoToGitea = async ({ giteaOwner: repoOwner, giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored pull requests for ${repository.name}`); + 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)}`); + 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` + ); } - // Mirror labels if enabled (and not already done via issues) - // Skip labels for starred repos if starredCodeOnly is enabled - const shouldMirrorLabels = config.giteaConfig?.mirrorLabels && !shouldMirrorIssues && - !(repository.isStarred && config.githubConfig?.starredCodeOnly); + const shouldMirrorLabels = + !!config.giteaConfig?.mirrorLabels && + !skipMetadataForStarred && + !shouldMirrorIssuesThisRun && + !metadataState.components.labels; - console.log(`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, shouldMirrorIssues=${shouldMirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`); + 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 { @@ -672,19 +726,33 @@ export const mirrorGithubRepoToGitea = async ({ giteaOwner: repoOwner, giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored labels for ${repository.name}`); + 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)}`); + 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` + ); } - // Mirror milestones if enabled - // Skip milestones for starred repos if starredCodeOnly is enabled - const shouldMirrorMilestones = config.giteaConfig?.mirrorMilestones && - !(repository.isStarred && config.githubConfig?.starredCodeOnly); + const shouldMirrorMilestones = + !!config.giteaConfig?.mirrorMilestones && + !skipMetadataForStarred && + !metadataState.components.milestones; - console.log(`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`); + 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 { @@ -695,11 +763,30 @@ export const mirrorGithubRepoToGitea = async ({ giteaOwner: repoOwner, giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored milestones for ${repository.name}`); + 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)}`); + 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}`); @@ -713,6 +800,9 @@ export const mirrorGithubRepoToGitea = async ({ lastMirrored: new Date(), errorMessage: null, mirroredLocation: `${repoOwner}/${targetRepoName}`, + metadata: metadataUpdated + ? serializeRepositoryMetadataState(metadataState) + : repository.metadata ?? null, }) .where(eq(repositories.id, repository.id!)); @@ -1053,12 +1143,17 @@ export async function mirrorGitHubRepoToGiteaOrg({ } ); - //mirror releases - // Skip releases for starred repos if starredCodeOnly is enabled - const shouldMirrorReleases = config.giteaConfig?.mirrorReleases && - !(repository.isStarred && config.githubConfig?.starredCodeOnly); + const metadataState = parseRepositoryMetadataState(repository.metadata); + let metadataUpdated = false; + const skipMetadataForStarred = + repository.isStarred && config.githubConfig?.starredCodeOnly; - console.log(`[Metadata] Release mirroring check: mirrorReleases=${config.giteaConfig?.mirrorReleases}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorReleases=${shouldMirrorReleases}`); + 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 { @@ -1069,21 +1164,31 @@ export async function mirrorGitHubRepoToGiteaOrg({ giteaOwner: orgName, giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored releases for ${repository.name}`); + 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)}`); + 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 } } - // Clone issues - // Skip issues for starred repos if starredCodeOnly is enabled - const shouldMirrorIssues = config.giteaConfig?.mirrorIssues && - !(repository.isStarred && config.githubConfig?.starredCodeOnly); - - console.log(`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssues}`); - - if (shouldMirrorIssues) { + 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, @@ -1092,19 +1197,37 @@ export async function mirrorGitHubRepoToGiteaOrg({ giteaOwner: orgName, giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored issues for ${repository.name} to org ${orgName}/${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)}`); + 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` + ); } - // Mirror pull requests if enabled - // Skip pull requests for starred repos if starredCodeOnly is enabled - const shouldMirrorPullRequests = config.giteaConfig?.mirrorPullRequests && - !(repository.isStarred && config.githubConfig?.starredCodeOnly); + const shouldMirrorPullRequests = + !!config.giteaConfig?.mirrorPullRequests && + !skipMetadataForStarred && + !metadataState.components.pullRequests; - console.log(`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`); + 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 { @@ -1115,19 +1238,37 @@ export async function mirrorGitHubRepoToGiteaOrg({ giteaOwner: orgName, giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored pull requests for ${repository.name} to org ${orgName}/${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)}`); + 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` + ); } - // Mirror labels if enabled (and not already done via issues) - // Skip labels for starred repos if starredCodeOnly is enabled - const shouldMirrorLabels = config.giteaConfig?.mirrorLabels && !shouldMirrorIssues && - !(repository.isStarred && config.githubConfig?.starredCodeOnly); + const shouldMirrorLabels = + !!config.giteaConfig?.mirrorLabels && + !skipMetadataForStarred && + !shouldMirrorIssuesThisRun && + !metadataState.components.labels; - console.log(`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, shouldMirrorIssues=${shouldMirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`); + 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 { @@ -1138,19 +1279,36 @@ export async function mirrorGitHubRepoToGiteaOrg({ giteaOwner: orgName, giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored labels for ${repository.name} to org ${orgName}/${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)}`); + 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` + ); } - // Mirror milestones if enabled - // Skip milestones for starred repos if starredCodeOnly is enabled - const shouldMirrorMilestones = config.giteaConfig?.mirrorMilestones && - !(repository.isStarred && config.githubConfig?.starredCodeOnly); + const shouldMirrorMilestones = + !!config.giteaConfig?.mirrorMilestones && + !skipMetadataForStarred && + !metadataState.components.milestones; - console.log(`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`); + 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 { @@ -1161,11 +1319,30 @@ export async function mirrorGitHubRepoToGiteaOrg({ giteaOwner: orgName, giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored milestones for ${repository.name} to org ${orgName}/${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)}`); + 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( @@ -1181,6 +1358,9 @@ export async function mirrorGitHubRepoToGiteaOrg({ lastMirrored: new Date(), errorMessage: null, mirroredLocation: `${orgName}/${targetRepoName}`, + metadata: metadataUpdated + ? serializeRepositoryMetadataState(metadataState) + : repository.metadata ?? null, }) .where(eq(repositories.id, repository.id!)); diff --git a/src/lib/metadata-state.ts b/src/lib/metadata-state.ts new file mode 100644 index 0000000..25b8cb4 --- /dev/null +++ b/src/lib/metadata-state.ts @@ -0,0 +1,75 @@ +interface MetadataComponentsState { + releases: boolean; + issues: boolean; + pullRequests: boolean; + labels: boolean; + milestones: boolean; +} + +export interface RepositoryMetadataState { + components: MetadataComponentsState; + lastSyncedAt?: string; +} + +const defaultComponents: MetadataComponentsState = { + releases: false, + issues: false, + pullRequests: false, + labels: false, + milestones: false, +}; + +export function createDefaultMetadataState(): RepositoryMetadataState { + return { + components: { ...defaultComponents }, + }; +} + +export function parseRepositoryMetadataState( + raw: unknown +): RepositoryMetadataState { + const base = createDefaultMetadataState(); + + if (!raw) { + return base; + } + + let parsed: any = raw; + + if (typeof raw === "string") { + try { + parsed = JSON.parse(raw); + } catch { + return base; + } + } + + if (!parsed || typeof parsed !== "object") { + return base; + } + + if (parsed.components && typeof parsed.components === "object") { + base.components = { + ...base.components, + releases: Boolean(parsed.components.releases), + issues: Boolean(parsed.components.issues), + pullRequests: Boolean(parsed.components.pullRequests), + labels: Boolean(parsed.components.labels), + milestones: Boolean(parsed.components.milestones), + }; + } + + if (typeof parsed.lastSyncedAt === "string") { + base.lastSyncedAt = parsed.lastSyncedAt; + } else if (typeof parsed.lastMetadataSync === "string") { + base.lastSyncedAt = parsed.lastMetadataSync; + } + + return base; +} + +export function serializeRepositoryMetadataState( + state: RepositoryMetadataState +): string { + return JSON.stringify(state); +}