From 86782a5755590bf19bdd96f88847c83c919c7a38 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 21 Apr 2026 14:32:42 +0200 Subject: [PATCH 01/21] feat: add tagging enabled configuration field --- .../0002_gitlabconfiguration_tagging_enabled.py | 16 ++++++++++++++++ api/integrations/gitlab/serializers.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 api/integrations/gitlab/migrations/0002_gitlabconfiguration_tagging_enabled.py diff --git a/api/integrations/gitlab/migrations/0002_gitlabconfiguration_tagging_enabled.py b/api/integrations/gitlab/migrations/0002_gitlabconfiguration_tagging_enabled.py new file mode 100644 index 000000000000..37a7cdc6d031 --- /dev/null +++ b/api/integrations/gitlab/migrations/0002_gitlabconfiguration_tagging_enabled.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("gitlab", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="gitlabconfiguration", + name="tagging_enabled", + field=models.BooleanField(default=False), + ), + ] diff --git a/api/integrations/gitlab/serializers.py b/api/integrations/gitlab/serializers.py index f6e3ba0f7f64..18c9997cfda7 100644 --- a/api/integrations/gitlab/serializers.py +++ b/api/integrations/gitlab/serializers.py @@ -11,7 +11,7 @@ class GitLabConfigurationSerializer(BaseProjectIntegrationModelSerializer): class Meta: model = GitLabConfiguration - fields = ("id", "gitlab_instance_url", "access_token") + fields = ("id", "gitlab_instance_url", "access_token", "tagging_enabled") def to_representation(self, instance: GitLabConfiguration) -> dict[str, Any]: data = super().to_representation(instance) From da7d355b04950057b4e4cf04b89080d619e2d1ec Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 21 Apr 2026 14:37:49 +0200 Subject: [PATCH 02/21] feat: label linked issues and mrs --- api/integrations/gitlab/services.py | 98 +++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 api/integrations/gitlab/services.py diff --git a/api/integrations/gitlab/services.py b/api/integrations/gitlab/services.py new file mode 100644 index 000000000000..2a08dd031996 --- /dev/null +++ b/api/integrations/gitlab/services.py @@ -0,0 +1,98 @@ +import re + +import requests +import structlog +from rest_framework.exceptions import ValidationError + +from features.feature_external_resources.models import ( + FeatureExternalResource, + ResourceType, +) +from integrations.gitlab.client import ( + add_flagsmith_label_to_gitlab_issue, + add_flagsmith_label_to_gitlab_merge_request, + create_flagsmith_label, + url_encode_gitlab_project_path, +) +from integrations.gitlab.models import GitLabConfiguration + +logger = structlog.get_logger("gitlab") + +_GITLAB_RESOURCE_URL_PATTERN = re.compile( + r"^https?://[^/]+/(?P.+?)/-/" + r"(?:issues|work_items|merge_requests)/(?P\d+)/?$" +) + + +def apply_flagsmith_label_to_resource( + resource: FeatureExternalResource, +) -> None: + """ + Ensure the "Flagsmith Flag" label exists on the GitLab project the + resource belongs to, and apply it to the resource itself. + + No-op when the project has no ``GitLabConfiguration`` or tagging is + disabled. Raises ``rest_framework.exceptions.ValidationError`` when the + GitLab API rejects the call — callers running inside + ``transaction.atomic`` will see the persisted resource rolled back. + """ + project = resource.feature.project + config: GitLabConfiguration | None = GitLabConfiguration.objects.filter( + project=project + ).first() + if not config or not config.tagging_enabled: + return + + path_with_namespace, resource_iid = _parse_gitlab_resource_url(resource.url) + gitlab_project = url_encode_gitlab_project_path(path_with_namespace) + + log = logger.bind( + organisation__id=project.organisation_id, + project__id=project.id, + feature__id=resource.feature_id, + gitlab_project__path=path_with_namespace, + resource__type=resource.type, + resource__iid=resource_iid, + ) + + try: + created = create_flagsmith_label( + config.gitlab_instance_url, + config.access_token, + gitlab_project=gitlab_project, + ) + if created: + log.info("label.created") + + if resource.type == ResourceType.GITLAB_ISSUE: + add_flagsmith_label_to_gitlab_issue( + config.gitlab_instance_url, + config.access_token, + gitlab_project=gitlab_project, + issue_iid=resource_iid, + ) + else: + add_flagsmith_label_to_gitlab_merge_request( + config.gitlab_instance_url, + config.access_token, + gitlab_project=gitlab_project, + merge_request_iid=resource_iid, + ) + log.info("label.applied") + except requests.RequestException as exc: + log.exception("label.failed") + raise ValidationError( + { + "detail": ( + "Failed to apply the Flagsmith Flag label on GitLab. " + "Check the GitLab access token's permissions and try again." + ), + }, + ) from exc + + +def _parse_gitlab_resource_url(url: str) -> tuple[str, int]: + match = _GITLAB_RESOURCE_URL_PATTERN.match(url) + if not match: + raise ValidationError({"url": "Could not parse GitLab resource URL."}) + return match["path"], int(match["iid"]) From e0abda3faaca3dbdd1dd429fbd956058cd5c2fcc Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 21 Apr 2026 14:38:12 +0200 Subject: [PATCH 03/21] feat: added tagging toggle flag --- frontend/common/stores/default-flags.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/common/stores/default-flags.ts b/frontend/common/stores/default-flags.ts index cb8cb6f03fe0..74eee31e3d29 100644 --- a/frontend/common/stores/default-flags.ts +++ b/frontend/common/stores/default-flags.ts @@ -102,6 +102,12 @@ const defaultFlags = { 'key': 'access_token', 'label': 'Access Token', }, + { + 'default': false, + 'inputType': 'checkbox', + 'key': 'tagging_enabled', + 'label': 'Add "Flagsmith Flag" label to linked issues and MRs', + }, ], 'image': '/static/images/integrations/gitlab.svg', 'perEnvironment': false, From e2b8e47c880a5f675cde62f86f324cc94b4c7e30 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 21 Apr 2026 18:06:40 +0200 Subject: [PATCH 04/21] fix: emit gitlab link only on success labelling --- api/integrations/gitlab/services.py | 18 ++--- .../web/components/ExternalResourcesTable.tsx | 11 +-- .../modals/CreateEditIntegrationModal.tsx | 75 ++++++++++++------- 3 files changed, 62 insertions(+), 42 deletions(-) diff --git a/api/integrations/gitlab/services.py b/api/integrations/gitlab/services.py index 2a08dd031996..57f959c67097 100644 --- a/api/integrations/gitlab/services.py +++ b/api/integrations/gitlab/services.py @@ -1,4 +1,5 @@ import re +from urllib.parse import urlsplit import requests import structlog @@ -18,9 +19,8 @@ logger = structlog.get_logger("gitlab") -_GITLAB_RESOURCE_URL_PATTERN = re.compile( - r"^https?://[^/]+/(?P.+?)/-/" - r"(?:issues|work_items|merge_requests)/(?P\d+)/?$" +_GITLAB_RESOURCE_PATH_PATTERN = re.compile( + r"^/(?P.+?)/-/(?:issues|work_items|merge_requests)/(?P\d+)/?$" ) @@ -28,13 +28,9 @@ def apply_flagsmith_label_to_resource( resource: FeatureExternalResource, ) -> None: """ - Ensure the "Flagsmith Flag" label exists on the GitLab project the - resource belongs to, and apply it to the resource itself. - - No-op when the project has no ``GitLabConfiguration`` or tagging is - disabled. Raises ``rest_framework.exceptions.ValidationError`` when the - GitLab API rejects the call — callers running inside - ``transaction.atomic`` will see the persisted resource rolled back. + Ensure the "Flagsmith Flag" label exists on the resource's GitLab project + and apply it to the resource. No-op if tagging is disabled or unconfigured; + raises ``ValidationError`` on parse/API failure (rolls back under atomic). """ project = resource.feature.project config: GitLabConfiguration | None = GitLabConfiguration.objects.filter( @@ -92,7 +88,7 @@ def apply_flagsmith_label_to_resource( def _parse_gitlab_resource_url(url: str) -> tuple[str, int]: - match = _GITLAB_RESOURCE_URL_PATTERN.match(url) + match = _GITLAB_RESOURCE_PATH_PATTERN.match(urlsplit(url).path) if not match: raise ValidationError({"url": "Could not parse GitLab resource URL."}) return match["path"], int(match["iid"]) diff --git a/frontend/web/components/ExternalResourcesTable.tsx b/frontend/web/components/ExternalResourcesTable.tsx index 3d3b16ecea68..ab6729c2c2fc 100644 --- a/frontend/web/components/ExternalResourcesTable.tsx +++ b/frontend/web/components/ExternalResourcesTable.tsx @@ -61,10 +61,11 @@ const ExternalResourceRow: FC = ({ - {`${ - externalResource?.metadata?.title - } (#${externalResource?.url.replace(/\D/g, '')})`}{' '} -
+ {externalResource?.metadata?.title} + + {`(#${externalResource?.url.replace(/\D/g, '')})`} + +
@@ -77,7 +78,7 @@ const ExternalResourceRow: FC = ({
-
+
{externalResource?.metadata?.state}
diff --git a/frontend/web/components/modals/CreateEditIntegrationModal.tsx b/frontend/web/components/modals/CreateEditIntegrationModal.tsx index 5fa4ac7e0f75..bf3d2579cbfa 100644 --- a/frontend/web/components/modals/CreateEditIntegrationModal.tsx +++ b/frontend/web/components/modals/CreateEditIntegrationModal.tsx @@ -105,6 +105,7 @@ const CreateEditIntegration: FC = (props) => { }, ) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const update = (key: string, e: any) => { @@ -261,24 +262,38 @@ const CreateEditIntegration: FC = (props) => { />
)} - {fields.map((field) => ( -
-
- -
- - {readOnly ? ( + {fields.map((field) => { + if (field.inputType === 'checkbox' && !readOnly) { + return ( +
+ update(field.key, e)} + type='checkbox' + /> +
+ ) + } + let fieldControl: React.ReactNode + if (readOnly) { + fieldControl = (
{field.hidden ? formData[field.key].replace(/./g, '*') : formData[field.key]}
- ) : field.options ? ( + ) + } else if (field.options) { + const selectedOption = field.options.find( + (v: IntegrationFieldOption) => v.value === formData[field.key], + ) + fieldControl = (
= (props) => { type={field.hidden ? 'password' : field.inputType || 'text'} className='full-width mb-2' /> - )} -
- ))} + ) + } + return ( +
+
+ +
+ {fieldControl} +
+ ) + })} {authorised && id === 'slack' && (
Can't see your channel? Enter your channel ID here (C0xxxxxx) From 8a89598be5392390ab4e3c57807f9c9e6d096ad5 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Apr 2026 08:58:15 +0200 Subject: [PATCH 05/21] feat: remove gitlab label when unlinking last feature --- .../feature_external_resources/models.py | 14 +++++++++++++ api/integrations/gitlab/client/__init__.py | 4 ++++ api/integrations/gitlab/services.py | 20 +++++++++++-------- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/api/features/feature_external_resources/models.py b/api/features/feature_external_resources/models.py index 47abf2f49e2d..3536c8933d61 100644 --- a/api/features/feature_external_resources/models.py +++ b/api/features/feature_external_resources/models.py @@ -16,6 +16,7 @@ from integrations.github.constants import GitHubEventType, GitHubTag from integrations.github.github import call_github_task from integrations.github.models import GitHubRepository +from integrations.gitlab.tasks import remove_flagsmith_label_from_gitlab_resource from organisations.models import Organisation from projects.tags.models import Tag, TagType @@ -141,6 +142,19 @@ def notify_github_on_link(self): # type: ignore[no-untyped-def] feature_states=feature_states, ) + @hook(BEFORE_DELETE, when="type", is_now="GITLAB_ISSUE") # type: ignore[misc] + @hook(BEFORE_DELETE, when="type", is_now="GITLAB_MR") # type: ignore[misc] + def notify_gitlab_on_unlink(self) -> None: + remove_flagsmith_label_from_gitlab_resource.delay( + kwargs={ + "project_id": self.feature.project_id, + "feature_id": self.feature_id, + "resource_pk": self.pk, + "resource_url": self.url, + "resource_type": self.type, + }, + ) + @hook(BEFORE_DELETE, when="type", is_now="GITHUB_ISSUE") # type: ignore[misc] @hook(BEFORE_DELETE, when="type", is_now="GITHUB_PR") # type: ignore[misc] def notify_github_on_unlink(self) -> None: diff --git a/api/integrations/gitlab/client/__init__.py b/api/integrations/gitlab/client/__init__.py index d08d2fb464b8..5df293547069 100644 --- a/api/integrations/gitlab/client/__init__.py +++ b/api/integrations/gitlab/client/__init__.py @@ -4,6 +4,8 @@ create_project_hook, delete_project_hook, fetch_gitlab_projects, + remove_flagsmith_label_from_gitlab_issue, + remove_flagsmith_label_from_gitlab_merge_request, search_gitlab_issues, search_gitlab_merge_requests, ) @@ -26,6 +28,8 @@ "create_project_hook", "delete_project_hook", "fetch_gitlab_projects", + "remove_flagsmith_label_from_gitlab_issue", + "remove_flagsmith_label_from_gitlab_merge_request", "search_gitlab_issues", "search_gitlab_merge_requests", ] diff --git a/api/integrations/gitlab/services.py b/api/integrations/gitlab/services.py index 57f959c67097..63ef7cb96f85 100644 --- a/api/integrations/gitlab/services.py +++ b/api/integrations/gitlab/services.py @@ -1,14 +1,13 @@ +from __future__ import annotations + import re +from typing import TYPE_CHECKING from urllib.parse import urlsplit import requests import structlog from rest_framework.exceptions import ValidationError -from features.feature_external_resources.models import ( - FeatureExternalResource, - ResourceType, -) from integrations.gitlab.client import ( add_flagsmith_label_to_gitlab_issue, add_flagsmith_label_to_gitlab_merge_request, @@ -17,9 +16,12 @@ ) from integrations.gitlab.models import GitLabConfiguration +if TYPE_CHECKING: + from features.feature_external_resources.models import FeatureExternalResource + logger = structlog.get_logger("gitlab") -_GITLAB_RESOURCE_PATH_PATTERN = re.compile( +GITLAB_RESOURCE_PATH_PATTERN = re.compile( r"^/(?P.+?)/-/(?:issues|work_items|merge_requests)/(?P\d+)/?$" ) @@ -32,6 +34,8 @@ def apply_flagsmith_label_to_resource( and apply it to the resource. No-op if tagging is disabled or unconfigured; raises ``ValidationError`` on parse/API failure (rolls back under atomic). """ + from features.feature_external_resources.models import ResourceType + project = resource.feature.project config: GitLabConfiguration | None = GitLabConfiguration.objects.filter( project=project @@ -39,7 +43,7 @@ def apply_flagsmith_label_to_resource( if not config or not config.tagging_enabled: return - path_with_namespace, resource_iid = _parse_gitlab_resource_url(resource.url) + path_with_namespace, resource_iid = parse_gitlab_resource_url(resource.url) gitlab_project = url_encode_gitlab_project_path(path_with_namespace) log = logger.bind( @@ -87,8 +91,8 @@ def apply_flagsmith_label_to_resource( ) from exc -def _parse_gitlab_resource_url(url: str) -> tuple[str, int]: - match = _GITLAB_RESOURCE_PATH_PATTERN.match(urlsplit(url).path) +def parse_gitlab_resource_url(url: str) -> tuple[str, int]: + match = GITLAB_RESOURCE_PATH_PATTERN.match(urlsplit(url).path) if not match: raise ValidationError({"url": "Could not parse GitLab resource URL."}) return match["path"], int(match["iid"]) From 86d1ba589113803d806f00f739a2980db6c9564d Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Apr 2026 09:02:55 +0200 Subject: [PATCH 06/21] feat: unlinking wording --- frontend/web/components/ExternalResourcesTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/web/components/ExternalResourcesTable.tsx b/frontend/web/components/ExternalResourcesTable.tsx index ab6729c2c2fc..f67027612c46 100644 --- a/frontend/web/components/ExternalResourcesTable.tsx +++ b/frontend/web/components/ExternalResourcesTable.tsx @@ -116,7 +116,7 @@ const ExternalResourceRow: FC = ({ }) }} > - Delete + Unlink
, From e91fa0205eada9f9e6fd5319ffca8b073b6552b5 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Apr 2026 14:54:40 +0200 Subject: [PATCH 07/21] feat: reviewed tests --- api/tests/unit/integrations/gitlab/test_configuration.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/tests/unit/integrations/gitlab/test_configuration.py b/api/tests/unit/integrations/gitlab/test_configuration.py index eb430983c89d..c66b77794eca 100644 --- a/api/tests/unit/integrations/gitlab/test_configuration.py +++ b/api/tests/unit/integrations/gitlab/test_configuration.py @@ -182,7 +182,6 @@ def test_delete_configuration__with_registered_webhooks__deregisters_each_and_cl assert len(deregister_events) == 2 assert {e["gitlab__hook__id"] for e in deregister_events} == {11, 22} - # Rows are cleared so a new config can register the same project afresh. assert not GitLabWebhook.objects.exists() @@ -225,7 +224,6 @@ def test_delete_configuration__gitlab_delete_fails_for_one__still_removes_config # Then assert response.status_code == status.HTTP_204_NO_CONTENT assert not GitLabConfiguration.objects.filter(project=project).exists() - # Second hook still deregistered; failure for the first was logged. assert any( e["event"] == "webhook.deregistered" and e["gitlab__hook__id"] == 22 for e in log.events From 1d732652fc60a9a2a8279a62f9c351440451ad14 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Apr 2026 16:15:03 +0200 Subject: [PATCH 08/21] feat: refactoring and merging client helpers --- api/integrations/gitlab/services.py | 30 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/api/integrations/gitlab/services.py b/api/integrations/gitlab/services.py index 63ef7cb96f85..f3f6e22fdef2 100644 --- a/api/integrations/gitlab/services.py +++ b/api/integrations/gitlab/services.py @@ -9,8 +9,8 @@ from rest_framework.exceptions import ValidationError from integrations.gitlab.client import ( - add_flagsmith_label_to_gitlab_issue, - add_flagsmith_label_to_gitlab_merge_request, + GitLabResourceKind, + add_flagsmith_label_to_gitlab_resource, create_flagsmith_label, url_encode_gitlab_project_path, ) @@ -25,6 +25,11 @@ r"^/(?P.+?)/-/(?:issues|work_items|merge_requests)/(?P\d+)/?$" ) +GITLAB_RESOURCE_KIND_BY_TYPE: dict[str, GitLabResourceKind] = { + ResourceType.GITLAB_ISSUE.value: "issues", + ResourceType.GITLAB_MR.value: "merge_requests", +} + def apply_flagsmith_label_to_resource( resource: FeatureExternalResource, @@ -64,20 +69,13 @@ def apply_flagsmith_label_to_resource( if created: log.info("label.created") - if resource.type == ResourceType.GITLAB_ISSUE: - add_flagsmith_label_to_gitlab_issue( - config.gitlab_instance_url, - config.access_token, - gitlab_project=gitlab_project, - issue_iid=resource_iid, - ) - else: - add_flagsmith_label_to_gitlab_merge_request( - config.gitlab_instance_url, - config.access_token, - gitlab_project=gitlab_project, - merge_request_iid=resource_iid, - ) + add_flagsmith_label_to_gitlab_resource( + config.gitlab_instance_url, + config.access_token, + gitlab_project=gitlab_project, + resource_kind=GITLAB_RESOURCE_KIND_BY_TYPE[resource.type], + resource_iid=resource_iid, + ) log.info("label.applied") except requests.RequestException as exc: log.exception("label.failed") From 1d5dab915a7cc18a10d60f4d3ec59ea19a87be30 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 23 Apr 2026 12:02:30 +0100 Subject: [PATCH 09/21] feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration --- ...002_gitlabconfiguration_tagging_enabled.py | 2 +- ...03_gitlabconfiguration_labeling_enabled.py | 16 + api/integrations/gitlab/models.py | 4 + api/integrations/gitlab/serializers.py | 2 +- api/integrations/gitlab/tasks.py | 49 +++ .../test_gitlab_external_resources.py | 395 ++++++++++++++++++ frontend/common/stores/default-flags.ts | 2 +- 7 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 api/integrations/gitlab/migrations/0003_gitlabconfiguration_labeling_enabled.py diff --git a/api/integrations/gitlab/migrations/0002_gitlabconfiguration_tagging_enabled.py b/api/integrations/gitlab/migrations/0002_gitlabconfiguration_tagging_enabled.py index 37a7cdc6d031..916f53eecbe7 100644 --- a/api/integrations/gitlab/migrations/0002_gitlabconfiguration_tagging_enabled.py +++ b/api/integrations/gitlab/migrations/0002_gitlabconfiguration_tagging_enabled.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( model_name="gitlabconfiguration", - name="tagging_enabled", + name="labeling_enabled", field=models.BooleanField(default=False), ), ] diff --git a/api/integrations/gitlab/migrations/0003_gitlabconfiguration_labeling_enabled.py b/api/integrations/gitlab/migrations/0003_gitlabconfiguration_labeling_enabled.py new file mode 100644 index 000000000000..916f53eecbe7 --- /dev/null +++ b/api/integrations/gitlab/migrations/0003_gitlabconfiguration_labeling_enabled.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("gitlab", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="gitlabconfiguration", + name="labeling_enabled", + field=models.BooleanField(default=False), + ), + ] diff --git a/api/integrations/gitlab/models.py b/api/integrations/gitlab/models.py index c60367dc37c3..f87a723e8365 100644 --- a/api/integrations/gitlab/models.py +++ b/api/integrations/gitlab/models.py @@ -11,6 +11,10 @@ class GitLabConfiguration(SoftDeleteExportableModel): ) gitlab_instance_url = models.URLField(max_length=200) access_token = models.CharField(max_length=300) +<<<<<<< HEAD +======= + labeling_enabled = models.BooleanField(default=False) +>>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) class GitLabWebhook(SoftDeleteExportableModel): diff --git a/api/integrations/gitlab/serializers.py b/api/integrations/gitlab/serializers.py index 18c9997cfda7..0c0297d29fb0 100644 --- a/api/integrations/gitlab/serializers.py +++ b/api/integrations/gitlab/serializers.py @@ -11,7 +11,7 @@ class GitLabConfigurationSerializer(BaseProjectIntegrationModelSerializer): class Meta: model = GitLabConfiguration - fields = ("id", "gitlab_instance_url", "access_token", "tagging_enabled") + fields = ("id", "gitlab_instance_url", "access_token", "labeling_enabled") def to_representation(self, instance: GitLabConfiguration) -> dict[str, Any]: data = super().to_representation(instance) diff --git a/api/integrations/gitlab/tasks.py b/api/integrations/gitlab/tasks.py index c41d1f2975ad..cb25f676c589 100644 --- a/api/integrations/gitlab/tasks.py +++ b/api/integrations/gitlab/tasks.py @@ -1,17 +1,27 @@ from task_processor.decorators import register_task_handler from features.feature_external_resources.models import FeatureExternalResource +<<<<<<< HEAD from features.models import FeatureState +======= +from integrations.gitlab.client import remove_flagsmith_label_from_gitlab_resource +>>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) from integrations.gitlab.models import GitLabConfiguration from integrations.gitlab.services import ( deregister_webhook_for_path, ensure_webhook_registered, has_live_resource_for_path, +<<<<<<< HEAD post_feature_deleted_comment, post_linked_comment, post_state_change_comment, post_unlinked_comment, +======= + parse_project_path, + parse_resource_iid, +>>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) ) +from integrations.gitlab.services.labels import GITLAB_RESOURCE_KIND_BY_TYPE @register_task_handler() @@ -66,12 +76,38 @@ def post_gitlab_unlinked_comment( has been unlinked. Dispatched at unlink time. All data is passed directly because the resource row no longer exists. """ +<<<<<<< HEAD post_unlinked_comment( feature_name=feature_name, feature_id=feature_id, resource_url=resource_url, resource_type=resource_type, project_id=project_id, +======= + config: GitLabConfiguration | None = GitLabConfiguration.objects.filter( + project_id=project_id + ).first() + if not config or not config.labeling_enabled: + return + if ( + FeatureExternalResource.objects.filter(url=resource_url) + .exclude(pk=resource_pk) + .exists() + ): + return + + path_with_namespace = parse_project_path(resource_url) + resource_iid = parse_resource_iid(resource_url) + if path_with_namespace is None or resource_iid is None: + return + + log = logger.bind( + project__id=project_id, + feature__id=feature_id, + gitlab_project__path=path_with_namespace, + resource__type=resource_type, + resource__iid=resource_iid, +>>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) ) @@ -81,6 +117,7 @@ def post_gitlab_state_change_comment(feature_state_id: int) -> None: state changes. Dispatched from the feature-state serialiser save hook. """ try: +<<<<<<< HEAD feature_state = FeatureState.objects.select_related( "feature", "environment", @@ -108,3 +145,15 @@ def post_gitlab_feature_deleted_comment( feature_id=feature_id, project_id=project_id, ) +======= + remove_flagsmith_label_from_gitlab_resource( + config.gitlab_instance_url, + config.access_token, + project_path=path_with_namespace, + resource_kind=GITLAB_RESOURCE_KIND_BY_TYPE[resource_type], + resource_iid=resource_iid, + ) + log.info("label.removed") + except requests.RequestException: + log.exception("label.removal_failed") +>>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) diff --git a/api/tests/integration/features/test_gitlab_external_resources.py b/api/tests/integration/features/test_gitlab_external_resources.py index 366fadf16033..58d98d11046e 100644 --- a/api/tests/integration/features/test_gitlab_external_resources.py +++ b/api/tests/integration/features/test_gitlab_external_resources.py @@ -14,6 +14,36 @@ from projects.tags.models import TagType +<<<<<<< HEAD +======= + +@pytest.fixture() +def gitlab_config(project: int) -> GitLabConfiguration: + config: GitLabConfiguration = GitLabConfiguration.objects.create( + project=Project.objects.get(id=project), + gitlab_instance_url=GITLAB_INSTANCE_URL, + access_token=GITLAB_ACCESS_TOKEN, + ) + return config + + +@pytest.fixture() +def gitlab_config_with_labeling(project: int) -> GitLabConfiguration: + config: GitLabConfiguration = GitLabConfiguration.objects.create( + project=Project.objects.get(id=project), + gitlab_instance_url=GITLAB_INSTANCE_URL, + access_token=GITLAB_ACCESS_TOKEN, + labeling_enabled=True, + ) + return config + + +def _mock_webhook_registration() -> None: + responses.post(GITLAB_HOOKS_URL, json={"id": 1, "project_id": 1}, status=201) + + +@pytest.mark.django_db() +>>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) def test_create_external_resource__gitlab_issue__returns_201( admin_client: APIClient, project: int, @@ -604,3 +634,368 @@ def test_list_external_resources__gitlab_merge_request__returns_200( assert len(results) == 1 assert results[0]["type"] == "GITLAB_MR" assert results[0]["metadata"] == {"title": "Add login button", "state": "opened"} +<<<<<<< HEAD +======= + + +@pytest.mark.django_db() +@responses.activate +def test_create_external_resource__gitlab_issue_with_labeling_enabled__creates_and_applies_label( + admin_client: APIClient, + project: int, + feature: int, + gitlab_config_with_labeling: GitLabConfiguration, + log: StructuredLogCapture, +) -> None: + # Given + label_create = responses.post( + GITLAB_LABELS_URL, + json={"id": 1, "name": "Flagsmith Flag"}, + status=201, + match=[ + responses.matchers.header_matcher({"PRIVATE-TOKEN": GITLAB_ACCESS_TOKEN}), + responses.matchers.json_params_matcher( + { + "name": "Flagsmith Flag", + "color": "#6633FF", + "description": ( + "This GitLab Issue/MR is linked to a Flagsmith Feature Flag" + ), + }, + ), + ], + ) + label_apply = responses.put( + GITLAB_ISSUE_API_URL, + json={"iid": 42, "labels": ["Flagsmith Flag"]}, + status=200, + match=[ + responses.matchers.json_params_matcher({"add_labels": "Flagsmith Flag"}), + ], + ) + _mock_webhook_registration() + + # When + response = admin_client.post( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", + data={"type": "GITLAB_ISSUE", "url": GITLAB_ISSUE_URL, "feature": feature}, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert label_create.call_count == 1 + assert label_apply.call_count == 1 + assert FeatureExternalResource.objects.count() == 1 + assert [e["event"] for e in log.events] == [ + "label.created", + "webhook.registered", + "issue.linked", + ] + + +@pytest.mark.django_db() +@responses.activate +def test_create_external_resource__gitlab_mr_with_labeling_enabled__creates_and_applies_label( + admin_client: APIClient, + project: int, + feature: int, + gitlab_config_with_labeling: GitLabConfiguration, + log: StructuredLogCapture, +) -> None: + # Given + responses.post( + GITLAB_LABELS_URL, + json={"id": 1, "name": "Flagsmith Flag"}, + status=201, + ) + label_apply = responses.put( + GITLAB_MR_API_URL, + json={"iid": 7, "labels": ["Flagsmith Flag"]}, + status=200, + match=[ + responses.matchers.json_params_matcher({"add_labels": "Flagsmith Flag"}), + ], + ) + _mock_webhook_registration() + + # When + response = admin_client.post( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", + data={"type": "GITLAB_MR", "url": GITLAB_MR_URL, "feature": feature}, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert label_apply.call_count == 1 + assert [e["event"] for e in log.events] == [ + "label.created", + "webhook.registered", + "merge_request.linked", + ] + + +@pytest.mark.django_db() +@responses.activate +def test_create_external_resource__gitlab_issue_with_labeling_disabled__skips_label_api( + admin_client: APIClient, + project: int, + feature: int, + gitlab_config: GitLabConfiguration, + log: StructuredLogCapture, +) -> None: + # Given + _mock_webhook_registration() + + # When + response = admin_client.post( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", + data={"type": "GITLAB_ISSUE", "url": GITLAB_ISSUE_URL, "feature": feature}, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert [e["event"] for e in log.events] == ["webhook.registered", "issue.linked"] + + +@pytest.mark.django_db() +@responses.activate +def test_create_external_resource__gitlab_issue_label_already_exists__applies_label( + admin_client: APIClient, + project: int, + feature: int, + gitlab_config_with_labeling: GitLabConfiguration, + log: StructuredLogCapture, +) -> None: + # Given + responses.post( + GITLAB_LABELS_URL, + json={"message": {"title": ["has already been taken"]}}, + status=409, + ) + label_apply = responses.put( + GITLAB_ISSUE_API_URL, + json={"iid": 42, "labels": ["Flagsmith Flag"]}, + status=200, + ) + _mock_webhook_registration() + + # When + response = admin_client.post( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", + data={"type": "GITLAB_ISSUE", "url": GITLAB_ISSUE_URL, "feature": feature}, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert label_apply.call_count == 1 + assert [e["event"] for e in log.events] == [ + "webhook.registered", + "issue.linked", + ] + + +@pytest.mark.django_db() +@responses.activate +def test_create_external_resource__gitlab_issue_label_apply_fails__rolls_back_link( + admin_client: APIClient, + project: int, + feature: int, + gitlab_config_with_labeling: GitLabConfiguration, + log: StructuredLogCapture, +) -> None: + # Given + responses.post( + GITLAB_LABELS_URL, + json={"id": 1, "name": "Flagsmith Flag"}, + status=201, + ) + responses.put(GITLAB_ISSUE_API_URL, json={"message": "403 Forbidden"}, status=403) + + # When + response = admin_client.post( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", + data={"type": "GITLAB_ISSUE", "url": GITLAB_ISSUE_URL, "feature": feature}, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert FeatureExternalResource.objects.count() == 0 + assert [e["event"] for e in log.events] == ["label.created", "label.failed"] + + +@pytest.mark.django_db() +@responses.activate +def test_create_external_resource__gitlab_issue_label_create_fails__rolls_back_link( + admin_client: APIClient, + project: int, + feature: int, + gitlab_config_with_labeling: GitLabConfiguration, + log: StructuredLogCapture, +) -> None: + # Given + responses.post( + GITLAB_LABELS_URL, + json={"message": "internal server error"}, + status=500, + ) + + # When + response = admin_client.post( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", + data={"type": "GITLAB_ISSUE", "url": GITLAB_ISSUE_URL, "feature": feature}, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert FeatureExternalResource.objects.count() == 0 + assert [e["event"] for e in log.events] == ["label.failed"] + + +@pytest.mark.django_db() +@responses.activate +def test_create_external_resource__gitlab_issue_invalid_url__rolls_back_link( + admin_client: APIClient, + project: int, + feature: int, + gitlab_config_with_labeling: GitLabConfiguration, +) -> None: + # Given / When + response = admin_client.post( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", + data={ + "type": "GITLAB_ISSUE", + "url": f"{GITLAB_INSTANCE_URL}/not-a-valid-resource-url", + "feature": feature, + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"url": "Could not parse GitLab resource URL."} + assert FeatureExternalResource.objects.count() == 0 + + +@pytest.mark.django_db() +@responses.activate +@pytest.mark.parametrize( + "other_links_count, expected_removal_calls", + [(0, 1), (1, 0)], + ids=["last_link_removes_label", "shared_link_keeps_label"], +) +def test_delete_external_resource__gitlab_issue__removes_label_only_when_last_link( + other_links_count: int, + expected_removal_calls: int, + admin_client: APIClient, + project: int, + feature: int, + gitlab_config_with_labeling: GitLabConfiguration, +) -> None: + # Given + resource = FeatureExternalResource.objects.create( + url=GITLAB_ISSUE_URL, + type="GITLAB_ISSUE", + feature=Feature.objects.get(id=feature), + ) + for i in range(other_links_count): + other_feature = Feature.objects.create( + name=f"other_feature_{i}", + project=gitlab_config_with_labeling.project, + ) + FeatureExternalResource.objects.create( + url=GITLAB_ISSUE_URL, + type="GITLAB_ISSUE", + feature=other_feature, + ) + label_remove = responses.put( + GITLAB_ISSUE_API_URL, + json={"iid": 42, "labels": []}, + status=200, + match=[ + responses.matchers.json_params_matcher({"remove_labels": "Flagsmith Flag"}), + ], + ) + + # When + response = admin_client.delete( + f"/api/v1/projects/{project}/features/{feature}" + f"/feature-external-resources/{resource.id}/", + ) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert label_remove.call_count == expected_removal_calls + + +@pytest.mark.django_db() +@responses.activate +def test_delete_external_resource__gitlab_mr_last_link__removes_label( + admin_client: APIClient, + project: int, + feature: int, + gitlab_config_with_labeling: GitLabConfiguration, +) -> None: + # Given + resource = FeatureExternalResource.objects.create( + url=GITLAB_MR_URL, + type="GITLAB_MR", + feature=Feature.objects.get(id=feature), + ) + label_remove = responses.put( + GITLAB_MR_API_URL, + json={"iid": 7, "labels": []}, + status=200, + match=[ + responses.matchers.json_params_matcher({"remove_labels": "Flagsmith Flag"}), + ], + ) + + # When + response = admin_client.delete( + f"/api/v1/projects/{project}/features/{feature}" + f"/feature-external-resources/{resource.id}/", + ) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert label_remove.call_count == 1 + + +@pytest.mark.django_db() +@responses.activate +def test_delete_external_resource__gitlab_label_removal_fails__unlink_still_succeeds( + admin_client: APIClient, + project: int, + feature: int, + gitlab_config_with_labeling: GitLabConfiguration, + log: StructuredLogCapture, +) -> None: + # Given + resource = FeatureExternalResource.objects.create( + url=GITLAB_ISSUE_URL, + type="GITLAB_ISSUE", + feature=Feature.objects.get(id=feature), + ) + responses.put( + GITLAB_ISSUE_API_URL, + json={"message": "500 Internal Server Error"}, + status=500, + ) + + # When + response = admin_client.delete( + f"/api/v1/projects/{project}/features/{feature}" + f"/feature-external-resources/{resource.id}/", + ) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not FeatureExternalResource.objects.filter(pk=resource.pk).exists() + assert "label.removal_failed" in {e["event"] for e in log.events} +>>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) diff --git a/frontend/common/stores/default-flags.ts b/frontend/common/stores/default-flags.ts index 74eee31e3d29..2245a9e3de35 100644 --- a/frontend/common/stores/default-flags.ts +++ b/frontend/common/stores/default-flags.ts @@ -105,7 +105,7 @@ const defaultFlags = { { 'default': false, 'inputType': 'checkbox', - 'key': 'tagging_enabled', + 'key': 'labeling_enabled', 'label': 'Add "Flagsmith Flag" label to linked issues and MRs', }, ], From 1172f98e05a3aefb8a63c2eeccac6f0441fcf552 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 23 Apr 2026 12:06:15 +0100 Subject: [PATCH 10/21] feat: extract label logic into its own service --- .../{services.py => services/labels.py} | 46 +++++++------------ 1 file changed, 16 insertions(+), 30 deletions(-) rename api/integrations/gitlab/{services.py => services/labels.py} (63%) diff --git a/api/integrations/gitlab/services.py b/api/integrations/gitlab/services/labels.py similarity index 63% rename from api/integrations/gitlab/services.py rename to api/integrations/gitlab/services/labels.py index f3f6e22fdef2..6b2855b96459 100644 --- a/api/integrations/gitlab/services.py +++ b/api/integrations/gitlab/services/labels.py @@ -1,29 +1,22 @@ -from __future__ import annotations - -import re -from typing import TYPE_CHECKING -from urllib.parse import urlsplit +from typing import Literal import requests import structlog from rest_framework.exceptions import ValidationError +from features.feature_external_resources.models import ( + FeatureExternalResource, + ResourceType, +) from integrations.gitlab.client import ( - GitLabResourceKind, add_flagsmith_label_to_gitlab_resource, create_flagsmith_label, - url_encode_gitlab_project_path, ) from integrations.gitlab.models import GitLabConfiguration -if TYPE_CHECKING: - from features.feature_external_resources.models import FeatureExternalResource - logger = structlog.get_logger("gitlab") -GITLAB_RESOURCE_PATH_PATTERN = re.compile( - r"^/(?P.+?)/-/(?:issues|work_items|merge_requests)/(?P\d+)/?$" -) +GitLabResourceKind = Literal["issues", "merge_requests"] GITLAB_RESOURCE_KIND_BY_TYPE: dict[str, GitLabResourceKind] = { ResourceType.GITLAB_ISSUE.value: "issues", @@ -34,22 +27,23 @@ def apply_flagsmith_label_to_resource( resource: FeatureExternalResource, ) -> None: - """ - Ensure the "Flagsmith Flag" label exists on the resource's GitLab project - and apply it to the resource. No-op if tagging is disabled or unconfigured; + """Ensure the "Flagsmith Flag" label exists on the resource's GitLab project + and apply it to the resource. No-op if labeling is disabled or unconfigured; raises ``ValidationError`` on parse/API failure (rolls back under atomic). """ - from features.feature_external_resources.models import ResourceType + from integrations.gitlab.services import parse_project_path, parse_resource_iid project = resource.feature.project config: GitLabConfiguration | None = GitLabConfiguration.objects.filter( project=project ).first() - if not config or not config.tagging_enabled: + if not config or not config.labeling_enabled: return - path_with_namespace, resource_iid = parse_gitlab_resource_url(resource.url) - gitlab_project = url_encode_gitlab_project_path(path_with_namespace) + path_with_namespace = parse_project_path(resource.url) + resource_iid = parse_resource_iid(resource.url) + if path_with_namespace is None or resource_iid is None: + raise ValidationError({"url": "Could not parse GitLab resource URL."}) log = logger.bind( organisation__id=project.organisation_id, @@ -64,7 +58,7 @@ def apply_flagsmith_label_to_resource( created = create_flagsmith_label( config.gitlab_instance_url, config.access_token, - gitlab_project=gitlab_project, + project_path=path_with_namespace, ) if created: log.info("label.created") @@ -72,11 +66,10 @@ def apply_flagsmith_label_to_resource( add_flagsmith_label_to_gitlab_resource( config.gitlab_instance_url, config.access_token, - gitlab_project=gitlab_project, + project_path=path_with_namespace, resource_kind=GITLAB_RESOURCE_KIND_BY_TYPE[resource.type], resource_iid=resource_iid, ) - log.info("label.applied") except requests.RequestException as exc: log.exception("label.failed") raise ValidationError( @@ -87,10 +80,3 @@ def apply_flagsmith_label_to_resource( ), }, ) from exc - - -def parse_gitlab_resource_url(url: str) -> tuple[str, int]: - match = GITLAB_RESOURCE_PATH_PATTERN.match(urlsplit(url).path) - if not match: - raise ValidationError({"url": "Could not parse GitLab resource URL."}) - return match["path"], int(match["iid"]) From 6e8060e88aac3d128c1f29d3af1deb1cc66ebd92 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 23 Apr 2026 12:09:49 +0100 Subject: [PATCH 11/21] feat: restore original comments in test_services.py and test_mappers.py --- .../unit/integrations/gitlab/test_mappers.py | 2 +- .../unit/integrations/gitlab/test_webhooks.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/api/tests/unit/integrations/gitlab/test_mappers.py b/api/tests/unit/integrations/gitlab/test_mappers.py index e5cbdd3372e7..8a92418d12de 100644 --- a/api/tests/unit/integrations/gitlab/test_mappers.py +++ b/api/tests/unit/integrations/gitlab/test_mappers.py @@ -5,7 +5,7 @@ def test_map_gitlab_resource_to_tag_label__non_gitlab_type__returns_none() -> None: - # Given + # Given — a non-GitLab resource type reaches the mapper defensively. resource = Mock(type=ResourceType.GITHUB_ISSUE.value, metadata='{"state": "open"}') # When diff --git a/api/tests/unit/integrations/gitlab/test_webhooks.py b/api/tests/unit/integrations/gitlab/test_webhooks.py index a62fcf139194..0c400f995153 100644 --- a/api/tests/unit/integrations/gitlab/test_webhooks.py +++ b/api/tests/unit/integrations/gitlab/test_webhooks.py @@ -24,7 +24,7 @@ def test_ensure_webhook_registered__gitlab_http_error__logs_and_raises( log: StructuredLogCapture, mocker: MockerFixture, ) -> None: - # Given + # Given — GitLab rejects the hook creation. responses.post( "https://gitlab.example.com/api/v4/projects/testorg%2Ftestrepo/hooks", status=500, @@ -52,10 +52,10 @@ def test_deregister_webhook_for_path__no_matching_webhook__noop( gitlab_config: GitLabConfiguration, log: StructuredLogCapture, ) -> None: - # Given / When + # Given / When — no webhook row exists for this (config, path) pair. deregister_webhook_for_path(gitlab_config, "never/registered") - # Then + # Then — nothing logged, nothing raised. assert log.events == [] @@ -63,10 +63,10 @@ def test_deregister_webhook_for_path__no_matching_webhook__noop( def test_register_gitlab_webhook_task__config_missing__noop( log: StructuredLogCapture, ) -> None: - # Given / When + # Given / When — a stale task fires after the config was hard-deleted. register_gitlab_webhook(config_id=999_999, project_path="testorg/testrepo") - # Then + # Then — no webhook created, no log. assert not GitLabWebhook.objects.exists() assert log.events == [] @@ -77,7 +77,7 @@ def test_deregister_gitlab_webhook_hook__unparseable_url__noop( feature: Feature, log: StructuredLogCapture, ) -> None: - # Given + # Given — a GitLab-typed link whose URL doesn't match the issue/MR shape. resource = FeatureExternalResource.objects.create( url="https://gitlab.example.com/not-a-resource", type=ResourceType.GITLAB_ISSUE.value, @@ -87,7 +87,7 @@ def test_deregister_gitlab_webhook_hook__unparseable_url__noop( # When resource.delete() - # Then + # Then — no deregistration attempted. assert log.events == [] @@ -96,7 +96,7 @@ def test_deregister_gitlab_webhook_hook__no_config__noop( feature: Feature, log: StructuredLogCapture, ) -> None: - # Given + # Given — a GitLab-typed link exists but the config was removed. resource = FeatureExternalResource.objects.create( url="https://gitlab.example.com/testorg/testrepo/-/issues/1", type=ResourceType.GITLAB_ISSUE.value, @@ -106,5 +106,5 @@ def test_deregister_gitlab_webhook_hook__no_config__noop( # When resource.delete() - # Then + # Then — no deregistration attempted. assert log.events == [] From e70e7c66d97e232a77f7fda29e2a5d3287aaaf73 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 23 Apr 2026 12:13:22 +0100 Subject: [PATCH 12/21] feat: removed useless div --- .../modals/CreateEditIntegrationModal.tsx | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/frontend/web/components/modals/CreateEditIntegrationModal.tsx b/frontend/web/components/modals/CreateEditIntegrationModal.tsx index bf3d2579cbfa..d539e1eb0f12 100644 --- a/frontend/web/components/modals/CreateEditIntegrationModal.tsx +++ b/frontend/web/components/modals/CreateEditIntegrationModal.tsx @@ -105,6 +105,7 @@ const CreateEditIntegration: FC = (props) => { }, ) } + // Intentionally runs only on mount — Slack channel setup is a one-time fetch. // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -263,7 +264,21 @@ const CreateEditIntegration: FC = (props) => {
)} {fields.map((field) => { - if (field.inputType === 'checkbox' && !readOnly) { + if (field.inputType === 'checkbox') { + if (readOnly) { + return ( +
+ {}} + disabled + type='checkbox' + /> +
+ ) + } return (
= (props) => { } return (
-
- -
+ {fieldControl}
) From d21c3ae1785c6108d4caa8c445d99b08405d4d6d Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 23 Apr 2026 15:51:19 +0100 Subject: [PATCH 13/21] feat: coverage and replaced wording flag with feature --- api/integrations/gitlab/services/labels.py | 4 ++-- .../test_gitlab_external_resources.py | 22 +++++++++---------- .../project-management/gitlab.md | 2 +- frontend/common/stores/default-flags.ts | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/api/integrations/gitlab/services/labels.py b/api/integrations/gitlab/services/labels.py index 6b2855b96459..281331119244 100644 --- a/api/integrations/gitlab/services/labels.py +++ b/api/integrations/gitlab/services/labels.py @@ -27,7 +27,7 @@ def apply_flagsmith_label_to_resource( resource: FeatureExternalResource, ) -> None: - """Ensure the "Flagsmith Flag" label exists on the resource's GitLab project + """Ensure the "Flagsmith Feature" label exists on the resource's GitLab project and apply it to the resource. No-op if labeling is disabled or unconfigured; raises ``ValidationError`` on parse/API failure (rolls back under atomic). """ @@ -75,7 +75,7 @@ def apply_flagsmith_label_to_resource( raise ValidationError( { "detail": ( - "Failed to apply the Flagsmith Flag label on GitLab. " + "Failed to apply the Flagsmith Feature label on GitLab. " "Check the GitLab access token's permissions and try again." ), }, diff --git a/api/tests/integration/features/test_gitlab_external_resources.py b/api/tests/integration/features/test_gitlab_external_resources.py index 58d98d11046e..6d730f8ef066 100644 --- a/api/tests/integration/features/test_gitlab_external_resources.py +++ b/api/tests/integration/features/test_gitlab_external_resources.py @@ -650,13 +650,13 @@ def test_create_external_resource__gitlab_issue_with_labeling_enabled__creates_a # Given label_create = responses.post( GITLAB_LABELS_URL, - json={"id": 1, "name": "Flagsmith Flag"}, + json={"id": 1, "name": "Flagsmith Feature"}, status=201, match=[ responses.matchers.header_matcher({"PRIVATE-TOKEN": GITLAB_ACCESS_TOKEN}), responses.matchers.json_params_matcher( { - "name": "Flagsmith Flag", + "name": "Flagsmith Feature", "color": "#6633FF", "description": ( "This GitLab Issue/MR is linked to a Flagsmith Feature Flag" @@ -667,10 +667,10 @@ def test_create_external_resource__gitlab_issue_with_labeling_enabled__creates_a ) label_apply = responses.put( GITLAB_ISSUE_API_URL, - json={"iid": 42, "labels": ["Flagsmith Flag"]}, + json={"iid": 42, "labels": ["Flagsmith Feature"]}, status=200, match=[ - responses.matchers.json_params_matcher({"add_labels": "Flagsmith Flag"}), + responses.matchers.json_params_matcher({"add_labels": "Flagsmith Feature"}), ], ) _mock_webhook_registration() @@ -706,15 +706,15 @@ def test_create_external_resource__gitlab_mr_with_labeling_enabled__creates_and_ # Given responses.post( GITLAB_LABELS_URL, - json={"id": 1, "name": "Flagsmith Flag"}, + json={"id": 1, "name": "Flagsmith Feature"}, status=201, ) label_apply = responses.put( GITLAB_MR_API_URL, - json={"iid": 7, "labels": ["Flagsmith Flag"]}, + json={"iid": 7, "labels": ["Flagsmith Feature"]}, status=200, match=[ - responses.matchers.json_params_matcher({"add_labels": "Flagsmith Flag"}), + responses.matchers.json_params_matcher({"add_labels": "Flagsmith Feature"}), ], ) _mock_webhook_registration() @@ -777,7 +777,7 @@ def test_create_external_resource__gitlab_issue_label_already_exists__applies_la ) label_apply = responses.put( GITLAB_ISSUE_API_URL, - json={"iid": 42, "labels": ["Flagsmith Flag"]}, + json={"iid": 42, "labels": ["Flagsmith Feature"]}, status=200, ) _mock_webhook_registration() @@ -810,7 +810,7 @@ def test_create_external_resource__gitlab_issue_label_apply_fails__rolls_back_li # Given responses.post( GITLAB_LABELS_URL, - json={"id": 1, "name": "Flagsmith Flag"}, + json={"id": 1, "name": "Flagsmith Feature"}, status=201, ) responses.put(GITLAB_ISSUE_API_URL, json={"message": "403 Forbidden"}, status=403) @@ -918,7 +918,7 @@ def test_delete_external_resource__gitlab_issue__removes_label_only_when_last_li json={"iid": 42, "labels": []}, status=200, match=[ - responses.matchers.json_params_matcher({"remove_labels": "Flagsmith Flag"}), + responses.matchers.json_params_matcher({"remove_labels": "Flagsmith Feature"}), ], ) @@ -952,7 +952,7 @@ def test_delete_external_resource__gitlab_mr_last_link__removes_label( json={"iid": 7, "labels": []}, status=200, match=[ - responses.matchers.json_params_matcher({"remove_labels": "Flagsmith Flag"}), + responses.matchers.json_params_matcher({"remove_labels": "Flagsmith Feature"}), ], ) diff --git a/docs/docs/third-party-integrations/project-management/gitlab.md b/docs/docs/third-party-integrations/project-management/gitlab.md index 5b23fe3ff0e0..5a157db076e7 100644 --- a/docs/docs/third-party-integrations/project-management/gitlab.md +++ b/docs/docs/third-party-integrations/project-management/gitlab.md @@ -53,7 +53,7 @@ Flagsmith will post a comment to the linked issue or MR with the flag's current state across all environments. When the flag state changes, a new comment is posted automatically. -A **Flagsmith Flag** label is added to linked issues and merge requests so your +A **Flagsmith Feature** label is added to linked issues and merge requests so your team can filter for them in GitLab. ## Automatic state sync diff --git a/frontend/common/stores/default-flags.ts b/frontend/common/stores/default-flags.ts index 2245a9e3de35..1af253c52265 100644 --- a/frontend/common/stores/default-flags.ts +++ b/frontend/common/stores/default-flags.ts @@ -106,7 +106,7 @@ const defaultFlags = { 'default': false, 'inputType': 'checkbox', 'key': 'labeling_enabled', - 'label': 'Add "Flagsmith Flag" label to linked issues and MRs', + 'label': 'Add "Flagsmith Feature" label to linked issues and MRs', }, ], 'image': '/static/images/integrations/gitlab.svg', From 0724f4c33e26b50ab36a0e646041580b5b38597d Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 23 Apr 2026 17:33:53 +0100 Subject: [PATCH 14/21] feat: rebased and included label in vcs dispatcher --- .../feature_external_resources/models.py | 14 - .../feature_external_resources/views.py | 2 + api/integrations/gitlab/client/__init__.py | 12 +- api/integrations/gitlab/client/api.py | 82 +++- api/integrations/gitlab/constants.py | 9 +- ...002_gitlabconfiguration_tagging_enabled.py | 16 - ...03_gitlabconfiguration_labeling_enabled.py | 2 +- api/integrations/gitlab/models.py | 3 - api/integrations/gitlab/services/__init__.py | 6 + api/integrations/gitlab/services/labels.py | 6 +- api/integrations/gitlab/tasks.py | 84 ++-- api/integrations/vcs/services.py | 12 + .../test_gitlab_external_resources.py | 410 ++++-------------- .../unit/integrations/gitlab/test_client.py | 61 +++ .../unit/integrations/gitlab/test_mappers.py | 2 +- .../unit/integrations/gitlab/test_tasks.py | 34 ++ .../unit/integrations/gitlab/test_webhooks.py | 18 +- .../observability/_events-catalogue.md | 54 ++- 18 files changed, 403 insertions(+), 424 deletions(-) delete mode 100644 api/integrations/gitlab/migrations/0002_gitlabconfiguration_tagging_enabled.py diff --git a/api/features/feature_external_resources/models.py b/api/features/feature_external_resources/models.py index 3536c8933d61..47abf2f49e2d 100644 --- a/api/features/feature_external_resources/models.py +++ b/api/features/feature_external_resources/models.py @@ -16,7 +16,6 @@ from integrations.github.constants import GitHubEventType, GitHubTag from integrations.github.github import call_github_task from integrations.github.models import GitHubRepository -from integrations.gitlab.tasks import remove_flagsmith_label_from_gitlab_resource from organisations.models import Organisation from projects.tags.models import Tag, TagType @@ -142,19 +141,6 @@ def notify_github_on_link(self): # type: ignore[no-untyped-def] feature_states=feature_states, ) - @hook(BEFORE_DELETE, when="type", is_now="GITLAB_ISSUE") # type: ignore[misc] - @hook(BEFORE_DELETE, when="type", is_now="GITLAB_MR") # type: ignore[misc] - def notify_gitlab_on_unlink(self) -> None: - remove_flagsmith_label_from_gitlab_resource.delay( - kwargs={ - "project_id": self.feature.project_id, - "feature_id": self.feature_id, - "resource_pk": self.pk, - "resource_url": self.url, - "resource_type": self.type, - }, - ) - @hook(BEFORE_DELETE, when="type", is_now="GITHUB_ISSUE") # type: ignore[misc] @hook(BEFORE_DELETE, when="type", is_now="GITHUB_PR") # type: ignore[misc] def notify_github_on_unlink(self) -> None: diff --git a/api/features/feature_external_resources/views.py b/api/features/feature_external_resources/views.py index 2438a72c20e4..1ae5261eeee8 100644 --- a/api/features/feature_external_resources/views.py +++ b/api/features/feature_external_resources/views.py @@ -1,5 +1,6 @@ import re +from django.db import transaction from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema @@ -143,6 +144,7 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def] status=status.HTTP_400_BAD_REQUEST, ) + @transaction.atomic def perform_create(self, serializer: FeatureExternalResourceSerializer) -> None: # type: ignore[override] resource = serializer.save() dispatch_vcs_on_resource_create(resource) diff --git a/api/integrations/gitlab/client/__init__.py b/api/integrations/gitlab/client/__init__.py index 5df293547069..d60b0d0fdd43 100644 --- a/api/integrations/gitlab/client/__init__.py +++ b/api/integrations/gitlab/client/__init__.py @@ -1,11 +1,13 @@ from integrations.gitlab.client.api import ( + GitLabResourceKind, + add_flagsmith_label_to_gitlab_resource, + create_flagsmith_label, create_issue_note, create_merge_request_note, create_project_hook, delete_project_hook, fetch_gitlab_projects, - remove_flagsmith_label_from_gitlab_issue, - remove_flagsmith_label_from_gitlab_merge_request, + remove_flagsmith_label_from_gitlab_resource, search_gitlab_issues, search_gitlab_merge_requests, ) @@ -23,13 +25,15 @@ "GitLabPage", "GitLabProject", "GitLabProjectHook", + "GitLabResourceKind", + "add_flagsmith_label_to_gitlab_resource", + "create_flagsmith_label", "create_issue_note", "create_merge_request_note", "create_project_hook", "delete_project_hook", "fetch_gitlab_projects", - "remove_flagsmith_label_from_gitlab_issue", - "remove_flagsmith_label_from_gitlab_merge_request", + "remove_flagsmith_label_from_gitlab_resource", "search_gitlab_issues", "search_gitlab_merge_requests", ] diff --git a/api/integrations/gitlab/client/api.py b/api/integrations/gitlab/client/api.py index 9f5db9a9b982..da6e0fdef892 100644 --- a/api/integrations/gitlab/client/api.py +++ b/api/integrations/gitlab/client/api.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from typing import Any +from typing import Any, Literal from urllib.parse import quote import requests @@ -12,7 +12,14 @@ GitLabProjectHook, T, ) -from integrations.gitlab.constants import GITLAB_CLIENT_TIMEOUT_SECONDS +from integrations.gitlab.constants import ( + GITLAB_CLIENT_TIMEOUT_SECONDS, + GITLAB_FLAGSMITH_LABEL, + GITLAB_FLAGSMITH_LABEL_COLOUR, + GITLAB_FLAGSMITH_LABEL_DESCRIPTION, +) + +GitLabResourceKind = Literal["issues", "merge_requests"] def _get_from_gitlab_api( @@ -26,6 +33,7 @@ def _get_from_gitlab_api( f"{instance_url}/api/v4/{path}", headers={"PRIVATE-TOKEN": access_token}, params=params, + timeout=GITLAB_CLIENT_TIMEOUT_SECONDS, ) response.raise_for_status() return response @@ -171,6 +179,7 @@ def create_project_hook( "merge_requests_events": True, "enable_ssl_verification": True, }, + timeout=GITLAB_CLIENT_TIMEOUT_SECONDS, ) response.raise_for_status() payload = response.json() @@ -187,6 +196,7 @@ def delete_project_hook( response = requests.delete( f"{instance_url}/api/v4/projects/{project_id}/hooks/{hook_id}", headers={"PRIVATE-TOKEN": access_token}, + timeout=GITLAB_CLIENT_TIMEOUT_SECONDS, ) if response.status_code == 404: return @@ -227,3 +237,71 @@ def create_merge_request_note( timeout=GITLAB_CLIENT_TIMEOUT_SECONDS, ) response.raise_for_status() + + +def create_flagsmith_label( + instance_url: str, + access_token: str, + *, + project_path: str, +) -> bool: + """Create the "Flagsmith Feature" label on a GitLab project. + + Returns True if the label was created, False if it already existed. + """ + encoded_path = quote(project_path, safe="") + try: + response = requests.post( + f"{instance_url}/api/v4/projects/{encoded_path}/labels", + headers={"PRIVATE-TOKEN": access_token}, + json={ + "name": GITLAB_FLAGSMITH_LABEL, + "color": GITLAB_FLAGSMITH_LABEL_COLOUR, + "description": GITLAB_FLAGSMITH_LABEL_DESCRIPTION, + }, + timeout=GITLAB_CLIENT_TIMEOUT_SECONDS, + ) + response.raise_for_status() + except requests.HTTPError as exc: + if exc.response is not None and exc.response.status_code == 409: + return False + raise + return True + + +def add_flagsmith_label_to_gitlab_resource( + instance_url: str, + access_token: str, + *, + project_path: str, + resource_kind: GitLabResourceKind, + resource_iid: int, +) -> None: + """Apply the "Flagsmith Feature" label to a GitLab issue or MR, additively.""" + encoded_path = quote(project_path, safe="") + response = requests.put( + f"{instance_url}/api/v4/projects/{encoded_path}/{resource_kind}/{resource_iid}", + headers={"PRIVATE-TOKEN": access_token}, + json={"add_labels": GITLAB_FLAGSMITH_LABEL}, + timeout=GITLAB_CLIENT_TIMEOUT_SECONDS, + ) + response.raise_for_status() + + +def remove_flagsmith_label_from_gitlab_resource( + instance_url: str, + access_token: str, + *, + project_path: str, + resource_kind: GitLabResourceKind, + resource_iid: int, +) -> None: + """Remove the "Flagsmith Feature" label from a GitLab issue or MR.""" + encoded_path = quote(project_path, safe="") + response = requests.put( + f"{instance_url}/api/v4/projects/{encoded_path}/{resource_kind}/{resource_iid}", + headers={"PRIVATE-TOKEN": access_token}, + json={"remove_labels": GITLAB_FLAGSMITH_LABEL}, + timeout=GITLAB_CLIENT_TIMEOUT_SECONDS, + ) + response.raise_for_status() diff --git a/api/integrations/gitlab/constants.py b/api/integrations/gitlab/constants.py index c1aa3b56eaad..b01d5dd5664a 100644 --- a/api/integrations/gitlab/constants.py +++ b/api/integrations/gitlab/constants.py @@ -1,8 +1,15 @@ from enum import Enum -GITLAB_TAG_COLOR = "#FC6D26" GITLAB_CLIENT_TIMEOUT_SECONDS = 10 +GITLAB_FLAGSMITH_LABEL = "Flagsmith Feature" +GITLAB_FLAGSMITH_LABEL_COLOUR = "#6633FF" +GITLAB_FLAGSMITH_LABEL_DESCRIPTION = ( + "This GitLab Issue/MR is linked to a Flagsmith feature" +) + +GITLAB_TAG_COLOR = "#FC6D26" + class GitLabTagLabel(Enum): ISSUE_OPEN = "Issue Open" diff --git a/api/integrations/gitlab/migrations/0002_gitlabconfiguration_tagging_enabled.py b/api/integrations/gitlab/migrations/0002_gitlabconfiguration_tagging_enabled.py deleted file mode 100644 index 916f53eecbe7..000000000000 --- a/api/integrations/gitlab/migrations/0002_gitlabconfiguration_tagging_enabled.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("gitlab", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="gitlabconfiguration", - name="labeling_enabled", - field=models.BooleanField(default=False), - ), - ] diff --git a/api/integrations/gitlab/migrations/0003_gitlabconfiguration_labeling_enabled.py b/api/integrations/gitlab/migrations/0003_gitlabconfiguration_labeling_enabled.py index 916f53eecbe7..98cbf4ad2507 100644 --- a/api/integrations/gitlab/migrations/0003_gitlabconfiguration_labeling_enabled.py +++ b/api/integrations/gitlab/migrations/0003_gitlabconfiguration_labeling_enabled.py @@ -4,7 +4,7 @@ class Migration(migrations.Migration): dependencies = [ - ("gitlab", "0001_initial"), + ("gitlab", "0002_add_gitlab_webhook_model"), ] operations = [ diff --git a/api/integrations/gitlab/models.py b/api/integrations/gitlab/models.py index f87a723e8365..1d4708f7f209 100644 --- a/api/integrations/gitlab/models.py +++ b/api/integrations/gitlab/models.py @@ -11,10 +11,7 @@ class GitLabConfiguration(SoftDeleteExportableModel): ) gitlab_instance_url = models.URLField(max_length=200) access_token = models.CharField(max_length=300) -<<<<<<< HEAD -======= labeling_enabled = models.BooleanField(default=False) ->>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) class GitLabWebhook(SoftDeleteExportableModel): diff --git a/api/integrations/gitlab/services/__init__.py b/api/integrations/gitlab/services/__init__.py index 442db5f3c7b3..25ba1c40466d 100644 --- a/api/integrations/gitlab/services/__init__.py +++ b/api/integrations/gitlab/services/__init__.py @@ -5,6 +5,10 @@ post_state_change_comment, post_unlinked_comment, ) +from integrations.gitlab.services.labels import ( + GITLAB_RESOURCE_KIND_BY_TYPE, + apply_flagsmith_label_to_resource, +) from integrations.gitlab.services.tagging import ( apply_initial_tag, apply_tag_for_event, @@ -23,6 +27,8 @@ ) __all__ = [ + "GITLAB_RESOURCE_KIND_BY_TYPE", + "apply_flagsmith_label_to_resource", "apply_initial_tag", "apply_tag_for_event", "deregister_gitlab_webhook_for_resource", diff --git a/api/integrations/gitlab/services/labels.py b/api/integrations/gitlab/services/labels.py index 281331119244..483acacd1cf5 100644 --- a/api/integrations/gitlab/services/labels.py +++ b/api/integrations/gitlab/services/labels.py @@ -13,6 +13,10 @@ create_flagsmith_label, ) from integrations.gitlab.models import GitLabConfiguration +from integrations.gitlab.services.url_parsing import ( + parse_project_path, + parse_resource_iid, +) logger = structlog.get_logger("gitlab") @@ -31,8 +35,6 @@ def apply_flagsmith_label_to_resource( and apply it to the resource. No-op if labeling is disabled or unconfigured; raises ``ValidationError`` on parse/API failure (rolls back under atomic). """ - from integrations.gitlab.services import parse_project_path, parse_resource_iid - project = resource.feature.project config: GitLabConfiguration | None = GitLabConfiguration.objects.filter( project=project diff --git a/api/integrations/gitlab/tasks.py b/api/integrations/gitlab/tasks.py index cb25f676c589..369dedb278a6 100644 --- a/api/integrations/gitlab/tasks.py +++ b/api/integrations/gitlab/tasks.py @@ -1,27 +1,27 @@ +import requests +import structlog from task_processor.decorators import register_task_handler from features.feature_external_resources.models import FeatureExternalResource -<<<<<<< HEAD from features.models import FeatureState -======= from integrations.gitlab.client import remove_flagsmith_label_from_gitlab_resource ->>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) from integrations.gitlab.models import GitLabConfiguration from integrations.gitlab.services import ( deregister_webhook_for_path, ensure_webhook_registered, has_live_resource_for_path, -<<<<<<< HEAD post_feature_deleted_comment, post_linked_comment, post_state_change_comment, post_unlinked_comment, -======= +) +from integrations.gitlab.services.labels import GITLAB_RESOURCE_KIND_BY_TYPE +from integrations.gitlab.services.url_parsing import ( parse_project_path, parse_resource_iid, ->>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) ) -from integrations.gitlab.services.labels import GITLAB_RESOURCE_KIND_BY_TYPE + +logger = structlog.get_logger("gitlab") @register_task_handler() @@ -76,38 +76,12 @@ def post_gitlab_unlinked_comment( has been unlinked. Dispatched at unlink time. All data is passed directly because the resource row no longer exists. """ -<<<<<<< HEAD post_unlinked_comment( feature_name=feature_name, feature_id=feature_id, resource_url=resource_url, resource_type=resource_type, project_id=project_id, -======= - config: GitLabConfiguration | None = GitLabConfiguration.objects.filter( - project_id=project_id - ).first() - if not config or not config.labeling_enabled: - return - if ( - FeatureExternalResource.objects.filter(url=resource_url) - .exclude(pk=resource_pk) - .exists() - ): - return - - path_with_namespace = parse_project_path(resource_url) - resource_iid = parse_resource_iid(resource_url) - if path_with_namespace is None or resource_iid is None: - return - - log = logger.bind( - project__id=project_id, - feature__id=feature_id, - gitlab_project__path=path_with_namespace, - resource__type=resource_type, - resource__iid=resource_iid, ->>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) ) @@ -117,7 +91,6 @@ def post_gitlab_state_change_comment(feature_state_id: int) -> None: state changes. Dispatched from the feature-state serialiser save hook. """ try: -<<<<<<< HEAD feature_state = FeatureState.objects.select_related( "feature", "environment", @@ -145,7 +118,47 @@ def post_gitlab_feature_deleted_comment( feature_id=feature_id, project_id=project_id, ) -======= + + +@register_task_handler() +def remove_gitlab_label( + *, + project_id: int, + feature_id: int, + resource_pk: int, + resource_url: str, + resource_type: str, +) -> None: + """Best-effort: remove the "Flagsmith Feature" label from a GitLab issue/MR. + No-op if another FeatureExternalResource still references the same URL. + Never raises — failures are logged via ``label.removal_failed``. + """ + config: GitLabConfiguration | None = GitLabConfiguration.objects.filter( + project_id=project_id + ).first() + if not config or not config.labeling_enabled: + return + if ( + FeatureExternalResource.objects.filter(url=resource_url) + .exclude(pk=resource_pk) + .exists() + ): + return + + path_with_namespace = parse_project_path(resource_url) + resource_iid = parse_resource_iid(resource_url) + if path_with_namespace is None or resource_iid is None: + return + + log = logger.bind( + project__id=project_id, + feature__id=feature_id, + gitlab_project__path=path_with_namespace, + resource__type=resource_type, + resource__iid=resource_iid, + ) + + try: remove_flagsmith_label_from_gitlab_resource( config.gitlab_instance_url, config.access_token, @@ -156,4 +169,3 @@ def post_gitlab_feature_deleted_comment( log.info("label.removed") except requests.RequestException: log.exception("label.removal_failed") ->>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) diff --git a/api/integrations/vcs/services.py b/api/integrations/vcs/services.py index 19329b0e6aa0..4fc0aa4c5011 100644 --- a/api/integrations/vcs/services.py +++ b/api/integrations/vcs/services.py @@ -11,6 +11,7 @@ FeatureExternalResource, ) from integrations.gitlab.services import ( + apply_flagsmith_label_to_resource, apply_initial_tag, deregister_gitlab_webhook_for_resource, register_gitlab_webhook_for_resource, @@ -18,6 +19,7 @@ from integrations.gitlab.tasks import ( post_gitlab_linked_comment, post_gitlab_unlinked_comment, + remove_gitlab_label, ) gitlab_logger = structlog.get_logger("gitlab") @@ -28,6 +30,7 @@ def dispatch_vcs_on_resource_create(resource: FeatureExternalResource) -> None: is created. """ if resource.type in GITLAB_RESOURCE_TYPES: + apply_flagsmith_label_to_resource(resource) gitlab_logger.info( "resource.linked", organisation__id=resource.feature.project.organisation_id, @@ -45,6 +48,15 @@ def dispatch_vcs_on_resource_destroy(resource: FeatureExternalResource) -> None: has been destroyed. `resource` is a memory-only object at this point. """ if resource.type in GITLAB_RESOURCE_TYPES: + remove_gitlab_label.delay( + kwargs={ + "project_id": resource.feature.project_id, + "feature_id": resource.feature_id, + "resource_pk": resource.pk, + "resource_url": resource.url, + "resource_type": resource.type, + }, + ) post_gitlab_unlinked_comment.delay( args=( resource.feature.name, diff --git a/api/tests/integration/features/test_gitlab_external_resources.py b/api/tests/integration/features/test_gitlab_external_resources.py index 6d730f8ef066..426b7dc1c44c 100644 --- a/api/tests/integration/features/test_gitlab_external_resources.py +++ b/api/tests/integration/features/test_gitlab_external_resources.py @@ -14,36 +14,6 @@ from projects.tags.models import TagType -<<<<<<< HEAD -======= - -@pytest.fixture() -def gitlab_config(project: int) -> GitLabConfiguration: - config: GitLabConfiguration = GitLabConfiguration.objects.create( - project=Project.objects.get(id=project), - gitlab_instance_url=GITLAB_INSTANCE_URL, - access_token=GITLAB_ACCESS_TOKEN, - ) - return config - - -@pytest.fixture() -def gitlab_config_with_labeling(project: int) -> GitLabConfiguration: - config: GitLabConfiguration = GitLabConfiguration.objects.create( - project=Project.objects.get(id=project), - gitlab_instance_url=GITLAB_INSTANCE_URL, - access_token=GITLAB_ACCESS_TOKEN, - labeling_enabled=True, - ) - return config - - -def _mock_webhook_registration() -> None: - responses.post(GITLAB_HOOKS_URL, json={"id": 1, "project_id": 1}, status=201) - - -@pytest.mark.django_db() ->>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) def test_create_external_resource__gitlab_issue__returns_201( admin_client: APIClient, project: int, @@ -610,392 +580,166 @@ def test_list_external_resources__gitlab_issue__returns_200( assert results[0]["metadata"] == {"title": "Fix login bug", "state": "opened"} -def test_list_external_resources__gitlab_merge_request__returns_200( - admin_client: APIClient, - project: int, - feature: int, -) -> None: - # Given - FeatureExternalResource.objects.create( - url="https://gitlab.com/testorg/testrepo/-/merge_requests/7", - type="GITLAB_MR", - feature=Feature.objects.get(id=feature), - metadata='{"title": "Add login button", "state": "opened"}', - ) - - # When - response = admin_client.get( - f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", - ) - - # Then - assert response.status_code == status.HTTP_200_OK - results = response.json()["results"] - assert len(results) == 1 - assert results[0]["type"] == "GITLAB_MR" - assert results[0]["metadata"] == {"title": "Add login button", "state": "opened"} -<<<<<<< HEAD -======= - - -@pytest.mark.django_db() @responses.activate -def test_create_external_resource__gitlab_issue_with_labeling_enabled__creates_and_applies_label( +def test_create_external_resource__gitlab_issue_with_labeling__applies_label( admin_client: APIClient, + organisation: int, project: int, feature: int, - gitlab_config_with_labeling: GitLabConfiguration, log: StructuredLogCapture, ) -> None: # Given - label_create = responses.post( - GITLAB_LABELS_URL, - json={"id": 1, "name": "Flagsmith Feature"}, + project_instance = Project.objects.get(id=project) + GitLabConfiguration.objects.create( + project=project_instance, + gitlab_instance_url="https://gitlab.example.com", + access_token="glpat-test-token", + labeling_enabled=True, + ) + responses.post( + "https://gitlab.example.com/api/v4/projects/testorg%2Ftestrepo/labels", + json={"name": "Flagsmith Feature"}, status=201, - match=[ - responses.matchers.header_matcher({"PRIVATE-TOKEN": GITLAB_ACCESS_TOKEN}), - responses.matchers.json_params_matcher( - { - "name": "Flagsmith Feature", - "color": "#6633FF", - "description": ( - "This GitLab Issue/MR is linked to a Flagsmith Feature Flag" - ), - }, - ), - ], ) - label_apply = responses.put( - GITLAB_ISSUE_API_URL, - json={"iid": 42, "labels": ["Flagsmith Feature"]}, + responses.put( + "https://gitlab.example.com/api/v4/projects/testorg%2Ftestrepo/issues/42", + json={"iid": 42}, status=200, match=[ - responses.matchers.json_params_matcher({"add_labels": "Flagsmith Feature"}), + responses.matchers.json_params_matcher( + {"add_labels": "Flagsmith Feature"}, + ), ], ) - _mock_webhook_registration() - - # When - response = admin_client.post( - f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", - data={"type": "GITLAB_ISSUE", "url": GITLAB_ISSUE_URL, "feature": feature}, - format="json", - ) - - # Then - assert response.status_code == status.HTTP_201_CREATED - assert label_create.call_count == 1 - assert label_apply.call_count == 1 - assert FeatureExternalResource.objects.count() == 1 - assert [e["event"] for e in log.events] == [ - "label.created", - "webhook.registered", - "issue.linked", - ] - - -@pytest.mark.django_db() -@responses.activate -def test_create_external_resource__gitlab_mr_with_labeling_enabled__creates_and_applies_label( - admin_client: APIClient, - project: int, - feature: int, - gitlab_config_with_labeling: GitLabConfiguration, - log: StructuredLogCapture, -) -> None: - # Given responses.post( - GITLAB_LABELS_URL, - json={"id": 1, "name": "Flagsmith Feature"}, + "https://gitlab.example.com/api/v4/projects/testorg%2Ftestrepo/hooks", + json={"id": 1, "project_id": 1}, status=201, ) - label_apply = responses.put( - GITLAB_MR_API_URL, - json={"iid": 7, "labels": ["Flagsmith Feature"]}, - status=200, - match=[ - responses.matchers.json_params_matcher({"add_labels": "Flagsmith Feature"}), - ], + responses.post( + "https://gitlab.example.com/api/v4/projects/testorg%2Ftestrepo/issues/42/notes", + json={"id": 1}, + status=201, ) - _mock_webhook_registration() # When response = admin_client.post( f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", - data={"type": "GITLAB_MR", "url": GITLAB_MR_URL, "feature": feature}, + data={ + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/testorg/testrepo/-/issues/42", + "feature": feature, + "metadata": {"title": "Bug fix", "state": "opened"}, + }, format="json", ) # Then assert response.status_code == status.HTTP_201_CREATED - assert label_apply.call_count == 1 - assert [e["event"] for e in log.events] == [ - "label.created", - "webhook.registered", - "merge_request.linked", - ] + assert any(e["event"] == "label.created" for e in log.events) + assert any(e["event"] == "resource.linked" for e in log.events) -@pytest.mark.django_db() @responses.activate def test_create_external_resource__gitlab_issue_with_labeling_disabled__skips_label_api( admin_client: APIClient, project: int, feature: int, - gitlab_config: GitLabConfiguration, log: StructuredLogCapture, ) -> None: # Given - _mock_webhook_registration() - - # When - response = admin_client.post( - f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", - data={"type": "GITLAB_ISSUE", "url": GITLAB_ISSUE_URL, "feature": feature}, - format="json", + project_instance = Project.objects.get(id=project) + GitLabConfiguration.objects.create( + project=project_instance, + gitlab_instance_url="https://gitlab.example.com", + access_token="glpat-test-token", + labeling_enabled=False, ) - - # Then - assert response.status_code == status.HTTP_201_CREATED - assert [e["event"] for e in log.events] == ["webhook.registered", "issue.linked"] - - -@pytest.mark.django_db() -@responses.activate -def test_create_external_resource__gitlab_issue_label_already_exists__applies_label( - admin_client: APIClient, - project: int, - feature: int, - gitlab_config_with_labeling: GitLabConfiguration, - log: StructuredLogCapture, -) -> None: - # Given responses.post( - GITLAB_LABELS_URL, - json={"message": {"title": ["has already been taken"]}}, - status=409, - ) - label_apply = responses.put( - GITLAB_ISSUE_API_URL, - json={"iid": 42, "labels": ["Flagsmith Feature"]}, - status=200, - ) - _mock_webhook_registration() - - # When - response = admin_client.post( - f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", - data={"type": "GITLAB_ISSUE", "url": GITLAB_ISSUE_URL, "feature": feature}, - format="json", + "https://gitlab.example.com/api/v4/projects/testorg%2Ftestrepo/hooks", + json={"id": 1, "project_id": 1}, + status=201, ) - - # Then - assert response.status_code == status.HTTP_201_CREATED - assert label_apply.call_count == 1 - assert [e["event"] for e in log.events] == [ - "webhook.registered", - "issue.linked", - ] - - -@pytest.mark.django_db() -@responses.activate -def test_create_external_resource__gitlab_issue_label_apply_fails__rolls_back_link( - admin_client: APIClient, - project: int, - feature: int, - gitlab_config_with_labeling: GitLabConfiguration, - log: StructuredLogCapture, -) -> None: - # Given responses.post( - GITLAB_LABELS_URL, - json={"id": 1, "name": "Flagsmith Feature"}, + "https://gitlab.example.com/api/v4/projects/testorg%2Ftestrepo/issues/42/notes", + json={"id": 1}, status=201, ) - responses.put(GITLAB_ISSUE_API_URL, json={"message": "403 Forbidden"}, status=403) # When response = admin_client.post( f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", - data={"type": "GITLAB_ISSUE", "url": GITLAB_ISSUE_URL, "feature": feature}, + data={ + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/testorg/testrepo/-/issues/42", + "feature": feature, + "metadata": {"title": "Bug fix", "state": "opened"}, + }, format="json", ) - # Then - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert FeatureExternalResource.objects.count() == 0 - assert [e["event"] for e in log.events] == ["label.created", "label.failed"] + # Then — no label API called, resource still created. + assert response.status_code == status.HTTP_201_CREATED + assert not any(e["event"] == "label.created" for e in log.events) -@pytest.mark.django_db() @responses.activate -def test_create_external_resource__gitlab_issue_label_create_fails__rolls_back_link( +def test_create_external_resource__gitlab_issue_label_api_failure__returns_400( admin_client: APIClient, project: int, feature: int, - gitlab_config_with_labeling: GitLabConfiguration, - log: StructuredLogCapture, ) -> None: # Given + project_instance = Project.objects.get(id=project) + GitLabConfiguration.objects.create( + project=project_instance, + gitlab_instance_url="https://gitlab.example.com", + access_token="glpat-test-token", + labeling_enabled=True, + ) responses.post( - GITLAB_LABELS_URL, - json={"message": "internal server error"}, - status=500, + "https://gitlab.example.com/api/v4/projects/testorg%2Ftestrepo/labels", + status=403, ) # When - response = admin_client.post( - f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", - data={"type": "GITLAB_ISSUE", "url": GITLAB_ISSUE_URL, "feature": feature}, - format="json", - ) - - # Then - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert FeatureExternalResource.objects.count() == 0 - assert [e["event"] for e in log.events] == ["label.failed"] - - -@pytest.mark.django_db() -@responses.activate -def test_create_external_resource__gitlab_issue_invalid_url__rolls_back_link( - admin_client: APIClient, - project: int, - feature: int, - gitlab_config_with_labeling: GitLabConfiguration, -) -> None: - # Given / When response = admin_client.post( f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", data={ "type": "GITLAB_ISSUE", - "url": f"{GITLAB_INSTANCE_URL}/not-a-valid-resource-url", + "url": "https://gitlab.example.com/testorg/testrepo/-/issues/42", "feature": feature, + "metadata": {"title": "Bug fix", "state": "opened"}, }, format="json", ) - # Then + # Then — resource not created, transaction rolled back. assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json() == {"url": "Could not parse GitLab resource URL."} - assert FeatureExternalResource.objects.count() == 0 + assert not FeatureExternalResource.objects.exists() -@pytest.mark.django_db() -@responses.activate -@pytest.mark.parametrize( - "other_links_count, expected_removal_calls", - [(0, 1), (1, 0)], - ids=["last_link_removes_label", "shared_link_keeps_label"], -) -def test_delete_external_resource__gitlab_issue__removes_label_only_when_last_link( - other_links_count: int, - expected_removal_calls: int, - admin_client: APIClient, - project: int, - feature: int, - gitlab_config_with_labeling: GitLabConfiguration, -) -> None: - # Given - resource = FeatureExternalResource.objects.create( - url=GITLAB_ISSUE_URL, - type="GITLAB_ISSUE", - feature=Feature.objects.get(id=feature), - ) - for i in range(other_links_count): - other_feature = Feature.objects.create( - name=f"other_feature_{i}", - project=gitlab_config_with_labeling.project, - ) - FeatureExternalResource.objects.create( - url=GITLAB_ISSUE_URL, - type="GITLAB_ISSUE", - feature=other_feature, - ) - label_remove = responses.put( - GITLAB_ISSUE_API_URL, - json={"iid": 42, "labels": []}, - status=200, - match=[ - responses.matchers.json_params_matcher({"remove_labels": "Flagsmith Feature"}), - ], - ) - - # When - response = admin_client.delete( - f"/api/v1/projects/{project}/features/{feature}" - f"/feature-external-resources/{resource.id}/", - ) - - # Then - assert response.status_code == status.HTTP_204_NO_CONTENT - assert label_remove.call_count == expected_removal_calls - - -@pytest.mark.django_db() -@responses.activate -def test_delete_external_resource__gitlab_mr_last_link__removes_label( +def test_list_external_resources__gitlab_merge_request__returns_200( admin_client: APIClient, project: int, feature: int, - gitlab_config_with_labeling: GitLabConfiguration, ) -> None: # Given - resource = FeatureExternalResource.objects.create( - url=GITLAB_MR_URL, + FeatureExternalResource.objects.create( + url="https://gitlab.com/testorg/testrepo/-/merge_requests/7", type="GITLAB_MR", feature=Feature.objects.get(id=feature), - ) - label_remove = responses.put( - GITLAB_MR_API_URL, - json={"iid": 7, "labels": []}, - status=200, - match=[ - responses.matchers.json_params_matcher({"remove_labels": "Flagsmith Feature"}), - ], - ) - - # When - response = admin_client.delete( - f"/api/v1/projects/{project}/features/{feature}" - f"/feature-external-resources/{resource.id}/", - ) - - # Then - assert response.status_code == status.HTTP_204_NO_CONTENT - assert label_remove.call_count == 1 - - -@pytest.mark.django_db() -@responses.activate -def test_delete_external_resource__gitlab_label_removal_fails__unlink_still_succeeds( - admin_client: APIClient, - project: int, - feature: int, - gitlab_config_with_labeling: GitLabConfiguration, - log: StructuredLogCapture, -) -> None: - # Given - resource = FeatureExternalResource.objects.create( - url=GITLAB_ISSUE_URL, - type="GITLAB_ISSUE", - feature=Feature.objects.get(id=feature), - ) - responses.put( - GITLAB_ISSUE_API_URL, - json={"message": "500 Internal Server Error"}, - status=500, + metadata='{"title": "Add login button", "state": "opened"}', ) # When - response = admin_client.delete( - f"/api/v1/projects/{project}/features/{feature}" - f"/feature-external-resources/{resource.id}/", + response = admin_client.get( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", ) # Then - assert response.status_code == status.HTTP_204_NO_CONTENT - assert not FeatureExternalResource.objects.filter(pk=resource.pk).exists() - assert "label.removal_failed" in {e["event"] for e in log.events} ->>>>>>> ee9265c90 (feat: rename tagging_enabled to labeling_enabled on GitLabConfiguration) + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["type"] == "GITLAB_MR" + assert results[0]["metadata"] == {"title": "Add login button", "state": "opened"} diff --git a/api/tests/unit/integrations/gitlab/test_client.py b/api/tests/unit/integrations/gitlab/test_client.py index 0c8e9df6fdda..32138d7b30e3 100644 --- a/api/tests/unit/integrations/gitlab/test_client.py +++ b/api/tests/unit/integrations/gitlab/test_client.py @@ -6,6 +6,7 @@ create_issue_note, create_merge_request_note, fetch_gitlab_projects, + remove_flagsmith_label_from_gitlab_resource, search_gitlab_issues, search_gitlab_merge_requests, ) @@ -316,3 +317,63 @@ def test_create_merge_request_note__server_error__raises() -> None: merge_request_iid=7, body="MR comment", ) + + +@responses.activate +def test_remove_flagsmith_label_from_gitlab_resource__gitlab_issue__puts_remove_labels() -> ( + None +): + # Given + responses.put( + f"{INSTANCE_URL}/api/v4/projects/g%2Fp/issues/42", + json={"iid": 42, "labels": []}, + status=200, + match=[ + responses.matchers.header_matcher({"PRIVATE-TOKEN": ACCESS_TOKEN}), + responses.matchers.json_params_matcher( + {"remove_labels": "Flagsmith Feature"}, + ), + ], + ) + + # When + remove_flagsmith_label_from_gitlab_resource( + INSTANCE_URL, + ACCESS_TOKEN, + project_path="g/p", + resource_kind="issues", + resource_iid=42, + ) + + # Then + assert len(responses.calls) == 1 + + +@responses.activate +def test_remove_flagsmith_label_from_gitlab_resource__gitlab_mr__puts_remove_labels() -> ( + None +): + # Given + responses.put( + f"{INSTANCE_URL}/api/v4/projects/g%2Fp/merge_requests/7", + json={"iid": 7, "labels": []}, + status=200, + match=[ + responses.matchers.header_matcher({"PRIVATE-TOKEN": ACCESS_TOKEN}), + responses.matchers.json_params_matcher( + {"remove_labels": "Flagsmith Feature"}, + ), + ], + ) + + # When + remove_flagsmith_label_from_gitlab_resource( + INSTANCE_URL, + ACCESS_TOKEN, + project_path="g/p", + resource_kind="merge_requests", + resource_iid=7, + ) + + # Then + assert len(responses.calls) == 1 diff --git a/api/tests/unit/integrations/gitlab/test_mappers.py b/api/tests/unit/integrations/gitlab/test_mappers.py index 8a92418d12de..e5cbdd3372e7 100644 --- a/api/tests/unit/integrations/gitlab/test_mappers.py +++ b/api/tests/unit/integrations/gitlab/test_mappers.py @@ -5,7 +5,7 @@ def test_map_gitlab_resource_to_tag_label__non_gitlab_type__returns_none() -> None: - # Given — a non-GitLab resource type reaches the mapper defensively. + # Given resource = Mock(type=ResourceType.GITHUB_ISSUE.value, metadata='{"state": "open"}') # When diff --git a/api/tests/unit/integrations/gitlab/test_tasks.py b/api/tests/unit/integrations/gitlab/test_tasks.py index 5527c922ac27..99c3e82de3a2 100644 --- a/api/tests/unit/integrations/gitlab/test_tasks.py +++ b/api/tests/unit/integrations/gitlab/test_tasks.py @@ -1,14 +1,23 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + import pytest import pytest_mock from features.models import FeatureState +from integrations.gitlab.models import GitLabConfiguration from integrations.gitlab.tasks import ( post_gitlab_feature_deleted_comment, post_gitlab_linked_comment, post_gitlab_state_change_comment, post_gitlab_unlinked_comment, + remove_gitlab_label, ) +if TYPE_CHECKING: + from projects.models import Project + @pytest.mark.django_db def test_post_gitlab_linked_comment_task__resource_missing__noop( @@ -109,3 +118,28 @@ def test_post_gitlab_feature_deleted_comment_task__called__delegates_to_service( feature_id=99, project_id=42, ) + + +@pytest.mark.django_db +def test_remove_gitlab_label__unparseable_url__noop( + project: Project, +) -> None: + # Given + config = GitLabConfiguration.objects.create( + project=project, + gitlab_instance_url="https://gitlab.example.com", + access_token="glpat-test-token", + labeling_enabled=True, + ) + + # When + remove_gitlab_label( + project_id=config.project_id, + feature_id=0, + resource_pk=0, + resource_url="https://gitlab.example.com/not-a-resource", + resource_type="GITLAB_ISSUE", + ) + + # Then + assert GitLabConfiguration.objects.filter(project=project).exists() diff --git a/api/tests/unit/integrations/gitlab/test_webhooks.py b/api/tests/unit/integrations/gitlab/test_webhooks.py index 0c400f995153..a62fcf139194 100644 --- a/api/tests/unit/integrations/gitlab/test_webhooks.py +++ b/api/tests/unit/integrations/gitlab/test_webhooks.py @@ -24,7 +24,7 @@ def test_ensure_webhook_registered__gitlab_http_error__logs_and_raises( log: StructuredLogCapture, mocker: MockerFixture, ) -> None: - # Given — GitLab rejects the hook creation. + # Given responses.post( "https://gitlab.example.com/api/v4/projects/testorg%2Ftestrepo/hooks", status=500, @@ -52,10 +52,10 @@ def test_deregister_webhook_for_path__no_matching_webhook__noop( gitlab_config: GitLabConfiguration, log: StructuredLogCapture, ) -> None: - # Given / When — no webhook row exists for this (config, path) pair. + # Given / When deregister_webhook_for_path(gitlab_config, "never/registered") - # Then — nothing logged, nothing raised. + # Then assert log.events == [] @@ -63,10 +63,10 @@ def test_deregister_webhook_for_path__no_matching_webhook__noop( def test_register_gitlab_webhook_task__config_missing__noop( log: StructuredLogCapture, ) -> None: - # Given / When — a stale task fires after the config was hard-deleted. + # Given / When register_gitlab_webhook(config_id=999_999, project_path="testorg/testrepo") - # Then — no webhook created, no log. + # Then assert not GitLabWebhook.objects.exists() assert log.events == [] @@ -77,7 +77,7 @@ def test_deregister_gitlab_webhook_hook__unparseable_url__noop( feature: Feature, log: StructuredLogCapture, ) -> None: - # Given — a GitLab-typed link whose URL doesn't match the issue/MR shape. + # Given resource = FeatureExternalResource.objects.create( url="https://gitlab.example.com/not-a-resource", type=ResourceType.GITLAB_ISSUE.value, @@ -87,7 +87,7 @@ def test_deregister_gitlab_webhook_hook__unparseable_url__noop( # When resource.delete() - # Then — no deregistration attempted. + # Then assert log.events == [] @@ -96,7 +96,7 @@ def test_deregister_gitlab_webhook_hook__no_config__noop( feature: Feature, log: StructuredLogCapture, ) -> None: - # Given — a GitLab-typed link exists but the config was removed. + # Given resource = FeatureExternalResource.objects.create( url="https://gitlab.example.com/testorg/testrepo/-/issues/1", type=ResourceType.GITLAB_ISSUE.value, @@ -106,5 +106,5 @@ def test_deregister_gitlab_webhook_hook__no_config__noop( # When resource.delete() - # Then — no deregistration attempted. + # Then assert log.events == [] diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 161ba34808f8..6937fa991275 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -133,10 +133,60 @@ Attributes: - `project.id` - `tag.label` +### `gitlab.label.created` + +Logged at `info` from: + - `api/integrations/gitlab/services/labels.py:66` + +Attributes: + - `feature.id` + - `gitlab_project.path` + - `organisation.id` + - `project.id` + - `resource.iid` + - `resource.type` + +### `gitlab.label.failed` + +Logged at `exception` from: + - `api/integrations/gitlab/services/labels.py:76` + +Attributes: + - `feature.id` + - `gitlab_project.path` + - `organisation.id` + - `project.id` + - `resource.iid` + - `resource.type` + +### `gitlab.label.removal_failed` + +Logged at `exception` from: + - `api/integrations/gitlab/tasks.py:171` + +Attributes: + - `feature.id` + - `gitlab_project.path` + - `project.id` + - `resource.iid` + - `resource.type` + +### `gitlab.label.removed` + +Logged at `info` from: + - `api/integrations/gitlab/tasks.py:169` + +Attributes: + - `feature.id` + - `gitlab_project.path` + - `project.id` + - `resource.iid` + - `resource.type` + ### `gitlab.resource.linked` Logged at `info` from: - - `api/integrations/vcs/services.py:31` + - `api/integrations/vcs/services.py:34` Attributes: - `feature.id` @@ -147,7 +197,7 @@ Attributes: ### `gitlab.resource.unlinked` Logged at `info` from: - - `api/integrations/vcs/services.py:58` + - `api/integrations/vcs/services.py:70` Attributes: - `feature.id` From 0e575883231becd6fb892b616be284b64fb1c502 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 23 Apr 2026 18:23:54 +0100 Subject: [PATCH 15/21] feat: test-coverage --- .../unit/integrations/gitlab/test_client.py | 20 +++++ .../unit/integrations/gitlab/test_tasks.py | 90 ++++++++++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/api/tests/unit/integrations/gitlab/test_client.py b/api/tests/unit/integrations/gitlab/test_client.py index 32138d7b30e3..c75828eeadf0 100644 --- a/api/tests/unit/integrations/gitlab/test_client.py +++ b/api/tests/unit/integrations/gitlab/test_client.py @@ -3,6 +3,7 @@ import responses from integrations.gitlab.client import ( + create_flagsmith_label, create_issue_note, create_merge_request_note, fetch_gitlab_projects, @@ -377,3 +378,22 @@ def test_remove_flagsmith_label_from_gitlab_resource__gitlab_mr__puts_remove_lab # Then assert len(responses.calls) == 1 + + +@responses.activate +def test_create_flagsmith_label__label_already_exists__returns_false() -> None: + # Given + responses.post( + f"{INSTANCE_URL}/api/v4/projects/g%2Fp/labels", + status=409, + ) + + # When + result = create_flagsmith_label( + INSTANCE_URL, + ACCESS_TOKEN, + project_path="g/p", + ) + + # Then + assert result is False diff --git a/api/tests/unit/integrations/gitlab/test_tasks.py b/api/tests/unit/integrations/gitlab/test_tasks.py index 99c3e82de3a2..e3e2c20aafa7 100644 --- a/api/tests/unit/integrations/gitlab/test_tasks.py +++ b/api/tests/unit/integrations/gitlab/test_tasks.py @@ -4,8 +4,11 @@ import pytest import pytest_mock +import responses +from pytest_structlog import StructuredLogCapture -from features.models import FeatureState +from features.feature_external_resources.models import FeatureExternalResource +from features.models import Feature, FeatureState from integrations.gitlab.models import GitLabConfiguration from integrations.gitlab.tasks import ( post_gitlab_feature_deleted_comment, @@ -143,3 +146,88 @@ def test_remove_gitlab_label__unparseable_url__noop( # Then assert GitLabConfiguration.objects.filter(project=project).exists() + + +@pytest.mark.django_db +def test_remove_gitlab_label__another_resource_exists__noop( + project: Project, + feature: Feature, +) -> None: + # Given + GitLabConfiguration.objects.create( + project=project, + gitlab_instance_url="https://gitlab.example.com", + access_token="glpat-test-token", + labeling_enabled=True, + ) + url = "https://gitlab.example.com/testorg/testrepo/-/issues/1" + resource = FeatureExternalResource.objects.create( + url=url, + type="GITLAB_ISSUE", + feature=feature, + ) + other = FeatureExternalResource.objects.create( + url=url, + type="GITLAB_ISSUE", + feature=Feature.objects.create( + name="other_flag", + project=project, + initial_value="", + ), + ) + + # When + remove_gitlab_label( + project_id=project.id, + feature_id=feature.id, + resource_pk=resource.pk, + resource_url=url, + resource_type="GITLAB_ISSUE", + ) + + # Then + assert FeatureExternalResource.objects.filter(pk=other.pk).exists() + + +@pytest.mark.django_db +@responses.activate +@pytest.mark.parametrize( + "api_status, expected_event", + [ + (200, "label.removed"), + (500, "label.removal_failed"), + ], + ids=["success", "api_failure"], +) +def test_remove_gitlab_label__last_resource__calls_api_and_logs( + project: Project, + feature: Feature, + log: StructuredLogCapture, + api_status: int, + expected_event: str, +) -> None: + # Given + GitLabConfiguration.objects.create( + project=project, + gitlab_instance_url="https://gitlab.example.com", + access_token="glpat-test-token", + labeling_enabled=True, + ) + responses.put( + "https://gitlab.example.com/api/v4/projects/testorg%2Ftestrepo/issues/1", + json={"iid": 1}, + status=api_status, + ) + + # When + remove_gitlab_label( + project_id=project.id, + feature_id=feature.id, + resource_pk=0, + resource_url="https://gitlab.example.com/testorg/testrepo/-/issues/1", + resource_type="GITLAB_ISSUE", + ) + + # Then + assert len(responses.calls) == 1 + assert any(e["event"] == expected_event for e in log.events) From 8e59908dc08dd4ad64fe77527ea22240f874ebc3 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Apr 2026 08:10:30 +0100 Subject: [PATCH 16/21] feat: fix tests --- api/integrations/vcs/services.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/integrations/vcs/services.py b/api/integrations/vcs/services.py index da79ccf7ab61..44e9d397746b 100644 --- a/api/integrations/vcs/services.py +++ b/api/integrations/vcs/services.py @@ -22,6 +22,9 @@ def dispatch_vcs_on_resource_create(resource: FeatureExternalResource) -> None: """ if resource.type in GITLAB_RESOURCE_TYPES: gitlab.apply_flagsmith_label_to_resource(resource) + gitlab.register_gitlab_webhook_for_resource(resource) + gitlab.apply_initial_tag(resource) + gitlab_tasks.post_gitlab_linked_comment.delay(args=(resource.id,)) gitlab_logger.info( "resource.linked", organisation__id=resource.feature.project.organisation_id, @@ -29,9 +32,6 @@ def dispatch_vcs_on_resource_create(resource: FeatureExternalResource) -> None: feature__id=resource.feature.id, resource__type=resource.type.lower(), ) - gitlab.register_gitlab_webhook_for_resource(resource) - gitlab.apply_initial_tag(resource) - gitlab_tasks.post_gitlab_linked_comment.delay(args=(resource.id,)) def dispatch_vcs_on_resource_destroy(resource: FeatureExternalResource) -> None: From 43a03a6601009bdffd0ff1329fe2b485f03516ef Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Apr 2026 08:43:19 +0100 Subject: [PATCH 17/21] feat: coverage and catalogue --- .../test_gitlab_external_resources.py | 30 +++++++++++++++++++ .../observability/_events-catalogue.md | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/api/tests/integration/features/test_gitlab_external_resources.py b/api/tests/integration/features/test_gitlab_external_resources.py index f9ce99ce1725..342f532add08 100644 --- a/api/tests/integration/features/test_gitlab_external_resources.py +++ b/api/tests/integration/features/test_gitlab_external_resources.py @@ -719,6 +719,36 @@ def test_create_external_resource__gitlab_issue_label_api_failure__returns_400( assert not FeatureExternalResource.objects.exists() +def test_create_external_resource__gitlab_issue_with_labeling_unparseable_url__returns_400( + admin_client: APIClient, + project: int, + feature: int, +) -> None: + # Given + project_instance = Project.objects.get(id=project) + GitLabConfiguration.objects.create( + project=project_instance, + gitlab_instance_url="https://gitlab.example.com", + access_token="glpat-test-token", + labeling_enabled=True, + ) + + # When + response = admin_client.post( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", + data={ + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/not-a-resource", + "feature": feature, + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not FeatureExternalResource.objects.exists() + + def test_list_external_resources__gitlab_merge_request__returns_200( admin_client: APIClient, project: int, diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 2c3414a20195..dffb48c565c1 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -186,7 +186,7 @@ Attributes: ### `gitlab.resource.linked` Logged at `info` from: - - `api/integrations/vcs/services.py:25` + - `api/integrations/vcs/services.py:28` Attributes: - `feature.id` From 55623b5f5b8ad7b1e7556d58ada8f2220de0f3f7 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Apr 2026 11:24:36 +0100 Subject: [PATCH 18/21] feat: reviewed docstrings --- api/integrations/gitlab/tasks.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/integrations/gitlab/tasks.py b/api/integrations/gitlab/tasks.py index 369dedb278a6..6e181b5a900e 100644 --- a/api/integrations/gitlab/tasks.py +++ b/api/integrations/gitlab/tasks.py @@ -129,9 +129,8 @@ def remove_gitlab_label( resource_url: str, resource_type: str, ) -> None: - """Best-effort: remove the "Flagsmith Feature" label from a GitLab issue/MR. + """Remove the "Flagsmith Feature" label from a GitLab issue/MR. No-op if another FeatureExternalResource still references the same URL. - Never raises — failures are logged via ``label.removal_failed``. """ config: GitLabConfiguration | None = GitLabConfiguration.objects.filter( project_id=project_id From d01117a29747cca8924f40e9fbe0e0a67a13996a Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Apr 2026 11:45:57 +0100 Subject: [PATCH 19/21] feat: regenerated catalogue --- .../observability/_events-catalogue.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index dffb48c565c1..85ccd0b28d52 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -162,7 +162,7 @@ Attributes: ### `gitlab.label.removal_failed` Logged at `exception` from: - - `api/integrations/gitlab/tasks.py:171` + - `api/integrations/gitlab/tasks.py:170` Attributes: - `feature.id` @@ -174,7 +174,7 @@ Attributes: ### `gitlab.label.removed` Logged at `info` from: - - `api/integrations/gitlab/tasks.py:169` + - `api/integrations/gitlab/tasks.py:168` Attributes: - `feature.id` From 383ca895428f1374594b412df3c3c92d328c56a5 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Apr 2026 14:16:28 +0100 Subject: [PATCH 20/21] feat: move apply label into a task --- .../feature_external_resources/views.py | 2 - api/integrations/gitlab/services/labels.py | 20 +++------ api/integrations/gitlab/tasks.py | 17 +++++++- api/integrations/vcs/services.py | 2 +- .../test_gitlab_external_resources.py | 31 ++++++++++---- .../unit/integrations/gitlab/test_tasks.py | 41 +++++++++++++++++++ .../observability/_events-catalogue.md | 4 +- 7 files changed, 89 insertions(+), 28 deletions(-) diff --git a/api/features/feature_external_resources/views.py b/api/features/feature_external_resources/views.py index 1ae5261eeee8..2438a72c20e4 100644 --- a/api/features/feature_external_resources/views.py +++ b/api/features/feature_external_resources/views.py @@ -1,6 +1,5 @@ import re -from django.db import transaction from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema @@ -144,7 +143,6 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def] status=status.HTTP_400_BAD_REQUEST, ) - @transaction.atomic def perform_create(self, serializer: FeatureExternalResourceSerializer) -> None: # type: ignore[override] resource = serializer.save() dispatch_vcs_on_resource_create(resource) diff --git a/api/integrations/gitlab/services/labels.py b/api/integrations/gitlab/services/labels.py index 483acacd1cf5..642e40944e3d 100644 --- a/api/integrations/gitlab/services/labels.py +++ b/api/integrations/gitlab/services/labels.py @@ -2,7 +2,6 @@ import requests import structlog -from rest_framework.exceptions import ValidationError from features.feature_external_resources.models import ( FeatureExternalResource, @@ -31,9 +30,10 @@ def apply_flagsmith_label_to_resource( resource: FeatureExternalResource, ) -> None: - """Ensure the "Flagsmith Feature" label exists on the resource's GitLab project - and apply it to the resource. No-op if labeling is disabled or unconfigured; - raises ``ValidationError`` on parse/API failure (rolls back under atomic). + """Ensure the "Flagsmith Feature" label exists on the resource's GitLab + project and apply it to the resource. No-op if labelling is disabled, + unconfigured, or the URL is unparseable. Never raises — failures are + logged via ``label.failed``. """ project = resource.feature.project config: GitLabConfiguration | None = GitLabConfiguration.objects.filter( @@ -45,7 +45,7 @@ def apply_flagsmith_label_to_resource( path_with_namespace = parse_project_path(resource.url) resource_iid = parse_resource_iid(resource.url) if path_with_namespace is None or resource_iid is None: - raise ValidationError({"url": "Could not parse GitLab resource URL."}) + return log = logger.bind( organisation__id=project.organisation_id, @@ -72,13 +72,5 @@ def apply_flagsmith_label_to_resource( resource_kind=GITLAB_RESOURCE_KIND_BY_TYPE[resource.type], resource_iid=resource_iid, ) - except requests.RequestException as exc: + except requests.RequestException: log.exception("label.failed") - raise ValidationError( - { - "detail": ( - "Failed to apply the Flagsmith Feature label on GitLab. " - "Check the GitLab access token's permissions and try again." - ), - }, - ) from exc diff --git a/api/integrations/gitlab/tasks.py b/api/integrations/gitlab/tasks.py index 6e181b5a900e..352675c21ebc 100644 --- a/api/integrations/gitlab/tasks.py +++ b/api/integrations/gitlab/tasks.py @@ -15,7 +15,10 @@ post_state_change_comment, post_unlinked_comment, ) -from integrations.gitlab.services.labels import GITLAB_RESOURCE_KIND_BY_TYPE +from integrations.gitlab.services.labels import ( + GITLAB_RESOURCE_KIND_BY_TYPE, + apply_flagsmith_label_to_resource, +) from integrations.gitlab.services.url_parsing import ( parse_project_path, parse_resource_iid, @@ -120,6 +123,18 @@ def post_gitlab_feature_deleted_comment( ) +@register_task_handler() +def apply_gitlab_label(resource_id: int) -> None: + """Apply the "Flagsmith Feature" label to the linked GitLab resource. + Dispatched at link time. No-op if labelling is disabled or unconfigured. + """ + try: + resource = FeatureExternalResource.objects.get(id=resource_id) + except FeatureExternalResource.DoesNotExist: + return + apply_flagsmith_label_to_resource(resource) + + @register_task_handler() def remove_gitlab_label( *, diff --git a/api/integrations/vcs/services.py b/api/integrations/vcs/services.py index 44e9d397746b..f40638b96a3c 100644 --- a/api/integrations/vcs/services.py +++ b/api/integrations/vcs/services.py @@ -21,9 +21,9 @@ def dispatch_vcs_on_resource_create(resource: FeatureExternalResource) -> None: is created. """ if resource.type in GITLAB_RESOURCE_TYPES: - gitlab.apply_flagsmith_label_to_resource(resource) gitlab.register_gitlab_webhook_for_resource(resource) gitlab.apply_initial_tag(resource) + gitlab_tasks.apply_gitlab_label.delay(args=(resource.id,)) gitlab_tasks.post_gitlab_linked_comment.delay(args=(resource.id,)) gitlab_logger.info( "resource.linked", diff --git a/api/tests/integration/features/test_gitlab_external_resources.py b/api/tests/integration/features/test_gitlab_external_resources.py index 342f532add08..918341c43e31 100644 --- a/api/tests/integration/features/test_gitlab_external_resources.py +++ b/api/tests/integration/features/test_gitlab_external_resources.py @@ -684,10 +684,11 @@ def test_create_external_resource__gitlab_issue_with_labeling_disabled__skips_la @responses.activate -def test_create_external_resource__gitlab_issue_label_api_failure__returns_400( +def test_create_external_resource__gitlab_issue_label_api_failure__still_creates_resource( admin_client: APIClient, project: int, feature: int, + log: StructuredLogCapture, ) -> None: # Given project_instance = Project.objects.get(id=project) @@ -701,6 +702,16 @@ def test_create_external_resource__gitlab_issue_label_api_failure__returns_400( "https://gitlab.example.com/api/v4/projects/testorg%2Ftestrepo/labels", status=403, ) + responses.post( + "https://gitlab.example.com/api/v4/projects/testorg%2Ftestrepo/hooks", + json={"id": 1, "project_id": 1}, + status=201, + ) + responses.post( + "https://gitlab.example.com/api/v4/projects/testorg%2Ftestrepo/issues/42/notes", + json={"id": 1}, + status=201, + ) # When response = admin_client.post( @@ -714,12 +725,16 @@ def test_create_external_resource__gitlab_issue_label_api_failure__returns_400( format="json", ) - # Then — resource not created, transaction rolled back. - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert not FeatureExternalResource.objects.exists() + # Then — resource created, webhook registered, comment posted despite label failure. + assert response.status_code == status.HTTP_201_CREATED + assert FeatureExternalResource.objects.exists() + assert any(e["event"] == "label.failed" for e in log.events) + assert any(e["event"] == "webhook.registered" for e in log.events) + assert any(e["event"] == "comment.posted" for e in log.events) + assert any(e["event"] == "resource.linked" for e in log.events) -def test_create_external_resource__gitlab_issue_with_labeling_unparseable_url__returns_400( +def test_create_external_resource__gitlab_issue_with_labeling_unparseable_url__still_creates_resource( admin_client: APIClient, project: int, feature: int, @@ -744,9 +759,9 @@ def test_create_external_resource__gitlab_issue_with_labeling_unparseable_url__r format="json", ) - # Then - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert not FeatureExternalResource.objects.exists() + # Then — resource still created, label silently skipped. + assert response.status_code == status.HTTP_201_CREATED + assert FeatureExternalResource.objects.exists() def test_list_external_resources__gitlab_merge_request__returns_200( diff --git a/api/tests/unit/integrations/gitlab/test_tasks.py b/api/tests/unit/integrations/gitlab/test_tasks.py index e3e2c20aafa7..8f5f8713595b 100644 --- a/api/tests/unit/integrations/gitlab/test_tasks.py +++ b/api/tests/unit/integrations/gitlab/test_tasks.py @@ -11,6 +11,7 @@ from features.models import Feature, FeatureState from integrations.gitlab.models import GitLabConfiguration from integrations.gitlab.tasks import ( + apply_gitlab_label, post_gitlab_feature_deleted_comment, post_gitlab_linked_comment, post_gitlab_state_change_comment, @@ -123,6 +124,46 @@ def test_post_gitlab_feature_deleted_comment_task__called__delegates_to_service( ) +@pytest.mark.django_db +def test_apply_gitlab_label__resource_missing__noop( + mocker: pytest_mock.MockerFixture, +) -> None: + # Given + mock_apply = mocker.patch( + "integrations.gitlab.tasks.apply_flagsmith_label_to_resource", + ) + + # When + apply_gitlab_label(resource_id=999_999) + + # Then + mock_apply.assert_not_called() + + +@pytest.mark.django_db +def test_apply_gitlab_label__resource_exists__calls_apply_flagsmith_label( + mocker: pytest_mock.MockerFixture, + feature: Feature, +) -> None: + # Given + resource = FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testorg/testrepo/-/issues/1", + type="GITLAB_ISSUE", + feature=feature, + ) + mock_apply = mocker.patch( + "integrations.gitlab.tasks.apply_flagsmith_label_to_resource", + ) + + # When + apply_gitlab_label(resource_id=resource.id) + + # Then + mock_apply.assert_called_once() + [call_args] = mock_apply.call_args_list + assert call_args.args[0].id == resource.id + + @pytest.mark.django_db def test_remove_gitlab_label__unparseable_url__noop( project: Project, diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 85ccd0b28d52..6b8a9e53c8a8 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -162,7 +162,7 @@ Attributes: ### `gitlab.label.removal_failed` Logged at `exception` from: - - `api/integrations/gitlab/tasks.py:170` + - `api/integrations/gitlab/tasks.py:185` Attributes: - `feature.id` @@ -174,7 +174,7 @@ Attributes: ### `gitlab.label.removed` Logged at `info` from: - - `api/integrations/gitlab/tasks.py:168` + - `api/integrations/gitlab/tasks.py:183` Attributes: - `feature.id` From ec25de477e3888b44d9ea29b2dc42a10fe1c3d02 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 24 Apr 2026 14:19:43 +0100 Subject: [PATCH 21/21] feat: removed comment --- .../integration/features/test_gitlab_external_resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/integration/features/test_gitlab_external_resources.py b/api/tests/integration/features/test_gitlab_external_resources.py index 918341c43e31..c1ed8a9e2a73 100644 --- a/api/tests/integration/features/test_gitlab_external_resources.py +++ b/api/tests/integration/features/test_gitlab_external_resources.py @@ -759,7 +759,7 @@ def test_create_external_resource__gitlab_issue_with_labeling_unparseable_url__s format="json", ) - # Then — resource still created, label silently skipped. + # Then assert response.status_code == status.HTTP_201_CREATED assert FeatureExternalResource.objects.exists()