diff --git a/api/integrations/launch_darkly/constants.py b/api/integrations/launch_darkly/constants.py index 21085564bf07..73547d7e378e 100644 --- a/api/integrations/launch_darkly/constants.py +++ b/api/integrations/launch_darkly/constants.py @@ -9,6 +9,7 @@ LAUNCH_DARKLY_IMPORTED_TAG_COLOR = "#3d4db6" LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL = "Imported" +LAUNCH_DARKLY_IMPORTED_DEPRECATED_TAG_LABEL = "Deprecated" BACKOFF_MAX_RETRIES = 5 BACKOFF_DEFAULT_RETRY_AFTER_SECONDS = 10 diff --git a/api/integrations/launch_darkly/models.py b/api/integrations/launch_darkly/models.py index d59034eb2799..f80d30ca73cf 100644 --- a/api/integrations/launch_darkly/models.py +++ b/api/integrations/launch_darkly/models.py @@ -17,6 +17,7 @@ class LaunchDarklyImportStatus(TypedDict): requested_environment_count: int requested_flag_count: int + deprecated_flag_count: NotRequired[int] result: NotRequired[LaunchDarklyImportResult] error_messages: list[str] diff --git a/api/integrations/launch_darkly/serializers.py b/api/integrations/launch_darkly/serializers.py index c9f1645b876d..9986db3a6f2a 100644 --- a/api/integrations/launch_darkly/serializers.py +++ b/api/integrations/launch_darkly/serializers.py @@ -11,6 +11,7 @@ class LaunchDarklyImportRequestStatusSerializer(serializers.Serializer): # type: ignore[type-arg] requested_environment_count = serializers.IntegerField(read_only=True) requested_flag_count = serializers.IntegerField(read_only=True) + deprecated_flag_count = serializers.IntegerField(read_only=True, default=0) result = serializers.ChoiceField( get_args(LaunchDarklyImportResult), read_only=True, diff --git a/api/integrations/launch_darkly/services.py b/api/integrations/launch_darkly/services.py index 84ca823f3594..8357e2930465 100644 --- a/api/integrations/launch_darkly/services.py +++ b/api/integrations/launch_darkly/services.py @@ -28,6 +28,7 @@ from integrations.launch_darkly.client import LaunchDarklyClient from integrations.launch_darkly.constants import ( LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL, + LAUNCH_DARKLY_IMPORTED_DEPRECATED_TAG_LABEL, LAUNCH_DARKLY_IMPORTED_TAG_COLOR, ) from integrations.launch_darkly.exceptions import LaunchDarklyRateLimitError @@ -124,7 +125,11 @@ def _create_tags_from_ld( ) -> dict[str, Tag]: tags_by_ld_tag = {} - for ld_tag in (*ld_tags, LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL): + for ld_tag in ( + *ld_tags, + LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL, + LAUNCH_DARKLY_IMPORTED_DEPRECATED_TAG_LABEL, + ): tags_by_ld_tag[ld_tag], _ = Tag.objects.update_or_create( label=ld_tag, project_id=project_id, @@ -903,6 +908,8 @@ def _create_feature_from_ld( tags_by_ld_tag[LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL], *(tags_by_ld_tag[ld_tag] for ld_tag in ld_flag["tags"]), ] + if ld_flag["deprecated"]: + tags.append(tags_by_ld_tag[LAUNCH_DARKLY_IMPORTED_DEPRECATED_TAG_LABEL]) feature, _ = Feature.objects.update_or_create( project_id=project_id, @@ -911,7 +918,7 @@ def _create_feature_from_ld( "description": ld_flag.get("description"), "default_enabled": False, "type": feature_type, - "is_archived": ld_flag["archived"], + "is_archived": ld_flag["archived"] or ld_flag["deprecated"], }, ) feature.tags.set(tags) @@ -1169,3 +1176,8 @@ def process_import_request( segments_by_ld_key=segments_by_ld_key, project_id=import_request.project_id, ) + + # Count deprecated flags for reporting + import_request.status["deprecated_flag_count"] = sum( + 1 for ld_flag in ld_flags if ld_flag["deprecated"] + ) diff --git a/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json b/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json index 22245da752c9..19184591949a 100644 --- a/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json +++ b/api/tests/unit/integrations/launch_darkly/client_responses/get_flags.json @@ -35,6 +35,7 @@ "offVariation": 1, "onVariation": 0 }, + "deprecated": true, "description": "", "environments": { "production": { @@ -162,6 +163,7 @@ "offVariation": 1, "onVariation": 0 }, + "deprecated": false, "description": "", "environments": { "production": { @@ -289,6 +291,7 @@ "offVariation": 1, "onVariation": 0 }, + "deprecated": false, "description": "", "environments": { "production": { @@ -420,6 +423,7 @@ "offVariation": 1, "onVariation": 0 }, + "deprecated": false, "description": "", "environments": { "production": { @@ -558,6 +562,7 @@ "offVariation": 1, "onVariation": 0 }, + "deprecated": false, "description": "", "environments": { "production": { diff --git a/api/tests/unit/integrations/launch_darkly/snapshots/test_process_import_request__large_segments__correctly_imported__import_request_status.json b/api/tests/unit/integrations/launch_darkly/snapshots/test_process_import_request__large_segments__correctly_imported__import_request_status.json index 4c7e66bb9417..5c8dd5470a34 100644 --- a/api/tests/unit/integrations/launch_darkly/snapshots/test_process_import_request__large_segments__correctly_imported__import_request_status.json +++ b/api/tests/unit/integrations/launch_darkly/snapshots/test_process_import_request__large_segments__correctly_imported__import_request_status.json @@ -1,4 +1,5 @@ { + "deprecated_flag_count": 1, "error_messages": [ "Targeting key 'user-...63a49' exceeds the limit of 1000 characters, skipping for segment 'Large User List (Override for test)'", "Segment condition value '.*1a6...\\.com' for property 'email' exceeds the limit of 1000 characters, skipping for segment 'Large Dynamic List (Override for test)'", diff --git a/api/tests/unit/integrations/launch_darkly/test_services.py b/api/tests/unit/integrations/launch_darkly/test_services.py index 46385c887f7d..2482af6c13a7 100644 --- a/api/tests/unit/integrations/launch_darkly/test_services.py +++ b/api/tests/unit/integrations/launch_darkly/test_services.py @@ -143,11 +143,13 @@ def test_process_import_request__success__expected_status( # type: ignore[no-un ("testtag", "#3d4db6"), ("testtag2", "#3d4db6"), ("Imported", "#3d4db6"), + ("Deprecated", "#3d4db6"), } assert set( Feature.objects.filter(project=project).values_list("name", "tags__label") ) == { ("flag1", "Imported"), + ("flag1", "Deprecated"), ("flag2_value", "Imported"), ("flag3_multivalue", "Imported"), ("flag4_multivalue", "Imported"), @@ -160,6 +162,10 @@ def test_process_import_request__success__expected_status( # type: ignore[no-un ("TEST_COMBINED_TARGET", "Imported"), } + # Deprecated flags are archived. + deprecated_feature = Feature.objects.get(project=project, name="flag1") + assert deprecated_feature.is_archived is True + # Standard feature states have expected values. boolean_standard_feature = Feature.objects.get(project=project, name="flag1") boolean_standard_feature_states_by_env_name = { diff --git a/api/tests/unit/integrations/launch_darkly/test_views.py b/api/tests/unit/integrations/launch_darkly/test_views.py index 8c661e8ae252..e901a0332e56 100644 --- a/api/tests/unit/integrations/launch_darkly/test_views.py +++ b/api/tests/unit/integrations/launch_darkly/test_views.py @@ -49,6 +49,7 @@ def test_launch_darkly_import_request_view__list__return_expected( "id": import_request.id, "project": project.id, "status": { + "deprecated_flag_count": 0, "error_messages": [], "requested_environment_count": 2, "requested_flag_count": 9, @@ -92,6 +93,7 @@ def test_launch_darkly_import_request_view__create__return_expected( "id": created_import_request.id, "project": project.id, "status": { + "deprecated_flag_count": 0, "error_messages": [], "requested_environment_count": 2, "requested_flag_count": 9, diff --git a/docs/docs/administration-and-security/data-management/import-from-launchdarkly.md b/docs/docs/administration-and-security/data-management/import-from-launchdarkly.md index 770eb5bfc4f1..3c0bc71bb0bf 100644 --- a/docs/docs/administration-and-security/data-management/import-from-launchdarkly.md +++ b/docs/docs/administration-and-security/data-management/import-from-launchdarkly.md @@ -65,3 +65,9 @@ Multivariate LaunchDarkly flags will be imported into Flagsmith as MultiVariate Multivariate values will be taken from the `variations` field of within LaunchDarkly. Values set to serve when targeting is off will be imported as control values. + +#### Archived and deprecated flags + +Archived flags in LaunchDarkly are imported as archived flags in Flagsmith. + +Deprecated flags in LaunchDarkly are also imported as archived flags in Flagsmith. They receive a "Deprecated" tag to distinguish them from flags that were archived in LaunchDarkly. diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 7f3f5236c767..b5f2d7e577ab 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -186,8 +186,9 @@ export type LaunchDarklyProjectImport = { status: { requested_environment_count: number requested_flag_count: number + deprecated_flag_count?: number result: string | null - error_message: string | null + error_messages: string[] } project: number } diff --git a/frontend/web/components/import-export/ImportPage.tsx b/frontend/web/components/import-export/ImportPage.tsx index 9d8311270deb..9dcc40952016 100644 --- a/frontend/web/components/import-export/ImportPage.tsx +++ b/frontend/web/components/import-export/ImportPage.tsx @@ -27,51 +27,43 @@ const ImportPage: FC = ({ projectId, projectName }) => { const [LDKey, setLDKey] = useState('') const [importId, setImportId] = useState() const [isLoading, setIsLoading] = useState(false) - const [isAppLoading, setAppIsLoading] = useState(false) const [projects, setProjects] = useState<{ key: string; name: string }[]>([]) const [createLaunchDarklyProjectImport, { data, isSuccess }] = useCreateLaunchDarklyProjectImportMutation() - const { - data: status, - isSuccess: statusLoaded, - isUninitialized, - refetch, - } = useGetLaunchDarklyProjectImportQuery( + const { data: status } = useGetLaunchDarklyProjectImportQuery( { import_id: `${importId}`, project_id: projectId, }, - { skip: !importId }, + { + pollingInterval: importId ? 1000 : 0, + skip: !importId, + }, ) useEffect(() => { - const checkImportStatus = async () => { - setAppIsLoading(true) - const intervalId = setInterval(async () => { - await refetch() - - if (statusLoaded && status && status.status.result === 'success') { - clearInterval(intervalId) - setAppIsLoading(false) - window.location.reload() - } - }, 1000) - } - - if (statusLoaded) { - checkImportStatus() + if (isSuccess && data?.id) { + setImportId(data.id) } - }, [statusLoaded, status, refetch]) + }, [isSuccess, data]) useEffect(() => { - if (isSuccess && data?.id) { - setImportId(data.id) - if (!isUninitialized) { - refetch() + if (status?.status?.result === 'success') { + const count = status.status.requested_flag_count + const deprecated = status.status.deprecated_flag_count ?? 0 + let message = `Imported ${count} flag${count !== 1 ? 's' : ''} from LaunchDarkly.` + if (deprecated > 0) { + message += ` ${deprecated} deprecated flag${deprecated !== 1 ? 's were' : ' was'} archived.` } + toast(message, 'success', 0) + history.push(`/project/${projectId}`) + } else if (status?.status?.result === 'failure') { + const errors = status.status.error_messages.join('; ') + toast(`Importing from LaunchDarkly failed: ${errors}`, 'danger', 0) + setImportId(undefined) } - }, [isSuccess, data, refetch, isUninitialized]) + }, [status, projectId, history]) const getProjectList = (LDKey: string) => { setIsLoading(true) @@ -218,11 +210,12 @@ const ImportPage: FC = ({ projectId, projectName }) => { ) + const isImporting = !!importId && status?.status?.result !== 'success' + return ( <> - {isAppLoading && ( + {isImporting && (
-
Importing Project
)} diff --git a/frontend/web/project/toast.tsx b/frontend/web/project/toast.tsx index 48d22712b023..9e7a08da56e6 100644 --- a/frontend/web/project/toast.tsx +++ b/frontend/web/project/toast.tsx @@ -68,6 +68,7 @@ const Message: FC = ({ }) => { const theme = _theme || 'success' useEffect(() => { + if (!expiry) return const timeout = setTimeout(remove, expiry) return () => clearTimeout(timeout) }, [remove, expiry])