diff --git a/src/lib/utils/config-mapper.test.ts b/src/lib/utils/config-mapper.test.ts index f818f7c..ec1af3c 100644 --- a/src/lib/utils/config-mapper.test.ts +++ b/src/lib/utils/config-mapper.test.ts @@ -191,3 +191,69 @@ test("DB row missing starredDuplicateStrategy defaults to suffix on read", () => const ui = mapDbToUiConfig({ githubConfig: { owner: "octo", token: "" } }); expect(ui.githubConfig.starredDuplicateStrategy).toBe("suffix"); }); + +// Regression for #338: saving any setting from the Configuration page reset +// giteaConfig.mirrorInterval (set via GITEA_MIRROR_INTERVAL) back to "8h" +// because the mapper hardcoded the default instead of preserving the stored value. +test("mapUiToDbConfig preserves env-configured mirrorInterval on save", () => { + const ui = buildMinimalUiConfigs(); + const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, { + giteaConfig: { mirrorInterval: "10m" }, + }); + expect(db.giteaConfig.mirrorInterval).toBe("10m"); +}); + +test("mapUiToDbConfig defaults mirrorInterval to 8h without existing config", () => { + const ui = buildMinimalUiConfigs(); + const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions); + expect(db.giteaConfig.mirrorInterval).toBe("8h"); +}); + +test("mapUiToDbConfig preserves non-UI fields from existing config on save", () => { + const ui = buildMinimalUiConfigs(); + const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, { + githubConfig: { type: "organization", includeArchived: true, includePublic: false }, + giteaConfig: { + createOrg: false, + templateOwner: "templates", + templateRepo: "base", + addTopics: false, + topicPrefix: "gh-", + preserveVisibility: true, + }, + }); + expect(db.githubConfig.type).toBe("organization"); + expect(db.githubConfig.includeArchived).toBe(true); + expect(db.githubConfig.includePublic).toBe(false); + expect(db.giteaConfig.createOrg).toBe(false); + expect(db.giteaConfig.templateOwner).toBe("templates"); + expect(db.giteaConfig.templateRepo).toBe("base"); + expect(db.giteaConfig.addTopics).toBe(false); + expect(db.giteaConfig.topicPrefix).toBe("gh-"); + expect(db.giteaConfig.preserveVisibility).toBe(true); +}); + +test("mapUiToDbConfig keeps env-configured full-copy forkStrategy when forks are included", () => { + const ui = buildMinimalUiConfigs(); + const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, { + giteaConfig: { forkStrategy: "full-copy" }, + }); + expect(db.giteaConfig.forkStrategy).toBe("full-copy"); +}); + +test("mapUiToDbConfig lets skipForks override a stored forkStrategy", () => { + const ui = buildMinimalUiConfigs(); + const withSkip: AdvancedOptions = { ...ui.advancedOptions, skipForks: true }; + const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, withSkip, { + giteaConfig: { forkStrategy: "full-copy" }, + }); + expect(db.giteaConfig.forkStrategy).toBe("skip"); +}); + +test("mapUiToDbConfig resets a stale skip forkStrategy to reference when skipForks is unchecked", () => { + const ui = buildMinimalUiConfigs(); + const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, { + giteaConfig: { forkStrategy: "skip" }, + }); + expect(db.giteaConfig.forkStrategy).toBe("reference"); +}); diff --git a/src/lib/utils/config-mapper.ts b/src/lib/utils/config-mapper.ts index daaf913..63cdb88 100644 --- a/src/lib/utils/config-mapper.ts +++ b/src/lib/utils/config-mapper.ts @@ -51,28 +51,37 @@ function normalizeOrgList(orgs: string[] | undefined): string[] { /** * Maps UI config structure to database schema structure + * + * `existing` is the stored DB config (if any). Fields the Configuration form + * doesn't expose (mirrorInterval, topics, templates, ...) are preserved from it + * so a UI save can't silently reset values configured via environment variables + * (e.g. GITEA_MIRROR_INTERVAL, see issue #338). */ export function mapUiToDbConfig( githubConfig: GitHubConfig, giteaConfig: GiteaConfig, mirrorOptions: MirrorOptions, - advancedOptions: AdvancedOptions + advancedOptions: AdvancedOptions, + existing?: { + githubConfig?: Partial; + giteaConfig?: Partial; + } ): { githubConfig: DbGitHubConfig; giteaConfig: DbGiteaConfig } { // Map GitHub config to match database schema fields const dbGithubConfig: DbGitHubConfig = { // Map username to owner field owner: githubConfig.username, - type: "personal", // Default to personal, could be made configurable + type: existing?.githubConfig?.type || "personal", // Not in UI; preserve stored value token: githubConfig.token || "", - + // Map checkbox fields with proper names includeStarred: githubConfig.mirrorStarred, includePrivate: githubConfig.privateRepositories, includeCollaboratorRepos: githubConfig.includeCollaboratorRepos ?? true, includeForks: !advancedOptions.skipForks, // Note: UI has skipForks, DB has includeForks skipForks: advancedOptions.skipForks, // Add skipForks field - includeArchived: false, // Not in UI yet, default to false - includePublic: true, // Not in UI yet, default to true + includeArchived: existing?.githubConfig?.includeArchived ?? false, // Not in UI; preserve stored value + includePublic: existing?.githubConfig?.includePublic ?? true, // Not in UI; preserve stored value // Organization related fields — opt-in allowlist (empty = all org repos) includeOrganizations: normalizeOrgList(githubConfig.includeOrganizations), @@ -102,28 +111,35 @@ export function mapUiToDbConfig( organization: giteaConfig.organization, // Add organization field preserveOrgStructure: giteaConfig.mirrorStrategy === "preserve" || giteaConfig.mirrorStrategy === "mixed", // Add preserveOrgStructure field - // Mirror interval and options - mirrorInterval: "8h", // Default value, could be made configurable + // Mirror interval — not in UI; preserve the stored value so a save doesn't + // reset an env-configured GITEA_MIRROR_INTERVAL back to the default (#338) + mirrorInterval: existing?.giteaConfig?.mirrorInterval || "8h", lfs: mirrorOptions.mirrorLFS || false, // LFS mirroring option wiki: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.wiki, - + // Visibility settings visibility: giteaConfig.visibility || "default", - preserveVisibility: false, // This should be a separate field, not the same as preserveOrgStructure - - // Organization creation - createOrg: true, // Default to true - - // Template settings (not in UI yet) - templateOwner: undefined, - templateRepo: undefined, - - // Topics - addTopics: true, // Default to true - topicPrefix: undefined, - - // Fork strategy - forkStrategy: advancedOptions.skipForks ? "skip" : "reference", + preserveVisibility: existing?.giteaConfig?.preserveVisibility ?? false, // Not in UI; preserve stored value + + // Organization creation — not in UI; preserve stored value + createOrg: existing?.giteaConfig?.createOrg ?? true, + + // Template settings (not in UI yet) — preserve stored values + templateOwner: existing?.giteaConfig?.templateOwner, + templateRepo: existing?.giteaConfig?.templateRepo, + + // Topics — not in UI; preserve stored values + addTopics: existing?.giteaConfig?.addTopics ?? true, + topicPrefix: existing?.giteaConfig?.topicPrefix, + + // Fork strategy — skipForks is the only UI control; keep an env-configured + // "full-copy" instead of downgrading it, but reset a stale "skip" once the + // user unchecks skipForks. + forkStrategy: advancedOptions.skipForks + ? "skip" + : existing?.giteaConfig?.forkStrategy && existing.giteaConfig.forkStrategy !== "skip" + ? existing.giteaConfig.forkStrategy + : "reference", // Mirror options from UI issueConcurrency: giteaConfig.issueConcurrency ?? 3, diff --git a/src/pages/api/config/index.ts b/src/pages/api/config/index.ts index 69e84ac..280d2b8 100644 --- a/src/pages/api/config/index.ts +++ b/src/pages/api/config/index.ts @@ -94,38 +94,48 @@ export const POST: APIRoute = async ({ request, locals }) => { const existingConfig = existingConfigResult[0]; + // Parse the stored configs once — used both to preserve fields the + // Configuration form doesn't expose (e.g. env-configured mirrorInterval, + // see issue #338) and to preserve tokens when the form submits them empty. + let existingGithub: Record | undefined; + let existingGitea: Record | undefined; + if (existingConfig) { + try { + existingGithub = + typeof existingConfig.githubConfig === "string" + ? JSON.parse(existingConfig.githubConfig) + : existingConfig.githubConfig; + + existingGitea = + typeof existingConfig.giteaConfig === "string" + ? JSON.parse(existingConfig.giteaConfig) + : existingConfig.giteaConfig; + } catch (parseError) { + console.error("Failed to parse existing config:", parseError); + } + } + // Map UI structure to database schema structure first const { githubConfig: mappedGithubConfig, giteaConfig: mappedGiteaConfig } = mapUiToDbConfig( githubConfig, giteaConfig, mirrorOptions, - advancedOptions + advancedOptions, + { githubConfig: existingGithub, giteaConfig: existingGitea } ); - + // Preserve tokens if fields are empty - if (existingConfig) { - try { - const existingGithub = - typeof existingConfig.githubConfig === "string" - ? JSON.parse(existingConfig.githubConfig) - : existingConfig.githubConfig; - - const existingGitea = - typeof existingConfig.giteaConfig === "string" - ? JSON.parse(existingConfig.giteaConfig) - : existingConfig.giteaConfig; - - // Decrypt existing tokens before preserving - if (!mappedGithubConfig.token && existingGithub.token) { - mappedGithubConfig.token = decrypt(existingGithub.token); - } - - if (!mappedGiteaConfig.token && existingGitea.token) { - mappedGiteaConfig.token = decrypt(existingGitea.token); - } - } catch (tokenError) { - console.error("Failed to preserve tokens:", tokenError); + try { + // Decrypt existing tokens before preserving + if (!mappedGithubConfig.token && existingGithub?.token) { + mappedGithubConfig.token = decrypt(existingGithub.token); } + + if (!mappedGiteaConfig.token && existingGitea?.token) { + mappedGiteaConfig.token = decrypt(existingGitea.token); + } + } catch (tokenError) { + console.error("Failed to preserve tokens:", tokenError); } // Encrypt tokens before saving