From c8860f760816549efe8fe162157a1bed019e016c Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Mon, 20 Jul 2026 15:21:12 -0500 Subject: [PATCH] feat: allow users to prefix all flags from a LaunchDarkly project --- src/launchdarkly/migrate.tsx | 448 +++++++++++++++++++----- tests/flag-failure-report.test.ts | 61 ++++ tests/launchdarkly/migrate-flag.test.ts | 296 ++++++++++++++-- 3 files changed, 691 insertions(+), 114 deletions(-) diff --git a/src/launchdarkly/migrate.tsx b/src/launchdarkly/migrate.tsx index 26a0f63..2051beb 100644 --- a/src/launchdarkly/migrate.tsx +++ b/src/launchdarkly/migrate.tsx @@ -209,6 +209,13 @@ export interface ConflictClassification { existingFlag?: DatadogFlagEntry; } +export type DatadogKeyConflictType = ConflictType | 'same_run'; + +export interface DatadogKeyConflictClassification { + type: DatadogKeyConflictType; + existingFlag?: DatadogFlagEntry; +} + export interface LDFlagMigrationSpec { sourceKey: string; datadogKey: string; @@ -288,10 +295,155 @@ export function classifyConflict( return { type: 'manual', existingFlag: keyMatch }; } +/** Classify whether a proposed DD flag key is already occupied. */ +export function classifyDatadogKeyConflict( + datadogFlags: DatadogFlagEntry[], + projectKey: string, + sourceFlagKey: string, + datadogFlagKey: string, + reservedDatadogKeys: ReadonlySet = new Set(), +): DatadogKeyConflictClassification { + const keyMatch = datadogFlags.find((f) => f.key === datadogFlagKey); + if (!keyMatch) { + if (reservedDatadogKeys.has(datadogFlagKey)) return { type: 'same_run' }; + return { type: 'none' }; + } + + const metadata = keyMatch.migration_metadata; + if ( + metadata?.project_key === projectKey && + metadata.flag_key === sourceFlagKey + ) { + return { type: 'same_project', existingFlag: keyMatch }; + } + + if (metadata) return { type: 'cross_project', existingFlag: keyMatch }; + + return { type: 'manual', existingFlag: keyMatch }; +} + export type ConflictResolution = | { action: 'skip' } | { action: 'prefix'; prefix: string }; +export type InteractiveDatadogTargetPlan = + | { + action: 'sync'; + datadogKey: string; + existingFlag: DatadogFlagEntry; + } + | { + action: 'create'; + datadogKey: string; + appliedPrefix?: string; + } + | { + action: 'blocked'; + datadogKey: string; + conflict: DatadogKeyConflictClassification; + }; + +export function planInteractiveDatadogTarget( + datadogFlags: DatadogFlagEntry[], + projectKey: string, + sourceFlagKey: string, + targetKey: string, + conflictResolution?: ConflictResolution, + reservedDatadogKeys: ReadonlySet = new Set(), +): InteractiveDatadogTargetPlan { + const sourceConflict = classifyConflict( + datadogFlags, + projectKey, + sourceFlagKey, + ); + const sourceExistingFlag = + sourceConflict.type === 'same_project' || sourceConflict.type === 'manual' + ? sourceConflict.existingFlag + : undefined; + if (sourceExistingFlag) { + return { + action: 'sync', + datadogKey: sourceExistingFlag.key, + existingFlag: sourceExistingFlag, + }; + } + + const datadogKey = + conflictResolution?.action === 'prefix' + ? `${conflictResolution.prefix}${sourceFlagKey}` + : targetKey; + const keyConflict = classifyDatadogKeyConflict( + datadogFlags, + projectKey, + sourceFlagKey, + datadogKey, + reservedDatadogKeys, + ); + if (keyConflict.type !== 'none') { + return { + action: 'blocked', + datadogKey, + conflict: keyConflict, + }; + } + + return { + action: 'create', + datadogKey, + ...(conflictResolution?.action === 'prefix' + ? { appliedPrefix: conflictResolution.prefix } + : {}), + }; +} + +export function buildFlagKeyMappingsForReport( + sourceKeys: readonly string[], + targetKeyBySource: ReadonlyMap | undefined, + runtimeFlagKeyMapping: ReadonlyMap, +): Array<{ sourceKey: string; datadogKey: string }> | undefined { + const sourceKeyOrder = [...sourceKeys]; + const seenSourceKeys = new Set(sourceKeyOrder); + for (const sourceKey of runtimeFlagKeyMapping.keys()) { + if (!seenSourceKeys.has(sourceKey)) sourceKeyOrder.push(sourceKey); + } + + const mappings = new Map(); + if (targetKeyBySource) { + for (const sourceKey of sourceKeyOrder) { + mappings.set(sourceKey, targetKeyBySource.get(sourceKey) ?? sourceKey); + } + } + for (const [sourceKey, datadogKey] of runtimeFlagKeyMapping) { + mappings.set(sourceKey, datadogKey); + } + + const result = sourceKeyOrder + .map((sourceKey) => ({ + sourceKey, + datadogKey: mappings.get(sourceKey) ?? sourceKey, + })) + .filter((mapping) => mapping.datadogKey !== mapping.sourceKey); + + if (result.length > 0 || targetKeyBySource !== undefined) return result; + return undefined; +} + +function describeDatadogKeyConflict( + conflict: DatadogKeyConflictClassification, +): string { + if (conflict.type === 'same_run') { + return 'is already selected for another flag in this migration'; + } + const metadata = conflict.existingFlag?.migration_metadata; + if (metadata?.project_key) { + return `already exists in Datadog from LaunchDarkly project "${metadata.project_key}"`; + } + if (conflict.type === 'manual') { + return 'already exists in Datadog without LaunchDarkly migration metadata'; + } + return 'already exists in Datadog'; +} + function flagLabel( flag: LDFlag, datadogFlags: DatadogFlagEntry[], @@ -315,12 +467,21 @@ function flagLabel( case 'cross_project': if (conflictResolution?.action === 'prefix') { indicator = chalk.hex('#632CA6')('⊕'); - badge = ` ${chalk.bgHex('#632CA6').white(` Will prefix with ${conflictResolution.prefix}- `)}`; + badge = ` ${chalk.bgHex('#632CA6').white(` Will prefix with ${conflictResolution.prefix} `)}`; } else { indicator = chalk.red('✗'); badge = ` ${chalk.bgRed.white(' Key conflict — will skip ')}`; } break; + case 'none': + if (conflictResolution?.action === 'prefix') { + indicator = chalk.hex('#632CA6')('⊕'); + badge = ` ${chalk.bgHex('#632CA6').white(` Will prefix with ${conflictResolution.prefix} `)}`; + } else { + indicator = ' '; + badge = ''; + } + break; default: indicator = ' '; badge = ''; @@ -546,10 +707,13 @@ async function selectFlags( let skipCount = 0; for (const f of flags) { const c = classifyConflict(datadogFlags, projectKey, f.key); - if (c.type === 'same_project' || c.type === 'manual') inDatadogCount++; - if (c.type === 'cross_project') { + if (c.type === 'same_project' || c.type === 'manual') { + inDatadogCount++; + } else if (c.type === 'cross_project') { if (conflictResolution?.action === 'prefix') prefixedCount++; else skipCount++; + } else if (conflictResolution?.action === 'prefix') { + prefixedCount++; } } const previousKeys = new Set(previouslySelected.map((f) => f.key)); @@ -570,7 +734,7 @@ async function selectFlags( if (prefixedCount > 0) { console.log( chalk.hex('#632CA6')( - ` ${prefixedCount} flag(s) will be prefixed with ${(conflictResolution as { action: 'prefix'; prefix: string }).prefix}-`, + ` ${prefixedCount} flag(s) will be prefixed with ${(conflictResolution as { action: 'prefix'; prefix: string }).prefix}`, ), ); } @@ -975,15 +1139,24 @@ async function executeMigration( const jsonArrayWrappedKeys: string[] = []; const dryRunRequests: Array<{ method: string; path: string; body: unknown }> = []; - const flagKeyMapping = - targetKeyBySource === undefined - ? undefined - : detailedFlags - .map((flag) => ({ - sourceKey: flag.key, - datadogKey: targetKeyBySource.get(flag.key) ?? flag.key, - })) - .filter((mapping) => mapping.datadogKey !== mapping.sourceKey); + const sourceFlagKeysForReport = detailedFlags.map((flag) => flag.key); + const runtimeFlagKeyMapping = new Map(); + const flagKeyMappingsForReport = (): + | Array<{ sourceKey: string; datadogKey: string }> + | undefined => + buildFlagKeyMappingsForReport( + sourceFlagKeysForReport, + targetKeyBySource, + runtimeFlagKeyMapping, + ); + const reservedDatadogKeys = new Set(datadogFlags.map((flag) => flag.key)); + const recordDatadogKeyMapping = ( + sourceKey: string, + datadogKey: string, + ): void => { + if (datadogKey !== sourceKey) + runtimeFlagKeyMapping.set(sourceKey, datadogKey); + }; let runner: MigrationRunnerHandle | undefined; const environmentMappingArr: LDMigrationFile['environmentMapping'] = []; @@ -1016,7 +1189,7 @@ async function executeMigration( enableFailures, skippedFlags: skippedFlags.length > 0 ? skippedFlags : undefined, syncedFlagKeys: syncedFlagKeys.length > 0 ? syncedFlagKeys : undefined, - flagKeyMapping, + flagKeyMapping: flagKeyMappingsForReport(), segmentMigration: segmentMigrationStats, flags: detailedFlags, environmentMapping: environmentMappingArr, @@ -1182,26 +1355,100 @@ async function executeMigration( doFail(flag.key, reason); continue; } - - // Cross-project conflict: skip or prefix - if (!nonInteractive && conflict.type === 'cross_project') { - if (!conflictResolution || conflictResolution.action === 'skip') { - doSkip( - flag.key, - `Skipped ${chalk.cyan(flag.key)} — key already used by a flag from a different LaunchDarkly project`, - 'Key conflict: flag key already exists in Datadog from a different LaunchDarkly project', - ); - continue; - } - // prefix case: fall through to creation below + if ( + nonInteractive && + conflict.type === 'none' && + reservedDatadogKeys.has(targetKey) + ) { + doFail( + flag.key, + `Duplicate Datadog flag key "${targetKey}" was already selected by another flag in this migration`, + ); + continue; } - // For same_project and manual conflicts, sync onto the existing flag - const existingFlagId = - conflict.type === 'same_project' || - (!nonInteractive && conflict.type === 'manual') + let resolvedDdKey = targetKey; + let appliedPrefix: string | undefined; + let existingFlagId = + nonInteractive && conflict.type === 'same_project' ? conflict.existingFlag?.id : undefined; + if (!nonInteractive) { + const targetPlan = planInteractiveDatadogTarget( + datadogFlags, + projectKey, + flag.key, + targetKey, + conflictResolution, + reservedDatadogKeys, + ); + resolvedDdKey = targetPlan.datadogKey; + if (targetPlan.action === 'sync') { + existingFlagId = targetPlan.existingFlag.id; + recordDatadogKeyMapping(flag.key, targetPlan.existingFlag.key); + } else if (targetPlan.action === 'create') { + appliedPrefix = targetPlan.appliedPrefix; + } else { + const conflictingKey = targetPlan.datadogKey; + const conflictDesc = describeDatadogKeyConflict( + targetPlan.conflict, + ); + + activeRunner.finalize(); + const action = await select<'rename' | 'skip'>({ + message: `Datadog flag key ${chalk.cyan(conflictingKey)} ${conflictDesc}. What would you like to do?`, + choices: [ + { + name: 'Enter a custom Datadog key for this flag', + value: 'rename', + }, + { name: 'Skip this flag', value: 'skip' }, + ], + }); + + if (action === 'skip') { + activeRunner.beginFlag(flag.key); + doSkip( + flag.key, + `Skipped ${chalk.cyan(flag.key)} — Datadog key ${chalk.cyan(conflictingKey)} already exists`, + `Key conflict: Datadog flag key "${conflictingKey}" already exists`, + ); + continue; + } + + // Prompt for a custom key, re-checking until conflict-free. + let customKey = ''; + for (;;) { + customKey = await input({ + message: `Enter a custom Datadog key for ${chalk.cyan(flag.key)}:`, + validate: (val) => { + const trimmed = val.trim(); + if (!trimmed) return 'Key cannot be empty'; + if (!/^[a-z0-9_-]+$/.test(trimmed)) + return 'Key must be lowercase alphanumeric with hyphens or underscores'; + return true; + }, + }); + customKey = customKey.trim(); + const recheckConflict = classifyDatadogKeyConflict( + datadogFlags, + projectKey, + flag.key, + customKey, + reservedDatadogKeys, + ); + if (recheckConflict.type === 'none') break; + console.log( + chalk.yellow( + ` ⚠ Key "${customKey}" ${describeDatadogKeyConflict(recheckConflict)}. Try a different key.`, + ), + ); + } + resolvedDdKey = customKey; + appliedPrefix = undefined; + activeRunner.beginFlag(flag.key); + } + } const allRuleCount = allocations.reduce( (sum, a) => sum + (a.targeting_rules?.length ?? 0), @@ -1558,13 +1805,9 @@ async function executeMigration( } } } else { - const usePrefix = - !nonInteractive && - conflict.type === 'cross_project' && - conflictResolution?.action === 'prefix'; - const ddKey = usePrefix - ? `${conflictResolution.prefix}-${flag.key}` - : targetKey; + const ddKey = resolvedDdKey; + reservedDatadogKeys.add(ddKey); + recordDatadogKeyMapping(flag.key, ddKey); const tags = buildFlagTags(flag.tags, projectKey, editorTeamHandles); @@ -1577,7 +1820,7 @@ async function executeMigration( migration_metadata: { project_key: projectKey, flag_key: flag.key, - ...(usePrefix ? { key_prefix: conflictResolution.prefix } : {}), + ...(appliedPrefix ? { key_prefix: appliedPrefix } : {}), }, ...(tags.length > 0 ? { tags } : {}), ...(hasSemverConditions(allocations) @@ -1638,7 +1881,7 @@ async function executeMigration( createdFlag.id, editorTeamIds, ddSite, - ddKey, + flag.key, restrictionPolicyFailures, ); } @@ -1656,7 +1899,7 @@ async function executeMigration( enabledCount++; } catch (err) { enableFailures.push({ - key: ddKey, + key: flag.key, env: ddEnv.name, error: formatAxiosError(err), }); @@ -1671,7 +1914,11 @@ async function executeMigration( ); } catch (err) { const error = formatAxiosError(err); - doFail(ddKey, error); + doFail( + flag.key, + error, + `Failed ${chalk.cyan(ddKey)}: ${chalk.red(error)}`, + ); } } } @@ -1714,7 +1961,7 @@ async function executeMigration( name: f.name, kind: f.kind, })), - flagKeyMapping, + flagKeyMapping: flagKeyMappingsForReport(), environmentMapping: environmentMappingArr, requests: dryRunRequests, }; @@ -1743,7 +1990,7 @@ async function executeMigration( semverForcedClientKeys.length > 0 ? semverForcedClientKeys : undefined, jsonArrayWrappedKeys: jsonArrayWrappedKeys.length > 0 ? jsonArrayWrappedKeys : undefined, - flagKeyMapping, + flagKeyMapping: flagKeyMappingsForReport(), segmentMigration: segmentMigrationStats, flags: detailedFlags, environmentMapping: environmentMappingArr, @@ -1906,49 +2153,86 @@ export async function runLaunchDarklyMigration( return; } - // Detect cross-project conflicts and prompt for resolution - const crossProjectConflicts = allFlags.filter( - (f) => - classifyConflict(datadogFlags, selectedProject.key, f.key).type === - 'cross_project', + // Detect prefix used by previously-migrated flags for this project + const alreadyMigratedForProject = datadogFlags.filter( + (f) => f.migration_metadata?.project_key === selectedProject.key, ); + let detectedPrefix: string | undefined; + for (const f of alreadyMigratedForProject) { + const storedPrefix = f.migration_metadata?.key_prefix; + if (storedPrefix) { + // New format already includes separator; old format does not — infer from key + if (/[-_]$/.test(storedPrefix)) { + detectedPrefix = storedPrefix; + } else { + const origKey = f.migration_metadata?.flag_key; + if (origKey && f.key.endsWith(`-${origKey}`)) + detectedPrefix = `${storedPrefix}-`; + else if (origKey && f.key.endsWith(`_${origKey}`)) + detectedPrefix = `${storedPrefix}_`; + else detectedPrefix = `${storedPrefix}-`; + } + break; + } + // Fallback: no key_prefix stored — infer separator from key vs original key + const origKey = f.migration_metadata?.flag_key; + if (origKey && f.key !== origKey) { + if (f.key.endsWith(`-${origKey}`)) + detectedPrefix = f.key.slice(0, f.key.length - origKey.length); + else if (f.key.endsWith(`_${origKey}`)) + detectedPrefix = f.key.slice(0, f.key.length - origKey.length); + if (detectedPrefix) break; + } + } - let conflictResolution: ConflictResolution | undefined; - if (crossProjectConflicts.length > 0) { - console.log(); - console.log( - chalk.yellow( - ` ${crossProjectConflicts.length} flag(s) have key conflicts with flags from other LaunchDarkly projects`, - ), - ); - console.log(); + // Always prompt for prefix before flag selection + console.log(); + type PrefixAction = 'skip' | 'use-detected' | 'custom'; + const prefixChoices: { name: string; value: PrefixAction }[] = [ + ...(detectedPrefix + ? [ + { + name: `Use existing prefix "${detectedPrefix}"`, + value: 'use-detected' as PrefixAction, + }, + ] + : [ + { + name: 'Add a prefix to flag keys', + value: 'custom' as PrefixAction, + }, + ]), + ...(detectedPrefix + ? [{ name: 'Add a prefix to flag keys', value: 'custom' as PrefixAction }] + : []), + { name: 'Skip — no prefix', value: 'skip' }, + ]; - const action = await select<'skip' | 'prefix'>({ - message: 'How would you like to handle these conflicts?', - choices: [ - { name: 'Skip conflicting flags', value: 'skip' }, - { - name: 'Add a prefix to conflicting flag keys', - value: 'prefix', - }, - ], - }); + const prefixAction = await select({ + message: 'Would you like to add a prefix to all migrated flag keys?', + choices: prefixChoices, + }); - if (action === 'skip') { - conflictResolution = { action: 'skip' }; - } else { - const prefix = await input({ - message: 'Enter a prefix for conflicting flag keys:', - validate: (val) => { - const trimmed = val.trim(); - if (trimmed.length === 0) return 'Prefix cannot be empty'; - if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(trimmed)) - return 'Prefix must contain only lowercase letters, numbers, and hyphens'; - return true; - }, - }); - conflictResolution = { action: 'prefix', prefix: prefix.trim() }; - } + let conflictResolution: ConflictResolution; + if (prefixAction === 'skip') { + conflictResolution = { action: 'skip' }; + } else if (prefixAction === 'use-detected') { + // detectedPrefix is guaranteed non-undefined when this option appears + // biome-ignore lint/style/noNonNullAssertion: only offered when detectedPrefix is set + conflictResolution = { action: 'prefix', prefix: detectedPrefix! }; + } else { + const prefix = await input({ + message: 'Enter a prefix for flag keys:', + validate: (val) => { + const trimmed = val.trim(); + if (trimmed.length === 0) return 'Prefix cannot be empty'; + if (!/^[a-z0-9][a-z0-9_-]*$/.test(trimmed)) + return 'Prefix must contain only lowercase letters, numbers, hyphens, or underscores'; + if (!/[-_]$/.test(trimmed)) return 'Prefix must end with - or _'; + return true; + }, + }); + conflictResolution = { action: 'prefix', prefix: prefix.trim() }; } let prevSelectedEnvKeys: string[] = []; diff --git a/tests/flag-failure-report.test.ts b/tests/flag-failure-report.test.ts index ffb790b..225184e 100644 --- a/tests/flag-failure-report.test.ts +++ b/tests/flag-failure-report.test.ts @@ -56,6 +56,7 @@ function parseLastJsonOutput(writes: string[]): { errored: number; }; failures: Array<{ key: string; error: string }>; + flagKeyMapping?: Array<{ sourceKey: string; datadogKey: string }>; } { for (let i = writes.length - 1; i >= 0; i--) { const trimmed = writes[i].trimStart(); @@ -305,4 +306,64 @@ describe('flag-level migration failures', () => { ]); expect(process.exitCode).toBe(1); }); + + it('captures LaunchDarkly remapped create failures under the source flag key', async () => { + const flag = ldFlag(); + const datadogKey = `mobile-${flag.key}`; + ldMock.onGet(`${LD_BASE}/api/v2/projects`).reply(200, { + items: [{ key: 'proj', name: 'Project' }], + totalCount: 1, + }); + ldMock.onGet(`${LD_BASE}/api/v2/flags/proj/${flag.key}`).reply(200, flag); + ldMock.onGet(`${LD_BASE}/api/v2/projects/proj`).reply(200, { + environments: { + items: [ + { + key: 'production', + name: 'Production', + color: '417505', + archived: false, + }, + ], + }, + }); + ldMock.onGet(`${LD_BASE}/api/v2/roles`).reply(200, { + items: [], + totalCount: 0, + }); + ldMock.onGet(`${LD_BASE}/api/v2/teams`).reply(200, { + items: [], + totalCount: 0, + }); + ddMock.onGet(`${DD_BASE}/api/v2/feature-flags`).reply(200, { + data: [], + meta: { page: { total: 0 } }, + }); + ddMock.onGet(`${DD_BASE}/api/v2/feature-flags/environments`).reply(200, { + data: [ddEnvironment('dd-prod', 'Production', true)], + }); + ddMock + .onPost(`${DD_BASE}/api/v2/feature-flags`) + .reply(400, { errors: [{ detail: 'Create failed' }] }); + + await runLaunchDarklyMigration('dd-api-key', 'dd-app-key', DD_SITE, false, { + nonInteractive: { + projectKey: 'proj', + envMap: [['production', 'Production']], + flagKeys: [`${flag.key},${datadogKey}`], + }, + doExport: false, + }); + + const report = parseLastJsonOutput(stdoutWrites); + expect(report.success).toBe(false); + expect(report.summary).toMatchObject({ created: 0, synced: 0, errored: 1 }); + expect(report.failures).toEqual([ + { key: flag.key, error: 'Create failed' }, + ]); + expect(report.flagKeyMapping).toEqual([ + { sourceKey: flag.key, datadogKey }, + ]); + expect(process.exitCode).toBe(1); + }); }); diff --git a/tests/launchdarkly/migrate-flag.test.ts b/tests/launchdarkly/migrate-flag.test.ts index 082f45d..bfc6e2e 100644 --- a/tests/launchdarkly/migrate-flag.test.ts +++ b/tests/launchdarkly/migrate-flag.test.ts @@ -21,9 +21,12 @@ import { shouldSkipFlag, } from '../../src/launchdarkly/helpers/migration.js'; import { + buildFlagKeyMappingsForReport, type ConflictResolution, classifyConflict, + classifyDatadogKeyConflict, flagCategories, + planInteractiveDatadogTarget, } from '../../src/launchdarkly/migrate.js'; import type { LDEnvironmentConfig, @@ -1282,47 +1285,40 @@ function migrateFlagWithConflicts( } const allocations = allocationsResult; const envsToEnable = getEnvsToEnable(flag, envMapping); - const conflict = classifyConflict(datadogFlags, projectKey, flag.key); - - // Cross-project conflict: skip or prefix - if (conflict.type === 'cross_project') { - if (!conflictResolution || conflictResolution.action === 'skip') { - return { - action: 'skip', - skipReason: - 'Key conflict: flag key already exists in Datadog from a different LaunchDarkly project', - envsToEnable: [], - }; - } - // prefix: fall through to creation below + const targetPlan = planInteractiveDatadogTarget( + datadogFlags, + projectKey, + flag.key, + flag.key, + conflictResolution, + ); + + if (targetPlan.action === 'blocked') { + return { + action: 'skip', + skipReason: `Key conflict: Datadog flag key "${targetPlan.datadogKey}" already exists`, + envsToEnable: [], + }; } // Same-project or manual: sync onto existing flag - const existingFlagId = - conflict.type === 'same_project' || conflict.type === 'manual' - ? conflict.existingFlag?.id - : undefined; - - if (existingFlagId) { + if (targetPlan.action === 'sync') { return { action: 'sync', - existingFlagId, + existingFlagId: targetPlan.existingFlag.id, envsToEnable, }; } // Create new flag (possibly with prefix) - const usePrefix = - conflict.type === 'cross_project' && - conflictResolution?.action === 'prefix'; - const ddKey = usePrefix - ? `${conflictResolution.prefix}-${flag.key}` - : flag.key; + const ddKey = targetPlan.datadogKey; const metadata: MigrationMetadata = { project_key: projectKey, flag_key: flag.key, - ...(usePrefix ? { key_prefix: conflictResolution.prefix } : {}), + ...(targetPlan.appliedPrefix + ? { key_prefix: targetPlan.appliedPrefix } + : {}), }; const tags = buildFlagTags(flag.tags, projectKey); @@ -1462,14 +1458,14 @@ describe('migrate a flag whose key conflicts with a flag from a different LD pro }); }); - describe('when user chooses to prefix with "mobile"', () => { + describe('when user chooses to prefix with "mobile-"', () => { const result = migrateFlagWithConflicts( conflictFlag, conflictEnvMapping, ['production'], datadogFlags, 'mobile', - { action: 'prefix', prefix: 'mobile' }, + { action: 'prefix', prefix: 'mobile-' }, ); it('creates a new flag', () => { @@ -1491,7 +1487,7 @@ describe('migrate a flag whose key conflicts with a flag from a different LD pro }); it('stores the prefix in migration_metadata.key_prefix', () => { - expect(result.request?.migration_metadata?.key_prefix).toBe('mobile'); + expect(result.request?.migration_metadata?.key_prefix).toBe('mobile-'); }); it('stores the project key in migration_metadata.project_key', () => { @@ -1526,6 +1522,178 @@ describe('migrate a flag whose key conflicts with a flag from a different LD pro }); }); +describe('migrate a flag with no conflict when a prefix is provided', () => { + // No existing DD flag — the prefix should be applied to all new flags, + // not only to cross-project conflicts. + const noConflictFlag: LDFlag = { + name: 'New Feature', + kind: 'boolean', + key: 'new-feature', + variations: [ + { _id: 'v0', value: true, name: 'on' }, + { _id: 'v1', value: false, name: 'off' }, + ], + defaults: { onVariation: 0, offVariation: 1 }, + environments: { + production: makeEnv({ + _environmentName: 'Production', + on: false, + fallthrough: { variation: 1 }, + }), + }, + tags: [], + archived: false, + deprecated: false, + temporary: false, + }; + const envMapping = new Map([['production', ddProd]]); + + describe('with prefix "acme-"', () => { + const result = migrateFlagWithConflicts( + noConflictFlag, + envMapping, + ['production'], + [], + 'my-project', + { action: 'prefix', prefix: 'acme-' }, + ); + + it('creates a new flag', () => { + expect(result.action).toBe('create'); + }); + + it('applies prefix to Datadog flag key', () => { + expect(result.request?.key).toBe('acme-new-feature'); + }); + + it('stores original LD key in migration_metadata.flag_key', () => { + expect(result.request?.migration_metadata?.flag_key).toBe('new-feature'); + }); + + it('stores prefix in migration_metadata.key_prefix', () => { + expect(result.request?.migration_metadata?.key_prefix).toBe('acme-'); + }); + }); + + describe('with action "skip" (no prefix)', () => { + const result = migrateFlagWithConflicts( + noConflictFlag, + envMapping, + ['production'], + [], + 'my-project', + { action: 'skip' }, + ); + + it('creates flag without prefix', () => { + expect(result.action).toBe('create'); + expect(result.request?.key).toBe('new-feature'); + }); + + it('does not store key_prefix in migration_metadata', () => { + expect(result.request?.migration_metadata?.key_prefix).toBeUndefined(); + }); + }); +}); + +describe('planInteractiveDatadogTarget', () => { + it('creates with the prefixed key when the original key conflicts but the prefixed key is free', () => { + const datadogFlags: DatadogFlagEntry[] = [ + { + id: 'dd-uuid-web', + key: 'enable-dark-mode', + migration_metadata: { + project_key: 'web', + flag_key: 'enable-dark-mode', + }, + }, + ]; + + const plan = planInteractiveDatadogTarget( + datadogFlags, + 'mobile', + 'enable-dark-mode', + 'enable-dark-mode', + { action: 'prefix', prefix: 'mobile-' }, + ); + + expect(plan).toEqual({ + action: 'create', + datadogKey: 'mobile-enable-dark-mode', + appliedPrefix: 'mobile-', + }); + }); + + it('syncs a previously migrated prefixed flag instead of treating the prefixed key as a collision', () => { + const datadogFlags: DatadogFlagEntry[] = [ + { + id: 'dd-uuid-prefixed', + key: 'mobile-enable-dark-mode', + migration_metadata: { + project_key: 'mobile', + flag_key: 'enable-dark-mode', + key_prefix: 'mobile-', + }, + }, + ]; + + const plan = planInteractiveDatadogTarget( + datadogFlags, + 'mobile', + 'enable-dark-mode', + 'enable-dark-mode', + { action: 'prefix', prefix: 'mobile-' }, + ); + + expect(plan.action).toBe('sync'); + expect(plan.datadogKey).toBe('mobile-enable-dark-mode'); + if (plan.action === 'sync') { + expect(plan.existingFlag.id).toBe('dd-uuid-prefixed'); + } + }); + + it('blocks a proposed prefixed key that collides with a manual Datadog flag', () => { + const datadogFlags: DatadogFlagEntry[] = [ + { + id: 'dd-uuid-manual-prefixed', + key: 'mobile-enable-dark-mode', + }, + ]; + + const plan = planInteractiveDatadogTarget( + datadogFlags, + 'mobile', + 'enable-dark-mode', + 'enable-dark-mode', + { action: 'prefix', prefix: 'mobile-' }, + ); + + expect(plan.action).toBe('blocked'); + expect(plan.datadogKey).toBe('mobile-enable-dark-mode'); + if (plan.action === 'blocked') { + expect(plan.conflict.type).toBe('manual'); + expect(plan.conflict.existingFlag?.id).toBe('dd-uuid-manual-prefixed'); + } + }); + + it('blocks a proposed key already reserved by an earlier create in the same migration', () => { + const plan = planInteractiveDatadogTarget( + [], + 'mobile', + 'new-feature', + 'new-feature', + { action: 'skip' }, + new Set(['new-feature']), + ); + + expect(plan.action).toBe('blocked'); + expect(plan.datadogKey).toBe('new-feature'); + if (plan.action === 'blocked') { + expect(plan.conflict.type).toBe('same_run'); + } + }); +}); + describe('classifyConflict edge cases', () => { it('returns none when no DD flag has the same key', () => { const c = classifyConflict([], 'mobile', 'brand-new-flag'); @@ -1569,6 +1737,70 @@ describe('classifyConflict edge cases', () => { }); }); +describe('classifyDatadogKeyConflict', () => { + it('returns manual when a proposed custom key is occupied by an unmanaged Datadog flag', () => { + const datadogFlags: DatadogFlagEntry[] = [ + { + id: 'dd-uuid-manual', + key: 'custom-dark-mode', + }, + ]; + + const c = classifyDatadogKeyConflict( + datadogFlags, + 'mobile', + 'enable-dark-mode', + 'custom-dark-mode', + ); + + expect(c.type).toBe('manual'); + expect(c.existingFlag?.id).toBe('dd-uuid-manual'); + }); + + it('returns same_run when a proposed custom key was already reserved by this migration', () => { + const c = classifyDatadogKeyConflict( + [], + 'mobile', + 'enable-dark-mode', + 'custom-dark-mode', + new Set(['custom-dark-mode']), + ); + + expect(c.type).toBe('same_run'); + expect(c.existingFlag).toBeUndefined(); + }); +}); + +describe('buildFlagKeyMappingsForReport', () => { + it('persists interactive remaps even when no non-interactive target map exists', () => { + expect( + buildFlagKeyMappingsForReport( + ['enable-dark-mode', 'new-feature'], + undefined, + new Map([['enable-dark-mode', 'mobile-enable-dark-mode']]), + ), + ).toEqual([ + { + sourceKey: 'enable-dark-mode', + datadogKey: 'mobile-enable-dark-mode', + }, + ]); + }); + + it('keeps non-interactive target maps and lets runtime mappings override them', () => { + expect( + buildFlagKeyMappingsForReport( + ['flag-a', 'flag-b'], + new Map([ + ['flag-a', 'renamed-flag-a'], + ['flag-b', 'flag-b'], + ]), + new Map([['flag-a', 'interactive-flag-a']]), + ), + ).toEqual([{ sourceKey: 'flag-a', datadogKey: 'interactive-flag-a' }]); + }); +}); + describe('flagCategories', () => { it('returns active/new/launched statuses seen in any environment', () => { const statusByEnv: Map | null> = new Map([ @@ -1675,7 +1907,7 @@ describe('migrate a cross-project prefixed flag that also has team tags', () => ['production'], datadogFlags, 'mobile', - { action: 'prefix', prefix: 'mobile' }, + { action: 'prefix', prefix: 'mobile-' }, ); it('creates a new flag with prefixed key', () => { @@ -1690,7 +1922,7 @@ describe('migrate a cross-project prefixed flag that also has team tags', () => it('has prefixed key and migration metadata in the same request', () => { expect(result.request?.key).toBe('mobile-feature-toggle'); expect(result.request?.tags).toEqual(['mobile-app', 'project:mobile']); - expect(result.request?.migration_metadata?.key_prefix).toBe('mobile'); + expect(result.request?.migration_metadata?.key_prefix).toBe('mobile-'); }); });