From c2355c68909bf1e0d6ac8b780dbdf02dd1ca927c Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 16 Jul 2025 13:50:36 +0530 Subject: [PATCH 1/6] wip: beta release --- api/audit/constants.py | 7 +- api/features/release_pipelines/core/models.py | 62 ++++++-- .../core/test_unit_release_pipeline_models.py | 132 +++++++++++++++--- 3 files changed, 166 insertions(+), 35 deletions(-) diff --git a/api/audit/constants.py b/api/audit/constants.py index 36656d6f3bcd..12ad5b6fa207 100644 --- a/api/audit/constants.py +++ b/api/audit/constants.py @@ -67,8 +67,13 @@ RELEASE_PIPELINE_CREATED_MESSAGE = "Release Pipeline: %s created" +RELEASE_PIPELINE_CLONED_MESSAGE = "Release Pipeline: %s cloned" +RELEASE_PIPELINE_UPDATED_MESSAGE = "Release Pipeline: %s updated" RELEASE_PIPELINE_PUBLISHED_MESSAGE = "Release Pipeline: %s published" -# TODO: Add audit log for pipeline update +RELEASE_PIPELINE_UNPUBLISHED_MESSAGE = "Release Pipeline: %s Converted to Draft" RELEASE_PIPELINE_DELETED_MESSAGE = "Release Pipeline: %s deleted" RELEASE_PIPELINE_FEATURE_ADDED_MESSAGE = "Feature: %s added to Release Pipeline: %s" +RELEASE_PIPELINE_FEATURE_REMOVED_MESSAGE = ( + "Feature: %s removed from Release Pipeline: %s" +) FEATURE_STATE_UPDATED_BY_RELEASE_PIPELINE_MESSAGE = "Flag state / Remote config updated for feature: %s by Release pipeline: %s (stage: %s)" diff --git a/api/features/release_pipelines/core/models.py b/api/features/release_pipelines/core/models.py index 40db5b9b013c..35271ce52a52 100644 --- a/api/features/release_pipelines/core/models.py +++ b/api/features/release_pipelines/core/models.py @@ -1,4 +1,6 @@ import typing +import uuid +from copy import deepcopy from django.core.validators import MaxValueValidator from django.db import models @@ -7,9 +9,7 @@ from audit.constants import ( RELEASE_PIPELINE_CREATED_MESSAGE, RELEASE_PIPELINE_DELETED_MESSAGE, - RELEASE_PIPELINE_PUBLISHED_MESSAGE, ) -from audit.models import AuditLog from audit.related_object_type import RelatedObjectType from core.models import ( SoftDeleteExportableModel, @@ -17,6 +17,7 @@ ) from features.release_pipelines.core.constants import MAX_PIPELINE_STAGES from features.release_pipelines.core.exceptions import InvalidPipelineStateError +from features.versioning.models import EnvironmentFeatureVersion from projects.models import Project from users.models import FFAdminUser @@ -72,7 +73,24 @@ def publish(self, published_by: FFAdminUser) -> None: self.published_at = timezone.now() self.published_by = published_by self.save() - self._create_pipeline_published_audit_log() + + def unpublish(self, unpublished_by: FFAdminUser) -> None: + if self.published_at is None: + raise InvalidPipelineStateError("Pipeline is not published.") + self.published_at = None + self.published_by = None + self.save() + + def clone(self) -> "ReleasePipeline": + clone = deepcopy(self) + clone.id = None + clone.uuid = uuid.uuid4() + clone.published_at = None + clone.published_by = None + clone.save() + for stage in self.stages.all(): + stage.clone(target_pipeline=clone) + return clone def get_first_stage(self) -> "PipelineStage | None": return self.stages.order_by("order").first() @@ -90,18 +108,14 @@ def get_delete_log_message( ) -> typing.Optional[str]: return RELEASE_PIPELINE_DELETED_MESSAGE % self.name + def has_feature_in_flight(self) -> bool: + return EnvironmentFeatureVersion.objects.filter( # type: ignore[no-any-return] + published_at__isnull=True, pipeline_stage__in=self.stages.all() + ).exists() + def _get_project(self) -> Project: return self.project - def _create_pipeline_published_audit_log(self) -> None: - AuditLog.objects.create( - related_object_id=self.id, - related_object_type=RelatedObjectType.RELEASE_PIPELINE.name, - project=self._get_project(), - log=RELEASE_PIPELINE_PUBLISHED_MESSAGE % self.name, - author=self.published_by, - ) - class PipelineStage(models.Model): name = models.CharField(max_length=255) @@ -132,6 +146,16 @@ def get_next_stage(self) -> "PipelineStage | None": .first() ) + def clone(self, target_pipeline: ReleasePipeline) -> "PipelineStage": + clone = deepcopy(self) + clone.id = None + clone.pipeline = target_pipeline + clone.save() + self.trigger.clone(target_stage=clone) + for action in self.actions.all(): + action.clone(target_stage=clone) + return clone + class PipelineStageTrigger(models.Model): trigger_type = models.CharField( @@ -147,6 +171,13 @@ class PipelineStageTrigger(models.Model): on_delete=models.CASCADE, ) + def clone(self, target_stage: PipelineStage) -> "PipelineStageTrigger": + clone = deepcopy(self) + clone.id = None + clone.stage = target_stage + clone.save() + return clone + class PipelineStageAction(models.Model): action_type = models.CharField( @@ -160,3 +191,10 @@ class PipelineStageAction(models.Model): related_name="actions", on_delete=models.CASCADE, ) + + def clone(self, target_stage: PipelineStage) -> "PipelineStageAction": + clone = deepcopy(self) + clone.id = None + clone.stage = target_stage + clone.save() + return clone diff --git a/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py index ff4fdf6f0054..8a16b84518ce 100644 --- a/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py +++ b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py @@ -3,38 +3,21 @@ from audit.constants import ( RELEASE_PIPELINE_CREATED_MESSAGE, RELEASE_PIPELINE_DELETED_MESSAGE, - RELEASE_PIPELINE_PUBLISHED_MESSAGE, ) -from audit.models import AuditLog -from audit.related_object_type import RelatedObjectType from environments.models import Environment from features.release_pipelines.core.exceptions import InvalidPipelineStateError from features.release_pipelines.core.models import ( PipelineStage, + PipelineStageAction, + PipelineStageTrigger, ReleasePipeline, + StageActionType, + StageTriggerType, ) +from segments.models import Segment from users.models import FFAdminUser -def test_release_pipeline_publish_creates_audit_log( - release_pipeline: ReleasePipeline, admin_user: FFAdminUser -) -> None: - # When - release_pipeline.publish(admin_user) - - # Then - assert ( - AuditLog.objects.filter( - related_object_id=release_pipeline.id, - related_object_type=RelatedObjectType.RELEASE_PIPELINE.name, - project=release_pipeline.project, - log=RELEASE_PIPELINE_PUBLISHED_MESSAGE % release_pipeline.name, - author=admin_user, - ).exists() - is True - ) - - def test_release_pipeline_publish_raises_error_if_pipeline_is_already_published( release_pipeline: ReleasePipeline, admin_user: FFAdminUser ) -> None: @@ -153,3 +136,108 @@ def test_release_pipeline_get_delete_log_message( # Then assert release_pipeline.get_delete_log_message(release_pipeline) == expected_message + + +def test_clone_release_pipeline( + release_pipeline: ReleasePipeline, + environment: Environment, + admin_user: FFAdminUser, + segment: Segment, +) -> None: + # Given - A release pipeline that is published + release_pipeline.publish(admin_user) + + # with two stages, each with a wait trigger and two actions + for i in range(2): + pipeline_stage = PipelineStage.objects.create( + name=f"Stage {i}", + pipeline=release_pipeline, + environment=environment, + order=i, + ) + PipelineStageTrigger.objects.create( + trigger_type=StageTriggerType.WAIT_FOR.value, + stage=pipeline_stage, + trigger_body={"wait_for": "00:00:01"}, + ) + PipelineStageAction.objects.create( + action_type=StageActionType.UPDATE_FEATURE_VALUE.value, + action_body={"string_value": "stage_one_value", "type": "unicode"}, + stage=pipeline_stage, + ) + PipelineStageAction.objects.create( + action_type=StageActionType.UPDATE_FEATURE_VALUE_FOR_SEGMENT.value, + action_body={ + "string_value": "stage_one_segment_override", + "type": "unicode", + "segment_id": segment.id, + }, + stage=pipeline_stage, + ) + # When + cloned_pipeline = release_pipeline.clone() + + # Then + # make sure the old pipeline is not modified + release_pipeline.refresh_from_db() + assert release_pipeline.published_at is not None + assert release_pipeline.published_by is not None + assert release_pipeline.stages.count() == 2 + + # Assertions for the cloned pipeline + assert cloned_pipeline.name == release_pipeline.name + assert cloned_pipeline.project == release_pipeline.project + assert cloned_pipeline.stages.count() == 2 + assert cloned_pipeline.published_at is None + assert cloned_pipeline.published_by is None + assert cloned_pipeline.id != release_pipeline.id + assert cloned_pipeline.uuid != release_pipeline.uuid + + # Assertions for stages in the cloned pipeline + assert cloned_pipeline.stages.count() == release_pipeline.stages.count() + source_stages = list(release_pipeline.stages.all().order_by("order")) + cloned_stages = list(cloned_pipeline.stages.all().order_by("order")) + + for i, source_stage in enumerate(source_stages): + cloned_stage = cloned_stages[i] + + assert cloned_stage.id != source_stage.id # Ensure it's a new object + assert cloned_stage.name == source_stage.name + assert cloned_stage.order == source_stage.order + assert cloned_stage.environment == source_stage.environment + assert ( + cloned_stage.pipeline == cloned_pipeline + ) # Ensure it points to the new pipeline + + # source stage still points to the original pipeline + assert source_stage.pipeline == release_pipeline + + # Assertions for trigger in the cloned stage + source_trigger = source_stage.trigger + cloned_trigger = cloned_stage.trigger + + assert cloned_trigger.id != source_trigger.id # Ensure it's a new object + assert cloned_trigger.trigger_type == source_trigger.trigger_type + assert cloned_trigger.trigger_body == source_trigger.trigger_body + assert cloned_trigger.stage == cloned_stage # Ensure it points to the new stage + + # source trigger still points to the original stage + assert source_trigger.stage == source_stage + + # Assertions for actions in the cloned stage + source_actions = list(source_stage.actions.all()) + cloned_actions = list(cloned_stage.actions.all()) + + assert len(cloned_actions) == len(source_actions) + + for j, source_action in enumerate(source_actions): + cloned_action = cloned_actions[j] + assert cloned_action.id != source_action.id # Ensure it's a new object + assert cloned_action.action_type == source_action.action_type + assert cloned_action.action_body == source_action.action_body + assert ( + cloned_action.stage == cloned_stage + ) # Ensure it points to the new stage + + # source action still points to the original stage + assert source_action.stage == source_stage From 25895055c1a4e1467b43a4ec4172006628054b51 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 21 Jul 2025 16:15:03 +0530 Subject: [PATCH 2/6] bump release pipelines logic --- api/poetry.lock | 12 ++++++------ api/pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/poetry.lock b/api/poetry.lock index 9d5b8d97bc01..c97ced12a0ca 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -2004,13 +2004,13 @@ files = [] develop = false [package.dependencies] -release-pipelines-logic = {git = "https://github.com/Flagsmith/flagsmith-private/", rev = "feat/release-pipeline-logic-v1", subdirectory = "flagsmith-release-pipelines-logic"} +release-pipelines-logic = {git = "https://github.com/Flagsmith/flagsmith-private/", rev = "feat/clone-pipeline", subdirectory = "flagsmith-release-pipelines-logic"} [package.source] type = "git" url = "https://github.com/Flagsmith/flagsmith-private/" -reference = "v0.1.0" -resolved_reference = "b75b2dea7a9b0e67fe78800305e73130cbb73509" +reference = "v0.2.0" +resolved_reference = "cf7e9d51896c7fd7f2f4cc9cb2d1d69c8c757a66" [[package]] name = "flagsmith-split-testing" @@ -4309,8 +4309,8 @@ develop = false [package.source] type = "git" url = "https://github.com/Flagsmith/flagsmith-private/" -reference = "feat/release-pipeline-logic-v1" -resolved_reference = "df9a4b160204fd0eeab69bf678d74b5de941d5ac" +reference = "feat/clone-pipeline" +resolved_reference = "76ab2032ec61b40fff87304fec9f037054ff54e6" subdirectory = "flagsmith-release-pipelines-logic" [[package]] @@ -5405,4 +5405,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">3.11,<3.13" -content-hash = "a77fbee7bc35ae5c1732a54ae358a949c10ba017523dcdbc5e999b62d2888236" +content-hash = "9ee6f9ba868ad1a536ea0a17730cff59f0eb4a0f19dc47e11ba8ad2c83b9d0c8" diff --git a/api/pyproject.toml b/api/pyproject.toml index e75c0338c08d..1a928711b5e5 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -207,7 +207,7 @@ flagsmith-split-testing = { git = "https://github.com/flagsmith/flagsmith-split- optional = true [tool.poetry.group.release-pipelines.dependencies] -flagsmith-private = { git = "https://github.com/Flagsmith/flagsmith-private/", rev = "v0.1.0" } +flagsmith-private = { git = "https://github.com/Flagsmith/flagsmith-private/", rev = "v0.2.0" } [tool.poetry.group.dev.dependencies] From 4270a2078a16a20cfde2b2fedfd4f98a06bf143e Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 21 Jul 2025 16:33:25 +0530 Subject: [PATCH 3/6] Add tests for uncovered methods --- .../release_pipeline/core/conftest.py | 37 +++++++++++++ .../core/test_unit_release_pipeline_models.py | 52 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/api/tests/unit/features/release_pipeline/core/conftest.py b/api/tests/unit/features/release_pipeline/core/conftest.py index 8088e1750beb..413d699f5a23 100644 --- a/api/tests/unit/features/release_pipeline/core/conftest.py +++ b/api/tests/unit/features/release_pipeline/core/conftest.py @@ -1,7 +1,13 @@ import pytest +from environments.models import Environment from features.release_pipelines.core.models import ( + PipelineStage, + PipelineStageAction, + PipelineStageTrigger, ReleasePipeline, + StageActionType, + StageTriggerType, ) from projects.models import Project @@ -13,3 +19,34 @@ def release_pipeline(project: Project) -> ReleasePipeline: project=project, ) return release_pipeline # type: ignore[no-any-return] + + +@pytest.fixture() +def pipeline_stage_enable_feature_on_enter( + release_pipeline: ReleasePipeline, + environment: Environment, + environment_v2_versioning: Environment, +) -> PipelineStage: + # Given + pipeline_stage = PipelineStage.objects.create( + name="Stage zero", + pipeline=release_pipeline, + order=0, + environment=environment, + ) + ( + PipelineStageTrigger.objects.create( + trigger_type=StageTriggerType.ON_ENTER.value, stage=pipeline_stage + ), + ) + PipelineStageAction.objects.create( + action_type=StageActionType.TOGGLE_FEATURE.value, + action_body={"enabled": True}, + stage=pipeline_stage, + ) + PipelineStageAction.objects.create( + action_type=StageActionType.UPDATE_FEATURE_VALUE.value, + action_body={"string_value": "stage_zero_value", "type": "unicode"}, + stage=pipeline_stage, + ) + return pipeline_stage diff --git a/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py index 8a16b84518ce..b488f5538dad 100644 --- a/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py +++ b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py @@ -1,10 +1,12 @@ import pytest +from django.utils import timezone from audit.constants import ( RELEASE_PIPELINE_CREATED_MESSAGE, RELEASE_PIPELINE_DELETED_MESSAGE, ) from environments.models import Environment +from features.models import EnvironmentFeatureVersion, Feature from features.release_pipelines.core.exceptions import InvalidPipelineStateError from features.release_pipelines.core.models import ( PipelineStage, @@ -138,6 +140,28 @@ def test_release_pipeline_get_delete_log_message( assert release_pipeline.get_delete_log_message(release_pipeline) == expected_message +def test_release_pipeline_unpublish( + release_pipeline: ReleasePipeline, admin_user: FFAdminUser +) -> None: + # Given - the pipeline is already published + release_pipeline.publish(admin_user) + + # When + release_pipeline.unpublish(admin_user) + + # Then + assert release_pipeline.published_at is None + assert release_pipeline.published_by is None + + +def test_should_raise_error_when_unpublishing_unpublished_pipeline( + release_pipeline: ReleasePipeline, admin_user: FFAdminUser +) -> None: + # When/ Then + with pytest.raises(InvalidPipelineStateError, match="Pipeline is not published."): + release_pipeline.unpublish(admin_user) + + def test_clone_release_pipeline( release_pipeline: ReleasePipeline, environment: Environment, @@ -241,3 +265,31 @@ def test_clone_release_pipeline( # source action still points to the original stage assert source_action.stage == source_stage + + +def test_release_pipeline_has_feature_in_flight( + release_pipeline: ReleasePipeline, + environment: Environment, + pipeline_stage_enable_feature_on_enter: PipelineStage, + admin_user: FFAdminUser, + feature: Feature, +) -> None: + # Given an unpublished environment feature version + feature = release_pipeline.project.features.first() + environment_version = EnvironmentFeatureVersion.objects.create( + feature=feature, + environment=environment, + pipeline_stage=pipeline_stage_enable_feature_on_enter, + published_at=None, + ) + + # Then + assert release_pipeline.has_feature_in_flight() is True + + # Next, publish the environment feature version + environment_version.published_at = timezone.now() + environment_version.published_by = admin_user + environment_version.save() + + # Then + assert release_pipeline.has_feature_in_flight() is False From c570702e892442b2f12c6d81827bc7d2e0b475d5 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 21 Jul 2025 16:50:40 +0530 Subject: [PATCH 4/6] fix typing errors --- .../core/test_unit_release_pipeline_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py index b488f5538dad..0b1791a74dcd 100644 --- a/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py +++ b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py @@ -6,7 +6,7 @@ RELEASE_PIPELINE_DELETED_MESSAGE, ) from environments.models import Environment -from features.models import EnvironmentFeatureVersion, Feature +from features.models import Feature from features.release_pipelines.core.exceptions import InvalidPipelineStateError from features.release_pipelines.core.models import ( PipelineStage, @@ -16,6 +16,7 @@ StageActionType, StageTriggerType, ) +from features.versioning.models import EnvironmentFeatureVersion from segments.models import Segment from users.models import FFAdminUser @@ -275,7 +276,6 @@ def test_release_pipeline_has_feature_in_flight( feature: Feature, ) -> None: # Given an unpublished environment feature version - feature = release_pipeline.project.features.first() environment_version = EnvironmentFeatureVersion.objects.create( feature=feature, environment=environment, From 44ba3645d2e318be9635304d87cac963b5084c3d Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 22 Jul 2025 09:38:07 +0530 Subject: [PATCH 5/6] remove clone --- api/features/release_pipelines/core/models.py | 40 +------ .../core/test_unit_release_pipeline_models.py | 110 ------------------ 2 files changed, 2 insertions(+), 148 deletions(-) diff --git a/api/features/release_pipelines/core/models.py b/api/features/release_pipelines/core/models.py index 35271ce52a52..69b11a86c32d 100644 --- a/api/features/release_pipelines/core/models.py +++ b/api/features/release_pipelines/core/models.py @@ -1,6 +1,4 @@ import typing -import uuid -from copy import deepcopy from django.core.validators import MaxValueValidator from django.db import models @@ -81,17 +79,6 @@ def unpublish(self, unpublished_by: FFAdminUser) -> None: self.published_by = None self.save() - def clone(self) -> "ReleasePipeline": - clone = deepcopy(self) - clone.id = None - clone.uuid = uuid.uuid4() - clone.published_at = None - clone.published_by = None - clone.save() - for stage in self.stages.all(): - stage.clone(target_pipeline=clone) - return clone - def get_first_stage(self) -> "PipelineStage | None": return self.stages.order_by("order").first() @@ -109,9 +96,10 @@ def get_delete_log_message( return RELEASE_PIPELINE_DELETED_MESSAGE % self.name def has_feature_in_flight(self) -> bool: - return EnvironmentFeatureVersion.objects.filter( # type: ignore[no-any-return] + has_feature_in_flight: bool = EnvironmentFeatureVersion.objects.filter( published_at__isnull=True, pipeline_stage__in=self.stages.all() ).exists() + return has_feature_in_flight def _get_project(self) -> Project: return self.project @@ -146,16 +134,6 @@ def get_next_stage(self) -> "PipelineStage | None": .first() ) - def clone(self, target_pipeline: ReleasePipeline) -> "PipelineStage": - clone = deepcopy(self) - clone.id = None - clone.pipeline = target_pipeline - clone.save() - self.trigger.clone(target_stage=clone) - for action in self.actions.all(): - action.clone(target_stage=clone) - return clone - class PipelineStageTrigger(models.Model): trigger_type = models.CharField( @@ -171,13 +149,6 @@ class PipelineStageTrigger(models.Model): on_delete=models.CASCADE, ) - def clone(self, target_stage: PipelineStage) -> "PipelineStageTrigger": - clone = deepcopy(self) - clone.id = None - clone.stage = target_stage - clone.save() - return clone - class PipelineStageAction(models.Model): action_type = models.CharField( @@ -191,10 +162,3 @@ class PipelineStageAction(models.Model): related_name="actions", on_delete=models.CASCADE, ) - - def clone(self, target_stage: PipelineStage) -> "PipelineStageAction": - clone = deepcopy(self) - clone.id = None - clone.stage = target_stage - clone.save() - return clone diff --git a/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py index 0b1791a74dcd..39b78a15f9a1 100644 --- a/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py +++ b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py @@ -10,14 +10,9 @@ from features.release_pipelines.core.exceptions import InvalidPipelineStateError from features.release_pipelines.core.models import ( PipelineStage, - PipelineStageAction, - PipelineStageTrigger, ReleasePipeline, - StageActionType, - StageTriggerType, ) from features.versioning.models import EnvironmentFeatureVersion -from segments.models import Segment from users.models import FFAdminUser @@ -163,111 +158,6 @@ def test_should_raise_error_when_unpublishing_unpublished_pipeline( release_pipeline.unpublish(admin_user) -def test_clone_release_pipeline( - release_pipeline: ReleasePipeline, - environment: Environment, - admin_user: FFAdminUser, - segment: Segment, -) -> None: - # Given - A release pipeline that is published - release_pipeline.publish(admin_user) - - # with two stages, each with a wait trigger and two actions - for i in range(2): - pipeline_stage = PipelineStage.objects.create( - name=f"Stage {i}", - pipeline=release_pipeline, - environment=environment, - order=i, - ) - PipelineStageTrigger.objects.create( - trigger_type=StageTriggerType.WAIT_FOR.value, - stage=pipeline_stage, - trigger_body={"wait_for": "00:00:01"}, - ) - PipelineStageAction.objects.create( - action_type=StageActionType.UPDATE_FEATURE_VALUE.value, - action_body={"string_value": "stage_one_value", "type": "unicode"}, - stage=pipeline_stage, - ) - PipelineStageAction.objects.create( - action_type=StageActionType.UPDATE_FEATURE_VALUE_FOR_SEGMENT.value, - action_body={ - "string_value": "stage_one_segment_override", - "type": "unicode", - "segment_id": segment.id, - }, - stage=pipeline_stage, - ) - # When - cloned_pipeline = release_pipeline.clone() - - # Then - # make sure the old pipeline is not modified - release_pipeline.refresh_from_db() - assert release_pipeline.published_at is not None - assert release_pipeline.published_by is not None - assert release_pipeline.stages.count() == 2 - - # Assertions for the cloned pipeline - assert cloned_pipeline.name == release_pipeline.name - assert cloned_pipeline.project == release_pipeline.project - assert cloned_pipeline.stages.count() == 2 - assert cloned_pipeline.published_at is None - assert cloned_pipeline.published_by is None - assert cloned_pipeline.id != release_pipeline.id - assert cloned_pipeline.uuid != release_pipeline.uuid - - # Assertions for stages in the cloned pipeline - assert cloned_pipeline.stages.count() == release_pipeline.stages.count() - source_stages = list(release_pipeline.stages.all().order_by("order")) - cloned_stages = list(cloned_pipeline.stages.all().order_by("order")) - - for i, source_stage in enumerate(source_stages): - cloned_stage = cloned_stages[i] - - assert cloned_stage.id != source_stage.id # Ensure it's a new object - assert cloned_stage.name == source_stage.name - assert cloned_stage.order == source_stage.order - assert cloned_stage.environment == source_stage.environment - assert ( - cloned_stage.pipeline == cloned_pipeline - ) # Ensure it points to the new pipeline - - # source stage still points to the original pipeline - assert source_stage.pipeline == release_pipeline - - # Assertions for trigger in the cloned stage - source_trigger = source_stage.trigger - cloned_trigger = cloned_stage.trigger - - assert cloned_trigger.id != source_trigger.id # Ensure it's a new object - assert cloned_trigger.trigger_type == source_trigger.trigger_type - assert cloned_trigger.trigger_body == source_trigger.trigger_body - assert cloned_trigger.stage == cloned_stage # Ensure it points to the new stage - - # source trigger still points to the original stage - assert source_trigger.stage == source_stage - - # Assertions for actions in the cloned stage - source_actions = list(source_stage.actions.all()) - cloned_actions = list(cloned_stage.actions.all()) - - assert len(cloned_actions) == len(source_actions) - - for j, source_action in enumerate(source_actions): - cloned_action = cloned_actions[j] - assert cloned_action.id != source_action.id # Ensure it's a new object - assert cloned_action.action_type == source_action.action_type - assert cloned_action.action_body == source_action.action_body - assert ( - cloned_action.stage == cloned_stage - ) # Ensure it points to the new stage - - # source action still points to the original stage - assert source_action.stage == source_stage - - def test_release_pipeline_has_feature_in_flight( release_pipeline: ReleasePipeline, environment: Environment, From f8bad46547f1c77c55e839f7cf7b6025bb1c32ba Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 22 Jul 2025 14:43:43 +0530 Subject: [PATCH 6/6] version bump --- api/poetry.lock | 12 ++++++------ api/pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/poetry.lock b/api/poetry.lock index c97ced12a0ca..d067de4178b9 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -2004,13 +2004,13 @@ files = [] develop = false [package.dependencies] -release-pipelines-logic = {git = "https://github.com/Flagsmith/flagsmith-private/", rev = "feat/clone-pipeline", subdirectory = "flagsmith-release-pipelines-logic"} +release-pipelines-logic = {git = "https://github.com/Flagsmith/flagsmith-private/", rev = "fix/move-clone-to-service-layer", subdirectory = "flagsmith-release-pipelines-logic"} [package.source] type = "git" url = "https://github.com/Flagsmith/flagsmith-private/" -reference = "v0.2.0" -resolved_reference = "cf7e9d51896c7fd7f2f4cc9cb2d1d69c8c757a66" +reference = "v0.2.1" +resolved_reference = "6faa6b784954dc15e65f7dc80097824b23bdfecf" [[package]] name = "flagsmith-split-testing" @@ -4309,8 +4309,8 @@ develop = false [package.source] type = "git" url = "https://github.com/Flagsmith/flagsmith-private/" -reference = "feat/clone-pipeline" -resolved_reference = "76ab2032ec61b40fff87304fec9f037054ff54e6" +reference = "fix/move-clone-to-service-layer" +resolved_reference = "186bea4cfba82208e827ea5a66ad4df2cd094b9a" subdirectory = "flagsmith-release-pipelines-logic" [[package]] @@ -5405,4 +5405,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">3.11,<3.13" -content-hash = "9ee6f9ba868ad1a536ea0a17730cff59f0eb4a0f19dc47e11ba8ad2c83b9d0c8" +content-hash = "6316753c1b1b6ef97aa160d885eb1c6e8b78eca9570e69c7e67298bf95151aa5" diff --git a/api/pyproject.toml b/api/pyproject.toml index 1a928711b5e5..27e93548eecf 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -207,7 +207,7 @@ flagsmith-split-testing = { git = "https://github.com/flagsmith/flagsmith-split- optional = true [tool.poetry.group.release-pipelines.dependencies] -flagsmith-private = { git = "https://github.com/Flagsmith/flagsmith-private/", rev = "v0.2.0" } +flagsmith-private = { git = "https://github.com/Flagsmith/flagsmith-private/", rev = "v0.2.1" } [tool.poetry.group.dev.dependencies]